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
@@ -32,6 +32,7 @@ npx termix-plugin test # run the plugin's Vitest suite
|
||||
npx termix-plugin validate # check manifest.json and the files it names
|
||||
npx termix-plugin migrations # write migrations from the table definitions
|
||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
||||
npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
|
||||
npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
|
||||
npx termix-plugin keygen --out <dir> # make a new signing key pair
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { loadSdkModule } from "./validate.mjs";
|
||||
|
||||
/**
|
||||
* Prints one version's notes from CHANGELOG.md, the manifest version unless
|
||||
* --version says otherwise. The release workflow uses it as the release body.
|
||||
*/
|
||||
export async function changelog({ cwd, args = [] }) {
|
||||
const index = args.indexOf("--version");
|
||||
const version =
|
||||
index === -1 ? readManifest(cwd).version : args[index + 1] || null;
|
||||
if (!version) throw new Error("--version needs a value");
|
||||
|
||||
const file = path.join(cwd, "CHANGELOG.md");
|
||||
if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md");
|
||||
|
||||
const { changelogSection } = await loadSdkModule("changelog");
|
||||
const section = changelogSection(fs.readFileSync(file, "utf8"), version);
|
||||
if (!section) {
|
||||
throw new Error(`CHANGELOG.md has no notes for ${version}`);
|
||||
}
|
||||
process.stdout.write(`${section}\n`);
|
||||
}
|
||||
@@ -2,24 +2,22 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { validateChangelog } from "../lib/changelog.mjs";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
* there is one implementation rather than a copy here that drifts.
|
||||
*/
|
||||
async function loadParseManifest() {
|
||||
const entry = new URL("../../dist/manifest.js", import.meta.url);
|
||||
export async function loadSdkModule(name) {
|
||||
const entry = new URL(`../../dist/${name}.js`, import.meta.url);
|
||||
if (!fs.existsSync(fileURLToPath(entry))) {
|
||||
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
||||
}
|
||||
const mod = await import(entry.href);
|
||||
return mod.parseManifest;
|
||||
return import(entry.href);
|
||||
}
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const parseManifest = await loadParseManifest();
|
||||
const { parseManifest } = await loadSdkModule("manifest");
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
@@ -41,7 +39,7 @@ export async function validate({ cwd }) {
|
||||
);
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
problems.push(...validateChangelogFile(cwd, raw));
|
||||
problems.push(...(await validateChangelogFile(cwd, raw)));
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -82,26 +80,20 @@ function validatePackage(cwd, raw) {
|
||||
}
|
||||
|
||||
/**
|
||||
* CHANGELOG.json is optional, but when it is there it has to parse and its
|
||||
* CHANGELOG.md is optional, but when it is there it has to parse and its
|
||||
* newest release has to match the manifest version.
|
||||
*/
|
||||
function validateChangelogFile(cwd, raw) {
|
||||
const jsonPath = path.join(cwd, "CHANGELOG.json");
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
return fs.existsSync(path.join(cwd, "CHANGELOG.md"))
|
||||
? [
|
||||
"CHANGELOG.md is no longer shipped; move the release notes to CHANGELOG.json",
|
||||
]
|
||||
: [];
|
||||
async function validateChangelogFile(cwd, raw) {
|
||||
if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
|
||||
return [
|
||||
"CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
|
||||
];
|
||||
}
|
||||
let changelog;
|
||||
try {
|
||||
changelog = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||
} catch (error) {
|
||||
return [`CHANGELOG.json is not valid JSON: ${error.message}`];
|
||||
}
|
||||
return validateChangelog(changelog, raw.version).map(
|
||||
(problem) => `CHANGELOG.json: ${problem}`,
|
||||
const mdPath = path.join(cwd, "CHANGELOG.md");
|
||||
if (!fs.existsSync(mdPath)) return [];
|
||||
const { validateChangelog } = await loadSdkModule("changelog");
|
||||
return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
|
||||
(problem) => `CHANGELOG.md: ${problem}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { pack } from "./commands/pack.mjs";
|
||||
import { sign, verify, keygen } from "./commands/sign.mjs";
|
||||
import { migrations } from "./commands/migrations.mjs";
|
||||
import { dev } from "./commands/dev.mjs";
|
||||
import { changelog } from "./commands/changelog.mjs";
|
||||
|
||||
const COMMANDS = {
|
||||
build,
|
||||
@@ -30,6 +31,7 @@ const COMMANDS = {
|
||||
keygen,
|
||||
migrations,
|
||||
dev,
|
||||
changelog,
|
||||
};
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
@@ -48,6 +50,7 @@ if (!command || command === "--help" || command === "-h") {
|
||||
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
||||
" keygen Write a new signing key pair [--out dir]",
|
||||
" migrations Generate migrations from the plugin's table definitions",
|
||||
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
|
||||
" dev Build, pack and install on a running server, again on every change",
|
||||
" [--server url] [--key tmx_...] [--once]",
|
||||
" (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
export const CHANGE_TYPES = [
|
||||
"added",
|
||||
"changed",
|
||||
"fixed",
|
||||
"removed",
|
||||
"deprecated",
|
||||
"security",
|
||||
];
|
||||
|
||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TOP_KEYS = ["$schema", "unreleased", "releases"];
|
||||
const RELEASE_KEYS = ["version", "date", "notes", "changes"];
|
||||
const CHANGE_KEYS = ["type", "text"];
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = SEMVER.exec(a);
|
||||
const pb = SEMVER.exec(b);
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const diff = Number(pa[i]) - Number(pb[i]);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (pa[4] === pb[4]) return 0;
|
||||
if (!pa[4]) return 1;
|
||||
if (!pb[4]) return -1;
|
||||
return pa[4] < pb[4] ? -1 : 1;
|
||||
}
|
||||
|
||||
function unknownKeys(value, allowed, where, problems) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.includes(key))
|
||||
problems.push(`${where} has unknown key "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkChanges(changes, where, problems) {
|
||||
if (!Array.isArray(changes)) {
|
||||
problems.push(`${where} must be an array`);
|
||||
return;
|
||||
}
|
||||
changes.forEach((change, i) => {
|
||||
const at = `${where}[${i}]`;
|
||||
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
||||
problems.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
unknownKeys(change, CHANGE_KEYS, at, problems);
|
||||
if (!CHANGE_TYPES.includes(change.type)) {
|
||||
problems.push(`${at}.type must be one of ${CHANGE_TYPES.join(", ")}`);
|
||||
}
|
||||
if (typeof change.text !== "string" || change.text.trim() === "") {
|
||||
problems.push(`${at}.text must be a non-empty string`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a parsed CHANGELOG.json. The newest release has to be the version
|
||||
* in the manifest, so a release never ships without its notes.
|
||||
*/
|
||||
export function validateChangelog(changelog, manifestVersion) {
|
||||
const problems = [];
|
||||
if (!changelog || typeof changelog !== "object" || Array.isArray(changelog)) {
|
||||
return ["CHANGELOG.json must be an object"];
|
||||
}
|
||||
unknownKeys(changelog, TOP_KEYS, "CHANGELOG.json", problems);
|
||||
|
||||
if (changelog.unreleased !== undefined) {
|
||||
checkChanges(changelog.unreleased, "unreleased", problems);
|
||||
}
|
||||
|
||||
const releases = changelog.releases;
|
||||
if (!Array.isArray(releases) || releases.length === 0) {
|
||||
problems.push("releases must be a non-empty array");
|
||||
return problems;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
releases.forEach((release, i) => {
|
||||
const at = `releases[${i}]`;
|
||||
if (!release || typeof release !== "object" || Array.isArray(release)) {
|
||||
problems.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
unknownKeys(release, RELEASE_KEYS, at, problems);
|
||||
if (typeof release.version !== "string" || !SEMVER.test(release.version)) {
|
||||
problems.push(`${at}.version must be a version like 1.2.3`);
|
||||
} else if (seen.has(release.version)) {
|
||||
problems.push(`${at}.version ${release.version} is listed twice`);
|
||||
} else {
|
||||
seen.add(release.version);
|
||||
}
|
||||
if (
|
||||
release.date !== undefined &&
|
||||
(typeof release.date !== "string" || !DATE.test(release.date))
|
||||
) {
|
||||
problems.push(`${at}.date must be a date like 2026-10-01`);
|
||||
}
|
||||
if (release.notes !== undefined && typeof release.notes !== "string") {
|
||||
problems.push(`${at}.notes must be a string`);
|
||||
}
|
||||
checkChanges(release.changes, `${at}.changes`, problems);
|
||||
if (Array.isArray(release.changes) && release.changes.length === 0) {
|
||||
problems.push(`${at}.changes must list at least one change`);
|
||||
}
|
||||
});
|
||||
|
||||
const versions = releases
|
||||
.map((release) => release?.version)
|
||||
.filter((v) => typeof v === "string" && SEMVER.test(v));
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
if (compareVersions(versions[i - 1], versions[i]) < 0) {
|
||||
problems.push("releases must be listed newest first");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const newest = releases[0]?.version;
|
||||
if (manifestVersion && newest !== manifestVersion) {
|
||||
problems.push(
|
||||
`the newest release is ${newest}, but manifest version is ${manifestVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.json",
|
||||
"CHANGELOG.md",
|
||||
"icon.svg",
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -49,6 +49,10 @@
|
||||
"types": "./dist/capabilities.d.ts",
|
||||
"default": "./dist/capabilities.js"
|
||||
},
|
||||
"./changelog": {
|
||||
"types": "./dist/changelog.d.ts",
|
||||
"default": "./dist/changelog.js"
|
||||
},
|
||||
"./settings": {
|
||||
"types": "./dist/settings.d.ts",
|
||||
"default": "./dist/settings.js"
|
||||
@@ -75,7 +79,6 @@
|
||||
},
|
||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||
"./schema/changelog.schema.json": "./schema/changelog.schema.json",
|
||||
"./ui": {
|
||||
"types": "./dist/host/ui.d.ts",
|
||||
"default": "./dist/host/ui.js"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://raw.githubusercontent.com/Termix-SSH/Termix/main/packages/plugin-sdk/schema/changelog.schema.json",
|
||||
"title": "Termix plugin changelog",
|
||||
"description": "Every release of a plugin, newest first. The newest release must match the manifest version.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["releases"],
|
||||
"properties": {
|
||||
"$schema": { "type": "string" },
|
||||
"unreleased": {
|
||||
"description": "Changes that are merged but not released yet.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/change" }
|
||||
},
|
||||
"releases": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/release" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"release": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "changes"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"date": {
|
||||
"description": "When the version was released. Leave it out until it ships.",
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"notes": {
|
||||
"description": "A short line shown above the list of changes.",
|
||||
"type": "string"
|
||||
},
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/change" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"change": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "text"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"added",
|
||||
"changed",
|
||||
"fixed",
|
||||
"removed",
|
||||
"deprecated",
|
||||
"security"
|
||||
]
|
||||
},
|
||||
"text": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,11 @@
|
||||
"minLength": 1,
|
||||
"description": "A Lucide icon name, like \"Play\"."
|
||||
},
|
||||
"video": {
|
||||
"type": "string",
|
||||
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
||||
"description": "A YouTube link shown at the top of the plugin's page."
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -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