From 66351e0d3157686025f8aac1159ff75ef616998a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:02:40 +0100 Subject: [PATCH] feat: show admins the changelog of every plugin version an update brings or a rollback undoes --- app/(main)/admin/_pages/extension-detail.tsx | 43 ++++++++-- .../admin/_pages/marketplace-detail.tsx | 26 +++++- app/(main)/admin/_pages/marketplace.tsx | 74 ++++++++++++----- app/api/admin/marketplace/[slug]/route.ts | 1 + .../admin/marketplace/__tests__/route.test.ts | 11 +++ app/api/admin/marketplace/route.ts | 4 + app/api/admin/marketplace/updates/route.ts | 4 +- app/api/admin/plugins/route.ts | 7 +- app/api/admin/themes/route.ts | 5 +- components/admin/__tests__/changelog.test.tsx | 59 ++++++++++++++ components/admin/changelog.tsx | 77 ++++++++++++++++++ lib/admin/__tests__/changelog-range.test.ts | 26 ++++++ lib/admin/__tests__/extension-history.test.ts | 14 ++++ lib/admin/changelog-markdown.ts | 81 +++++++++++++++++++ lib/admin/changelog-range.ts | 34 ++++++++ lib/admin/extension-history.ts | 16 +++- lib/admin/marketplace-directory.ts | 10 +++ lib/admin/plugin-registry.ts | 8 ++ 18 files changed, 462 insertions(+), 38 deletions(-) create mode 100644 components/admin/__tests__/changelog.test.tsx create mode 100644 components/admin/changelog.tsx create mode 100644 lib/admin/__tests__/changelog-range.test.ts create mode 100644 lib/admin/changelog-markdown.ts create mode 100644 lib/admin/changelog-range.ts diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx index 68f6e5c82..1d39bd725 100644 --- a/app/(main)/admin/_pages/extension-detail.tsx +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from 'react'; import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; import { adminPath } from '@/lib/admin/admin-nav'; @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'; import { useExtensionIcons } from './use-extension-icons'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { ChangelogNotes } from '@/components/admin/changelog'; export interface ConfigField { type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; @@ -54,6 +55,10 @@ export interface ExtensionEntry { source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' }; /** Versions kept on this server for a rollback, most recently replaced first. */ previousVersions?: string[]; + /** The installed version's changelog, kept from a marketplace install. */ + changelog?: string; + /** The changelogs of the kept versions that have one, by version. */ + previousChangelogs?: Record; /** Loaded from the dev plugins directory rather than installed. */ dev?: boolean; configSchema?: Record; @@ -233,7 +238,8 @@ export function ExtensionDetailPage({ kind, id }: Props) { if (!entry) return; const ok = await confirm({ title: `Roll back to v${version}?`, - message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.`, + message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.` + + (entry.changelog ? ` What v${entry.version} changed, listed under Versions, is undone.` : ''), confirmText: 'Roll back', cancelText: 'Cancel', }); @@ -405,16 +411,16 @@ export function ExtensionDetailPage({ kind, id }: Props) { )} - + v{entry.version} - + {previous.map((version) => ( - + - + ))} @@ -461,6 +467,31 @@ export function ExtensionDetailPage({ kind, id }: Props) { ); } +/** A row of the Versions section: like a SettingItem, with the version's changelog under its label. */ +function VersionRow({ label, description, changelog, notesLabel, children }: { + label: string; + description?: string; + changelog?: string; + notesLabel: string; + children: ReactNode; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} + {changelog && ( +
+ {notesLabel} + +
+ )} +
+
{children}
+
+ ); +} + function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: { field: ConfigField; value: string; diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index ee3866ab5..92c946843 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -39,6 +39,8 @@ import { useAdminFeedback } from '@/components/admin/feedback'; import { installVersion } from './marketplace'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { ChangelogBadges, ChangelogEntries, ChangelogNotes, type ChangelogFlags } from '@/components/admin/changelog'; +import { versionsBetween } from '@/lib/admin/changelog-range'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -53,6 +55,7 @@ interface VersionRow { sha256: string; yanked: { at: string; reason: string | null } | null; changelog: string | null; + notes: ChangelogFlags | null; downloads: number; signed: boolean; commit: string | null; @@ -309,6 +312,15 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { ? compareVersions(preview.version, data.installedVersion) : 0; const installedRow = data.installedVersion ? ext.versions.find((v) => v.version === data.installedVersion) ?? null : null; + // The notes for the previewed version: everything an update to it brings, + // or everything going back to it undoes, or just its own. + const comparing = !!preview && data.installed && !!data.installedVersion && !previewIsInstalled; + const changeRows = !preview ? [] + : comparing ? versionsBetween(ext.versions, data.installedVersion!, preview.version, data.channel) + : ext.versions.filter((v) => v.version === preview.version); + const changesTitle = !comparing ? `Changelog of v${preview?.version}` + : previewDirection > 0 ? `What's new since v${data.installedVersion}` + : `What going back to v${preview!.version} undoes`; const suspended = ext.status === 'suspended'; const historyVersions = new Set(data.history.map((h) => h.version)); const canInstallPreview = !!preview && !bundle.error && previewFits && !suspended && bundle.signature?.status !== 'invalid'; @@ -529,6 +541,12 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { + {changeRows.some((r) => r.changelog) && ( + + ({ version: r.version, publishedAt: r.publishedAt, changelog: r.changelog, notes: r.notes ?? undefined }))} /> + + )} + v{v.version} {v.channel === 'beta' && beta} {v.signed && } + - {v.changelog &&

{v.changelog}

} + {v.changelog && ( +
+ Changelog + +
+ )} {fmtDate(v.publishedAt)} diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx index d721c876e..9838ce1ec 100644 --- a/app/(main)/admin/_pages/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -22,6 +22,7 @@ import { useAdminFeedback } from '@/components/admin/feedback'; import { ConfigNote, inputClass } from '@/components/admin/config-items'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { ChangelogBadges, ChangelogEntries, anyFlag, type ChangelogEntry, type ChangelogFlags } from '@/components/admin/changelog'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -43,7 +44,10 @@ interface InstalledEntry { interface UpdatesResult { installed: InstalledEntry[]; - updates: Array<{ slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; changelog: string | null }>; + updates: Array<{ + slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; publishedAt: string | null; + changelog: string | null; notes?: ChangelogFlags; changelogs?: ChangelogEntry[]; + }>; yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>; deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>; suspended: string[]; @@ -70,6 +74,16 @@ const PAGE = adminPageDef('marketplace'); export const detailHref = (slug: string, version?: string | null) => `${adminPath('marketplace', slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`; +/** + * The notes an update brings: every version between the installed one and + * the offered one, or, from a directory older than 2026-10-30, the offered + * version's alone. + */ +function updateNotes(u: UpdatesResult['updates'][number]): ChangelogEntry[] { + if (u.changelogs?.length) return u.changelogs; + return [{ version: u.version, publishedAt: u.publishedAt ?? null, changelog: u.changelog, notes: u.notes }]; +} + /** The segmented Browse / Installed and All / Plugins / Themes switches. */ const pillGroupClass = 'flex items-center gap-0.5 rounded-md bg-muted p-0.5 self-start'; const pillClass = (active: boolean) => cn( @@ -437,28 +451,44 @@ function UpdatesSection({ )}
    - {updates.updates.map((u) => ( -
  • -
    -
    - {name(u.slug)} - v{u.currentVersion} → v{u.version} - {u.channel === 'beta' && beta} + {updates.updates.map((u) => { + const entries = updateNotes(u); + const withNotes = entries.filter((e) => e.changelog); + return ( +
  • +
    +
    + {name(u.slug)} + v{u.currentVersion} → v{u.version} + {u.channel === 'beta' && beta} + +
    + {withNotes.length > 0 ? ( +
    + + {entries.length > 1 ? `What's new in ${entries.length} versions` : "What's new"} + +
    + +
    +
    + ) : ( +

    No changelog for this update.

    + )}
    - {u.changelog &&

    {u.changelog}

    } - - -
  • - ))} + + + ); + })}
)} diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index f35fd899b..e53a2a545 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -40,6 +40,7 @@ function versionRow(v: DirectoryVersion) { sha256: v.sha256, yanked: v.yanked, changelog: v.changelog ?? null, + notes: v.notes ?? null, downloads: v.downloads ?? 0, signed: !!v.signature, commit: v.provenance?.source?.commit ?? null, diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts index 92b1b768e..4caa86fbe 100644 --- a/app/api/admin/marketplace/__tests__/route.test.ts +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -103,6 +103,17 @@ describe('POST /api/admin/marketplace', () => { ]); }); + it("keeps the version's changelog with the install", async () => { + directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), { changelog: '### Fixed\n- Dates in March' }); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200); + expect(registry.get('pretty-dates')).toMatchObject({ changelog: '### Fixed\n- Dates in March' }); + + // A version without notes doesn't keep the last one's. + directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), { changelog: null }); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200); + expect(registry.get('pretty-dates')).not.toHaveProperty('changelog'); + }); + it('answers 404 for a version the directory does not have', async () => { directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), {}, { missing: true }); const res = await install({ slug: 'pretty-dates', version: '9.9.9', type: 'plugin' }); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 19cfb739e..bea46fc9d 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -236,6 +236,8 @@ export async function POST(request: NextRequest) { } const source = { kind: 'marketplace' as const, slug, directoryUrl }; + // Kept with the install, and so with its rollback copy later. + const notes = typeof meta.changelog === 'string' && meta.changelog.trim() ? { changelog: meta.changelog.slice(0, 20_000) } : {}; const installExtras = { signature: signature.status, channel: meta.channel, @@ -282,6 +284,7 @@ export async function POST(request: NextRequest) { installedAt: existingTheme?.installedAt ?? now, updatedAt: now, source, + ...notes, }; await saveTheme(theme, css, skin); @@ -357,6 +360,7 @@ export async function POST(request: NextRequest) { ...(manifest.locales ? { locales: manifest.locales } : {}), ...(manifest.credentials ? { credentials: manifest.credentials } : {}), source, + ...notes, }; await savePlugin(plugin, code); diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index 7d2643452..e32bf5162 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -13,11 +13,13 @@ import { installedExtensions, v2Url, type DirectoryAdvisory, + type DirectoryChangelogEntry, type DirectoryVersion, } from '@/lib/admin/marketplace-directory'; export interface UpdatesAnswer { - updates: Array; + /** `changelogs` is missing from directories older than 2026-10-30; `changelog` is then the offered version's alone. */ + updates: Array; yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>; deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>; suspended: string[]; diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 9eb98fa7e..aeaf5c7bf 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -12,7 +12,7 @@ import { import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup'; -import { listHistory } from '@/lib/admin/extension-history'; +import { keptVersions, listHistory } from '@/lib/admin/extension-history'; import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; @@ -33,14 +33,13 @@ export async function GET(request: NextRequest) { // Dev plugins win on id collision so admins see what users actually load. const devIds = new Set(devEntries.map(e => e.plugin.id)); const merged = [ - ...devEntries.map(e => ({ ...e.plugin, dev: true as const, previousVersions: [] as string[] })), + ...devEntries.map(e => ({ ...e.plugin, dev: true as const, ...keptVersions([]) })), ...(await Promise.all(registry.plugins .filter(p => !devIds.has(p.id)) .map(async p => ({ ...p, dev: false as const, - // Versions this server still has a copy of (lib/admin/extension-history.ts). - previousVersions: (await listHistory('plugin', p.id).catch(() => [])).map(h => h.version), + ...keptVersions(await listHistory('plugin', p.id).catch(() => [])), })))), ]; return NextResponse.json(merged, { diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index f9840a5ce..8a2eeca7d 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -11,7 +11,7 @@ import { type ServerTheme, } from '@/lib/admin/plugin-registry'; import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import { listHistory } from '@/lib/admin/extension-history'; +import { keptVersions, listHistory } from '@/lib/admin/extension-history'; import { readThemePackage } from '@/lib/extension-package'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; @@ -27,8 +27,7 @@ export async function GET(request: NextRequest) { const registry = await getThemeRegistry(); const themes = await Promise.all(registry.themes.map(async t => ({ ...t, - // Versions this server still has a copy of (lib/admin/extension-history.ts). - previousVersions: (await listHistory('theme', t.id).catch(() => [])).map(h => h.version), + ...keptVersions(await listHistory('theme', t.id).catch(() => [])), }))); return NextResponse.json(themes, { headers: { 'Cache-Control': 'no-store' }, diff --git a/components/admin/__tests__/changelog.test.tsx b/components/admin/__tests__/changelog.test.tsx new file mode 100644 index 000000000..a94bbf5aa --- /dev/null +++ b/components/admin/__tests__/changelog.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChangelogEntries, ChangelogNotes } from '../changelog'; +import { parseChangelog } from '@/lib/admin/changelog-markdown'; + +describe('parseChangelog', () => { + it('reads headings, lists, paragraphs, code, bold and links', () => { + expect(parseChangelog('### Fixed\n- Keep the **header**\n intact. #31\n- Use `Intl`\n\nSee [the docs](https://example.org/a).\n\n```\nx\n```')).toEqual([ + { t: 'heading', c: [{ t: 'text', v: 'Fixed' }] }, + { t: 'list', ordered: false, items: [ + [{ t: 'text', v: 'Keep the ' }, { t: 'strong', c: [{ t: 'text', v: 'header' }] }, { t: 'text', v: ' intact. #31' }], + [{ t: 'text', v: 'Use ' }, { t: 'code', v: 'Intl' }], + ] }, + { t: 'para', c: [{ t: 'text', v: 'See ' }, { t: 'link', href: 'https://example.org/a', c: [{ t: 'text', v: 'the docs' }] }, { t: 'text', v: '.' }] }, + { t: 'code', v: 'x' }, + ]); + }); + + it('keeps links to anything but http(s) as their text', () => { + expect(parseChangelog('[click](javascript:alert`1`) [mail](mailto:a@b.c)')).toEqual([ + { t: 'para', c: [{ t: 'text', v: 'click' }, { t: 'text', v: ' ' }, { t: 'text', v: 'mail' }] }, + ]); + }); + + it('splits a numbered list from a bulleted one', () => { + expect(parseChangelog('1. One\n2. Two\n- Three').map((b) => b.t === 'list' && b.ordered)).toEqual([true, false]); + }); +}); + +describe('ChangelogNotes', () => { + it('shows HTML in the notes as text and never as elements', () => { + const { container } = render( and \n\nx'} />); + expect(container.querySelector('img, script')).toBeNull(); + expect(container.querySelectorAll('a')).toHaveLength(0); + expect(container.textContent).toContain(''); + }); + + it('opens links in a new tab without the opener', () => { + render(); + const link = screen.getByRole('link', { name: 'the docs' }); + expect(link).toHaveAttribute('href', 'https://example.org/a'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); + +describe('ChangelogEntries', () => { + it('lists each version with its flags, and says when one has no notes', () => { + render(); + const items = screen.getAllByRole('listitem').filter((li) => li.parentElement?.tagName === 'OL'); + expect(items.map((li) => li.querySelector('span')?.textContent)).toEqual(['v1.3.0', 'v1.2.0']); + expect(items[0]).toHaveTextContent('Security'); + expect(items[0]).toHaveTextContent('Escape names'); + expect(items[1]).toHaveTextContent('No notes for this version.'); + }); +}); diff --git a/components/admin/changelog.tsx b/components/admin/changelog.tsx new file mode 100644 index 000000000..7f1c6251f --- /dev/null +++ b/components/admin/changelog.tsx @@ -0,0 +1,77 @@ +import { Fragment, useMemo, type ReactNode } from 'react'; +import { ShieldAlert, AlertTriangle } from '@/components/icons'; +import { parseChangelog, type Inline } from '@/lib/admin/changelog-markdown'; +import { cn } from '@/lib/utils'; +import { Badge } from './badge'; + +export interface ChangelogFlags { + security: boolean; + breaking: boolean; +} + +export interface ChangelogEntry { + version: string; + publishedAt: string | null; + changelog: string | null; + notes?: ChangelogFlags; +} + +function inline(nodes: Inline[]): ReactNode { + return nodes.map((n, i) => { + if (n.t === 'text') return {n.v}; + if (n.t === 'code') return {n.v}; + if (n.t === 'strong') return {inline(n.c)}; + return {inline(n.c)}; + }); +} + +/** An extension's changelog: a safe subset of Markdown, drawn as text. */ +export function ChangelogNotes({ markdown, className }: { markdown: string; className?: string }) { + const blocks = useMemo(() => parseChangelog(markdown), [markdown]); + return ( +
+ {blocks.map((b, i) => { + if (b.t === 'heading') return

{inline(b.c)}

; + if (b.t === 'para') return

{inline(b.c)}

; + if (b.t === 'code') return
{b.v}
; + const List = b.ordered ? 'ol' : 'ul'; + return ( + + {b.items.map((item, j) =>
  • {inline(item)}
  • )} +
    + ); + })} +
    + ); +} + +/** "Security" and "Breaking" next to an update, when its notes have those headings. */ +export function ChangelogBadges({ flags }: { flags: ChangelogFlags | undefined }) { + if (!flags?.security && !flags?.breaking) return null; + return ( + <> + {flags.security && }>Security} + {flags.breaking && }>Breaking} + + ); +} + +export const anyFlag = (entries: ChangelogEntry[], key: keyof ChangelogFlags) => entries.some((e) => e.notes?.[key]); + +/** The notes of several versions, newest first, each under its version. */ +export function ChangelogEntries({ entries }: { entries: ChangelogEntry[] }) { + return ( +
      + {entries.map((e) => ( +
    1. +
      + v{e.version} + {e.publishedAt && {new Date(e.publishedAt).toLocaleDateString()}} + +
      + {e.changelog ? :

      No notes for this version.

      } +
    2. + ))} +
    + ); +} diff --git a/lib/admin/__tests__/changelog-range.test.ts b/lib/admin/__tests__/changelog-range.test.ts new file mode 100644 index 000000000..2d6565bc7 --- /dev/null +++ b/lib/admin/__tests__/changelog-range.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { versionsBetween } from '../changelog-range'; + +const v = (version: string, yanked: unknown = null) => ({ version, channel: version.includes('-') ? 'beta' as const : 'stable' as const, yanked }); +const ALL = [v('1.4.0'), v('1.3.0'), v('1.3.0-beta.1'), v('1.2.0', { at: 'x' }), v('1.1.0'), v('1.0.0')]; +const versions = (installed: string, target: string, channel: 'stable' | 'beta' = 'stable') => + versionsBetween(ALL, installed, target, channel).map((r) => r.version); + +describe('versionsBetween', () => { + it('lists what an update brings, newest first, without withdrawn versions', () => { + expect(versions('1.0.0', '1.3.0')).toEqual(['1.3.0', '1.1.0']); + }); + + it('lists what going back undoes: the installed version down to just above the target', () => { + expect(versions('1.4.0', '1.1.0')).toEqual(['1.4.0', '1.3.0']); + }); + + it('keeps a withdrawn version that is installed', () => { + expect(versions('1.2.0', '1.0.0')).toEqual(['1.2.0', '1.1.0']); + }); + + it('shows betas on the beta channel or when going to a beta, ordered below their release', () => { + expect(versions('1.1.0', '1.3.0', 'beta')).toEqual(['1.3.0', '1.3.0-beta.1']); + expect(versions('1.1.0', '1.3.0-beta.1')).toEqual(['1.3.0-beta.1']); + }); +}); diff --git a/lib/admin/__tests__/extension-history.test.ts b/lib/admin/__tests__/extension-history.test.ts index daace58ae..5a601a123 100644 --- a/lib/admin/__tests__/extension-history.test.ts +++ b/lib/admin/__tests__/extension-history.test.ts @@ -120,6 +120,20 @@ describe('plugin history', () => { expect(await registry.getPluginBundle('demo')).toBe('v1()'); expect((await history.listHistory('plugin', 'demo')).map((e) => e.version)).toEqual(['1.1.0']); }); + + it("keeps a version's changelog with its copy and lists it for the admin", async () => { + const registry = await import('../plugin-registry'); + const history = await import('../extension-history'); + await registry.savePlugin({ ...PLUGIN, changelog: '- First' }, 'v1()'); + await registry.savePlugin({ ...PLUGIN, version: '1.1.0' }, 'v2()'); + await registry.savePlugin({ ...PLUGIN, version: '1.2.0', changelog: '- Third' }, 'v3()'); + + const kept = await history.listHistory('plugin', 'demo'); + const { previousVersions, previousChangelogs } = history.keptVersions(kept); + expect([...previousVersions].sort()).toEqual(['1.0.0', '1.1.0']); + expect(previousChangelogs).toEqual({ '1.0.0': '- First' }); + expect((await history.readHistoryEntry('plugin', 'demo', '1.0.0')).entry.changelog).toBe('- First'); + }); }); describe('theme history', () => { diff --git a/lib/admin/changelog-markdown.ts b/lib/admin/changelog-markdown.ts new file mode 100644 index 000000000..d7a1b674c --- /dev/null +++ b/lib/admin/changelog-markdown.ts @@ -0,0 +1,81 @@ +/** + * The Markdown of an extension's changelog, as a small tree the admin UI + * renders with React elements. Changelogs come from the extension directory + * and are not covered by the bundle signature, so they never become HTML: + * only headings, paragraphs, lists, code, bold and http(s) links are read, + * and everything else stays text. + */ + +export type Inline = + | { t: 'text'; v: string } + | { t: 'code'; v: string } + | { t: 'strong'; c: Inline[] } + | { t: 'link'; href: string; c: Inline[] }; + +export type Block = + | { t: 'heading'; c: Inline[] } + | { t: 'para'; c: Inline[] } + | { t: 'list'; ordered: boolean; items: Inline[][] } + | { t: 'code'; v: string }; + +const MAX_LENGTH = 20_000; +const INLINE = /`([^`\n]+)`|\*\*(.+?)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g; + +export function parseInline(text: string): Inline[] { + const out: Inline[] = []; + let last = 0; + for (const m of text.matchAll(INLINE)) { + if (m.index > last) out.push({ t: 'text', v: text.slice(last, m.index) }); + if (m[1] !== undefined) out.push({ t: 'code', v: m[1] }); + else if (m[2] !== undefined) out.push({ t: 'strong', c: parseInline(m[2]) }); + else if (/^https?:\/\//i.test(m[4])) out.push({ t: 'link', href: m[4], c: parseInline(m[3]) }); + else out.push({ t: 'text', v: m[3] }); + last = m.index + m[0].length; + } + if (last < text.length) out.push({ t: 'text', v: text.slice(last) }); + return out; +} + +const ITEM = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/; + +export function parseChangelog(markdown: string): Block[] { + const lines = markdown.slice(0, MAX_LENGTH).replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + let para: string[] = []; + let list: { ordered: boolean; items: string[] } | null = null; + const flush = () => { + if (para.length) blocks.push({ t: 'para', c: parseInline(para.join(' ')) }); + if (list) blocks.push({ t: 'list', ordered: list.ordered, items: list.items.map(parseInline) }); + para = []; + list = null; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*(```|~~~)/.test(line)) { + flush(); + const fence = line.trim().slice(0, 3); + const code: string[] = []; + while (++i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i]); + blocks.push({ t: 'code', v: code.join('\n') }); + continue; + } + if (!line.trim()) { flush(); continue; } + const heading = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/); + if (heading) { flush(); blocks.push({ t: 'heading', c: parseInline(heading[1]) }); continue; } + const item = line.match(ITEM); + if (item) { + const ordered = /\d/.test(item[1]); + if (para.length || (list && list.ordered !== ordered)) flush(); + list ??= { ordered, items: [] }; + list.items.push(item[2]); + continue; + } + // An indented line goes on with the list item above it. + if (list && /^\s+\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; } + if (list) flush(); + para.push(line.trim()); + } + flush(); + return blocks; +} diff --git a/lib/admin/changelog-range.ts b/lib/admin/changelog-range.ts new file mode 100644 index 000000000..61c18ad6a --- /dev/null +++ b/lib/admin/changelog-range.ts @@ -0,0 +1,34 @@ +import { compareVersions } from '@/lib/version-compare'; + +interface Row { + version: string; + channel: 'stable' | 'beta'; + yanked: unknown; +} + +/** compareVersions, with a release above its pre-releases (1.3.0-beta.1 < 1.3.0). */ +function compare(a: string, b: string): number { + const d = compareVersions(a, b); + if (d !== 0) return d; + const pa = a.split('+')[0].split('-').slice(1).join('-'); + const pb = b.split('+')[0].split('-').slice(1).join('-'); + if (!pa || !pb) return pa ? -1 : pb ? 1 : 0; + return pa.localeCompare(pb, 'en', { numeric: true }); +} + +/** + * The versions whose notes matter when going from `installed` to `target`, + * newest first. Going up: the versions above the installed one up to the + * target, what the update brings. Going down: the installed version down to + * just above the target, what going back undoes. Withdrawn versions are left + * out unless installed, and betas unless the target is one or the channel is + * beta. + */ +export function versionsBetween(versions: T[], installed: string, target: string, channel: 'stable' | 'beta'): T[] { + const [low, high] = compare(target, installed) > 0 ? [installed, target] : [target, installed]; + const betas = channel === 'beta' || target.includes('-'); + return versions + .filter((v) => (!v.yanked || v.version === installed) && (v.channel !== 'beta' || betas) + && compare(v.version, low) > 0 && compare(v.version, high) <= 0) + .sort((a, b) => compare(b.version, a.version)); +} diff --git a/lib/admin/extension-history.ts b/lib/admin/extension-history.ts index 61fb88a0a..080ed1534 100644 --- a/lib/admin/extension-history.ts +++ b/lib/admin/extension-history.ts @@ -43,6 +43,8 @@ export interface HistoryEntry { /** SHA-256 of the bundle (plugins) or css + skin (themes). */ contentHash: string; source?: ServerPlugin['source']; + /** The kept version's changelog, when it came from the marketplace with one. */ + changelog?: string; } const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -112,14 +114,26 @@ async function prune(kind: ExtensionKind, id: string): Promise { } } +/** + * What the admin lists say about the kept versions: the versions, most + * recently replaced first, and the changelog of each that has one. + */ +export function keptVersions(history: HistoryEntry[]): { previousVersions: string[]; previousChangelogs: Record } { + return { + previousVersions: history.map((h) => h.version), + previousChangelogs: Object.fromEntries(history.filter((h) => h.changelog).map((h) => [h.version, h.changelog!])), + }; +} + /** The versions kept for an extension, most recently replaced first. */ export async function listHistory(kind: ExtensionKind, id: string): Promise { if (!SAFE_ID_RE.test(id)) return []; - return (await readAll(kind, id)).map(({ version, savedAt, contentHash, source }) => ({ + return (await readAll(kind, id)).map(({ version, savedAt, contentHash, source, entry }) => ({ version, savedAt, contentHash, ...(source ? { source } : {}), + ...(typeof entry.changelog === 'string' ? { changelog: entry.changelog } : {}), })); } diff --git a/lib/admin/marketplace-directory.ts b/lib/admin/marketplace-directory.ts index fba212d0a..2afa20812 100644 --- a/lib/admin/marketplace-directory.ts +++ b/lib/admin/marketplace-directory.ts @@ -138,11 +138,21 @@ export interface DirectoryVersion { downloadUrl: string; /** Only on the full view (`/versions`, `/versions/{v}`). */ changelog?: string | null; + /** Whether the changelog has a Security or Breaking heading (directories since 2026-10-30). */ + notes?: { security: boolean; breaking: boolean }; downloads?: number; signature?: { keyId: string } | null; provenance?: { source: { host: string; repo: string | null; commit: string | null } } | null; } +/** One version's notes in an update: every version between the installed one and the offered one has one. */ +export interface DirectoryChangelogEntry { + version: string; + publishedAt: string | null; + changelog: string | null; + notes: { security: boolean; breaking: boolean }; +} + export interface DirectoryAuthor { login: string; name: string; diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 09b932e6b..ece86c968 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -106,6 +106,12 @@ export interface ServerPlugin { /** Credentials the plugin uses through the server proxy (no secrets here). */ credentials?: Record; source?: ExtensionSource; + /** + * The version's changelog (Markdown) from the directory, kept from a + * marketplace install so the admin UI and the rollback history can show it + * without the directory. Admin-only: /api/plugins doesn't send it. + */ + changelog?: string; } export interface ServerTheme { @@ -120,6 +126,8 @@ export interface ServerTheme { installedAt: string; updatedAt: string; source?: ExtensionSource; + /** The version's changelog from the directory; see ServerPlugin.changelog. */ + changelog?: string; /** True when a Theme API v2 skin.css is stored next to the CSS. */ hasSkin?: boolean; /**