mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add in-app release notes viewer
Introduces a new feature to fetch, parse, and display app release notes and update highlights directly within the UI. - Backend: Adds the `release-notes` module to fetch GitHub releases and parse markdown highlight sections. - Frontend: Adds the `WhatsNew` dialog and view, including custom markdown rendering and an image lightbox for update media. - Tools: Adds validation scripts (`whats-new-lint`, `whats-new-preview`) to ensure release content parses correctly before publication.
This commit is contained in:
+25
-1
@@ -1,16 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, provide } from 'vue'
|
import { computed, provide, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
|
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
|
||||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
|
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
|
||||||
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
||||||
|
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
|
||||||
|
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const { isOpen } = useChangePasswordDialog()
|
const { isOpen } = useChangePasswordDialog()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { popupOpen, evaluate, syncPopup } = useWhatsNew()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => user.value,
|
||||||
|
async (current) => {
|
||||||
|
if (!current) return
|
||||||
|
await evaluate()
|
||||||
|
syncPopup(route.name as string | undefined)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name) => syncPopup(name as string | undefined),
|
||||||
|
)
|
||||||
|
|
||||||
initChartThemes()
|
initChartThemes()
|
||||||
|
|
||||||
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
|
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
|
||||||
@@ -28,6 +51,7 @@ provide(
|
|||||||
</Transition>
|
</Transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
<ChangePasswordDialog v-if="isOpen" />
|
<ChangePasswordDialog v-if="isOpen" />
|
||||||
|
<WhatsNewDialog v-if="popupOpen" />
|
||||||
<Toaster rich-colors position="bottom-right" :visible-toasts="5" :gap="8" />
|
<Toaster rich-colors position="bottom-right" :visible-toasts="5" :gap="8" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
BadgeQuestionMark,
|
BadgeQuestionMark,
|
||||||
Star,
|
Star,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -50,6 +51,7 @@ import { useLibraryUploadEvents } from '@/features/library/composables/useLibrar
|
|||||||
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
|
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
|
||||||
import NotificationSheet from '@/features/notifications/components/NotificationSheet.vue'
|
import NotificationSheet from '@/features/notifications/components/NotificationSheet.vue'
|
||||||
import { useNotifications } from '@/features/notifications/composables/useNotifications'
|
import { useNotifications } from '@/features/notifications/composables/useNotifications'
|
||||||
|
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types'
|
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -63,6 +65,7 @@ const { hasPermission, isDemoRestrictedAccount } = usePermissions()
|
|||||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||||
const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
|
const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
|
||||||
const { subscribe: subscribeNotifications } = useNotifications()
|
const { subscribe: subscribeNotifications } = useNotifications()
|
||||||
|
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const documentationUrl = 'https://bookorbit.app/what-is-bookorbit.html'
|
const documentationUrl = 'https://bookorbit.app/what-is-bookorbit.html'
|
||||||
const githubRepositoryUrl = 'https://github.com/bookorbit/bookorbit'
|
const githubRepositoryUrl = 'https://github.com/bookorbit/bookorbit'
|
||||||
@@ -98,6 +101,10 @@ function navigateToSettings() {
|
|||||||
router.push({ name: 'settings-libraries' })
|
router.push({ name: 'settings-libraries' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateToWhatsNew() {
|
||||||
|
router.push({ name: 'whats-new' })
|
||||||
|
}
|
||||||
|
|
||||||
const uploadOpen = ref(false)
|
const uploadOpen = ref(false)
|
||||||
|
|
||||||
const searchFocused = ref(false)
|
const searchFocused = ref(false)
|
||||||
@@ -547,6 +554,21 @@ function formatBadgeStyle(fmt: string) {
|
|||||||
<Settings :size="15" class="mr-2 text-muted-foreground" />
|
<Settings :size="15" class="mr-2 text-muted-foreground" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem @click="navigateToWhatsNew">
|
||||||
|
<Sparkles :size="15" class="mr-2 text-muted-foreground" />
|
||||||
|
What's New
|
||||||
|
<span v-if="hasUnseenWhatsNew" class="ml-auto h-1.5 w-1.5 rounded-full bg-primary" aria-label="New" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem as-child>
|
||||||
|
<a :href="documentationUrl" target="_blank" rel="noopener noreferrer">
|
||||||
|
<BadgeQuestionMark :size="15" class="mr-2 text-muted-foreground" />
|
||||||
|
Documentation
|
||||||
|
<ExternalLink :size="12" class="ml-auto text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -650,23 +672,43 @@ function formatBadgeStyle(fmt: string) {
|
|||||||
<div class="hidden md:block h-4 w-px bg-foreground/20" />
|
<div class="hidden md:block h-4 w-px bg-foreground/20" />
|
||||||
<div class="hidden md:flex items-center gap-2.5">
|
<div class="hidden md:flex items-center gap-2.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<DropdownMenu>
|
||||||
<Button
|
<TooltipTrigger as-child>
|
||||||
as-child
|
<DropdownMenuTrigger as-child>
|
||||||
data-tour="documentation-link"
|
<Button
|
||||||
variant="ghost"
|
data-tour="documentation-link"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
:class="[
|
size="icon"
|
||||||
'h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
|
:class="[
|
||||||
iconRadiusClass,
|
'relative h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
|
||||||
]"
|
iconRadiusClass,
|
||||||
>
|
]"
|
||||||
<a :href="documentationUrl" target="_blank" rel="noopener noreferrer">
|
>
|
||||||
<BadgeQuestionMark :size="15" />
|
<BadgeQuestionMark :size="15" />
|
||||||
</a>
|
<span
|
||||||
</Button>
|
v-if="hasUnseenWhatsNew"
|
||||||
</TooltipTrigger>
|
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-background"
|
||||||
<TooltipContent>Documentation</TooltipContent>
|
aria-label="New release notes available"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuItem as-child>
|
||||||
|
<a :href="documentationUrl" target="_blank" rel="noopener noreferrer">
|
||||||
|
<BadgeQuestionMark :size="14" class="mr-2 text-muted-foreground" />
|
||||||
|
Documentation
|
||||||
|
<ExternalLink :size="12" class="ml-auto text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="navigateToWhatsNew">
|
||||||
|
<Sparkles :size="14" class="mr-2 text-muted-foreground" />
|
||||||
|
What's New
|
||||||
|
<span v-if="hasUnseenWhatsNew" class="ml-auto h-1.5 w-1.5 rounded-full bg-primary" aria-label="New" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<TooltipContent>Help</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useLibraryCreationRedirect } from '@/features/library/composables/useLi
|
|||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||||
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
|
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
|
||||||
|
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
|
|
||||||
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
||||||
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
|
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
|
||||||
@@ -58,6 +59,7 @@ const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
|||||||
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
|
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
|
||||||
|
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
|
||||||
|
|
||||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||||
const versionUi = computed(() => {
|
const versionUi = computed(() => {
|
||||||
@@ -441,16 +443,10 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
<SidebarFooter v-if="versionUi.currentLabel" class="border-t border-sidebar-border/60">
|
<SidebarFooter v-if="versionUi.currentLabel" class="border-t border-sidebar-border/60">
|
||||||
<div class="px-2 py-2 group-data-[collapsible=icon]:hidden">
|
<div class="px-2 py-2 group-data-[collapsible=icon]:hidden">
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
|
<div class="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
|
||||||
<a
|
<RouterLink to="/whats-new" class="inline-flex items-center gap-1.5 transition-colors hover:text-sidebar-foreground">
|
||||||
v-if="versionUi.currentHref"
|
|
||||||
:href="versionUi.currentHref"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="transition-colors hover:text-sidebar-foreground"
|
|
||||||
>
|
|
||||||
{{ versionUi.currentLabel }}
|
{{ versionUi.currentLabel }}
|
||||||
</a>
|
<span v-if="hasUnseenWhatsNew" class="h-1.5 w-1.5 rounded-full bg-primary" aria-label="New release notes available" />
|
||||||
<span v-else>{{ versionUi.currentLabel }}</span>
|
</RouterLink>
|
||||||
|
|
||||||
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
|
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
interface UseModalOptions {
|
||||||
|
/** The modal panel root. The focus trap cycles within it and it receives initial focus as a fallback. */
|
||||||
|
container: Ref<HTMLElement | null>
|
||||||
|
/** Invoked when Escape is pressed (unless `disabled` returns true). */
|
||||||
|
onClose: () => void
|
||||||
|
/** Makes this modal inert (no Escape, no focus trap) while true, e.g. when a nested modal is open above it. */
|
||||||
|
disabled?: () => boolean
|
||||||
|
/** Element to focus on open; falls back to the first focusable element in the container. */
|
||||||
|
initialFocus?: Ref<HTMLElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared modal behaviour for hand-rolled dialogs: body scroll lock, focus save/restore,
|
||||||
|
* Escape-to-close, and a Tab focus trap. Wire it once per modal component in setup.
|
||||||
|
*/
|
||||||
|
export function useModal(options: UseModalOptions): void {
|
||||||
|
let previouslyFocused: HTMLElement | null = null
|
||||||
|
let previousOverflow = ''
|
||||||
|
|
||||||
|
function focusable(): HTMLElement[] {
|
||||||
|
const root = options.container.value
|
||||||
|
if (!root) return []
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((el) => el.getClientRects().length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (options.disabled?.()) return
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
options.onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
const elements = focusable()
|
||||||
|
const first = elements[0]
|
||||||
|
const last = elements[elements.length - 1]
|
||||||
|
if (!first || !last) return
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
const outside = !options.container.value?.contains(active)
|
||||||
|
if (event.shiftKey && (active === first || outside)) {
|
||||||
|
event.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!event.shiftKey && (active === last || outside)) {
|
||||||
|
event.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
previouslyFocused = document.activeElement as HTMLElement | null
|
||||||
|
previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
void nextTick(() => {
|
||||||
|
const target = options.initialFocus?.value ?? focusable()[0] ?? options.container.value
|
||||||
|
target?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
previouslyFocused?.focus?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composa
|
|||||||
import { useSetupStatus } from './useSetupStatus'
|
import { useSetupStatus } from './useSetupStatus'
|
||||||
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
|
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
|
||||||
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
|
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
|
||||||
|
import { resetWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
|
|
||||||
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ function clearAuth() {
|
|||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
disconnectAuthorEnrichmentSocket()
|
disconnectAuthorEnrichmentSocket()
|
||||||
disconnectBookMetadataFetchSocket()
|
disconnectBookMetadataFetchSocket()
|
||||||
|
resetWhatsNew()
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnAuthFailure(() => {
|
setOnAuthFailure(() => {
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Save } from 'lucide-vue-next'
|
import { Save } from 'lucide-vue-next'
|
||||||
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, type NotificationCategory, type NotificationPreferences } from '@bookorbit/types'
|
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, type NotificationCategory, type NotificationPreferences } from '@bookorbit/types'
|
||||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
|
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
|
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||||
|
|
||||||
const { user, me } = useAuth()
|
const { user, me } = useAuth()
|
||||||
|
const { popupEnabled, setPopupEnabled, loadPrefs } = useWhatsNew()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadPrefs()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleWhatsNewToggle() {
|
||||||
|
try {
|
||||||
|
await setPopupEnabled(!popupEnabled.value)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save preference')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const preferences = ref<NotificationPreferences>({})
|
const preferences = ref<NotificationPreferences>({})
|
||||||
|
|
||||||
function loadFromUser() {
|
function loadFromUser() {
|
||||||
@@ -122,5 +136,29 @@ async function handleSave() {
|
|||||||
<span v-if="hasChanges" class="text-xs text-muted-foreground">Unsaved changes</span>
|
<span v-if="hasChanges" class="text-xs text-muted-foreground">Unsaved changes</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-border bg-card p-4 md:p-5 shadow-xs">
|
||||||
|
<div class="flex items-center justify-between gap-4 py-1.5">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-foreground">Show "What's New" after updates</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
A popup highlighting new features after the app updates. The archive stays available either way.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="popupEnabled"
|
||||||
|
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
:class="popupEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
|
||||||
|
@click="handleWhatsNewToggle"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-xs ring-0 transition-transform"
|
||||||
|
:class="popupEnabled ? 'translate-x-4' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ArrowRight, ChevronDown, ExternalLink, Sparkles, X } from 'lucide-vue-next'
|
||||||
|
import { useModal } from '@/composables/useModal'
|
||||||
|
import { useWhatsNew } from './composables/useWhatsNew'
|
||||||
|
import { formatReleaseDate } from './lib/whats-new.logic'
|
||||||
|
import { anyLightboxOpen } from './lib/lightbox-state'
|
||||||
|
import HighlightItem from './components/HighlightItem.vue'
|
||||||
|
|
||||||
|
const { version, releases, hasMore, acknowledge, remindLater } = useWhatsNew()
|
||||||
|
|
||||||
|
const expanded = ref(new Set<string>(releases.value[0] ? [releases.value[0].version] : []))
|
||||||
|
const panel = ref<HTMLElement | null>(null)
|
||||||
|
const ackButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
function isExpanded(version: string): boolean {
|
||||||
|
return expanded.value.has(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(version: string): void {
|
||||||
|
const next = new Set(expanded.value)
|
||||||
|
if (next.has(version)) next.delete(version)
|
||||||
|
else next.add(version)
|
||||||
|
expanded.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAck(): void {
|
||||||
|
void acknowledge()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLater(): void {
|
||||||
|
remindLater()
|
||||||
|
}
|
||||||
|
|
||||||
|
useModal({
|
||||||
|
container: panel,
|
||||||
|
onClose: remindLater,
|
||||||
|
disabled: () => anyLightboxOpen.value,
|
||||||
|
initialFocus: ackButton,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[75] flex items-end justify-center bg-black/50 motion-safe:animate-in motion-safe:fade-in sm:items-center sm:px-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="whats-new-title"
|
||||||
|
@click.self="handleLater"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="panel"
|
||||||
|
class="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-t-2xl border border-border bg-card shadow-xl motion-safe:animate-in motion-safe:slide-in-from-bottom-4 sm:rounded-2xl"
|
||||||
|
>
|
||||||
|
<header class="relative overflow-hidden border-b border-border px-5 pb-4 pt-5">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0"
|
||||||
|
style="
|
||||||
|
background-image: radial-gradient(ellipse 90% 70% at 0% -20%, color-mix(in oklch, var(--primary) 14%, transparent) 0%, transparent 70%);
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="relative flex items-start justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
|
<Sparkles :size="22" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 id="whats-new-title" class="text-lg font-semibold tracking-tight text-foreground">What's New</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<template v-if="version">Version {{ version }} · </template>{{ releases.length }} new update{{ releases.length === 1 ? '' : 's' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="-mr-1 -mt-1 shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
aria-label="Remind me later"
|
||||||
|
@click="handleLater"
|
||||||
|
>
|
||||||
|
<X :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||||
|
<section v-for="(release, index) in releases" :key="release.version" class="rounded-lg border border-border bg-background p-4">
|
||||||
|
<button type="button" class="flex w-full items-center justify-between gap-3 text-left" @click="toggle(release.version)">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">{{ release.version }}</span>
|
||||||
|
<span
|
||||||
|
v-if="index === 0"
|
||||||
|
class="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
<span v-if="formatReleaseDate(release.date)" class="text-xs text-muted-foreground">{{ formatReleaseDate(release.date) }}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
:size="16"
|
||||||
|
class="shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none"
|
||||||
|
:class="isExpanded(release.version) ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="isExpanded(release.version) && release.highlights.length">
|
||||||
|
<ul class="mt-3 divide-y divide-border/60">
|
||||||
|
<li v-for="(highlight, hIndex) in release.highlights" :key="hIndex" class="py-4 first:pt-0 last:pb-0">
|
||||||
|
<HighlightItem :highlight="highlight" :show-media="index === 0" compact />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="isExpanded(release.version)"
|
||||||
|
:href="release.changelogUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:text-primary/85"
|
||||||
|
>
|
||||||
|
Full changelog
|
||||||
|
<ExternalLink :size="12" />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="hasMore"
|
||||||
|
to="/whats-new"
|
||||||
|
class="inline-flex items-center gap-1 text-sm font-medium text-primary transition-colors hover:text-primary/85"
|
||||||
|
@click="handleLater"
|
||||||
|
>
|
||||||
|
See all releases
|
||||||
|
<ArrowRight :size="14" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||||
|
@click="handleLater"
|
||||||
|
>
|
||||||
|
Remind me later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref="ackButton"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
@click="handleAck"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { ChevronDown, ExternalLink, Search, Sparkles } from 'lucide-vue-next'
|
||||||
|
import type { ReleaseNote, ReleaseNotesResponse } from '@bookorbit/types'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useWhatsNew } from './composables/useWhatsNew'
|
||||||
|
import { formatReleaseDate } from './lib/whats-new.logic'
|
||||||
|
import { renderChangelogMarkdown } from './lib/markdown'
|
||||||
|
import HighlightItem from './components/HighlightItem.vue'
|
||||||
|
|
||||||
|
const { version, markArchiveSeen } = useWhatsNew()
|
||||||
|
|
||||||
|
const releases = ref<ReleaseNote[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const expandedChangelogs = ref(new Set<string>())
|
||||||
|
const activeVersion = ref<string | null>(null)
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
if (!q) return releases.value
|
||||||
|
return releases.value.filter((release) => {
|
||||||
|
if (release.version.toLowerCase().includes(q)) return true
|
||||||
|
if (release.name?.toLowerCase().includes(q)) return true
|
||||||
|
return release.highlights.some((h) => h.title.toLowerCase().includes(q) || h.body.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPage(target: number): Promise<void> {
|
||||||
|
const res = await api(`/api/v1/release-notes?page=${target}`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = (await res.json()) as ReleaseNotesResponse
|
||||||
|
releases.value = target === 1 ? data.releases : [...releases.value, ...data.releases]
|
||||||
|
hasMore.value = data.hasMore
|
||||||
|
page.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFirst(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await loadPage(1)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load releases'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(): Promise<void> {
|
||||||
|
loadingMore.value = true
|
||||||
|
try {
|
||||||
|
await loadPage(page.value + 1)
|
||||||
|
} catch {
|
||||||
|
// Keep the releases already loaded; surface the failure so the button isn't a no-op.
|
||||||
|
toast.error('Could not load more releases. Please try again.')
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry(): void {
|
||||||
|
void loadFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChangelog(release: ReleaseNote): void {
|
||||||
|
const next = new Set(expandedChangelogs.value)
|
||||||
|
if (next.has(release.version)) next.delete(release.version)
|
||||||
|
else next.add(release.version)
|
||||||
|
expandedChangelogs.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChangelogOpen(release: ReleaseNote): boolean {
|
||||||
|
return expandedChangelogs.value.has(release.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrent(release: ReleaseNote): boolean {
|
||||||
|
return !!version.value && release.version === version.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorId(versionTag: string): string {
|
||||||
|
return 'release-' + versionTag.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollspy: highlight the rail entry whose card the user has scrolled to.
|
||||||
|
let scrollEl: EventTarget | null = null
|
||||||
|
|
||||||
|
function updateActiveVersion(): void {
|
||||||
|
const first = filtered.value[0]
|
||||||
|
if (!first) return
|
||||||
|
const offset = 120
|
||||||
|
let current = first.version
|
||||||
|
for (const release of filtered.value) {
|
||||||
|
const el = document.getElementById(anchorId(release.version))
|
||||||
|
if (!el) continue
|
||||||
|
if (el.getBoundingClientRect().top - offset <= 0) current = release.version
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
activeVersion.value = current
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filtered.value.map((r) => r.version).join(','),
|
||||||
|
() => nextTick(updateActiveVersion),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadFirst().then(() => nextTick(updateActiveVersion))
|
||||||
|
void markArchiveSeen()
|
||||||
|
scrollEl = document.querySelector<HTMLElement>('.app-shell-scroll') ?? window
|
||||||
|
scrollEl.addEventListener('scroll', updateActiveVersion, { passive: true })
|
||||||
|
window.addEventListener('resize', updateActiveVersion)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
scrollEl?.removeEventListener('scroll', updateActiveVersion)
|
||||||
|
window.removeEventListener('resize', updateActiveVersion)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-6xl px-4 py-6">
|
||||||
|
<header class="mb-5 flex items-center gap-3">
|
||||||
|
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<Sparkles :size="20" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold tracking-tight text-foreground">What's New</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Everything that's shipped, newest first.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="lg:flex lg:gap-6">
|
||||||
|
<nav v-if="!loading && !error && filtered.length" class="hidden w-48 shrink-0 lg:order-last lg:block">
|
||||||
|
<div class="sticky top-2 space-y-1">
|
||||||
|
<p class="px-2 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Versions</p>
|
||||||
|
<a
|
||||||
|
v-for="release in filtered"
|
||||||
|
:key="release.version"
|
||||||
|
:href="'#' + anchorId(release.version)"
|
||||||
|
:aria-current="activeVersion === release.version ? 'location' : undefined"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors"
|
||||||
|
:class="
|
||||||
|
activeVersion === release.version
|
||||||
|
? 'bg-muted font-semibold text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ release.version }}</span>
|
||||||
|
<span v-if="isCurrent(release)" class="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-label="Current version" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="relative mb-5">
|
||||||
|
<Search :size="16" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search releases…"
|
||||||
|
class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-3">
|
||||||
|
<div v-for="n in 4" :key="n" class="h-28 animate-pulse rounded-lg border border-border bg-card" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="rounded-lg border border-border bg-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">Couldn't load releases. Check your connection and try again.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
||||||
|
@click="retry"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filtered.length === 0" class="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No releases found.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<article
|
||||||
|
v-for="release in filtered"
|
||||||
|
:id="anchorId(release.version)"
|
||||||
|
:key="release.version"
|
||||||
|
class="scroll-mt-4 rounded-lg border border-border bg-card p-4 shadow-xs md:p-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-base font-semibold text-foreground">{{ release.version }}</h2>
|
||||||
|
<span v-if="isCurrent(release)" class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">You're here</span>
|
||||||
|
<span v-if="formatReleaseDate(release.date)" class="text-xs text-muted-foreground">{{ formatReleaseDate(release.date) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="release.highlights.length">
|
||||||
|
<ul class="mt-3 divide-y divide-border/60">
|
||||||
|
<li v-for="(highlight, hIndex) in release.highlights" :key="hIndex" class="py-4 first:pt-0 last:pb-0">
|
||||||
|
<HighlightItem :highlight="highlight" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="mt-3 text-sm text-muted-foreground">No highlights for this release.</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||||
|
<button
|
||||||
|
v-if="release.changelogBody"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-foreground transition-colors hover:text-primary"
|
||||||
|
@click="toggleChangelog(release)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
:size="14"
|
||||||
|
class="transition-transform motion-reduce:transition-none"
|
||||||
|
:class="isChangelogOpen(release) ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
{{ isChangelogOpen(release) ? 'Hide' : 'Show' }} full changelog
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
:href="release.changelogUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:text-primary/85"
|
||||||
|
>
|
||||||
|
Open on GitHub
|
||||||
|
<ExternalLink :size="12" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by renderChangelogMarkdown (DOMPurify) -->
|
||||||
|
<div
|
||||||
|
v-if="isChangelogOpen(release) && release.changelogBody"
|
||||||
|
class="whats-new-changelog mt-3 border-t border-border/60 pt-3 text-sm text-muted-foreground"
|
||||||
|
v-html="renderChangelogMarkdown(release.changelogBody)"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-if="hasMore && !search.trim()" class="pt-1 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="loadingMore"
|
||||||
|
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
{{ loadingMore ? 'Loading…' : 'Load more' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.whats-new-changelog :deep(h1),
|
||||||
|
.whats-new-changelog :deep(h2),
|
||||||
|
.whats-new-changelog :deep(h3) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.whats-new-changelog :deep(ul) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.whats-new-changelog :deep(a) {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.whats-new-changelog :deep(code) {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type Component } from 'vue'
|
||||||
|
import * as Icons from 'lucide-vue-next'
|
||||||
|
import { Sparkles } from 'lucide-vue-next'
|
||||||
|
import { isIconNameShape } from '../lib/whats-new.logic'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ name: string | null; size?: number }>(), { size: 18 })
|
||||||
|
|
||||||
|
const iconComponent = computed<Component>(() => {
|
||||||
|
const name = props.name
|
||||||
|
if (isIconNameShape(name) && name! in Icons) {
|
||||||
|
return (Icons as Record<string, unknown>)[name!] as Component
|
||||||
|
}
|
||||||
|
return Sparkles
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="iconComponent" :size="size" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { ReleaseHighlight, ReleaseMedia } from '@bookorbit/types'
|
||||||
|
import { isAllowedImageHost } from '../lib/whats-new.logic'
|
||||||
|
import HighlightIcon from './HighlightIcon.vue'
|
||||||
|
import ImageLightbox from './ImageLightbox.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ highlight: ReleaseHighlight; showMedia?: boolean; compact?: boolean }>(), {
|
||||||
|
showMedia: true,
|
||||||
|
compact: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Local copy so a media item that fails to load can be removed without mutating the prop.
|
||||||
|
const items = ref<ReleaseMedia[]>([...props.highlight.media])
|
||||||
|
watch(
|
||||||
|
() => props.highlight.media,
|
||||||
|
(media) => {
|
||||||
|
items.value = [...media]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleMedia = computed(() => items.value.filter((m) => isAllowedImageHost(m.url)))
|
||||||
|
const images = computed(() => visibleMedia.value.filter((m) => m.type === 'image').map((m) => m.url))
|
||||||
|
|
||||||
|
// Every image sits in an identical-size frame and is letterboxed inside it (object-contain), so
|
||||||
|
// highlights with very different screenshot shapes line up uniformly. The frame background fills
|
||||||
|
// the leftover space; nothing is cropped or distorted.
|
||||||
|
const mediaFrameClass = computed(() => (props.compact ? 'h-36 w-56' : 'h-48 w-72'))
|
||||||
|
|
||||||
|
const lightboxOpen = ref(false)
|
||||||
|
const lightboxIndex = ref(0)
|
||||||
|
|
||||||
|
function openLightbox(item: ReleaseMedia): void {
|
||||||
|
const idx = images.value.indexOf(item.url)
|
||||||
|
if (idx === -1) return
|
||||||
|
lightboxIndex.value = idx
|
||||||
|
lightboxOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(): void {
|
||||||
|
lightboxOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMediaError(item: ReleaseMedia): void {
|
||||||
|
items.value = items.value.filter((m) => m !== item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<HighlightIcon :name="highlight.icon" :size="16" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="line-clamp-2 text-sm font-semibold text-foreground">{{ highlight.title }}</p>
|
||||||
|
<p v-if="highlight.body" class="mt-1 line-clamp-4 text-sm leading-relaxed text-muted-foreground">{{ highlight.body }}</p>
|
||||||
|
|
||||||
|
<div v-if="props.showMedia && visibleMedia.length" class="relative mt-3">
|
||||||
|
<span
|
||||||
|
v-if="visibleMedia.length > 1"
|
||||||
|
class="pointer-events-none absolute right-1 top-1 z-10 rounded-full bg-background/80 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ visibleMedia.length }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1 snap-x">
|
||||||
|
<template v-for="m in visibleMedia" :key="m.url">
|
||||||
|
<button
|
||||||
|
v-if="m.type === 'image'"
|
||||||
|
type="button"
|
||||||
|
class="block shrink-0 snap-start overflow-hidden rounded-lg border border-border bg-muted/40 transition-opacity hover:opacity-90"
|
||||||
|
:class="mediaFrameClass"
|
||||||
|
@click="openLightbox(m)"
|
||||||
|
>
|
||||||
|
<img :src="m.url" :alt="highlight.title" loading="lazy" class="h-full w-full object-contain" @error="handleMediaError(m)" />
|
||||||
|
</button>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="m.url"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
class="block shrink-0 snap-start rounded-lg border border-border bg-black object-contain"
|
||||||
|
:class="mediaFrameClass"
|
||||||
|
@error="handleMediaError(m)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageLightbox v-if="lightboxOpen && images.length" :images="images" :start-index="lightboxIndex" :alt="highlight.title" @close="closeLightbox" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
|
||||||
|
import { useModal } from '@/composables/useModal'
|
||||||
|
import { registerLightbox } from '../lib/lightbox-state'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ images: string[]; startIndex?: number; alt?: string }>(), {
|
||||||
|
startIndex: 0,
|
||||||
|
})
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const current = ref(props.startIndex)
|
||||||
|
const panel = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
current.value = (current.value + 1) % props.images.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
current.value = (current.value - 1 + props.images.length) % props.images.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowKeys(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowRight') next()
|
||||||
|
else if (event.key === 'ArrowLeft') prev()
|
||||||
|
}
|
||||||
|
|
||||||
|
let unregister: (() => void) | null = null
|
||||||
|
|
||||||
|
useModal({ container: panel, onClose: handleClose })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
unregister = registerLightbox()
|
||||||
|
window.addEventListener('keydown', handleArrowKeys)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
unregister?.()
|
||||||
|
window.removeEventListener('keydown', handleArrowKeys)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
ref="panel"
|
||||||
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-4 motion-safe:animate-in motion-safe:fade-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Image viewer"
|
||||||
|
@click.self="handleClose"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-4 top-4 text-white/80 transition-colors hover:text-white"
|
||||||
|
aria-label="Close image"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<X :size="24" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="images.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
aria-label="Previous image"
|
||||||
|
@click="prev"
|
||||||
|
>
|
||||||
|
<ChevronLeft :size="28" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img :src="images[current]" :alt="alt ?? ''" class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="images.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
aria-label="Next image"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
<ChevronRight :size="28" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span v-if="images.length > 1" class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-sm text-white/90">
|
||||||
|
{{ current + 1 }} / {{ images.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { ReleaseNote } from '@bookorbit/types'
|
||||||
|
|
||||||
|
import { resetWhatsNew, useWhatsNew } from './useWhatsNew'
|
||||||
|
|
||||||
|
describe('resetWhatsNew', () => {
|
||||||
|
it('clears module-scope state so a user switch cannot inherit the previous user state', () => {
|
||||||
|
const { releases, popupEnabled, popupOpen, hasMore, error } = useWhatsNew()
|
||||||
|
|
||||||
|
releases.value = [{ version: 'v1.2.0' } as ReleaseNote]
|
||||||
|
popupEnabled.value = false
|
||||||
|
popupOpen.value = true
|
||||||
|
hasMore.value = true
|
||||||
|
error.value = 'boom'
|
||||||
|
|
||||||
|
resetWhatsNew()
|
||||||
|
|
||||||
|
expect(releases.value).toEqual([])
|
||||||
|
expect(popupEnabled.value).toBe(true)
|
||||||
|
expect(popupOpen.value).toBe(false)
|
||||||
|
expect(hasMore.value).toBe(false)
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { ReleaseNote, ReleaseNotesResponse, WhatsNewPreferences } from '@bookorbit/types'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||||
|
import { isNewer, isSemverTag, shouldShowPopup } from '../lib/whats-new.logic'
|
||||||
|
|
||||||
|
const lastSeenVersion = ref<string | null>(null)
|
||||||
|
const popupEnabled = ref(true)
|
||||||
|
const releases = ref<ReleaseNote[]>([])
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const popupOpen = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
let prefsLoaded = false
|
||||||
|
let evaluated = false
|
||||||
|
let evaluating = false
|
||||||
|
let dismissedThisSession = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the module-scope singleton so a user switch in the same SPA session can't inherit the
|
||||||
|
* previous user's seen-state. Call from auth teardown (see clearAuth in useAuth).
|
||||||
|
*/
|
||||||
|
export function resetWhatsNew(): void {
|
||||||
|
lastSeenVersion.value = null
|
||||||
|
popupEnabled.value = true
|
||||||
|
releases.value = []
|
||||||
|
hasMore.value = false
|
||||||
|
popupOpen.value = false
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
prefsLoaded = false
|
||||||
|
evaluated = false
|
||||||
|
evaluating = false
|
||||||
|
dismissedThisSession = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWhatsNew() {
|
||||||
|
const { version, loadAppInfo } = useAppInfo()
|
||||||
|
|
||||||
|
const hasUnseen = computed(() => releases.value.length > 0)
|
||||||
|
|
||||||
|
async function loadPrefs(): Promise<void> {
|
||||||
|
if (prefsLoaded) return
|
||||||
|
const res = await api('/api/v1/user-preferences/whats-new')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = (await res.json()) as { settings: WhatsNewPreferences }
|
||||||
|
lastSeenVersion.value = data.settings.lastSeenVersion
|
||||||
|
popupEnabled.value = data.settings.popupEnabled
|
||||||
|
prefsLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(settings: Partial<WhatsNewPreferences>): Promise<Response> {
|
||||||
|
return api('/api/v1/user-preferences/whats-new', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ settings }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLastSeen(value: string): Promise<void> {
|
||||||
|
lastSeenVersion.value = value
|
||||||
|
releases.value = []
|
||||||
|
hasMore.value = false
|
||||||
|
await persist({ lastSeenVersion: value }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(): Promise<void> {
|
||||||
|
if (evaluated || evaluating) return
|
||||||
|
evaluating = true
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await loadAppInfo()
|
||||||
|
await loadPrefs()
|
||||||
|
|
||||||
|
const current = version.value
|
||||||
|
if (!isSemverTag(current)) return
|
||||||
|
|
||||||
|
if (lastSeenVersion.value === null) {
|
||||||
|
await saveLastSeen(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNewer(current, lastSeenVersion.value)) return
|
||||||
|
|
||||||
|
const res = await api(`/api/v1/release-notes?since=${encodeURIComponent(lastSeenVersion.value)}`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = (await res.json()) as ReleaseNotesResponse
|
||||||
|
releases.value = data.releases
|
||||||
|
hasMore.value = data.hasMore
|
||||||
|
evaluated = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load release notes'
|
||||||
|
} finally {
|
||||||
|
evaluating = false
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPopup(routeName: string | null | undefined): void {
|
||||||
|
if (
|
||||||
|
shouldShowPopup({ hasUnseen: hasUnseen.value, popupEnabled: popupEnabled.value, dismissedThisSession, routeName, alreadyOpen: popupOpen.value })
|
||||||
|
) {
|
||||||
|
popupOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acknowledge(): Promise<void> {
|
||||||
|
popupOpen.value = false
|
||||||
|
if (isSemverTag(version.value)) await saveLastSeen(version.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remindLater(): void {
|
||||||
|
dismissedThisSession = true
|
||||||
|
popupOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markArchiveSeen(): Promise<void> {
|
||||||
|
await loadAppInfo()
|
||||||
|
await loadPrefs().catch(() => {})
|
||||||
|
const current = version.value
|
||||||
|
if (isSemverTag(current) && (lastSeenVersion.value === null || isNewer(current, lastSeenVersion.value))) {
|
||||||
|
await saveLastSeen(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPopupEnabled(enabled: boolean): Promise<void> {
|
||||||
|
popupEnabled.value = enabled
|
||||||
|
const res = await persist({ popupEnabled: enabled })
|
||||||
|
if (!res.ok) {
|
||||||
|
popupEnabled.value = !enabled
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
releases,
|
||||||
|
hasMore,
|
||||||
|
hasUnseen,
|
||||||
|
popupOpen,
|
||||||
|
popupEnabled,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
evaluate,
|
||||||
|
syncPopup,
|
||||||
|
acknowledge,
|
||||||
|
remindLater,
|
||||||
|
markArchiveSeen,
|
||||||
|
setPopupEnabled,
|
||||||
|
loadPrefs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const openCount = ref(0)
|
||||||
|
|
||||||
|
/** Returns an unregister fn; lets the popup know not to also close on Escape. */
|
||||||
|
export function registerLightbox(): () => void {
|
||||||
|
openCount.value++
|
||||||
|
return () => {
|
||||||
|
openCount.value = Math.max(0, openCount.value - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const anyLightboxOpen = computed(() => openCount.value > 0)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { renderChangelogMarkdown } from './markdown'
|
||||||
|
|
||||||
|
describe('renderChangelogMarkdown', () => {
|
||||||
|
it('renders headings, lists, bold, code and links', () => {
|
||||||
|
const md = ['### Features', '- add **two-way** sync', '- speed up `jumpRail`', '', '[issue](https://example.com/1)'].join('\n')
|
||||||
|
const html = renderChangelogMarkdown(md)
|
||||||
|
expect(html).toContain('<h3>Features</h3>')
|
||||||
|
expect(html).toContain('<ul>')
|
||||||
|
expect(html).toContain('<strong>two-way</strong>')
|
||||||
|
expect(html).toContain('<code>jumpRail</code>')
|
||||||
|
expect(html).toMatch(/<a [^>]*href="https:\/\/example\.com\/1"[^>]*>issue<\/a>/)
|
||||||
|
expect(html).toContain('target="_blank"')
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders fenced code blocks and horizontal rules', () => {
|
||||||
|
const html = renderChangelogMarkdown(['---', '```bash', 'docker pull x', '```'].join('\n'))
|
||||||
|
expect(html).toContain('<hr>')
|
||||||
|
expect(html).toContain('<pre><code>docker pull x</code></pre>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips dangerous markup', () => {
|
||||||
|
const html = renderChangelogMarkdown('<script>alert(1)</script>\n[x](javascript:alert(1))')
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
expect(html).not.toContain('javascript:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(renderChangelogMarkdown(null)).toBe('')
|
||||||
|
expect(renderChangelogMarkdown('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
// Minimal markdown renderer for the technical changelog (semantic-release output:
|
||||||
|
// headings, bullet lists, links, bold, inline code, fenced code, horizontal rules).
|
||||||
|
// Kept dependency-free on purpose; DOMPurify is the security backstop.
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = [
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'p',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'del',
|
||||||
|
'code',
|
||||||
|
'pre',
|
||||||
|
'blockquote',
|
||||||
|
'a',
|
||||||
|
]
|
||||||
|
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
node.setAttribute('target', '_blank')
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): string {
|
||||||
|
let out = escapeHtml(text)
|
||||||
|
out = out.replace(/`([^`]+)`/g, (_, code: string) => `<code>${code}</code>`)
|
||||||
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, url: string) => `<a href="${escapeHtml(url)}">${label}</a>`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHtml(md: string): string {
|
||||||
|
const lines = md.split('\n')
|
||||||
|
const out: string[] = []
|
||||||
|
let inList = false
|
||||||
|
let inCode = false
|
||||||
|
let code: string[] = []
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
out.push('</ul>')
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^```/.test(line)) {
|
||||||
|
if (inCode) {
|
||||||
|
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`)
|
||||||
|
inCode = false
|
||||||
|
code = []
|
||||||
|
} else {
|
||||||
|
closeList()
|
||||||
|
inCode = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inCode) {
|
||||||
|
code.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,6})\s+(.*)$/)
|
||||||
|
if (heading) {
|
||||||
|
closeList()
|
||||||
|
const level = (heading[1] ?? '#').length
|
||||||
|
out.push(`<h${level}>${renderInline(heading[2] ?? '')}</h${level}>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
||||||
|
closeList()
|
||||||
|
out.push('<hr>')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const item = line.match(/^\s*[-*]\s+(.*)$/)
|
||||||
|
if (item) {
|
||||||
|
if (!inList) {
|
||||||
|
out.push('<ul>')
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
out.push(`<li>${renderInline(item[1] ?? '')}</li>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.trim() === '') {
|
||||||
|
closeList()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
out.push(`<p>${renderInline(line)}</p>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCode) out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`)
|
||||||
|
closeList()
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderChangelogMarkdown(md: string | null | undefined): string {
|
||||||
|
if (!md) return ''
|
||||||
|
return DOMPurify.sanitize(toHtml(md), { ALLOWED_TAGS, ALLOWED_ATTR: ['href', 'target', 'rel'] })
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { isAllowedImageHost, isIconNameShape, isNewer, isSemverTag, shouldShowPopup } from './whats-new.logic'
|
||||||
|
|
||||||
|
describe('isSemverTag', () => {
|
||||||
|
it('accepts release tags and rejects dev builds', () => {
|
||||||
|
expect(isSemverTag('v1.2.0')).toBe(true)
|
||||||
|
expect(isSemverTag('v1.2.0-rc.1')).toBe(true)
|
||||||
|
expect(isSemverTag('Local build')).toBe(false)
|
||||||
|
expect(isSemverTag(null)).toBe(false)
|
||||||
|
expect(isSemverTag('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isNewer', () => {
|
||||||
|
it('compares semver precedence', () => {
|
||||||
|
expect(isNewer('v1.2.0', 'v1.1.0')).toBe(true)
|
||||||
|
expect(isNewer('v2.0.0', 'v1.9.9')).toBe(true)
|
||||||
|
expect(isNewer('v1.1.0', 'v1.1.0')).toBe(false)
|
||||||
|
expect(isNewer('v1.0.0', 'v1.1.0')).toBe(false)
|
||||||
|
expect(isNewer('Local build', 'v1.0.0')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isAllowedImageHost', () => {
|
||||||
|
it('allows GitHub attachment hosts only', () => {
|
||||||
|
expect(isAllowedImageHost('https://github.com/user-attachments/assets/x')).toBe(true)
|
||||||
|
expect(isAllowedImageHost('https://user-images.githubusercontent.com/1/x.png')).toBe(true)
|
||||||
|
expect(isAllowedImageHost('https://evil.example.com/x.png')).toBe(false)
|
||||||
|
expect(isAllowedImageHost('http://github.com/user-attachments/assets/x')).toBe(false)
|
||||||
|
expect(isAllowedImageHost('https://github.com/o/r/raw/main/x.png')).toBe(false)
|
||||||
|
expect(isAllowedImageHost('nonsense')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isIconNameShape', () => {
|
||||||
|
it('accepts PascalCase icon names and rejects utility exports', () => {
|
||||||
|
expect(isIconNameShape('BookHeart')).toBe(true)
|
||||||
|
expect(isIconNameShape('Zap')).toBe(true)
|
||||||
|
expect(isIconNameShape('createLucideIcon')).toBe(false)
|
||||||
|
expect(isIconNameShape('icons')).toBe(false)
|
||||||
|
expect(isIconNameShape('book-heart')).toBe(false)
|
||||||
|
expect(isIconNameShape(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldShowPopup', () => {
|
||||||
|
const base = { hasUnseen: true, popupEnabled: true, dismissedThisSession: false, routeName: 'dashboard', alreadyOpen: false }
|
||||||
|
|
||||||
|
it('shows when there is unseen content and conditions allow', () => {
|
||||||
|
expect(shouldShowPopup(base)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses while in the reader', () => {
|
||||||
|
expect(shouldShowPopup({ ...base, routeName: 'reader' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses when disabled, dismissed, already open, or nothing unseen', () => {
|
||||||
|
expect(shouldShowPopup({ ...base, popupEnabled: false })).toBe(false)
|
||||||
|
expect(shouldShowPopup({ ...base, dismissedThisSession: true })).toBe(false)
|
||||||
|
expect(shouldShowPopup({ ...base, alreadyOpen: true })).toBe(false)
|
||||||
|
expect(shouldShowPopup({ ...base, hasUnseen: false })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export function isSemverTag(value: string | null | undefined): boolean {
|
||||||
|
return !!value && /^v\d+\.\d+\.\d+/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSemver(value: string): [number, number, number] | null {
|
||||||
|
const match = value
|
||||||
|
.trim()
|
||||||
|
.replace(/^v/, '')
|
||||||
|
.match(/^(\d+)\.(\d+)\.(\d+)/)
|
||||||
|
if (!match) return null
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewer(candidate: string, baseline: string): boolean {
|
||||||
|
const a = parseSemver(candidate)
|
||||||
|
const b = parseSemver(baseline)
|
||||||
|
if (!a || !b) return false
|
||||||
|
if (a[0] !== b[0]) return a[0] > b[0]
|
||||||
|
if (a[1] !== b[1]) return a[1] > b[1]
|
||||||
|
return a[2] > b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of the server allowlist: only render GitHub-hosted attachment images. */
|
||||||
|
export function isAllowedImageHost(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
if (u.protocol !== 'https:') return false
|
||||||
|
const host = u.hostname.toLowerCase()
|
||||||
|
if (host === 'github.com') return u.pathname.startsWith('/user-attachments/')
|
||||||
|
return host === 'githubusercontent.com' || host.endsWith('.githubusercontent.com')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lucide icon names are PascalCase; reject anything else so we never resolve a non-icon export. */
|
||||||
|
export function isIconNameShape(name: string | null | undefined): boolean {
|
||||||
|
return !!name && /^[A-Z][A-Za-z0-9]*$/.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReleaseDate(date: string | null): string {
|
||||||
|
if (!date) return ''
|
||||||
|
const parsed = new Date(date)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return ''
|
||||||
|
return new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowPopup(opts: {
|
||||||
|
hasUnseen: boolean
|
||||||
|
popupEnabled: boolean
|
||||||
|
dismissedThisSession: boolean
|
||||||
|
routeName: string | null | undefined
|
||||||
|
alreadyOpen: boolean
|
||||||
|
}): boolean {
|
||||||
|
if (opts.alreadyOpen) return false
|
||||||
|
if (!opts.hasUnseen || !opts.popupEnabled || opts.dismissedThisSession) return false
|
||||||
|
return opts.routeName !== 'reader'
|
||||||
|
}
|
||||||
@@ -232,6 +232,12 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/BookDockView.vue'),
|
component: () => import('@/views/BookDockView.vue'),
|
||||||
meta: { title: 'Book Dock' },
|
meta: { title: 'Book Dock' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/whats-new',
|
||||||
|
name: 'whats-new',
|
||||||
|
component: () => import('@/features/whats-new/WhatsNewView.vue'),
|
||||||
|
meta: { title: "What's New" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/statistics',
|
path: '/statistics',
|
||||||
name: 'statistics',
|
name: 'statistics',
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ The workflow will:
|
|||||||
|
|
||||||
If all commits since the last release are non-releasable types (`chore`, `docs`, `test`, `build`, `ci`, `refactor`), semantic-release will skip the release. The Docker publish job will also be skipped.
|
If all commits since the last release are non-releasable types (`chore`, `docs`, `test`, `build`, `ci`, `refactor`), semantic-release will skip the release. The Docker publish job will also be skipped.
|
||||||
|
|
||||||
|
### Authoring the "What's New" highlights
|
||||||
|
|
||||||
|
semantic-release creates the GitHub release as a **draft** with a `## Highlights` scaffold (added by `release.config.js`) that lists this release's `feat` commits as candidate highlights. Open the draft, curate those candidates into user-facing highlight bullets (with optional icons and dragged-in media), validate with `cd server && pnpm whats-new:check <tag>`, then click **Publish release**. The highlights power the in-app "What's New" popup and the `/whats-new` archive; the draft stays hidden in the app until you publish (even though the `:latest` Docker image is pushed beforehand).
|
||||||
|
|
||||||
|
Full step-by-step walkthrough, format reference, and pre-publish checklist: [docs/whats-new-authoring-guide.md](whats-new-authoring-guide.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Image Publishing
|
## Docker Image Publishing
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Authoring the "What's New" highlights
|
||||||
|
|
||||||
|
This is the end-to-end guide for writing the `## Highlights` section of a GitHub release so it
|
||||||
|
powers BookOrbit's in-app **What's New** popup and the `/whats-new` archive. It is the single
|
||||||
|
source of truth for both the workflow and the format.
|
||||||
|
|
||||||
|
## How it shows up in the app
|
||||||
|
|
||||||
|
- **Popup:** when a user opens the app on a newer version than they last saw, a popup shows the
|
||||||
|
highlights for the versions they missed (newest expanded, up to 4 versions cumulatively).
|
||||||
|
- **Archive:** the `/whats-new` page lists every release (highlights + full changelog).
|
||||||
|
- **Caching:** the server fetches and caches releases for about 10 minutes, so edits appear
|
||||||
|
within a few minutes (immediately on a server restart). No redeploy is needed.
|
||||||
|
- **No highlights, no popup:** a release with no `## Highlights` section (or the scaffold left in
|
||||||
|
place with no bullets) simply shows no popup. Its changelog still appears in the archive. That
|
||||||
|
is fine for patch releases with nothing user-facing to call out.
|
||||||
|
|
||||||
|
## Walkthrough (every release)
|
||||||
|
|
||||||
|
1. **Cut the release.** Run the release workflow (Actions -> Release -> Run workflow on `main`).
|
||||||
|
semantic-release creates the GitHub release as a **draft** and the Docker images
|
||||||
|
(`:latest` and `:<version>`) are built and pushed. The draft is not visible in the app yet.
|
||||||
|
2. **Open the draft to edit.** Go to the repo's Releases, click the draft (or open
|
||||||
|
`https://github.com/bookorbit/bookorbit/releases/edit/<tag>`), and click the edit (pencil) icon.
|
||||||
|
3. **Review the candidate highlights.** The `## Highlights` scaffold contains a commented list of
|
||||||
|
this release's `feat` commits as starting candidates. Curate them: keep the user-facing ones,
|
||||||
|
reword them into friendly benefit-focused lines, and delete the rest.
|
||||||
|
4. **Write the bullets** above the comment block, one per highlight (see the format below).
|
||||||
|
5. **Add an icon** to a bullet by ending the line with `<!-- icon: Name -->` (optional).
|
||||||
|
6. **Add media** by dragging an image or video into the editor (see the media rules below).
|
||||||
|
7. **Delete the scaffold comment block** once you are done with it.
|
||||||
|
8. **Validate:** from `server/`, run `pnpm whats-new:check <tag>` (lints, then previews exactly
|
||||||
|
what the app will render and checks media reachability). Fix any warnings.
|
||||||
|
9. **Publish.** Click **Publish release**. Within ~10 minutes the popup appears for users on this
|
||||||
|
version. (Until you publish, the draft stays hidden in the app even though `:latest` is already
|
||||||
|
live, so do not leave it unpublished.)
|
||||||
|
|
||||||
|
## Copy-paste starter
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Two-way Kobo highlight sync** - Your highlights now sync both ways with Kobo, so a note on the device shows up on the web and vice versa. <!-- icon: BookHeart -->
|
||||||
|
- **Instant library scroll** - The alphabet jump rail is now instant on libraries with thousands of books. <!-- icon: Zap -->
|
||||||
|
- **Audiobook chapters** - Chapter markers are extracted and shown in the player; jump straight to any chapter. <!-- icon: Headphones -->
|
||||||
|
|
||||||
|
<img width="800" alt="chapters" src="https://github.com/user-attachments/assets/REPLACE-WITH-DROPPED-IMAGE" />
|
||||||
|
|
||||||
|
- **Smaller fixes** - A batch of polish across the reader and library grid.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format reference
|
||||||
|
|
||||||
|
**Heading**
|
||||||
|
|
||||||
|
- The section heading must be exactly `## Highlights`.
|
||||||
|
- Everything after the next heading (`##` / `###`) or a `---` rule is treated as the technical
|
||||||
|
changelog, not a highlight.
|
||||||
|
|
||||||
|
**Bullets**
|
||||||
|
|
||||||
|
- One highlight per line, starting with `- `.
|
||||||
|
- Shape: `- **Title** - one or two friendly sentences.`
|
||||||
|
- `**Title**` is the bold headline; the text after the `-` is the description.
|
||||||
|
|
||||||
|
**Icons** (optional)
|
||||||
|
|
||||||
|
- End the bullet with an HTML comment GitHub renders as nothing: `<!-- icon: Name -->`. It can
|
||||||
|
also sit on its own indented line right below the bullet.
|
||||||
|
- `Name` is a PascalCase [lucide](https://lucide.dev/icons) icon name (e.g. `BookHeart`, `Zap`,
|
||||||
|
`ShieldCheck`, `Headphones`, `Film`). Omit the comment for the default icon (`Sparkles`).
|
||||||
|
- Unknown names fall back to the default; the lint warns so you can fix a typo.
|
||||||
|
- The old `- [Icon] **Title** ...` prefix still parses but renders as literal `[Icon]` text on the
|
||||||
|
GitHub page, so the lint warns against it. Use the comment form.
|
||||||
|
|
||||||
|
**Media** (optional)
|
||||||
|
|
||||||
|
- Drag an image or video into the GitHub release editor. GitHub uploads it and inserts either an
|
||||||
|
`<img ... src="https://github.com/user-attachments/assets/...">` tag (images) or a bare
|
||||||
|
`https://github.com/user-attachments/assets/...` URL (videos). Leave whatever it inserts as-is.
|
||||||
|
- Only **GitHub-hosted** media renders: `github.com/user-attachments/...` or any
|
||||||
|
`*.githubusercontent.com` host. Media from other hosts (imgur, etc.) is dropped (the text is
|
||||||
|
kept) and the lint warns.
|
||||||
|
- Image vs video is detected by file extension (`.mp4`, `.webm`, `.mov`, `.m4v`, `.ogv`, `.ogg`
|
||||||
|
are video); GitHub's extension-less attachment URLs are probed for their content type.
|
||||||
|
- You can attach **several** media to one bullet (put each on its own line under it). They render
|
||||||
|
as a horizontal gallery; clicking an image opens a carousel. At most **6** media per highlight
|
||||||
|
render; extras are dropped (the lint warns).
|
||||||
|
|
||||||
|
**Soft limits** (the app clamps and the lint warns; nothing breaks)
|
||||||
|
|
||||||
|
- Title up to **120** characters, body up to **400** characters (visually line-clamped in the
|
||||||
|
popup; the full text stays in the archive and search).
|
||||||
|
- Up to **12** highlights per release.
|
||||||
|
|
||||||
|
## Validate before publishing
|
||||||
|
|
||||||
|
Run from `server/`:
|
||||||
|
|
||||||
|
- `pnpm whats-new:check <tag>` - the one-stop check: lints, then previews exactly what the app
|
||||||
|
renders (including a media reachability probe). Exits non-zero if the lint fails.
|
||||||
|
- `pnpm whats-new:lint <file.md>` / `pnpm whats-new:lint --tag <tag>` - static validation only.
|
||||||
|
Add `--strict` to treat warnings as failures.
|
||||||
|
- `pnpm whats-new:preview [tag]` - render preview only.
|
||||||
|
|
||||||
|
Point these at a different repo for testing with the `GITHUB_RELEASES_REPO` env var (and
|
||||||
|
`GITHUB_RELEASES_TOKEN` for private repos / rate limits), or pass `--repo owner/name` to preview.
|
||||||
|
|
||||||
|
## Pre-publish checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm whats-new:check <tag>` is clean (no errors; warnings addressed).
|
||||||
|
- [ ] Each bullet is `- **Title** - benefit.`, short and user-facing.
|
||||||
|
- [ ] Icons are valid PascalCase lucide names (no typos), or omitted for the default.
|
||||||
|
- [ ] Media was dragged in (GitHub-hosted), no more than 6 per highlight.
|
||||||
|
- [ ] Titles <= 120 chars, bodies <= 400 chars.
|
||||||
|
- [ ] The scaffold candidate/instruction comment block is deleted.
|
||||||
|
- [ ] Clicked **Publish release** (the draft is not enough).
|
||||||
|
|
||||||
|
## Common mistakes
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
|
| Highlight shows the default Sparkles icon | Icon name typo / not a lucide name | Correct the PascalCase name (lint says which) |
|
||||||
|
| `[Icon]` text shows on the GitHub page | Legacy `[Icon]` prefix | Move the icon into a `<!-- icon: Name -->` comment |
|
||||||
|
| Image/video does not appear | Media not GitHub-hosted | Drag the file into the editor (do not paste an external URL) |
|
||||||
|
| Only 6 of N images show | Over the per-highlight media cap | Keep the best 6 |
|
||||||
|
| Title/body looks cut off in the popup | Over the soft length cap | Shorten it (full text stays in the archive) |
|
||||||
|
| No popup for the release | No `## Highlights` bullets, or release left as a draft | Add bullets and click Publish |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Moved
|
||||||
|
|
||||||
|
The "What's New" highlights format and template now live in the consolidated guide:
|
||||||
|
|
||||||
|
**[docs/whats-new-authoring-guide.md](whats-new-authoring-guide.md)**
|
||||||
|
|
||||||
|
It covers the end-to-end workflow, a copy-paste starter, the full format reference (icons,
|
||||||
|
media, length caps), validation commands, and a pre-publish checklist.
|
||||||
@@ -33,6 +33,7 @@ export * from "./user-statistics";
|
|||||||
export * from "./migration";
|
export * from "./migration";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
export * from "./app-info";
|
export * from "./app-info";
|
||||||
|
export * from "./release-notes";
|
||||||
export * from "./series";
|
export * from "./series";
|
||||||
export * from "./table-layout";
|
export * from "./table-layout";
|
||||||
export * from "./series-collapse";
|
export * from "./series-collapse";
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** A single allowlisted (GitHub-hosted) media item attached to a highlight. */
|
||||||
|
export interface ReleaseMedia {
|
||||||
|
url: string;
|
||||||
|
type: 'image' | 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseHighlight {
|
||||||
|
/** Lucide icon name parsed from an `<!-- icon: Name -->` comment (or legacy `[Name]` token), or null for the default icon. */
|
||||||
|
icon: string | null;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Allowlisted (GitHub-hosted) media in author order; images and videos interleaved. Empty if none. */
|
||||||
|
media: ReleaseMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseNote {
|
||||||
|
/** Git tag, e.g. "v1.2.0". */
|
||||||
|
version: string;
|
||||||
|
/** Release title, or null. */
|
||||||
|
name: string | null;
|
||||||
|
/** ISO-8601 published date, or null. */
|
||||||
|
date: string | null;
|
||||||
|
highlights: ReleaseHighlight[];
|
||||||
|
/** Link to the full GitHub release page. */
|
||||||
|
changelogUrl: string;
|
||||||
|
/** Raw markdown changelog with the Highlights section removed, for inline rendering. */
|
||||||
|
changelogBody: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseNotesResponse {
|
||||||
|
releases: ReleaseNote[];
|
||||||
|
/** Whether more releases exist beyond what was returned. */
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsNewPreferences {
|
||||||
|
lastSeenVersion: string | null;
|
||||||
|
popupEnabled: boolean;
|
||||||
|
}
|
||||||
+39
-3
@@ -51,6 +51,19 @@ const commitPartial = `\
|
|||||||
// strips the warning emoji from breaking-changes headings, and appends a
|
// strips the warning emoji from breaking-changes headings, and appends a
|
||||||
// Docker pull block after the commit groups.
|
// Docker pull block after the commit groups.
|
||||||
const mainTemplate = [
|
const mainTemplate = [
|
||||||
|
"## Highlights",
|
||||||
|
"",
|
||||||
|
"<!-- Author: write 1 line per user-facing highlight ABOVE this comment, then delete this block.",
|
||||||
|
" Full guide: docs/whats-new-authoring-guide.md",
|
||||||
|
" Format: - **Title** - short user benefit {icon comment}",
|
||||||
|
" - Optional icon: end the line with an HTML comment GitHub hides, using a PascalCase",
|
||||||
|
" lucide.dev name, e.g. BookHeart. Omit it for the default icon. Literal syntax in the guide.",
|
||||||
|
" - Image/video: drag the file in; leave GitHub's inserted <img> tag or bare URL as-is.",
|
||||||
|
" Example: - **Kobo highlight sync** - your highlights now sync both ways. {icon: BookHeart}",
|
||||||
|
" Validate before publishing: cd server && pnpm whats-new:check <tag>",
|
||||||
|
" Leaving this comment in place (no bullets) means no What's New popup for this release.",
|
||||||
|
"{{{highlightCandidatesBlock}}}-->",
|
||||||
|
"",
|
||||||
"{{#if noteGroups}}",
|
"{{#if noteGroups}}",
|
||||||
"{{#each noteGroups}}",
|
"{{#each noteGroups}}",
|
||||||
"",
|
"",
|
||||||
@@ -142,15 +155,34 @@ function finalizeContext(ctx) {
|
|||||||
const githubUser = shaToGithubUser.get(commit.hash);
|
const githubUser = shaToGithubUser.get(commit.hash);
|
||||||
if (githubUser) commit.githubUser = githubUser;
|
if (githubUser) commit.githubUser = githubUser;
|
||||||
}
|
}
|
||||||
// Pre-compute commit URL — {{commitUrlFormat}} does not resolve inside
|
// Pre-compute commit URL: {{commitUrlFormat}} does not resolve inside
|
||||||
// Handlebars partials because partials don't walk the parent context chain.
|
// Handlebars partials because partials don't walk the parent context chain.
|
||||||
if (commit.hash && ctx.host && ctx.owner && ctx.repository) {
|
if (commit.hash && ctx.host && ctx.owner && ctx.repository) {
|
||||||
commit.commitUrl = `${ctx.host}/${ctx.owner}/${ctx.repository}/commit/${commit.hash}`;
|
commit.commitUrl = `${ctx.host}/${ctx.owner}/${ctx.repository}/commit/${commit.hash}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect this release's feat commits as candidate highlights for the author
|
||||||
|
// to curate. Rendered inside the scaffold's HTML comment (never shown publicly)
|
||||||
|
// as a pre-built string, so the comment never gains an inner "-->".
|
||||||
|
const candidates = [];
|
||||||
|
for (const group of ctx.commitGroups ?? []) {
|
||||||
|
if (group.title !== "Features") continue;
|
||||||
|
for (const commit of group.commits ?? []) {
|
||||||
|
const text = (commit.subject || commit.header || "").trim();
|
||||||
|
if (!text || text.includes("-->")) continue;
|
||||||
|
candidates.push(text.charAt(0).toUpperCase() + text.slice(1));
|
||||||
|
if (candidates.length >= 8) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.highlightCandidatesBlock = candidates.length
|
||||||
|
? "\n Candidates from this release (curate the good ones into real bullets above, then delete):\n" +
|
||||||
|
candidates.map((c) => ` - **${c}**`).join("\n") +
|
||||||
|
"\n"
|
||||||
|
: "";
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal: commits render without author attribution
|
// non-fatal: commits render without author attribution / candidates
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -188,6 +220,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@semantic-release/github",
|
// Create the GitHub release as a DRAFT so the maintainer can author the
|
||||||
|
// "## Highlights" section (see docs/whats-new-authoring-guide.md) before it
|
||||||
|
// goes public, then click "Publish release". The git tag and Docker images
|
||||||
|
// are still produced; BookOrbit hides draft releases until they are published.
|
||||||
|
["@semantic-release/github", { draftRelease: true }],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
"e2e:db:prepare": "tsx src/scripts/db-prepare-e2e.ts",
|
"e2e:db:prepare": "tsx src/scripts/db-prepare-e2e.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx src/scripts/db-seed.ts",
|
"db:seed": "tsx src/scripts/db-seed.ts",
|
||||||
|
"whats-new:preview": "tsx src/scripts/whats-new-preview.ts",
|
||||||
|
"whats-new:lint": "tsx src/scripts/whats-new-lint.ts",
|
||||||
|
"whats-new:check": "tsx src/scripts/whats-new-check.ts",
|
||||||
|
"whats-new:images": "tsx src/scripts/whats-new-images.ts",
|
||||||
"pretype-check": "pnpm --filter @bookorbit/types build",
|
"pretype-check": "pnpm --filter @bookorbit/types build",
|
||||||
"type-check": "tsc --noEmit -p tsconfig.build.json",
|
"type-check": "tsc --noEmit -p tsconfig.build.json",
|
||||||
"lint": "pnpm run lint:check",
|
"lint": "pnpm run lint:check",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { EntityManagerModule } from './modules/entity-manager/entity-manager.mod
|
|||||||
import { FontModule } from './modules/font/font.module';
|
import { FontModule } from './modules/font/font.module';
|
||||||
import { KoreaderModule } from './modules/koreader/koreader.module';
|
import { KoreaderModule } from './modules/koreader/koreader.module';
|
||||||
import { AppInfoModule } from './modules/app-info/app-info.module';
|
import { AppInfoModule } from './modules/app-info/app-info.module';
|
||||||
|
import { ReleaseNotesModule } from './modules/release-notes/release-notes.module';
|
||||||
import { AchievementModule } from './modules/achievement/achievement.module';
|
import { AchievementModule } from './modules/achievement/achievement.module';
|
||||||
import { HardcoverModule } from './modules/hardcover/hardcover.module';
|
import { HardcoverModule } from './modules/hardcover/hardcover.module';
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ import { HardcoverModule } from './modules/hardcover/hardcover.module';
|
|||||||
FontModule,
|
FontModule,
|
||||||
KoreaderModule,
|
KoreaderModule,
|
||||||
AppInfoModule,
|
AppInfoModule,
|
||||||
|
ReleaseNotesModule,
|
||||||
AchievementModule,
|
AchievementModule,
|
||||||
HardcoverModule,
|
HardcoverModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ describe('config', () => {
|
|||||||
nodeEnv: 'development',
|
nodeEnv: 'development',
|
||||||
appUrl: 'http://localhost:5173',
|
appUrl: 'http://localhost:5173',
|
||||||
version: 'Local build',
|
version: 'Local build',
|
||||||
|
githubReleasesRepo: 'bookorbit/bookorbit',
|
||||||
|
githubReleasesToken: undefined,
|
||||||
oidcAllowLocalIssuers: false,
|
oidcAllowLocalIssuers: false,
|
||||||
koboCloudscraperPython: undefined,
|
koboCloudscraperPython: undefined,
|
||||||
});
|
});
|
||||||
@@ -54,11 +56,15 @@ describe('config', () => {
|
|||||||
process.env.APP_VERSION = 'v2.3.4';
|
process.env.APP_VERSION = 'v2.3.4';
|
||||||
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
|
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
|
||||||
process.env.KOBO_CLOUDSCRAPER_PYTHON = '/opt/bookorbit-python/bin/python';
|
process.env.KOBO_CLOUDSCRAPER_PYTHON = '/opt/bookorbit-python/bin/python';
|
||||||
|
process.env.GITHUB_RELEASES_REPO = 'acme/app';
|
||||||
|
process.env.GITHUB_RELEASES_TOKEN = 'ghp_example';
|
||||||
|
|
||||||
expect(appConfig()).toEqual({
|
expect(appConfig()).toEqual({
|
||||||
nodeEnv: 'production',
|
nodeEnv: 'production',
|
||||||
appUrl: 'https://bookorbit.local',
|
appUrl: 'https://bookorbit.local',
|
||||||
version: 'v2.3.4',
|
version: 'v2.3.4',
|
||||||
|
githubReleasesRepo: 'acme/app',
|
||||||
|
githubReleasesToken: 'ghp_example',
|
||||||
oidcAllowLocalIssuers: true,
|
oidcAllowLocalIssuers: true,
|
||||||
koboCloudscraperPython: '/opt/bookorbit-python/bin/python',
|
koboCloudscraperPython: '/opt/bookorbit-python/bin/python',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const appConfig = registerAs('app', () => ({
|
|||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
||||||
version: process.env.APP_VERSION ?? 'Local build',
|
version: process.env.APP_VERSION ?? 'Local build',
|
||||||
|
githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit',
|
||||||
|
githubReleasesToken: process.env.GITHUB_RELEASES_TOKEN?.trim() || undefined,
|
||||||
oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false),
|
oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false),
|
||||||
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
|
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ const envSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.transform((val) => val.trim())
|
.transform((val) => val.trim())
|
||||||
.optional(),
|
.optional(),
|
||||||
|
GITHUB_RELEASES_REPO: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => val.trim())
|
||||||
|
.optional(),
|
||||||
|
GITHUB_RELEASES_TOKEN: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => val.trim())
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function validateEnv(config: Record<string, unknown>) {
|
export function validateEnv(config: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNumberString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReleaseNotesQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
since?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
@MaxLength(10)
|
||||||
|
page?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { lintHighlights } from './highlights.lint';
|
||||||
|
import { MAX_BODY_LENGTH, MAX_MEDIA_PER_HIGHLIGHT, MAX_TITLE_LENGTH } from './release-notes.constants';
|
||||||
|
|
||||||
|
describe('lintHighlights', () => {
|
||||||
|
it('passes a well-formed section with no errors or warnings', () => {
|
||||||
|
const body = [
|
||||||
|
'## Highlights',
|
||||||
|
'- **Kobo sync** - syncs both ways now. <!-- icon: BookHeart -->',
|
||||||
|
'- **Faster scroll** - instant now. <!-- icon: Zap -->',
|
||||||
|
].join('\n');
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.errors).toEqual([]);
|
||||||
|
expect(r.warnings).toEqual([]);
|
||||||
|
expect(r.highlightCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when a highlight uses the legacy [Icon] prefix', () => {
|
||||||
|
const body = '## Highlights\n- [BookHeart] **Kobo sync** - syncs both ways now.';
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('legacy') && w.includes('icon: BookHeart'))).toBe(true);
|
||||||
|
expect(r.highlightCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when there is no Highlights section', () => {
|
||||||
|
const r = lintHighlights('### Features\n- a fix');
|
||||||
|
expect(r.errors[0]).toContain('No "## Highlights" section');
|
||||||
|
expect(r.highlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when the section exists but no bullets parse', () => {
|
||||||
|
const r = lintHighlights('## Highlights\n\nsome prose but no bullets');
|
||||||
|
expect(r.errors.some((e) => e.includes('no valid bullets'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when an explicit image uses a non-allowlisted host', () => {
|
||||||
|
const body = ['## Highlights', '- **Thing** - body.', ''].join('\n');
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('dropped') && w.includes('imgur.com'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn for an allowlisted image', () => {
|
||||||
|
const body = ['## Highlights', '- **Thing** - body.', ''].join('\n');
|
||||||
|
expect(lintHighlights(body).warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns on a malformed icon token left in the title', () => {
|
||||||
|
const body = '## Highlights\n- [book heart] **Sync** - body.';
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('PascalCase'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns on a highlight with no body text', () => {
|
||||||
|
const body = '## Highlights\n- **Title only** <!-- icon: Zap -->';
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('no description'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when an icon name is not a known lucide icon', () => {
|
||||||
|
const body = '## Highlights\n- **Sync** - body. <!-- icon: BookHart -->';
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('BookHart') && w.includes('not a known lucide icon'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn for a valid lucide icon name', () => {
|
||||||
|
const body = '## Highlights\n- **Sync** - body. <!-- icon: BookHeart -->';
|
||||||
|
expect(lintHighlights(body).warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when a title exceeds the length cap', () => {
|
||||||
|
const body = `## Highlights\n- **${'a'.repeat(MAX_TITLE_LENGTH + 10)}** - body.`;
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('title is') && w.includes('clamped'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when a body exceeds the length cap', () => {
|
||||||
|
const body = `## Highlights\n- **Title** - ${'b'.repeat(MAX_BODY_LENGTH + 10)}`;
|
||||||
|
const r = lintHighlights(body);
|
||||||
|
expect(r.warnings.some((w) => w.includes('body is') && w.includes('clamped'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when a highlight has more media than the cap', () => {
|
||||||
|
const lines = ['## Highlights', '- **Many** - lots of shots.'];
|
||||||
|
for (let i = 0; i < MAX_MEDIA_PER_HIGHLIGHT + 2; i++) lines.push(``);
|
||||||
|
const r = lintHighlights(lines.join('\n'));
|
||||||
|
expect(r.warnings.some((w) => w.includes('media items') && w.includes(`first ${MAX_MEDIA_PER_HIGHLIGHT}`))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { extractHighlightsBlock, findExplicitMediaUrls, isAllowedImageHost, parseHighlightEntries } from './highlights.parser';
|
||||||
|
import { LUCIDE_ICON_NAMES } from './lucide-icon-names';
|
||||||
|
import { MAX_BODY_LENGTH, MAX_MEDIA_PER_HIGHLIGHT, MAX_TITLE_LENGTH } from './release-notes.constants';
|
||||||
|
|
||||||
|
const LEGACY_ICON_BULLET_RE = /^\s*[-*]\s+\[[A-Za-z][A-Za-z0-9]*\]/;
|
||||||
|
|
||||||
|
export interface HighlightsLintResult {
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
highlightCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the `## Highlights` text of a release body (static, no network), using the same
|
||||||
|
* parser the app uses. Errors mean "the popup won't render correctly"; warnings are likely mistakes.
|
||||||
|
*/
|
||||||
|
export function lintHighlights(body: string | null | undefined): HighlightsLintResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const block = extractHighlightsBlock(body ?? '');
|
||||||
|
if (block === null) {
|
||||||
|
errors.push('No "## Highlights" section found. Add a heading exactly like "## Highlights".');
|
||||||
|
return { errors, warnings, highlightCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = parseHighlightEntries(body);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
errors.push('A "## Highlights" section exists but no valid bullets were parsed. Each highlight must be a line starting with "- ".');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of findExplicitMediaUrls(block.join('\n'))) {
|
||||||
|
if (!isAllowedImageHost(url)) {
|
||||||
|
warnings.push(
|
||||||
|
`Media will be dropped (host not allowed): ${url}. Use a GitHub user-attachments or githubusercontent URL (drag-drop into the release).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.some((line) => LEGACY_ICON_BULLET_RE.test(line))) {
|
||||||
|
warnings.push(
|
||||||
|
'A highlight uses the legacy "[Icon]" prefix, which renders as literal "[Icon]" text on the GitHub release page. Move the icon into a trailing comment instead, e.g. "- **Title** - body. <!-- icon: BookHeart -->".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(({ highlight: h, mediaCount }, i) => {
|
||||||
|
const n = i + 1;
|
||||||
|
if (h.icon && !LUCIDE_ICON_NAMES.has(h.icon)) {
|
||||||
|
warnings.push(
|
||||||
|
`Highlight ${n} icon "${h.icon}" is not a known lucide icon; it will fall back to the default Sparkles icon. Check the spelling/casing at lucide.dev/icons.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/\[[A-Za-z][^\]]*\]/.test(h.title)) {
|
||||||
|
warnings.push(`Highlight ${n} title contains an unrecognized "[...]" token: "${h.title}". Icon tokens must be PascalCase, e.g. [BookHeart].`);
|
||||||
|
}
|
||||||
|
if (!h.body.trim()) {
|
||||||
|
warnings.push(`Highlight ${n} ("${h.title}") has a title but no description text after " - ".`);
|
||||||
|
}
|
||||||
|
if (h.title.length > MAX_TITLE_LENGTH) {
|
||||||
|
warnings.push(
|
||||||
|
`Highlight ${n} title is ${h.title.length} chars (cap ${MAX_TITLE_LENGTH}); it will be visually clamped in the popup. Consider shortening it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (h.body.length > MAX_BODY_LENGTH) {
|
||||||
|
warnings.push(
|
||||||
|
`Highlight ${n} body is ${h.body.length} chars (cap ${MAX_BODY_LENGTH}); it will be visually clamped in the popup. Consider shortening it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mediaCount > MAX_MEDIA_PER_HIGHLIGHT) {
|
||||||
|
warnings.push(`Highlight ${n} has ${mediaCount} media items; only the first ${MAX_MEDIA_PER_HIGHLIGHT} will render. Remove the extras.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { errors, warnings, highlightCount: entries.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { isAllowedImageHost, parseHighlights, stripHighlightsSection } from './highlights.parser';
|
||||||
|
import { MAX_MEDIA_PER_HIGHLIGHT } from './release-notes.constants';
|
||||||
|
|
||||||
|
const ATTACH = 'https://github.com/user-attachments/assets';
|
||||||
|
|
||||||
|
describe('parseHighlights', () => {
|
||||||
|
it('parses a well-formed bullet with a trailing icon comment, title and body', () => {
|
||||||
|
const body = ['## Highlights', '- **Kobo sync** - syncs both ways now. <!-- icon: BookHeart -->', '', '### Features', '- something'].join('\n');
|
||||||
|
expect(parseHighlights(body)).toEqual([{ icon: 'BookHeart', title: 'Kobo sync', body: 'syncs both ways now.', media: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an icon comment on a continuation line', () => {
|
||||||
|
const body = ['## Highlights', '- **Kobo sync** - syncs both ways now.', ' <!-- icon: BookHeart -->'].join('\n');
|
||||||
|
expect(parseHighlights(body)[0]).toEqual({ icon: 'BookHeart', title: 'Kobo sync', body: 'syncs both ways now.', media: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the icon comment case-insensitively and trims whitespace', () => {
|
||||||
|
const body = '## Highlights\n- **Thing** - body. <!-- ICON: Zap -->';
|
||||||
|
expect(parseHighlights(body)[0].icon).toBe('Zap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaks the icon comment into the rendered title or body', () => {
|
||||||
|
const body = '## Highlights\n- **Hardened** - safer now. <!-- icon: ShieldCheck -->';
|
||||||
|
const result = parseHighlights(body)[0];
|
||||||
|
expect(result.title).toBe('Hardened');
|
||||||
|
expect(result.body).toBe('safer now.');
|
||||||
|
expect(result.icon).toBe('ShieldCheck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still parses the legacy leading [Icon] token (old published releases)', () => {
|
||||||
|
const body = ['## Highlights', '- [BookHeart] **Kobo sync** - syncs both ways now.'].join('\n');
|
||||||
|
expect(parseHighlights(body)).toEqual([{ icon: 'BookHeart', title: 'Kobo sync', body: 'syncs both ways now.', media: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the icon comment over a legacy token when both are present', () => {
|
||||||
|
const body = '## Highlights\n- [Zap] **Thing** - body. <!-- icon: BookHeart -->';
|
||||||
|
expect(parseHighlights(body)[0].icon).toBe('BookHeart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when there is no Highlights section', () => {
|
||||||
|
expect(parseHighlights('### Features\n- a fix')).toEqual([]);
|
||||||
|
expect(parseHighlights('')).toEqual([]);
|
||||||
|
expect(parseHighlights(null)).toEqual([]);
|
||||||
|
expect(parseHighlights(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults a missing icon token to null', () => {
|
||||||
|
const body = '## Highlights\n- **Minor polish** - small tweaks.';
|
||||||
|
expect(parseHighlights(body)).toEqual([{ icon: null, title: 'Minor polish', body: 'small tweaks.', media: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an unknown icon name through (the lint and client safelist it)', () => {
|
||||||
|
const body = '## Highlights\n- [TotallyNotALucideIcon] **Thing** - body.';
|
||||||
|
expect(parseHighlights(body)[0].icon).toBe('TotallyNotALucideIcon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tolerant of a bullet without a bold title', () => {
|
||||||
|
const body = '## Highlights\n- just some plain text';
|
||||||
|
expect(parseHighlights(body)).toEqual([{ icon: null, title: 'just some plain text', body: '', media: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an allowlisted image as a single media item', () => {
|
||||||
|
const body = ['## Highlights', '- [Zap] **Fast** - speed.', ` `].join('\n');
|
||||||
|
expect(parseHighlights(body)[0]).toEqual({
|
||||||
|
icon: 'Zap',
|
||||||
|
title: 'Fast',
|
||||||
|
body: 'speed.',
|
||||||
|
media: [{ url: `${ATTACH}/abc-123`, type: 'image' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies an allowlisted media URL with a video extension as a video media item', () => {
|
||||||
|
const body = ['## Highlights', '- [Film] **Demo** - watch it.', ' '].join('\n');
|
||||||
|
const result = parseHighlights(body)[0];
|
||||||
|
expect(result.media).toEqual([{ url: 'https://raw.githubusercontent.com/o/r/main/demo.mp4', type: 'video' }]);
|
||||||
|
expect(result.title).toBe('Demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the clean src from a GitHub <img> HTML tag (no trailing quote)', () => {
|
||||||
|
const body = [
|
||||||
|
'## Highlights',
|
||||||
|
'- [Shield] **Hardened** - safer now.',
|
||||||
|
'',
|
||||||
|
`<img width="800" height="450" alt="x" src="${ATTACH}/b7a04eef" />`,
|
||||||
|
].join('\n');
|
||||||
|
const result = parseHighlights(body)[0];
|
||||||
|
expect(result.media).toEqual([{ url: `${ATTACH}/b7a04eef`, type: 'image' }]);
|
||||||
|
expect(result.title).toBe('Hardened');
|
||||||
|
expect(result.body).toBe('safer now.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects multiple media under one bullet in document order, classifying each', () => {
|
||||||
|
const body = [
|
||||||
|
'## Highlights',
|
||||||
|
'- **Gallery** - three shots.',
|
||||||
|
` <img src="${ATTACH}/one" />`,
|
||||||
|
` `,
|
||||||
|
` ${ATTACH}/three.mp4`,
|
||||||
|
].join('\n');
|
||||||
|
expect(parseHighlights(body)[0].media).toEqual([
|
||||||
|
{ url: `${ATTACH}/one`, type: 'image' },
|
||||||
|
{ url: `${ATTACH}/two`, type: 'image' },
|
||||||
|
{ url: `${ATTACH}/three.mp4`, type: 'video' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates a markdown image (markdown link + bare URL match the same URL)', () => {
|
||||||
|
const body = ['## Highlights', '- **One** - x.', ` `].join('\n');
|
||||||
|
expect(parseHighlights(body)[0].media).toEqual([{ url: `${ATTACH}/abc`, type: 'image' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-allowlisted media while keeping allowlisted media (and the text)', () => {
|
||||||
|
const body = ['## Highlights', '- **Fast** - speed. ', ` `].join('\n');
|
||||||
|
const result = parseHighlights(body)[0];
|
||||||
|
expect(result.media).toEqual([{ url: `${ATTACH}/good`, type: 'image' }]);
|
||||||
|
expect(result.title).toBe('Fast');
|
||||||
|
expect(result.body).toBe('speed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips media markup from the title/body when media shares the bullet line', () => {
|
||||||
|
const body = `## Highlights\n- **Fast** - speed. `;
|
||||||
|
const result = parseHighlights(body)[0];
|
||||||
|
expect(result.title).toBe('Fast');
|
||||||
|
expect(result.body).toBe('speed.');
|
||||||
|
expect(result.media).toEqual([{ url: `${ATTACH}/abc`, type: 'image' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`caps media per highlight at MAX_MEDIA_PER_HIGHLIGHT (${MAX_MEDIA_PER_HIGHLIGHT}), keeping the first in order`, () => {
|
||||||
|
const lines = ['## Highlights', '- **Many** - lots of shots.'];
|
||||||
|
for (let i = 0; i < MAX_MEDIA_PER_HIGHLIGHT + 3; i++) lines.push(` `);
|
||||||
|
const media = parseHighlights(lines.join('\n'))[0].media;
|
||||||
|
expect(media).toHaveLength(MAX_MEDIA_PER_HIGHLIGHT);
|
||||||
|
expect(media[0].url).toBe(`${ATTACH}/img-0`);
|
||||||
|
expect(media[MAX_MEDIA_PER_HIGHLIGHT - 1].url).toBe(`${ATTACH}/img-${MAX_MEDIA_PER_HIGHLIGHT - 1}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an unfilled HTML-comment scaffold', () => {
|
||||||
|
const body = [
|
||||||
|
'## Highlights',
|
||||||
|
'<!-- Author: 1 line per highlight, then delete this comment.',
|
||||||
|
' - [LucideIcon] **Title** - short user benefit -->',
|
||||||
|
'',
|
||||||
|
'### Features',
|
||||||
|
'- x',
|
||||||
|
].join('\n');
|
||||||
|
expect(parseHighlights(body)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fully strips a multi-line author comment even when it mentions an icon marker', () => {
|
||||||
|
const body = [
|
||||||
|
'## Highlights',
|
||||||
|
'<!-- Author: 1 line per highlight; end a line with an icon comment, e.g.',
|
||||||
|
' - Add an icon like icon: BookHeart',
|
||||||
|
' - Image/video: drag the file in -->',
|
||||||
|
'',
|
||||||
|
'### Features',
|
||||||
|
'- x',
|
||||||
|
].join('\n');
|
||||||
|
expect(parseHighlights(body)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the next heading or horizontal rule', () => {
|
||||||
|
const body = ['## Highlights', '- **One** - first.', '', '---', '**Docker**', '- not a highlight'].join('\n');
|
||||||
|
expect(parseHighlights(body)).toEqual([{ icon: null, title: 'One', body: 'first.', media: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses CRLF bodies (GitHub web-editor line endings)', () => {
|
||||||
|
const body = ['## Highlights', '- [BookHeart] **Kobo sync** - both ways now.', '', `${ATTACH}/abc`, '', '### Features', '- f'].join('\r\n');
|
||||||
|
const result = parseHighlights(body);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Kobo sync');
|
||||||
|
expect(result[0].body).toBe('both ways now.');
|
||||||
|
expect(result[0].media).toEqual([{ url: `${ATTACH}/abc`, type: 'image' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple highlights in order', () => {
|
||||||
|
const body = ['## Highlights', '- [BookHeart] **A** - one.', '- [Zap] **B** - two.', '- **C** - three.'].join('\n');
|
||||||
|
expect(parseHighlights(body).map((h) => h.title)).toEqual(['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripHighlightsSection', () => {
|
||||||
|
it('removes the Highlights block but keeps the changelog', () => {
|
||||||
|
const body = ['## Highlights', '- **A** - one.', '', '### Features', '- a feature'].join('\n');
|
||||||
|
expect(stripHighlightsSection(body)).toBe('### Features\n- a feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing remains', () => {
|
||||||
|
expect(stripHighlightsSection('## Highlights\n- **A** - one.')).toBeNull();
|
||||||
|
expect(stripHighlightsSection(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAllowedImageHost', () => {
|
||||||
|
it('allows GitHub attachment hosts', () => {
|
||||||
|
expect(isAllowedImageHost('https://github.com/user-attachments/assets/x')).toBe(true);
|
||||||
|
expect(isAllowedImageHost('https://user-images.githubusercontent.com/1/x.png')).toBe(true);
|
||||||
|
expect(isAllowedImageHost('https://private-user-images.githubusercontent.com/1/x.png')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects other hosts, non-https, and github non-attachment paths', () => {
|
||||||
|
expect(isAllowedImageHost('https://evil.example.com/x.png')).toBe(false);
|
||||||
|
expect(isAllowedImageHost('http://github.com/user-attachments/assets/x')).toBe(false);
|
||||||
|
expect(isAllowedImageHost('https://github.com/bookorbit/bookorbit/raw/main/x.png')).toBe(false);
|
||||||
|
expect(isAllowedImageHost('not a url')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { ReleaseHighlight, ReleaseMedia } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { MAX_HIGHLIGHTS_PER_RELEASE, MAX_MEDIA_PER_HIGHLIGHT } from './release-notes.constants';
|
||||||
|
|
||||||
|
const HEADING_RE = /^#{1,6}\s+/;
|
||||||
|
const HEADING_TEXT_RE = /^#{1,6}\s+(.+?)\s*$/;
|
||||||
|
const HR_RE = /^\s*([-*_])\1{2,}\s*$/;
|
||||||
|
const BULLET_RE = /^\s*[-*]\s+(.+)$/;
|
||||||
|
// Primary icon carrier: an HTML comment GitHub renders as nothing, e.g. `<!-- icon: BookHeart -->`.
|
||||||
|
const ICON_COMMENT_RE = /<!--\s*icon:\s*([A-Za-z][A-Za-z0-9]*)\s*-->/i;
|
||||||
|
// Anchored form: the whole comment IS an icon marker (not a larger comment that merely mentions one).
|
||||||
|
const ICON_COMMENT_ONLY_RE = /^<!--\s*icon:\s*[A-Za-z][A-Za-z0-9]*\s*-->$/i;
|
||||||
|
// Legacy carrier: a leading `[BookHeart]` token (renders as literal text on GitHub; kept for old releases).
|
||||||
|
const ICON_TOKEN_RE = /^\[([A-Za-z][A-Za-z0-9]*)\]\s*/;
|
||||||
|
const BOLD_TITLE_RE = /^\*\*(.+?)\*\*\s*/;
|
||||||
|
// Matches both `` and `[label](url)`.
|
||||||
|
const MEDIA_LINK_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||||
|
// Explicit image/video markup only (``), used by the linter to flag dropped media.
|
||||||
|
const IMAGE_MD_RE = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||||
|
// GitHub's web editor inserts dropped images as `<img ... src="...">` (and video sources similarly).
|
||||||
|
const HTML_MEDIA_SRC_RE = /<(?:img|video|source)\b[^>]*?\bsrc=["']([^"']+)["']/gi;
|
||||||
|
const HTML_TAG_RE = /<[^>]+>/g;
|
||||||
|
const BARE_URL_RE = /https?:\/\/[^\s)"'<>\]]+/g;
|
||||||
|
const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|ogv|ogg)(?:[?#].*)?$/i;
|
||||||
|
const LEADING_SEP_RE = /^\s*[-:–—]\s*/;
|
||||||
|
|
||||||
|
/** Strip HTML comments but preserve standalone `<!-- icon: X -->` markers so the parser can read the icon. */
|
||||||
|
function stripHtmlComments(text: string): string {
|
||||||
|
return text.replace(/<!--[\s\S]*?-->/g, (m) => (ICON_COMMENT_ONLY_RE.test(m.trim()) ? m : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlightsHeading(line: string): boolean {
|
||||||
|
const m = line.match(HEADING_TEXT_RE);
|
||||||
|
if (!m) return false;
|
||||||
|
return m[1].replace(/[^a-zA-Z]/g, '').toLowerCase() === 'highlights';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only render media served from GitHub's own attachment hosts. */
|
||||||
|
export function isAllowedImageHost(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol !== 'https:') return false;
|
||||||
|
const host = u.hostname.toLowerCase();
|
||||||
|
if (host === 'github.com') return u.pathname.startsWith('/user-attachments/');
|
||||||
|
return host === 'githubusercontent.com' || host.endsWith('.githubusercontent.com');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All allowlisted media in a block, in source order, de-duplicated, classified image/video by
|
||||||
|
* extension. Ordering is by position in the text so interleaved `<img>`, ``, and bare URLs
|
||||||
|
* keep the order the author wrote them. The three syntaxes can each match the same URL (e.g. a bare
|
||||||
|
* URL inside a markdown image); the earliest-positioned occurrence wins via the `seen` set.
|
||||||
|
*/
|
||||||
|
function orderedAllowlistedMedia(text: string): ReleaseMedia[] {
|
||||||
|
const found: { url: string; index: number }[] = [];
|
||||||
|
for (const m of text.matchAll(HTML_MEDIA_SRC_RE)) found.push({ url: m[1], index: m.index ?? 0 });
|
||||||
|
for (const m of text.matchAll(MEDIA_LINK_RE)) found.push({ url: m[1], index: m.index ?? 0 });
|
||||||
|
for (const m of text.matchAll(BARE_URL_RE)) found.push({ url: m[0], index: m.index ?? 0 });
|
||||||
|
found.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
const media: ReleaseMedia[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const { url } of found) {
|
||||||
|
if (seen.has(url) || !isAllowedImageHost(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
media.push({ url, type: VIDEO_EXT_RE.test(url) ? 'video' : 'image' });
|
||||||
|
}
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URLs from explicit media markup (`<img>/<video>` tags and ``), for lint host-validation. */
|
||||||
|
export function findExplicitMediaUrls(text: string): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const m of text.matchAll(HTML_MEDIA_SRC_RE)) urls.push(m[1]);
|
||||||
|
for (const m of text.matchAll(IMAGE_MD_RE)) urls.push(m[1]);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHighlightsBlock(body: string): string[] | null {
|
||||||
|
const lines = stripHtmlComments(body).split(/\r?\n/);
|
||||||
|
const start = lines.findIndex(isHighlightsHeading);
|
||||||
|
if (start === -1) return null;
|
||||||
|
|
||||||
|
const block: string[] = [];
|
||||||
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (HEADING_RE.test(line) || HR_RE.test(line)) break;
|
||||||
|
block.push(line);
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed highlight plus its uncapped allowlisted media count, used by the linter for cap warnings. */
|
||||||
|
export interface ParsedHighlight {
|
||||||
|
highlight: ReleaseHighlight;
|
||||||
|
/** Total allowlisted media on the bullet before the `MAX_MEDIA_PER_HIGHLIGHT` cap is applied. */
|
||||||
|
mediaCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `## Highlights` section into highlights paired with their uncapped media count.
|
||||||
|
* Tolerant: missing section -> []; missing/unknown icon -> null; malformed bullet -> skipped;
|
||||||
|
* non-allowlisted image -> dropped (text kept); HTML-comment scaffold -> ignored.
|
||||||
|
*/
|
||||||
|
export function parseHighlightEntries(body: string | null | undefined): ParsedHighlight[] {
|
||||||
|
if (!body) return [];
|
||||||
|
const block = extractHighlightsBlock(body);
|
||||||
|
if (!block) return [];
|
||||||
|
|
||||||
|
const entries: { text: string; cont: string }[] = [];
|
||||||
|
for (const line of block) {
|
||||||
|
const bullet = line.match(BULLET_RE);
|
||||||
|
if (bullet) {
|
||||||
|
entries.push({ text: bullet[1].trim(), cont: '' });
|
||||||
|
} else if (entries.length > 0) {
|
||||||
|
entries[entries.length - 1].cont += `\n${line}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ParsedHighlight[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const combined = `${entry.text}\n${entry.cont}`;
|
||||||
|
const allMedia = orderedAllowlistedMedia(combined);
|
||||||
|
const media = allMedia.slice(0, MAX_MEDIA_PER_HIGHLIGHT);
|
||||||
|
|
||||||
|
// Prefer the `<!-- icon: X -->` comment (invisible on GitHub); fall back to a legacy `[X]` token.
|
||||||
|
let icon: string | null = null;
|
||||||
|
const iconComment = combined.match(ICON_COMMENT_RE);
|
||||||
|
if (iconComment) icon = iconComment[1];
|
||||||
|
|
||||||
|
let text = entry.text.replace(HTML_TAG_RE, '').replace(MEDIA_LINK_RE, '').replace(BARE_URL_RE, '').trim();
|
||||||
|
|
||||||
|
const tokenMatch = text.match(ICON_TOKEN_RE);
|
||||||
|
if (tokenMatch) {
|
||||||
|
if (!icon) icon = tokenMatch[1];
|
||||||
|
text = text.slice(tokenMatch[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: string;
|
||||||
|
let highlightBody = '';
|
||||||
|
const boldMatch = text.match(BOLD_TITLE_RE);
|
||||||
|
if (boldMatch) {
|
||||||
|
title = boldMatch[1].trim();
|
||||||
|
highlightBody = text.slice(boldMatch[0].length).replace(LEADING_SEP_RE, '').trim();
|
||||||
|
} else {
|
||||||
|
title = text.replace(/\*\*/g, '').replace(LEADING_SEP_RE, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) continue;
|
||||||
|
|
||||||
|
result.push({ highlight: { icon, title, body: highlightBody, media }, mediaCount: allMedia.length });
|
||||||
|
if (result.length >= MAX_HIGHLIGHTS_PER_RELEASE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured highlights for the `## Highlights` section of a release body. */
|
||||||
|
export function parseHighlights(body: string | null | undefined): ReleaseHighlight[] {
|
||||||
|
return parseHighlightEntries(body).map((e) => e.highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the release body with the Highlights section removed, for the inline changelog. */
|
||||||
|
export function stripHighlightsSection(body: string | null | undefined): string | null {
|
||||||
|
if (!body) return null;
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const out: string[] = [];
|
||||||
|
let removed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (!removed && isHighlightsHeading(lines[i])) {
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !HEADING_RE.test(lines[i]) && !HR_RE.test(lines[i])) i++;
|
||||||
|
i--;
|
||||||
|
removed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(lines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = out.join('\n').trim();
|
||||||
|
return result.length ? result : null;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
|||||||
|
export const RELEASE_NOTES_ROUTE = 'release-notes';
|
||||||
|
export const RELEASE_NOTES_TIMEOUT_MS = 10_000;
|
||||||
|
export const RELEASE_NOTES_LIST_TTL_MS = 600_000;
|
||||||
|
export const RELEASE_NOTES_PER_PAGE = 30;
|
||||||
|
|
||||||
|
/** Max number of versions shown cumulatively in the popup. */
|
||||||
|
export const CUMULATIVE_VERSION_CAP = 4;
|
||||||
|
|
||||||
|
/** Max highlights parsed from a single release (abuse guard). */
|
||||||
|
export const MAX_HIGHLIGHTS_PER_RELEASE = 12;
|
||||||
|
|
||||||
|
/** Max media items rendered per highlight; extras are dropped (the lint warns). */
|
||||||
|
export const MAX_MEDIA_PER_HIGHLIGHT = 6;
|
||||||
|
|
||||||
|
/** Soft caps for highlight text. Over-cap text is line-clamped in the UI; the lint warns. */
|
||||||
|
export const MAX_TITLE_LENGTH = 120;
|
||||||
|
export const MAX_BODY_LENGTH = 400;
|
||||||
|
|
||||||
|
/** Max concurrent media content-type probes, so a media-heavy page doesn't fan out unbounded. */
|
||||||
|
export const MEDIA_PROBE_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
/** Cap the in-memory caches so repeated paging or media-heavy bodies can't grow memory unbounded. */
|
||||||
|
export const PAGE_CACHE_MAX = 10;
|
||||||
|
export const MEDIA_TYPE_CACHE_MAX = 1000;
|
||||||
|
|
||||||
|
export const SEMVER_RE = /^v\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { ReleaseNotesResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { ReleaseNotesQueryDto } from './dto/release-notes-query.dto';
|
||||||
|
import { RELEASE_NOTES_ROUTE } from './release-notes.constants';
|
||||||
|
import { ReleaseNotesService } from './release-notes.service';
|
||||||
|
|
||||||
|
@Controller(RELEASE_NOTES_ROUTE)
|
||||||
|
export class ReleaseNotesController {
|
||||||
|
constructor(private readonly releaseNotesService: ReleaseNotesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async get(@Query() query: ReleaseNotesQueryDto): Promise<ReleaseNotesResponse> {
|
||||||
|
if (query.page !== undefined) {
|
||||||
|
const pageNum = Number(query.page);
|
||||||
|
return this.releaseNotesService.getAll(Number.isFinite(pageNum) ? pageNum : 1);
|
||||||
|
}
|
||||||
|
const trimmed = query.since?.trim();
|
||||||
|
return this.releaseNotesService.getSince(trimmed ? trimmed : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||||
|
import { ReleaseNotesController } from './release-notes.controller';
|
||||||
|
import { ReleaseNotesService } from './release-notes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppSettingsModule],
|
||||||
|
controllers: [ReleaseNotesController],
|
||||||
|
providers: [ReleaseNotesService],
|
||||||
|
})
|
||||||
|
export class ReleaseNotesModule {}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
|
import { ReleaseNotesService } from './release-notes.service';
|
||||||
|
|
||||||
|
function ghRelease(tag: string, highlights: boolean) {
|
||||||
|
return {
|
||||||
|
tag_name: tag,
|
||||||
|
name: tag,
|
||||||
|
published_at: '2026-06-14T00:00:00Z',
|
||||||
|
html_url: `https://github.com/o/r/releases/tag/${tag}`,
|
||||||
|
draft: false,
|
||||||
|
body: highlights ? `## Highlights\n- **${tag} thing** - did stuff.\n\n### Features\n- f` : '### Features\n- f',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(releases: unknown[], enabled = true, currentVersion?: string) {
|
||||||
|
const config = {
|
||||||
|
get: (key: string) => {
|
||||||
|
if (key === 'app.githubReleasesRepo') return 'o/r';
|
||||||
|
if (key === 'app.version') return currentVersion;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
const appSettings = { isUpdateCheckEnabled: vi.fn().mockResolvedValue(enabled) } as unknown as AppSettingsService;
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(releases) }));
|
||||||
|
return new ReleaseNotesService(config, appSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReleaseNotesService.getSince', () => {
|
||||||
|
it('returns only versions newer than the baseline that have highlights, newest first', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.2.0', true), ghRelease('v1.1.0', true), ghRelease('v1.0.0', true)]);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases.map((r) => r.version)).toEqual(['v1.2.0', 'v1.1.0']);
|
||||||
|
expect(res.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes releases without a Highlights section', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.2.0', false), ghRelease('v1.1.0', true)]);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases.map((r) => r.version)).toEqual(['v1.1.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the cumulative list and flags hasMore', async () => {
|
||||||
|
const service = makeService([
|
||||||
|
ghRelease('v1.5.0', true),
|
||||||
|
ghRelease('v1.4.0', true),
|
||||||
|
ghRelease('v1.3.0', true),
|
||||||
|
ghRelease('v1.2.0', true),
|
||||||
|
ghRelease('v1.1.0', true),
|
||||||
|
]);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases).toHaveLength(4);
|
||||||
|
expect(res.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never announces versions newer than the running build', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.3.0', true), ghRelease('v1.2.0', true), ghRelease('v1.1.0', true)], true, 'v1.2.0');
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases.map((r) => r.version)).toEqual(['v1.2.0', 'v1.1.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when update checks are disabled', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.2.0', true)], false);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reclassifies extensionless GitHub media as video via a content-type probe, once per unique URL', async () => {
|
||||||
|
const sharedUrl = 'https://github.com/user-attachments/assets/abc';
|
||||||
|
const releases = [
|
||||||
|
{
|
||||||
|
tag_name: 'v1.2.0',
|
||||||
|
name: 'v1.2.0',
|
||||||
|
published_at: '2026-06-14T00:00:00Z',
|
||||||
|
html_url: 'https://github.com/o/r/releases/tag/v1.2.0',
|
||||||
|
draft: false,
|
||||||
|
body: `## Highlights\n- **Demo** - watch this.\n \n- **Again** - same clip.\n `,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const config = {
|
||||||
|
get: (key: string) => (key === 'app.githubReleasesRepo' ? 'o/r' : key === 'app.githubReleasesToken' ? 'tkn' : undefined),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
const appSettings = { isUpdateCheckEnabled: vi.fn().mockResolvedValue(true) } as unknown as AppSettingsService;
|
||||||
|
const fetchMock = vi.fn((url: string) =>
|
||||||
|
url.includes('api.github.com')
|
||||||
|
? Promise.resolve({ ok: true, json: () => Promise.resolve(releases) })
|
||||||
|
: Promise.resolve({ headers: { get: () => 'video/mp4' } }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const service = new ReleaseNotesService(config, appSettings);
|
||||||
|
const highlights = (await service.getSince('v1.0.0')).releases[0].highlights;
|
||||||
|
expect(highlights[0].media).toEqual([{ url: sharedUrl, type: 'video' }]);
|
||||||
|
expect(highlights[1].media).toEqual([{ url: sharedUrl, type: 'video' }]);
|
||||||
|
const probeCalls = fetchMock.mock.calls.filter(([u]) => !String(u).includes('api.github.com'));
|
||||||
|
expect(probeCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores releases whose tag is not a clean vX.Y.Z (extra segments / trailing garbage)', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.2.0.4', true), ghRelease('v1.2.0-nope-extra', true), ghRelease('v1.1.0', true)]);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases.map((r) => r.version)).toEqual(['v1.1.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the Highlights section from the changelog body', async () => {
|
||||||
|
const service = makeService([ghRelease('v1.2.0', true)]);
|
||||||
|
const res = await service.getSince('v1.0.0');
|
||||||
|
expect(res.releases[0].changelogBody).toBe('### Features\n- f');
|
||||||
|
expect(res.releases[0].highlights).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import type { ReleaseNote, ReleaseNotesResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||||
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
|
import { parseHighlights, stripHighlightsSection } from './highlights.parser';
|
||||||
|
import {
|
||||||
|
CUMULATIVE_VERSION_CAP,
|
||||||
|
MEDIA_PROBE_CONCURRENCY,
|
||||||
|
MEDIA_TYPE_CACHE_MAX,
|
||||||
|
PAGE_CACHE_MAX,
|
||||||
|
RELEASE_NOTES_LIST_TTL_MS,
|
||||||
|
RELEASE_NOTES_PER_PAGE,
|
||||||
|
RELEASE_NOTES_TIMEOUT_MS,
|
||||||
|
SEMVER_RE,
|
||||||
|
} from './release-notes.constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReleaseNotesService {
|
||||||
|
private readonly logger = new Logger(ReleaseNotesService.name);
|
||||||
|
private readonly pageCache = new Map<number, { releases: ReleaseNote[]; fetchedAt: number }>();
|
||||||
|
private readonly mediaTypeCache = new Map<string, 'image' | 'video' | 'other'>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly appSettingsService: AppSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSince(since: string | null): Promise<ReleaseNotesResponse> {
|
||||||
|
const all = await this.getPage(1);
|
||||||
|
const current = this.config.get<string>('app.version') ?? '';
|
||||||
|
const capByCurrent = SEMVER_RE.test(current);
|
||||||
|
|
||||||
|
const eligible = all.filter((r) => {
|
||||||
|
if (r.highlights.length === 0) return false;
|
||||||
|
if (since && !this.isNewer(r.version, since)) return false;
|
||||||
|
// Never announce versions newer than the running build (not yet installed here).
|
||||||
|
if (capByCurrent && this.isNewer(r.version, current)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const releases = eligible.slice(0, CUMULATIVE_VERSION_CAP);
|
||||||
|
return { releases, hasMore: eligible.length > releases.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(page: number): Promise<ReleaseNotesResponse> {
|
||||||
|
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||||
|
const releases = await this.getPage(safePage);
|
||||||
|
return { releases, hasMore: releases.length >= RELEASE_NOTES_PER_PAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPage(page: number): Promise<ReleaseNote[]> {
|
||||||
|
const cached = this.pageCache.get(page);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < RELEASE_NOTES_LIST_TTL_MS) {
|
||||||
|
return cached.releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetched = await this.fetchPage(page);
|
||||||
|
if (fetched) {
|
||||||
|
this.setCapped(this.pageCache, page, { releases: fetched, fetchedAt: Date.now() }, PAGE_CACHE_MAX);
|
||||||
|
return fetched;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached?.releases ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert into a cache map, evicting the oldest entry first when at capacity (FIFO). */
|
||||||
|
private setCapped<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
|
||||||
|
if (!map.has(key) && map.size >= max) {
|
||||||
|
const oldest = map.keys().next();
|
||||||
|
if (!oldest.done) map.delete(oldest.value);
|
||||||
|
}
|
||||||
|
map.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(page: number): Promise<ReleaseNote[] | null> {
|
||||||
|
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
|
||||||
|
if (!enabled) return [];
|
||||||
|
|
||||||
|
const repo = this.config.get<string>('app.githubReleasesRepo') ?? 'bookorbit/bookorbit';
|
||||||
|
const token = this.config.get<string>('app.githubReleasesToken');
|
||||||
|
const url = `https://api.github.com/repos/${repo}/releases?per_page=${RELEASE_NOTES_PER_PAGE}&page=${page}`;
|
||||||
|
const start = Date.now();
|
||||||
|
this.logger.log(`[release_notes.fetch] [start] repo="${sanitizeLogValue(repo)}" page=${page} - fetching releases`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), RELEASE_NOTES_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { headers, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.warn(
|
||||||
|
`[release_notes.fetch] [fail] repo="${sanitizeLogValue(repo)}" page=${page} durationMs=${durationMs} errorClass=${this.httpErrorClass(response.status)} error="GitHub API returned ${response.status}" - fetch releases failed`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.warn(
|
||||||
|
`[release_notes.fetch] [fail] repo="${sanitizeLogValue(repo)}" page=${page} durationMs=${durationMs} errorClass=ParseError error="invalid JSON response" - fetch releases failed`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.warn(
|
||||||
|
`[release_notes.fetch] [fail] repo="${sanitizeLogValue(repo)}" page=${page} durationMs=${durationMs} errorClass=ParseError error="unexpected response shape" - fetch releases failed`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = data.map((raw) => this.toReleaseNote(raw, repo)).filter((r): r is ReleaseNote => r !== null);
|
||||||
|
await this.resolveMediaTypes(releases, token);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.log(
|
||||||
|
`[release_notes.fetch] [end] repo="${sanitizeLogValue(repo)}" page=${page} durationMs=${durationMs} count=${releases.length} - fetch releases completed`,
|
||||||
|
);
|
||||||
|
return releases;
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(
|
||||||
|
`[release_notes.fetch] [fail] repo="${sanitizeLogValue(repo)}" page=${page} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - fetch releases failed`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toReleaseNote(raw: unknown, repo: string): ReleaseNote | null {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return null;
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
if (r.draft === true) return null;
|
||||||
|
|
||||||
|
const version = typeof r.tag_name === 'string' ? r.tag_name : null;
|
||||||
|
if (!version) return null;
|
||||||
|
|
||||||
|
const body = typeof r.body === 'string' ? r.body : null;
|
||||||
|
const name = typeof r.name === 'string' && r.name.trim() ? r.name : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
name,
|
||||||
|
date: typeof r.published_at === 'string' ? r.published_at : null,
|
||||||
|
highlights: parseHighlights(body),
|
||||||
|
changelogUrl: typeof r.html_url === 'string' ? r.html_url : `https://github.com/${repo}/releases/tag/${version}`,
|
||||||
|
changelogBody: stripHighlightsSection(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub drag-drop media (user-attachments) has no file extension, so the parser defaults it to
|
||||||
|
* 'image'. Probe the content-type once per URL and reclassify the ones that are actually video.
|
||||||
|
*/
|
||||||
|
private async resolveMediaTypes(releases: ReleaseNote[], token?: string): Promise<void> {
|
||||||
|
const pending = new Set<string>();
|
||||||
|
for (const release of releases) {
|
||||||
|
for (const highlight of release.highlights) {
|
||||||
|
for (const item of highlight.media) {
|
||||||
|
if (item.type === 'image' && this.needsContentTypeProbe(item.url) && !this.mediaTypeCache.has(item.url)) {
|
||||||
|
pending.add(item.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
|
||||||
|
const urls = [...pending];
|
||||||
|
for (let i = 0; i < urls.length; i += MEDIA_PROBE_CONCURRENCY) {
|
||||||
|
await Promise.all(urls.slice(i, i + MEDIA_PROBE_CONCURRENCY).map((url) => this.probeMediaType(url, token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const release of releases) {
|
||||||
|
for (const highlight of release.highlights) {
|
||||||
|
for (const item of highlight.media) {
|
||||||
|
if (item.type === 'image' && this.mediaTypeCache.get(item.url) === 'video') {
|
||||||
|
item.type = 'video';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private needsContentTypeProbe(url: string): boolean {
|
||||||
|
return !/\.(png|jpe?g|gif|webp|svg|avif)(?:[?#].*)?$/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeMediaType(url: string, token?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), RELEASE_NOTES_TIMEOUT_MS);
|
||||||
|
// GitHub user-attachments redirect to a presigned S3 URL that only allows GET, so HEAD
|
||||||
|
// returns 403. A single-byte ranged GET gets the real content-type without downloading.
|
||||||
|
const headers: Record<string, string> = { Range: 'bytes=0-0' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||||
|
this.setCapped(
|
||||||
|
this.mediaTypeCache,
|
||||||
|
url,
|
||||||
|
type.startsWith('video/') ? 'video' : type.startsWith('image/') ? 'image' : 'other',
|
||||||
|
MEDIA_TYPE_CACHE_MAX,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.setCapped(this.mediaTypeCache, url, 'other', MEDIA_TYPE_CACHE_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private httpErrorClass(status: number): string {
|
||||||
|
if (status === 401 || status === 403) return 'AuthError';
|
||||||
|
if (status === 404) return 'NotFoundError';
|
||||||
|
if (status === 429) return 'RateLimitError';
|
||||||
|
return 'HttpError';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNewer(candidate: string, baseline: string): boolean {
|
||||||
|
if (!SEMVER_RE.test(candidate)) return false;
|
||||||
|
const a = this.parseSemver(candidate.replace(/^v/, ''));
|
||||||
|
const b = this.parseSemver(baseline.replace(/^v/, ''));
|
||||||
|
if (!a || !b) return false;
|
||||||
|
if (a[0] !== b[0]) return a[0] > b[0];
|
||||||
|
if (a[1] !== b[1]) return a[1] > b[1];
|
||||||
|
return a[2] > b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSemver(version: string): [number, number, number] | null {
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
if (parts.length < 3 || parts.some((p) => isNaN(p))) return null;
|
||||||
|
return [parts[0], parts[1], parts[2]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,4 +32,16 @@ export class UserPreferencesController {
|
|||||||
async upsertDisplayPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
|
async upsertDisplayPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
|
||||||
await this.userPreferencesService.upsertDisplayPreferences(user.id, dto.settings);
|
await this.userPreferencesService.upsertDisplayPreferences(user.id, dto.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('whats-new')
|
||||||
|
async getWhatsNewPreferences(@CurrentUser() user: RequestUser) {
|
||||||
|
const settings = await this.userPreferencesService.getWhatsNewPreferences(user.id);
|
||||||
|
return { settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('whats-new')
|
||||||
|
@HttpCode(204)
|
||||||
|
async upsertWhatsNewPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
|
||||||
|
await this.userPreferencesService.upsertWhatsNewPreferences(user.id, dto.settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,4 +336,30 @@ describe('UserPreferencesService', () => {
|
|||||||
await expect(service.upsertDisplayPreferences(11, withoutSecondary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
await expect(service.upsertDisplayPreferences(11, withoutSecondary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
||||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardSecondaryLabel: 'hidden' }));
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardSecondaryLabel: 'hidden' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getWhatsNewPreferences returns defaults when repository has no row', async () => {
|
||||||
|
await expect(service.getWhatsNewPreferences(7)).resolves.toEqual({ lastSeenVersion: null, popupEnabled: true });
|
||||||
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'whats-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getWhatsNewPreferences fills missing fields with defaults', async () => {
|
||||||
|
repo.findByCategory.mockResolvedValueOnce({ data: { lastSeenVersion: 'v1.2.0' } } as never);
|
||||||
|
await expect(service.getWhatsNewPreferences(7)).resolves.toEqual({ lastSeenVersion: 'v1.2.0', popupEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsertWhatsNewPreferences merges a partial update onto existing values', async () => {
|
||||||
|
repo.findByCategory.mockResolvedValueOnce({ data: { lastSeenVersion: 'v1.0.0', popupEnabled: true } } as never);
|
||||||
|
await expect(service.upsertWhatsNewPreferences(11, { popupEnabled: false })).resolves.toBeUndefined();
|
||||||
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'whats-new', { lastSeenVersion: 'v1.0.0', popupEnabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsertWhatsNewPreferences rejects unknown fields', async () => {
|
||||||
|
await expect(service.upsertWhatsNewPreferences(11, { lastSeenVersion: 'v1.0.0', nope: true })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsertWhatsNewPreferences rejects a non-boolean popupEnabled', async () => {
|
||||||
|
await expect(service.upsertWhatsNewPreferences(11, { popupEnabled: 'yes' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
THEME_IDS,
|
THEME_IDS,
|
||||||
type DisplayPreferences,
|
type DisplayPreferences,
|
||||||
type ThemePreferences,
|
type ThemePreferences,
|
||||||
|
type WhatsNewPreferences,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -70,12 +71,60 @@ const DISPLAY_PREFERENCES_SCHEMA = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WHATS_NEW_PREFERENCES_SCHEMA = z
|
||||||
|
.object({
|
||||||
|
lastSeenVersion: z.string().min(1).max(50).optional(),
|
||||||
|
popupEnabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const WHATS_NEW_DEFAULTS: WhatsNewPreferences = { lastSeenVersion: null, popupEnabled: true };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserPreferencesService {
|
export class UserPreferencesService {
|
||||||
private readonly logger = new Logger(UserPreferencesService.name);
|
private readonly logger = new Logger(UserPreferencesService.name);
|
||||||
|
|
||||||
constructor(private readonly repo: UserPreferencesRepository) {}
|
constructor(private readonly repo: UserPreferencesRepository) {}
|
||||||
|
|
||||||
|
async getWhatsNewPreferences(userId: number): Promise<WhatsNewPreferences> {
|
||||||
|
const row = await this.repo.findByCategory(userId, 'whats-new');
|
||||||
|
if (!row) return { ...WHATS_NEW_DEFAULTS };
|
||||||
|
const stored = row.data as Partial<WhatsNewPreferences>;
|
||||||
|
return {
|
||||||
|
lastSeenVersion: stored.lastSeenVersion ?? null,
|
||||||
|
popupEnabled: stored.popupEnabled ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertWhatsNewPreferences(userId: number, data: Record<string, unknown>): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
this.logger.log(`[user_preferences.upsert_whats_new] [start] userId=${userId} - upsert whats-new preferences started`);
|
||||||
|
|
||||||
|
const result = WHATS_NEW_PREFERENCES_SCHEMA.safeParse(data);
|
||||||
|
if (!result.success) {
|
||||||
|
const firstIssue = result.error.issues[0];
|
||||||
|
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
|
||||||
|
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
|
||||||
|
throw new BadRequestException(`Invalid whats-new preferences at "${issuePath}": ${issueMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.getWhatsNewPreferences(userId);
|
||||||
|
const merged: WhatsNewPreferences = { ...existing, ...result.data };
|
||||||
|
await this.repo.upsert(userId, 'whats-new', { ...merged });
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.log(`[user_preferences.upsert_whats_new] [end] userId=${userId} durationMs=${durationMs} - upsert whats-new preferences completed`);
|
||||||
|
} catch (err) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||||
|
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||||
|
this.logger.error(
|
||||||
|
`[user_preferences.upsert_whats_new] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${error}" - upsert whats-new preferences failed`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getThemePreferences(userId: number): Promise<ThemePreferences | null> {
|
async getThemePreferences(userId: number): Promise<ThemePreferences | null> {
|
||||||
const row = await this.repo.findByCategory(userId, 'theme');
|
const row = await this.repo.findByCategory(userId, 'theme');
|
||||||
return row ? (row.data as ThemePreferences) : null;
|
return row ? (row.data as ThemePreferences) : null;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# server scripts
|
||||||
|
|
||||||
|
One-off and operational scripts, run from the `server/` directory via the `pnpm` scripts in
|
||||||
|
`server/package.json` (or directly with `tsx src/scripts/<file>.ts`).
|
||||||
|
|
||||||
|
## What's New release tooling
|
||||||
|
|
||||||
|
These validate and preview a release's `## Highlights` section before you publish it. Full
|
||||||
|
authoring walkthrough and format reference: [docs/whats-new-authoring-guide.md](../../../docs/whats-new-authoring-guide.md).
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `pnpm whats-new:check <tag>` | One-stop: lints, then previews, the release for `<tag>`. Exits non-zero if the lint fails. |
|
||||||
|
| `pnpm whats-new:lint <file.md>` \| `--tag <tag>` `[--strict]` | Static validation with the same parser the app uses. `--strict` fails on warnings too. |
|
||||||
|
| `pnpm whats-new:preview [tag]` `[--repo owner/name]` | Renders exactly what the app will show and probes each media URL for reachability. |
|
||||||
|
|
||||||
|
Each accepts `--help`. They read `GITHUB_RELEASES_REPO`, `GITHUB_RELEASES_TOKEN`, and
|
||||||
|
`APP_VERSION` from `server/.env`; point them at another repo with `GITHUB_RELEASES_REPO=owner/name`
|
||||||
|
(or `--repo owner/name` for `preview`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
pnpm whats-new:check v4.5.0 # lint + preview the release
|
||||||
|
pnpm whats-new:lint notes.md --strict # validate a draft locally
|
||||||
|
GITHUB_RELEASES_REPO=neonsolstice/playground pnpm whats-new:preview v4.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other scripts
|
||||||
|
|
||||||
|
| File | Run with | Purpose |
|
||||||
|
| ------------------------------------ | ---------------------------------------------------- | --------------------------------------------- |
|
||||||
|
| `db-seed.ts` | `pnpm db:seed` | Seed the dev database. |
|
||||||
|
| `db-prepare-e2e.ts` | `pnpm e2e:db:prepare` | Prepare the e2e test database. |
|
||||||
|
| `migrate.ts` | `pnpm db:migrate:runtime` (compiled) | Apply database migrations at runtime/startup. |
|
||||||
|
| `setup-kobo-cloudscraper.ts` | `pnpm setup:kobo-cloudscraper` | Set up the Kobo cloudscraper integration. |
|
||||||
|
| `backfill-reading-session-events.ts` | `tsx src/scripts/backfill-reading-session-events.ts` | One-off backfill of reading-session events. |
|
||||||
|
| `bench-user-statistics.ts` | `tsx src/scripts/bench-user-statistics.ts` | Benchmark the user-statistics queries. |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* One-stop check for a release's "What's New" highlights: lint, then preview.
|
||||||
|
*
|
||||||
|
* pnpm whats-new:check <tag> # e.g. pnpm whats-new:check v4.5.0
|
||||||
|
*
|
||||||
|
* Runs whats-new:lint --tag <tag> followed by whats-new:preview <tag>, sharing
|
||||||
|
* GITHUB_RELEASES_REPO / GITHUB_RELEASES_TOKEN from server/.env. Exits non-zero
|
||||||
|
* if the lint fails; the preview is informational and always runs.
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'Usage: pnpm whats-new:check <tag>',
|
||||||
|
'',
|
||||||
|
'Lints, then previews, the release for <tag> (runs whats-new:lint --tag then',
|
||||||
|
'whats-new:preview). Exits non-zero if the lint fails.',
|
||||||
|
'',
|
||||||
|
'Env (server/.env): GITHUB_RELEASES_REPO, GITHUB_RELEASES_TOKEN',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = process.argv[2];
|
||||||
|
if (tag === '--help' || tag === '-h') {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!tag || tag.startsWith('-')) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lintOk = true;
|
||||||
|
try {
|
||||||
|
execFileSync('tsx', ['src/scripts/whats-new-lint.ts', '--tag', tag], { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
lintOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('tsx', ['src/scripts/whats-new-preview.ts', tag], { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
// The preview is informational; do not let its exit status mask the lint result.
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(lintOk ? 0 : 1);
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Normalize "What's New" highlight screenshots before dragging them into a GitHub release:
|
||||||
|
* convert to webp, cap the largest dimension, strip metadata. Aspect ratio is preserved
|
||||||
|
* (no cropping) - the app normalizes display size in CSS; this only trims file weight so the
|
||||||
|
* popup loads fast and stays sharp in the lightbox.
|
||||||
|
*
|
||||||
|
* pnpm whats-new:images <folder> # process every image into <folder>/processed
|
||||||
|
* pnpm whats-new:images <file.png> # process a single file (handy for the webp test)
|
||||||
|
* pnpm whats-new:images <folder> --out <dir> # custom output directory
|
||||||
|
* pnpm whats-new:images <folder> --max 1600 # custom max dimension (default 1280)
|
||||||
|
* pnpm whats-new:images <folder> --quality 82 # custom webp quality (default 80)
|
||||||
|
*
|
||||||
|
* Output filenames keep the original basename (extension swapped to .webp) so it stays obvious
|
||||||
|
* which processed file maps to which highlight in the release markdown.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { basename, extname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const INPUT_EXT_RE = /\.(png|jpe?g|webp|tiff?)$/i;
|
||||||
|
const DEFAULT_MAX = 1280;
|
||||||
|
const DEFAULT_QUALITY = 90;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
input: string;
|
||||||
|
out: string;
|
||||||
|
max: number;
|
||||||
|
quality: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_FLAGS = new Set(['--out', '--max', '--quality']);
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): Options {
|
||||||
|
const flag = (name: string): string | undefined => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i !== -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionals = args.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(args[i - 1]));
|
||||||
|
const input = positionals[0];
|
||||||
|
if (!input) throw new Error('Provide a folder or image file. See: pnpm whats-new:images --help');
|
||||||
|
|
||||||
|
const resolvedInput = resolve(input);
|
||||||
|
if (!existsSync(resolvedInput)) throw new Error(`Path not found: ${resolvedInput}`);
|
||||||
|
|
||||||
|
const isDir = statSync(resolvedInput).isDirectory();
|
||||||
|
const defaultOut = isDir ? join(resolvedInput, 'processed') : join(resolve(resolvedInput, '..'), 'processed');
|
||||||
|
|
||||||
|
const max = Number(flag('--max') ?? DEFAULT_MAX);
|
||||||
|
const quality = Number(flag('--quality') ?? DEFAULT_QUALITY);
|
||||||
|
if (!Number.isFinite(max) || max < 64) throw new Error('--max must be a number >= 64');
|
||||||
|
if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw new Error('--quality must be 1-100');
|
||||||
|
|
||||||
|
return { input: resolvedInput, out: resolve(flag('--out') ?? defaultOut), max, quality };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImages(input: string): string[] {
|
||||||
|
if (statSync(input).isFile()) return INPUT_EXT_RE.test(input) ? [input] : [];
|
||||||
|
return readdirSync(input)
|
||||||
|
.filter((f) => INPUT_EXT_RE.test(f))
|
||||||
|
.map((f) => join(input, f))
|
||||||
|
.filter((f) => statSync(f).isFile())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKb(bytes: number): string {
|
||||||
|
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'Usage: pnpm whats-new:images <folder|file> [--out <dir>] [--max <px>] [--quality <1-100>]',
|
||||||
|
'',
|
||||||
|
'Converts highlight screenshots to webp, caps the largest dimension, strips metadata.',
|
||||||
|
'Aspect ratio is preserved (no cropping). Output basenames match the input (.webp).',
|
||||||
|
'',
|
||||||
|
` --out <dir> output directory (default: <input>/processed)`,
|
||||||
|
` --max <px> cap the largest dimension (default: ${DEFAULT_MAX})`,
|
||||||
|
` --quality <n> webp quality 1-100 (default: ${DEFAULT_QUALITY})`,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--help') || args.length === 0) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(args.length === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = parseArgs(args);
|
||||||
|
const images = collectImages(opts.input);
|
||||||
|
if (images.length === 0) {
|
||||||
|
console.log('No images found (png, jpg, jpeg, webp, tiff).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(opts.out, { recursive: true });
|
||||||
|
console.log(`\nProcessing ${images.length} image(s) -> ${opts.out}`);
|
||||||
|
console.log(`webp q${opts.quality} · max ${opts.max}px · aspect preserved\n${'─'.repeat(72)}`);
|
||||||
|
|
||||||
|
let totalIn = 0;
|
||||||
|
let totalOut = 0;
|
||||||
|
|
||||||
|
for (const src of images) {
|
||||||
|
const inSize = statSync(src).size;
|
||||||
|
const outName = `${basename(src, extname(src))}.webp`;
|
||||||
|
const dest = join(opts.out, outName);
|
||||||
|
|
||||||
|
const pipeline = sharp(src)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: opts.max, height: opts.max, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: opts.quality });
|
||||||
|
const info = await pipeline.toFile(dest);
|
||||||
|
|
||||||
|
totalIn += inSize;
|
||||||
|
totalOut += info.size;
|
||||||
|
const pct = ((1 - info.size / inSize) * 100).toFixed(0);
|
||||||
|
console.log(` ${basename(src)}\n -> ${outName} ${info.width}x${info.height} ${formatKb(inSize)} -> ${formatKb(info.size)} (-${pct}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${'─'.repeat(72)}`);
|
||||||
|
const savedPct = totalIn > 0 ? ((1 - totalOut / totalIn) * 100).toFixed(0) : '0';
|
||||||
|
console.log(`Total: ${formatKb(totalIn)} -> ${formatKb(totalOut)} (-${savedPct}%)`);
|
||||||
|
console.log('✓ Done. Drag the .webp files from the output folder into the release editor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e instanceof Error ? e.message : String(e));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Lint a "What's New" Highlights section before (or after) publishing: static, no network.
|
||||||
|
* Uses the same parser the app uses, so a clean lint means it will parse correctly.
|
||||||
|
*
|
||||||
|
* pnpm whats-new:lint path/to/notes.md # lint a local markdown file (before pasting)
|
||||||
|
* pnpm whats-new:lint --tag v3.2.0 # fetch + lint a published release
|
||||||
|
* pnpm whats-new:lint notes.md --strict # treat warnings as failures too
|
||||||
|
*
|
||||||
|
* Exits 1 if there are errors (or warnings under --strict), so it can gate CI.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { lintHighlights } from '../modules/release-notes/highlights.lint';
|
||||||
|
|
||||||
|
function loadEnv(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync('.env', 'utf8');
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
||||||
|
if (m && !line.startsWith('#')) out[m[1]] = m[2].trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBody(args: string[]): Promise<{ source: string; body: string }> {
|
||||||
|
const tagIdx = args.indexOf('--tag');
|
||||||
|
if (tagIdx !== -1) {
|
||||||
|
const tag = args[tagIdx + 1];
|
||||||
|
if (!tag) throw new Error('--tag requires a value, e.g. --tag v3.2.0');
|
||||||
|
const env = { ...process.env, ...loadEnv() };
|
||||||
|
const repo = env.GITHUB_RELEASES_REPO ?? 'bookorbit/bookorbit';
|
||||||
|
const token = env.GITHUB_RELEASES_TOKEN;
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, {
|
||||||
|
headers: { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}@${tag}`);
|
||||||
|
const body = ((await res.json()) as { body: string | null }).body ?? '';
|
||||||
|
return { source: `${repo}@${tag}`, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = args.find((a) => !a.startsWith('--'));
|
||||||
|
if (!file) throw new Error('Provide a file path or --tag <tag>. See: pnpm whats-new:lint --help');
|
||||||
|
return { source: file, body: readFileSync(file, 'utf8') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'Usage: pnpm whats-new:lint <file.md> | --tag <tag> [--strict]',
|
||||||
|
'',
|
||||||
|
'Validates a Highlights section with the same parser the app uses',
|
||||||
|
'(no network for a file; one GitHub fetch for --tag).',
|
||||||
|
'',
|
||||||
|
' <file.md> lint a local markdown file',
|
||||||
|
' --tag <tag> fetch + lint a release by tag',
|
||||||
|
' --strict treat warnings as failures too',
|
||||||
|
'',
|
||||||
|
'Env (server/.env): GITHUB_RELEASES_REPO, GITHUB_RELEASES_TOKEN',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--help') || args.length === 0) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(args.length === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
const strict = args.includes('--strict');
|
||||||
|
|
||||||
|
const { source, body } = await resolveBody(args);
|
||||||
|
const { errors, warnings, highlightCount } = lintHighlights(body);
|
||||||
|
|
||||||
|
console.log(`\nLinting Highlights · ${source}\n${'─'.repeat(56)}`);
|
||||||
|
for (const e of errors) console.log(` ✗ ERROR ${e}`);
|
||||||
|
for (const w of warnings) console.log(` ⚠ WARN ${w}`);
|
||||||
|
console.log(`${'─'.repeat(56)}`);
|
||||||
|
console.log(`${highlightCount} highlight(s) · ${errors.length} error(s) · ${warnings.length} warning(s)`);
|
||||||
|
|
||||||
|
const failed = errors.length > 0 || (strict && warnings.length > 0);
|
||||||
|
console.log(failed ? '✗ FAILED' : '✓ OK');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e instanceof Error ? e.message : String(e));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Preview exactly what BookOrbit's "What's New" will render for a repo's releases,
|
||||||
|
* using the SAME parser + media content-type probe the app uses (no drift).
|
||||||
|
*
|
||||||
|
* pnpm whats-new:preview # all releases on page 1 of the configured repo
|
||||||
|
* pnpm whats-new:preview v3.2.0 # just that tag
|
||||||
|
* pnpm whats-new:preview --repo owner/r # override the repo
|
||||||
|
*
|
||||||
|
* Reads GITHUB_RELEASES_REPO / GITHUB_RELEASES_TOKEN / APP_VERSION from server/.env.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import type { AppSettingsService } from '../modules/app-settings/app-settings.service';
|
||||||
|
import { ReleaseNotesService } from '../modules/release-notes/release-notes.service';
|
||||||
|
|
||||||
|
Logger.overrideLogger(false);
|
||||||
|
|
||||||
|
function loadEnv(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync('.env', 'utf8');
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
||||||
|
if (m && !line.startsWith('#')) out[m[1]] = m[2].trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReachable(url: string): Promise<{ ok: boolean; detail: string }> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: 'GET', headers: { Range: 'bytes=0-0' } });
|
||||||
|
const type = (res.headers.get('content-type') ?? '').split(';')[0];
|
||||||
|
return { ok: res.status >= 200 && res.status < 300, detail: `${res.status} ${type}` };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, detail: 'unreachable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'Usage: pnpm whats-new:preview [tag] [--repo owner/name]',
|
||||||
|
'',
|
||||||
|
"Renders exactly what the app will show for the configured repo's releases,",
|
||||||
|
'and probes each media URL for reachability.',
|
||||||
|
'',
|
||||||
|
' [tag] preview a single release (e.g. v4.5.0); omit for all on page 1',
|
||||||
|
' --repo o/n override the source repo',
|
||||||
|
'',
|
||||||
|
'Env (server/.env): GITHUB_RELEASES_REPO, GITHUB_RELEASES_TOKEN, APP_VERSION',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const env = { ...process.env, ...loadEnv() };
|
||||||
|
let repoOverride: string | undefined;
|
||||||
|
let tag: string | undefined;
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--repo') repoOverride = args[++i];
|
||||||
|
else tag = args[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = repoOverride ?? env.GITHUB_RELEASES_REPO ?? 'bookorbit/bookorbit';
|
||||||
|
const token = env.GITHUB_RELEASES_TOKEN;
|
||||||
|
const version = env.APP_VERSION;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === 'app.githubReleasesRepo' ? repo : key === 'app.githubReleasesToken' ? token : key === 'app.version' ? version : undefined,
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
const appSettings = { isUpdateCheckEnabled: () => Promise.resolve(true) } as unknown as AppSettingsService;
|
||||||
|
const service = new ReleaseNotesService(config, appSettings);
|
||||||
|
|
||||||
|
console.log(`\nWhat's New preview · repo=${repo}${tag ? ` tag=${tag}` : ''}\n${'─'.repeat(60)}`);
|
||||||
|
|
||||||
|
const { releases } = await service.getAll(1);
|
||||||
|
const shown = tag ? releases.filter((r) => r.version === tag) : releases;
|
||||||
|
|
||||||
|
if (shown.length === 0) {
|
||||||
|
if (releases.length === 0) {
|
||||||
|
const probe = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=1`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
}).catch(() => null);
|
||||||
|
console.log(`No releases returned. GitHub API status: ${probe?.status ?? 'unreachable'} (check GITHUB_RELEASES_REPO / token).`);
|
||||||
|
} else {
|
||||||
|
console.log(`No release "${tag}" on page 1 (only the 30 most recent are checked).`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let warnings = 0;
|
||||||
|
for (const rel of shown) {
|
||||||
|
console.log(`\n■ ${rel.version} ${rel.date?.slice(0, 10) ?? ''}`);
|
||||||
|
|
||||||
|
if (rel.highlights.length === 0) {
|
||||||
|
console.log(' ⚠ 0 highlights parsed - popup is skipped for this version (only the changelog shows).');
|
||||||
|
console.log(' If you wrote a "## Highlights" section, check the heading spelling and that bullets start with "- ".');
|
||||||
|
warnings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const h of rel.highlights) {
|
||||||
|
console.log(` • ${h.icon ? `[${h.icon}]` : '[default icon]'} ${h.title}`);
|
||||||
|
if (h.body) console.log(` ${h.body.length > 96 ? `${h.body.slice(0, 96)}…` : h.body}`);
|
||||||
|
else {
|
||||||
|
console.log(' ⚠ no body text');
|
||||||
|
warnings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of h.media) {
|
||||||
|
const { ok, detail } = await checkReachable(m.url);
|
||||||
|
console.log(` ${ok ? '✓' : '⚠'} ${m.type}: ${detail} ${m.url}`);
|
||||||
|
if (!ok) warnings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${'─'.repeat(60)}`);
|
||||||
|
console.log(warnings === 0 ? '✓ No problems detected - this is what BookOrbit will render.' : `⚠ ${warnings} warning(s) above.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e instanceof Error ? e.message : String(e));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user