diff --git a/.env.example b/.env.example index f4470d1..77dc817 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,7 @@ FFMPEG_BIN= # NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true # NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true # NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai +# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json # NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro # NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true # NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a087c62..b04ddde 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -3,10 +3,18 @@ name: Docs Deploy on: push: branches: [main] + release: + types: [published, edited, deleted] workflow_run: workflows: ["Version Docs on Tag"] types: [completed] workflow_dispatch: + inputs: + full_reconcile: + description: 'Run full changelog reconcile from GitHub Releases' + required: false + default: false + type: boolean permissions: contents: read @@ -43,6 +51,13 @@ jobs: - name: Install docs dependencies run: pnpm --dir docs-site install --frozen-lockfile + - name: Build changelog feed + env: + CHANGELOG_PUBLIC_BASE_URL: https://docs.openreader.richardr.dev + CHANGELOG_FORCE_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.full_reconcile && '1' || '0' }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/build-changelog-feed.mjs + - name: Build docs run: pnpm --dir docs-site build diff --git a/docs-site/.gitignore b/docs-site/.gitignore index 531b31c..4bcd110 100644 --- a/docs-site/.gitignore +++ b/docs-site/.gitignore @@ -1,3 +1,4 @@ .docusaurus build node_modules +static/changelog diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 01e0d23..2bd5d15 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -65,6 +65,7 @@ Runtime-editable settings, one row per key: | Key | What it controls | | --- | --- | | `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). | +| `changelogFeedUrl` | Public changelog manifest URL used by the Settings modal changelog panel. | | `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. | | `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. | | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 3aff3e1..d2414de 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -17,6 +17,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | | `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | | `NEXT_PUBLIC_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | +| `NEXT_PUBLIC_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | | `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL | | `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx | @@ -405,6 +406,14 @@ Sets the default TTS provider for new users. `showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**. +### NEXT_PUBLIC_CHANGELOG_FEED_URL + +Sets the changelog manifest URL used by the Settings modal changelog viewer. + +- Default: `https://docs.openreader.richardr.dev/changelog/manifest.json` +- Use this in self-hosted deployments when you publish changelog feeds to a custom docs domain/path. +- Runtime key: `changelogFeedUrl` + ### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT diff --git a/scripts/build-changelog-feed.mjs b/scripts/build-changelog-feed.mjs new file mode 100644 index 0000000..16683b3 --- /dev/null +++ b/scripts/build-changelog-feed.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const REPO = process.env.CHANGELOG_REPO || process.env.GITHUB_REPOSITORY || 'richardr1126/openreader'; +const PUBLIC_BASE = (process.env.CHANGELOG_PUBLIC_BASE_URL || 'https://docs.openreader.richardr.dev').replace(/\/$/, ''); +const OUTPUT_DIR = path.resolve('docs-site/static/changelog'); +const RELEASES_DIR = path.join(OUTPUT_DIR, 'releases'); +const MUTABLE_COUNT = Number(process.env.CHANGELOG_MUTABLE_COUNT || '3'); +const FULL_MODE = process.argv.includes('--full') || process.env.CHANGELOG_FORCE_FULL === '1'; + +function normalizeTagSlug(tagName) { + const normalized = String(tagName || '').trim().toLowerCase(); + const collapsed = normalized + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return collapsed || 'release'; +} + +function sortEntries(entries) { + return [...entries].sort((a, b) => { + const aMs = Date.parse(a.published_at); + const bMs = Date.parse(b.published_at); + if (Number.isFinite(aMs) && Number.isFinite(bMs) && aMs !== bMs) { + return bMs - aMs; + } + return String(b.tag_name || '').localeCompare(String(a.tag_name || '')); + }); +} + +function toManifestEntry(release) { + return { + tag_name: String(release.tag_name || ''), + name: String(release.name || release.tag_name || ''), + published_at: String(release.published_at || release.created_at || new Date().toISOString()), + html_url: String(release.html_url || ''), + prerelease: Boolean(release.prerelease), + body_path: '', + }; +} + +function toBodyRecord(release) { + return { + tag_name: String(release.tag_name || ''), + name: String(release.name || release.tag_name || ''), + published_at: String(release.published_at || release.created_at || new Date().toISOString()), + html_url: String(release.html_url || ''), + prerelease: Boolean(release.prerelease), + body: String(release.body || ''), + }; +} + +function applyBodyPath(entries) { + const seen = new Map(); + return entries.map((entry) => { + const base = normalizeTagSlug(entry.tag_name); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + const slug = count === 0 ? base : `${base}-${count + 1}`; + return { ...entry, body_path: `changelog/releases/${slug}.json` }; + }); +} + +async function fetchJson(url, { auth = false } = {}) { + const headers = { Accept: 'application/vnd.github+json' }; + if (auth && process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`Fetch failed ${res.status} for ${url}`); + } + return res.json(); +} + +async function fetchGitHubReleases() { + const out = []; + for (let page = 1; page <= 20; page += 1) { + const url = `https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`; + const data = await fetchJson(url, { auth: true }); + if (!Array.isArray(data) || data.length === 0) break; + out.push(...data); + if (data.length < 100) break; + } + return out; +} + +async function readEventPayload() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) return null; + try { + const raw = await readFile(eventPath, 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +async function fetchRemoteManifest() { + const url = `${PUBLIC_BASE}/changelog/manifest.json`; + try { + const data = await fetchJson(url); + if (!Array.isArray(data?.releases)) return null; + return data; + } catch { + return null; + } +} + +async function fetchRemoteBody(bodyPath) { + const url = `${PUBLIC_BASE}/${bodyPath.replace(/^\/+/, '')}`; + try { + return await fetchJson(url); + } catch { + return null; + } +} + +async function loadRemoteState() { + const manifestDoc = await fetchRemoteManifest(); + if (!manifestDoc) return null; + const manifest = Array.isArray(manifestDoc.releases) ? manifestDoc.releases : []; + const bodies = new Map(); + await Promise.all(manifest.map(async (entry) => { + if (!entry?.body_path) return; + const bodyDoc = await fetchRemoteBody(entry.body_path); + if (bodyDoc && typeof bodyDoc.body === 'string') { + bodies.set(entry.tag_name, bodyDoc); + } + })); + return { manifest, bodies }; +} + +function isMutable(manifest, tagName) { + const idx = manifest.findIndex((x) => x.tag_name === tagName); + return idx >= 0 && idx < MUTABLE_COUNT; +} + +async function buildState() { + const eventName = process.env.GITHUB_EVENT_NAME || ''; + const payload = await readEventPayload(); + const releaseEvent = payload?.release; + const releaseAction = payload?.action || ''; + + const remoteState = await loadRemoteState(); + const shouldFull = FULL_MODE || !remoteState; + + if (shouldFull) { + const releases = await fetchGitHubReleases(); + const filtered = releases.filter((r) => !r.draft); + const entries = sortEntries(filtered.map(toManifestEntry)); + const entriesWithPath = applyBodyPath(entries); + const bodies = new Map(filtered.map((r) => [r.tag_name, toBodyRecord(r)])); + return { entries: entriesWithPath, bodies, mode: 'full' }; + } + + const manifest = sortEntries(remoteState.manifest.filter((x) => !!x?.tag_name)); + const bodies = new Map(remoteState.bodies); + + if (eventName === 'release' && releaseEvent?.tag_name) { + const tagName = String(releaseEvent.tag_name); + const isDraft = Boolean(releaseEvent.draft); + const mutable = isMutable(manifest, tagName); + + if (releaseAction === 'deleted') { + if (mutable) { + const next = manifest.filter((entry) => entry.tag_name !== tagName); + bodies.delete(tagName); + return { entries: applyBodyPath(sortEntries(next)), bodies, mode: 'incremental-delete' }; + } + return { entries: applyBodyPath(manifest), bodies, mode: 'incremental-delete-skipped' }; + } + + if (!isDraft) { + const incoming = toManifestEntry(releaseEvent); + const existingIdx = manifest.findIndex((entry) => entry.tag_name === tagName); + if (existingIdx === -1 || mutable || existingIdx < MUTABLE_COUNT) { + if (existingIdx >= 0) manifest.splice(existingIdx, 1); + manifest.push(incoming); + bodies.set(tagName, toBodyRecord(releaseEvent)); + } + } + + return { entries: applyBodyPath(sortEntries(manifest)), bodies, mode: 'incremental-upsert' }; + } + + return { entries: applyBodyPath(manifest), bodies, mode: 'mirror' }; +} + +async function writeState({ entries, bodies, mode }) { + await rm(OUTPUT_DIR, { recursive: true, force: true }); + await mkdir(RELEASES_DIR, { recursive: true }); + + const manifest = { + generated_at: new Date().toISOString(), + source: `https://github.com/${REPO}/releases`, + mutable_window: MUTABLE_COUNT, + mode, + releases: entries, + }; + + for (const entry of entries) { + let body = bodies.get(entry.tag_name); + if (!body) { + body = { + tag_name: entry.tag_name, + name: entry.name, + published_at: entry.published_at, + html_url: entry.html_url, + prerelease: entry.prerelease, + body: '', + }; + } + const outPath = path.resolve('docs-site/static', entry.body_path); + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, `${JSON.stringify(body, null, 2)}\n`, 'utf8'); + } + + await writeFile(path.join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + console.log(`Wrote ${entries.length} changelog releases (${mode})`); +} + +const state = await buildState(); +await writeState(state); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9117663..1ebd8c7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Figtree } from "next/font/google"; import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics"; import { CookieConsentBanner } from "@/components/CookieConsentBanner"; import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config"; +import pkg from "../../package.json"; const figtree = Figtree({ subsets: ["latin"], @@ -51,7 +52,11 @@ function jsonEmbedSafe(value: unknown): string { export default async function RootLayout({ children }: { children: ReactNode }) { const runtimeConfig = await getResolvedRuntimeConfig(); - const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfig)};`; + const runtimeConfigWithAppVersion = { + ...runtimeConfig, + appVersion: pkg.version, + }; + const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`; return ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index d5aa550..eeeb4a2 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -64,6 +64,15 @@ import { segmentedButtonClass, segmentedGroupClass, } from '@/components/formPrimitives'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog'; +import { + findCurrentVersionIndex, + normalizeVersion, + type ChangelogManifestEntry, + type ChangelogReleaseBody, +} from '@/lib/shared/changelog'; // Hard-coded theme color palettes for the visual theme selector type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string }; @@ -131,6 +140,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys; const [isOpen, setIsOpen] = useState(false); + const [isChangelogOpen, setIsChangelogOpen] = useState(false); const [activeSection, setActiveSection] = useState(enableTTSProvidersTab ? 'api' : 'theme'); const { theme, setTheme, applyCustomColors } = useTheme(); @@ -410,6 +420,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const resetToCurrent = useCallback(() => { setIsOpen(false); + setIsChangelogOpen(false); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); @@ -501,6 +512,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const selectedModelVersion = selectedModel?.id?.includes(':') ? selectedModel.id.slice(selectedModel.id.indexOf(':')) : ''; + const displayVersion = normalizeVersion(runtimeConfig.appVersion || ''); return ( <> @@ -538,22 +550,38 @@ export function SettingsModal({ className = '' }: { className?: string }) { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + {/* Header */}
Settings - {authEnabled && ( +
- )} + {authEnabled && ( + + )} +
+ {isChangelogOpen && ( + setIsChangelogOpen(false)} + /> + )} + {/* Mobile: 2x2 grid nav */}
{visibleSections.map((section) => { @@ -1347,3 +1375,179 @@ export function SettingsModal({ className = '' }: { className?: string }) { ); } + +function SettingsChangelogPanel({ + appVersion, + manifestUrl, + onClose, +}: { + appVersion: string; + manifestUrl: string; + onClose: () => void; +}) { + const [manifest, setManifest] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>({}); + const [bodies, setBodies] = useState>({}); + const normalizedAppVersion = normalizeVersion(appVersion || ''); + + useEffect(() => { + const controller = new AbortController(); + async function loadManifest() { + setLoading(true); + setError(null); + try { + const entries = await fetchChangelogManifest(manifestUrl, controller.signal); + setManifest(entries); + const initialIndex = findCurrentVersionIndex(entries, normalizedAppVersion); + if (initialIndex >= 0) { + const entry = entries[initialIndex]; + setExpanded((prev) => ({ ...prev, [entry.tag_name]: true })); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load changelog'); + } finally { + setLoading(false); + } + } + void loadManifest(); + return () => controller.abort(); + }, [manifestUrl, normalizedAppVersion]); + + useEffect(() => { + const controller = new AbortController(); + const tagsToLoad = manifest + .filter((entry) => expanded[entry.tag_name] && !bodies[entry.tag_name]) + .map((entry) => entry.tag_name); + if (tagsToLoad.length === 0) return () => controller.abort(); + + async function loadBodies() { + await Promise.all(tagsToLoad.map(async (tag) => { + const entry = manifest.find((item) => item.tag_name === tag); + if (!entry) return; + try { + const body = await fetchChangelogReleaseBody(manifestUrl, entry.body_path, controller.signal); + setBodies((prev) => ({ ...prev, [tag]: body })); + } catch { + // Keep entry expanded; inline fallback appears below. + } + })); + } + void loadBodies(); + return () => controller.abort(); + }, [expanded, manifest, manifestUrl, bodies]); + + return ( +
+
+
+

Changelog

+

+ {normalizedAppVersion + ? `Current version: v${normalizedAppVersion}` + : 'Release history from GitHub'} +

+
+ +
+ +
+ {loading && ( +
+ Loading changelog… +
+ )} + + {!loading && error && ( +
+

Could not load changelog right now.

+

{error}

+ + Open GitHub Releases + +
+ )} + + {!loading && !error && manifest.length === 0 && ( +
+ No releases found. +
+ )} + + {!loading && !error && manifest.map((entry) => { + const isCurrent = normalizedAppVersion && normalizeVersion(entry.tag_name) === normalizedAppVersion; + const body = bodies[entry.tag_name]; + const isExpanded = !!expanded[entry.tag_name]; + return ( +
+ + + {isExpanded && ( +
+ {body ? ( +
+ + {body.body || '_No release notes provided._'} + +
+ ) : ( +

Loading release notes…

+ )} + + View on GitHub + +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index b1f5975..92f3247 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -11,6 +11,7 @@ import { ToggleRow, btnPrimary, btnSecondary, + inputClass, listboxButtonClass, listboxOptionClass, listboxOptionsClass, @@ -277,6 +278,24 @@ export function AdminFeaturesPanel() { subtitle="Feature flags for all users." action={Feature Flags} > +
+
+
+

Changelog feed URL

+

+ Public URL to the changelog manifest JSON used by Settings. +

+
+
{renderSource('changelogFeedUrl')}
+
+ updateDraft('changelogFeedUrl', event.target.value)} + placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json" + /> +
{ + const res = await fetch(url, { + signal, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`Failed to fetch changelog manifest: HTTP ${res.status}`); + } + + const data = (await res.json()) as ManifestDocument; + if (!Array.isArray(data.releases)) { + throw new Error('Invalid changelog manifest format'); + } + + const sanitized = data.releases.filter((entry) => ( + !!entry + && typeof entry.tag_name === 'string' + && typeof entry.name === 'string' + && typeof entry.published_at === 'string' + && typeof entry.html_url === 'string' + && typeof entry.body_path === 'string' + && typeof entry.prerelease === 'boolean' + )); + + return sortManifestEntries(sanitized); +} + +export async function fetchChangelogReleaseBody(baseManifestUrl: string, bodyPath: string, signal?: AbortSignal): Promise { + const bodyUrl = new URL(bodyPath.replace(/^\/+/, ''), `${new URL(baseManifestUrl).origin}/`).toString(); + const res = await fetch(bodyUrl, { + signal, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`Failed to fetch changelog release body: HTTP ${res.status}`); + } + + const data = await res.json() as ChangelogReleaseBody; + if (typeof data.body !== 'string') { + throw new Error('Invalid changelog release body format'); + } + return data; +} diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 425489e..200f9df 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -76,6 +76,7 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef< export const RUNTIME_CONFIG_SCHEMA = { defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'), + changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'NEXT_PUBLIC_CHANGELOG_FEED_URL'), restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'), // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. diff --git a/src/lib/shared/changelog.ts b/src/lib/shared/changelog.ts new file mode 100644 index 0000000..09a4f65 --- /dev/null +++ b/src/lib/shared/changelog.ts @@ -0,0 +1,52 @@ +export interface ChangelogManifestEntry { + tag_name: string; + name: string; + published_at: string; + html_url: string; + prerelease: boolean; + body_path: string; +} + +export interface ChangelogReleaseBody extends ChangelogManifestEntry { + body: string; +} + +export function normalizeVersion(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return ''; + return trimmed.startsWith('v') ? trimmed.slice(1) : trimmed; +} + +export function tagsMatchVersion(tag: string, version: string): boolean { + const left = normalizeVersion(tag); + const right = normalizeVersion(version); + return !!left && !!right && left === right; +} + +export function sortManifestEntries(entries: ChangelogManifestEntry[]): ChangelogManifestEntry[] { + return [...entries].sort((a, b) => { + const aMs = Date.parse(a.published_at); + const bMs = Date.parse(b.published_at); + if (Number.isFinite(aMs) && Number.isFinite(bMs) && aMs !== bMs) { + return bMs - aMs; + } + return b.tag_name.localeCompare(a.tag_name); + }); +} + +export function findCurrentVersionIndex(entries: ChangelogManifestEntry[], appVersion: string): number { + return entries.findIndex((entry) => tagsMatchVersion(entry.tag_name, appVersion)); +} + +export function toSafeTagSlug(tagName: string): string { + const normalized = tagName.trim().toLowerCase(); + const collapsed = normalized + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return collapsed || 'release'; +} + +export function isMutableIndex(index: number, mutableCount = 3): boolean { + return index >= 0 && index < mutableCount; +} diff --git a/tests/unit/changelog.spec.ts b/tests/unit/changelog.spec.ts new file mode 100644 index 0000000..02117d9 --- /dev/null +++ b/tests/unit/changelog.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +import { + findCurrentVersionIndex, + isMutableIndex, + normalizeVersion, + sortManifestEntries, + tagsMatchVersion, + toSafeTagSlug, + type ChangelogManifestEntry, +} from '../../src/lib/shared/changelog'; + +test.describe('changelog utilities', () => { + test('normalizes version strings with and without v prefix', () => { + expect(normalizeVersion('v2.2.0')).toBe('2.2.0'); + expect(normalizeVersion('2.2.0')).toBe('2.2.0'); + expect(normalizeVersion(' V2.2.0 ')).toBe('2.2.0'); + }); + + test('matches tag and version reliably', () => { + expect(tagsMatchVersion('v2.2.0', '2.2.0')).toBe(true); + expect(tagsMatchVersion('2.2.0', 'v2.2.0')).toBe(true); + expect(tagsMatchVersion('v2.2.1', '2.2.0')).toBe(false); + }); + + test('finds the running version index in manifest entries', () => { + const entries: ChangelogManifestEntry[] = [ + { + tag_name: 'v2.3.0', + name: 'v2.3.0', + published_at: '2026-05-01T00:00:00.000Z', + html_url: 'https://example.com/1', + prerelease: false, + body_path: 'changelog/releases/v2-3-0.json', + }, + { + tag_name: 'v2.2.0', + name: 'v2.2.0', + published_at: '2026-04-01T00:00:00.000Z', + html_url: 'https://example.com/2', + prerelease: false, + body_path: 'changelog/releases/v2-2-0.json', + }, + ]; + + expect(findCurrentVersionIndex(entries, '2.2.0')).toBe(1); + expect(findCurrentVersionIndex(entries, 'v2.3.0')).toBe(0); + expect(findCurrentVersionIndex(entries, '1.0.0')).toBe(-1); + }); + + test('sorts manifest newest first by published_at', () => { + const entries: ChangelogManifestEntry[] = [ + { + tag_name: 'v2.1.0', + name: 'v2.1.0', + published_at: '2026-03-01T00:00:00.000Z', + html_url: 'https://example.com/1', + prerelease: false, + body_path: 'changelog/releases/v2-1-0.json', + }, + { + tag_name: 'v2.3.0', + name: 'v2.3.0', + published_at: '2026-05-01T00:00:00.000Z', + html_url: 'https://example.com/3', + prerelease: false, + body_path: 'changelog/releases/v2-3-0.json', + }, + { + tag_name: 'v2.2.0', + name: 'v2.2.0', + published_at: '2026-04-01T00:00:00.000Z', + html_url: 'https://example.com/2', + prerelease: false, + body_path: 'changelog/releases/v2-2-0.json', + }, + ]; + + expect(sortManifestEntries(entries).map((entry) => entry.tag_name)).toEqual(['v2.3.0', 'v2.2.0', 'v2.1.0']); + }); + + test('treats only newest three entries as mutable by default', () => { + expect(isMutableIndex(0)).toBe(true); + expect(isMutableIndex(1)).toBe(true); + expect(isMutableIndex(2)).toBe(true); + expect(isMutableIndex(3)).toBe(false); + }); + + test('builds filesystem-safe slugs for release tags', () => { + expect(toSafeTagSlug('v2.2.0')).toBe('v2.2.0'); + expect(toSafeTagSlug('v2.2.0+build/meta')).toBe('v2.2.0-build-meta'); + }); +});