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.
This commit is contained in:
Richard R 2026-05-14 09:45:04 -06:00
parent f300dfb01f
commit 9e09f80067
14 changed files with 687 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -1,3 +1,4 @@
.docusaurus
build
node_modules
static/changelog

View file

@ -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). |

View file

@ -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

View file

@ -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);

View file

@ -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 (
<html lang="en" className={figtree.variable} suppressHydrationWarning>

View file

@ -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<SectionId>(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"
>
<DialogPanel data-testid="settings-modal" className="w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
<DialogPanel data-testid="settings-modal" className="relative w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
Settings
</DialogTitle>
{authEnabled && (
<div className="flex items-center gap-4">
<Button
onClick={() => showPrivacyModal({ authEnabled })}
onClick={() => setIsChangelogOpen(true)}
className="text-sm font-medium text-muted hover:text-accent transition-colors"
>
Privacy
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
</Button>
)}
{authEnabled && (
<Button
onClick={() => showPrivacyModal({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent transition-colors"
>
Privacy
</Button>
)}
</div>
</div>
{isChangelogOpen && (
<SettingsChangelogPanel
appVersion={runtimeConfig.appVersion}
manifestUrl={runtimeConfig.changelogFeedUrl}
onClose={() => setIsChangelogOpen(false)}
/>
)}
{/* Mobile: 2x2 grid nav */}
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2">
{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<ChangelogManifestEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [bodies, setBodies] = useState<Record<string, ChangelogReleaseBody>>({});
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 (
<div className="absolute inset-x-0 top-[65px] bottom-0 z-20 bg-base border-t border-offbase">
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-background">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-foreground">Changelog</h4>
<p className="text-xs text-muted truncate">
{normalizedAppVersion
? `Current version: v${normalizedAppVersion}`
: 'Release history from GitHub'}
</p>
</div>
<Button
onClick={onClose}
className="text-sm font-medium text-muted hover:text-accent transition-colors"
>
Back to settings
</Button>
</div>
<div className="h-[calc(100%-57px)] overflow-y-auto p-3 space-y-2">
{loading && (
<div className="rounded-lg border border-offbase bg-background p-3 text-sm text-muted">
Loading changelog
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-offbase bg-background p-3 space-y-2">
<p className="text-sm text-foreground">Could not load changelog right now.</p>
<p className="text-xs text-muted break-words">{error}</p>
<a
href="https://github.com/richardr1126/openreader/releases"
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-accent hover:underline"
>
Open GitHub Releases
</a>
</div>
)}
{!loading && !error && manifest.length === 0 && (
<div className="rounded-lg border border-offbase bg-background p-3 text-sm text-muted">
No releases found.
</div>
)}
{!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 (
<div
key={entry.tag_name}
className={`rounded-lg border bg-background overflow-hidden ${
isCurrent ? 'border-accent' : 'border-offbase'
}`}
>
<button
type="button"
onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))}
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-base transition-colors"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground truncate">{entry.tag_name}</span>
{entry.prerelease && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-muted">
prerelease
</span>
)}
{isCurrent && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-accent">
current
</span>
)}
</div>
<p className="text-xs text-muted truncate">{entry.name}</p>
<p className="text-[11px] text-muted">
{new Date(entry.published_at).toLocaleDateString()}
</p>
</div>
<span className="text-xs text-muted">{isExpanded ? 'Hide' : 'Show'}</span>
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-1 border-t border-offbase space-y-2">
{body ? (
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-offbase [&_code]:rounded [&_code]:px-1 [&_pre]:bg-offbase [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{body.body || '_No release notes provided._'}
</ReactMarkdown>
</div>
) : (
<p className="text-xs text-muted">Loading release notes</p>
)}
<a
href={entry.html_url}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-accent hover:underline"
>
View on GitHub
</a>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -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={<Badge tone="foreground">Feature Flags</Badge>}
>
<div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Changelog feed URL</p>
<p className="text-xs text-muted mt-0.5">
Public URL to the changelog manifest JSON used by Settings.
</p>
</div>
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
</div>
<input
type="text"
className={inputClass}
value={String(draft.changelogFeedUrl ?? '')}
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/>
</div>
<ToggleRow
label="Word-level highlighting"
description="Highlight words during TTS playback."

View file

@ -13,6 +13,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react';
*/
export interface RuntimeConfig {
defaultTtsProvider: string;
changelogFeedUrl: string;
appVersion: string;
restrictUserApiKeys: boolean;
enableTtsProvidersTab: boolean;
enableWordHighlight: boolean;
@ -24,6 +26,8 @@ export interface RuntimeConfig {
const RUNTIME_DEFAULTS: RuntimeConfig = {
defaultTtsProvider: 'custom-openai',
changelogFeedUrl: 'https://docs.openreader.richardr.dev/changelog/manifest.json',
appVersion: '0.0.0',
restrictUserApiKeys: true,
enableTtsProvidersTab: true,
enableWordHighlight: true,

View file

@ -0,0 +1,51 @@
import type { ChangelogManifestEntry, ChangelogReleaseBody } from '@/lib/shared/changelog';
import { sortManifestEntries } from '@/lib/shared/changelog';
interface ManifestDocument {
generated_at?: string;
releases?: ChangelogManifestEntry[];
}
export async function fetchChangelogManifest(url: string, signal?: AbortSignal): Promise<ChangelogManifestEntry[]> {
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<ChangelogReleaseBody> {
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;
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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');
});
});