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:
neon
2026-06-18 03:36:10 +00:00
parent c92ab8cf49
commit c4ab4a9153
49 changed files with 3110 additions and 31 deletions
+25 -1
View File
@@ -1,16 +1,39 @@
<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 { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useThemeStore } from '@/stores/theme'
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
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 { TooltipProvider } from '@/components/ui/tooltip'
const { isOpen } = useChangePasswordDialog()
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()
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
@@ -28,6 +51,7 @@ provide(
</Transition>
</router-view>
<ChangePasswordDialog v-if="isOpen" />
<WhatsNewDialog v-if="popupOpen" />
<Toaster rich-colors position="bottom-right" :visible-toasts="5" :gap="8" />
</TooltipProvider>
</template>
+59 -17
View File
@@ -17,6 +17,7 @@ import {
BadgeQuestionMark,
Star,
ExternalLink,
Sparkles,
} from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
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 NotificationSheet from '@/features/notifications/components/NotificationSheet.vue'
import { useNotifications } from '@/features/notifications/composables/useNotifications'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types'
import { useThemeStore } from '@/stores/theme'
@@ -63,6 +65,7 @@ const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
const { subscribe: subscribeNotifications } = useNotifications()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const themeStore = useThemeStore()
const documentationUrl = 'https://bookorbit.app/what-is-bookorbit.html'
const githubRepositoryUrl = 'https://github.com/bookorbit/bookorbit'
@@ -98,6 +101,10 @@ function navigateToSettings() {
router.push({ name: 'settings-libraries' })
}
function navigateToWhatsNew() {
router.push({ name: 'whats-new' })
}
const uploadOpen = ref(false)
const searchFocused = ref(false)
@@ -547,6 +554,21 @@ function formatBadgeStyle(fmt: string) {
<Settings :size="15" class="mr-2 text-muted-foreground" />
Settings
</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>
</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:flex items-center gap-2.5">
<Tooltip>
<TooltipTrigger as-child>
<Button
as-child
data-tour="documentation-link"
variant="ghost"
size="icon"
:class="[
'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" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Documentation</TooltipContent>
<DropdownMenu>
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button
data-tour="documentation-link"
variant="ghost"
size="icon"
:class="[
'relative h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
iconRadiusClass,
]"
>
<BadgeQuestionMark :size="15" />
<span
v-if="hasUnseenWhatsNew"
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-background"
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>
+5 -9
View File
@@ -32,6 +32,7 @@ import { useLibraryCreationRedirect } from '@/features/library/composables/useLi
import { useThemeStore } from '@/stores/theme'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
function resolveIcon(name: string | null | undefined, fallback: Component): 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 themeStore = useThemeStore()
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const versionUi = computed(() => {
@@ -441,16 +443,10 @@ onUnmounted(() => stopLibraryUploadListener())
<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="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
<a
v-if="versionUi.currentHref"
:href="versionUi.currentHref"
target="_blank"
rel="noopener noreferrer"
class="transition-colors hover:text-sidebar-foreground"
>
<RouterLink to="/whats-new" class="inline-flex items-center gap-1.5 transition-colors hover:text-sidebar-foreground">
{{ versionUi.currentLabel }}
</a>
<span v-else>{{ versionUi.currentLabel }}</span>
<span v-if="hasUnseenWhatsNew" class="h-1.5 w-1.5 rounded-full bg-primary" aria-label="New release notes available" />
</RouterLink>
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
+76
View File
@@ -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 { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
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
@@ -52,6 +53,7 @@ function clearAuth() {
setAccessToken(null)
disconnectAuthorEnrichmentSocket()
disconnectBookMetadataFetchSocket()
resetWhatsNew()
}
setOnAuthFailure(() => {
@@ -1,18 +1,32 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Save } from 'lucide-vue-next'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, type NotificationCategory, type NotificationPreferences } from '@bookorbit/types'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { api } from '@/lib/api'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const { user, me } = useAuth()
const { popupEnabled, setPopupEnabled, loadPrefs } = useWhatsNew()
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>({})
function loadFromUser() {
@@ -122,5 +136,29 @@ async function handleSave() {
<span v-if="hasChanges" class="text-xs text-muted-foreground">Unsaved changes</span>
</div>
</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>
</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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'
}
+6
View File
@@ -232,6 +232,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/BookDockView.vue'),
meta: { title: 'Book Dock' },
},
{
path: '/whats-new',
name: 'whats-new',
component: () => import('@/features/whats-new/WhatsNewView.vue'),
meta: { title: "What's New" },
},
{
path: '/statistics',
name: 'statistics',
+6
View File
@@ -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.
### 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
+128
View File
@@ -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 |
+8
View File
@@ -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.
+1
View File
@@ -33,6 +33,7 @@ export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
export * from "./app-info";
export * from "./release-notes";
export * from "./series";
export * from "./table-layout";
export * from "./series-collapse";
+39
View File
@@ -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
View File
@@ -51,6 +51,19 @@ const commitPartial = `\
// strips the warning emoji from breaking-changes headings, and appends a
// Docker pull block after the commit groups.
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}}",
"{{#each noteGroups}}",
"",
@@ -142,15 +155,34 @@ function finalizeContext(ctx) {
const githubUser = shaToGithubUser.get(commit.hash);
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.
if (commit.hash && ctx.host && ctx.owner && ctx.repository) {
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 {
// non-fatal: commits render without author attribution
// non-fatal: commits render without author attribution / candidates
}
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 }],
],
};
+4
View File
@@ -33,6 +33,10 @@
"e2e:db:prepare": "tsx src/scripts/db-prepare-e2e.ts",
"db:studio": "drizzle-kit studio",
"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",
"type-check": "tsc --noEmit -p tsconfig.build.json",
"lint": "pnpm run lint:check",
+2
View File
@@ -60,6 +60,7 @@ import { EntityManagerModule } from './modules/entity-manager/entity-manager.mod
import { FontModule } from './modules/font/font.module';
import { KoreaderModule } from './modules/koreader/koreader.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 { HardcoverModule } from './modules/hardcover/hardcover.module';
@@ -130,6 +131,7 @@ import { HardcoverModule } from './modules/hardcover/hardcover.module';
FontModule,
KoreaderModule,
AppInfoModule,
ReleaseNotesModule,
AchievementModule,
HardcoverModule,
],
+6
View File
@@ -43,6 +43,8 @@ describe('config', () => {
nodeEnv: 'development',
appUrl: 'http://localhost:5173',
version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit',
githubReleasesToken: undefined,
oidcAllowLocalIssuers: false,
koboCloudscraperPython: undefined,
});
@@ -54,11 +56,15 @@ describe('config', () => {
process.env.APP_VERSION = 'v2.3.4';
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
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({
nodeEnv: 'production',
appUrl: 'https://bookorbit.local',
version: 'v2.3.4',
githubReleasesRepo: 'acme/app',
githubReleasesToken: 'ghp_example',
oidcAllowLocalIssuers: true,
koboCloudscraperPython: '/opt/bookorbit-python/bin/python',
});
+2
View File
@@ -5,6 +5,8 @@ export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
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),
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
}));
+8
View File
@@ -63,6 +63,14 @@ const envSchema = z.object({
.string()
.transform((val) => val.trim())
.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>) {
@@ -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.', '![](https://imgur.com/x.png)'].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.', '![](https://github.com/user-attachments/assets/abc)'].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(`![](https://github.com/user-attachments/assets/img-${i})`);
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.', ` ![](${ATTACH}/abc-123)`].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.', ' ![](https://raw.githubusercontent.com/o/r/main/demo.mp4)'].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}/two)`,
` ${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.', ` ![](${ATTACH}/abc)`].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. ![](https://evil.example.com/tracker.png)', ` ![](${ATTACH}/good)`].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. ![](${ATTACH}/abc)`;
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(` ![](${ATTACH}/img-${i})`);
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 `![alt](url)` and `[label](url)`.
const MEDIA_LINK_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
// Explicit image/video markup only (`![](url)`), 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>`, `![](url)`, 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 `![](url)`), 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 ![](${sharedUrl})\n- **Again** - same clip.\n ![](${sharedUrl})`,
},
];
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) {
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();
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,
type DisplayPreferences,
type ThemePreferences,
type WhatsNewPreferences,
} from '@bookorbit/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
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()
export class UserPreferencesService {
private readonly logger = new Logger(UserPreferencesService.name);
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> {
const row = await this.repo.findByCategory(userId, 'theme');
return row ? (row.data as ThemePreferences) : null;
+37
View File
@@ -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. |
+50
View File
@@ -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);
+133
View File
@@ -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);
});
+92
View File
@@ -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);
});
+136
View File
@@ -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);
});