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.
225 lines
7.4 KiB
JavaScript
225 lines
7.4 KiB
JavaScript
#!/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);
|