From 9e09f800673fc385f80cb1c6a772152b044e8b73 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 14 May 2026 09:45:04 -0600 Subject: [PATCH] feat(changelog): add changelog feed support and settings integration Introduce changelog feed manifest support, including client and shared utilities for fetching and parsing changelog data. Add a new Settings modal panel for viewing changelog entries, with version detection based on the current app version. Expose a configurable changelog feed URL in both environment variables and admin panel runtime settings. Update documentation and deployment workflow to support changelog feed generation and consumption. Include unit tests for changelog utilities. --- .env.example | 1 + .github/workflows/docs-deploy.yml | 15 ++ docs-site/.gitignore | 1 + docs-site/docs/configure/admin-panel.md | 1 + .../docs/reference/environment-variables.md | 9 + scripts/build-changelog-feed.mjs | 225 ++++++++++++++++++ src/app/layout.tsx | 7 +- src/components/SettingsModal.tsx | 214 ++++++++++++++++- src/components/admin/AdminFeaturesPanel.tsx | 19 ++ src/contexts/RuntimeConfigContext.tsx | 4 + src/lib/client/changelog.ts | 51 ++++ src/lib/server/admin/settings.ts | 1 + src/lib/shared/changelog.ts | 52 ++++ tests/unit/changelog.spec.ts | 93 ++++++++ 14 files changed, 687 insertions(+), 6 deletions(-) create mode 100644 scripts/build-changelog-feed.mjs create mode 100644 src/lib/client/changelog.ts create mode 100644 src/lib/shared/changelog.ts create mode 100644 tests/unit/changelog.spec.ts 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'); + }); +});