feat: changelog.md release notes, v tags, plugin release notes and videos

This commit is contained in:
LukeGus committed 2026-10-06 18:13:39 -05:00
1 parent b9dd3488a5
commit 9bc2be721b
45 files changed
+1462 -816

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
/**
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
*
* # Changelog
*
* ## Unreleased
*
* ### Added
* - Something merged but not shipped yet
*
* ## 1.1.0 - 2026-10-06
*
* An optional summary paragraph.
*
* ### Added
* - Something new
*
* ### Fixed
* - Something broken
*
* Releases are listed newest first, and the newest one has to be the
* manifest version. The date is optional until the version ships.
*/
import semver from "semver";
export const CHANGE_TYPES = [
"added",
"changed",
"fixed",
"removed",
"deprecated",
"security",
] as const;
export type ChangeType = (typeof CHANGE_TYPES)[number];
export interface ChangelogChange {
type: ChangeType;
text: string;
}
export interface ChangelogRelease {
version: string;
date?: string;
summary?: string;
changes: ChangelogChange[];
}
export interface Changelog {
unreleased: ChangelogChange[];
/** Newest first, as written. */
releases: ChangelogRelease[];
}
export interface ReleaseNotes {
summary?: string;
changes: ChangelogChange[];
}
const RELEASE_HEADING =
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const BULLET = /^[-*]\s+(.*)$/;
const COMMENT = /^<!--.*-->$/;
/**
* Parses the body under one release heading: an optional summary, then
* `### Added` style sections of bullets. Indented lines continue the bullet
* above them.
*/
export function parseReleaseNotes(
body: string,
where = "release",
): ReleaseNotes & { problems: string[] } {
const problems: string[] = [];
const summary: string[] = [];
const changes: ChangelogChange[] = [];
let section: ChangeType | null = null;
let current: ChangelogChange | null = null;
let fence = false;
for (const raw of body.replace(/\r/g, "").split("\n")) {
const line = raw.trimEnd();
const trimmed = line.trim();
if (trimmed.startsWith("```")) fence = !fence;
if (!fence && trimmed.startsWith("### ")) {
const name = trimmed.slice(4).trim().toLowerCase();
if ((CHANGE_TYPES as readonly string[]).includes(name)) {
section = name as ChangeType;
} else {
problems.push(
`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`,
);
section = null;
}
current = null;
continue;
}
if (!section) {
if (trimmed || summary.length > 0) summary.push(line);
continue;
}
if (!trimmed || COMMENT.test(trimmed)) continue;
const bullet = fence ? null : BULLET.exec(line);
if (bullet) {
current = { type: section, text: bullet[1].trim() };
changes.push(current);
} else if (current && /^\s/.test(line)) {
current.text += `\n${trimmed}`;
} else {
problems.push(
`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`,
);
}
}
for (const change of changes) {
if (!change.text) {
problems.push(`${where} has an empty list item`);
}
}
const text = summary.join("\n").trim();
return { ...(text ? { summary: text } : {}), changes, problems };
}
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
export function parseChangelog(markdown: string): {
changelog: Changelog;
problems: string[];
} {
const problems: string[] = [];
const changelog: Changelog = { unreleased: [], releases: [] };
const lines = markdown.replace(/\r/g, "").split("\n");
type Block = { heading: string; line: number; body: string[] };
const blocks: Block[] = [];
let fence = false;
lines.forEach((line, i) => {
if (line.trim().startsWith("```")) fence = !fence;
if (!fence && /^##\s/.test(line)) {
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
} else if (blocks.length > 0) {
blocks[blocks.length - 1].body.push(line);
}
});
const seen = new Set<string>();
blocks.forEach((block, i) => {
if (UNRELEASED_HEADING.test(block.heading)) {
if (i !== 0) problems.push(`"## Unreleased" has to come first`);
const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased");
problems.push(...notes.problems);
changelog.unreleased = notes.changes;
return;
}
const match = RELEASE_HEADING.exec(block.heading);
if (!match) {
problems.push(
`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`,
);
return;
}
const [, version, date] = match;
if (date !== undefined && !DATE.test(date)) {
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
}
if (seen.has(version)) problems.push(`${version} is listed twice`);
seen.add(version);
const notes = parseReleaseNotes(block.body.join("\n"), version);
problems.push(...notes.problems);
if (notes.changes.length === 0) {
problems.push(`${version} has no changes listed`);
}
changelog.releases.push({
version,
...(date && DATE.test(date) ? { date } : {}),
...(notes.summary ? { summary: notes.summary } : {}),
changes: notes.changes,
});
});
const versions = changelog.releases.map((r) => r.version);
for (let i = 1; i < versions.length; i++) {
if (semver.lt(versions[i - 1], versions[i])) {
problems.push("releases must be listed newest first");
break;
}
}
return { changelog, problems };
}
/**
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
* be that version, so a release never ships without its notes.
*/
export function validateChangelog(
markdown: string,
manifestVersion?: string,
): string[] {
const { changelog, problems } = parseChangelog(markdown);
if (changelog.releases.length === 0) {
problems.push('no releases found, add a "## 1.0.0" heading');
return problems;
}
const newest = changelog.releases[0].version;
if (manifestVersion && newest !== manifestVersion) {
problems.push(
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
);
}
return problems;
}
/**
* The Markdown under one version's heading, as written, for a release body.
* Null when the version is not in the file.
*/
export function changelogSection(
markdown: string,
version: string,
): string | null {
const lines = markdown.replace(/\r/g, "").split("\n");
let start = -1;
let fence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) fence = !fence;
if (fence || !/^##\s/.test(line)) continue;
if (start >= 0) {
return lines.slice(start, i).join("\n").trim();
}
const match = RELEASE_HEADING.exec(line.trim());
if (match && match[1] === version) start = i + 1;
}
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
}
function title(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
+45
View File
@@ -49,6 +49,43 @@ export function isTermixCompatible(
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
}
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"youtu.be",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
]);
/**
* The video id of a YouTube link, or null for anything else. Only the id is
* ever used, so a manifest can never point the embed at another site.
*/
export function youtubeVideoId(value: unknown): string | null {
if (typeof value !== "string") return null;
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password) return null;
const host = url.hostname.toLowerCase();
if (!YOUTUBE_HOSTS.has(host)) return null;
let id: string | null | undefined;
if (host === "youtu.be") {
id = url.pathname.slice(1);
} else if (url.pathname === "/watch") {
id = url.searchParams.get("v");
} else {
id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1];
}
return id && YOUTUBE_ID.test(id) ? id : null;
}
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
@@ -540,6 +577,8 @@ export interface PluginManifest {
repository?: string;
category: string;
icon?: string;
/** A YouTube link shown at the top of the plugin's page. */
video?: string;
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
@@ -591,6 +630,7 @@ const ALLOWED_TOP_LEVEL = new Set([
...REQUIRED_TOP_LEVEL,
"repository",
"icon",
"video",
"dependencies",
"optionalDependencies",
"provides",
@@ -747,6 +787,11 @@ export function validateManifest(manifest: unknown): string[] {
if ("repository" in m) requireString(m.repository, "repository", errors);
if ("icon" in m) requireString(m.icon, "icon", errors);
if ("video" in m && youtubeVideoId(m.video) === null) {
errors.push(
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
);
}
for (const field of ["backend", "frontend", "locales"] as const) {
if (field in m && requireString(m[field], field, errors)) {
requireRelativePath(m[field] as string, field, errors);