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

@@ -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`);
}
+16 -24
View File
@@ -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}`,
);
}
+3
View File
@@ -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)",
-126
View File
@@ -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;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [
"locales",
"migrations",
"README.md",
"CHANGELOG.json",
"CHANGELOG.md",
"icon.svg",
];