mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: changelog.md release notes, v tags, plugin release notes and videos
This commit is contained in:
1 parent
b9dd3488a5
commit
9bc2be721b
45 files changed
+1462
-816
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user