Alex2Node/scripts/capability-table.js
David 785944b2da readme: quick start, generated capability table, recipes, bridge contract, migration
The readme is rewritten for 2.0: what the library needs, install, the lamp
as quick start, concepts, the table of interfaces, recipes, the bridge
contract, errors, testing without Alexa, migration from 1.x and limits.
scripts/capability-table.js (npm run docs) writes the table from the
registry and the text of three example files into their code blocks;
"Through Alexa" names only the runs of 2026-09-28, made with 1.5.2.
test/readme.test.js fails on a stale table or file, runs
examples/testing/lamp.test.js (MemoryPublisher, no broker) and compiles the
other code blocks. package.json: description, keywords, CHANGELOG.md in files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 22:04:03 +00:00

119 lines
5.2 KiB
JavaScript

"use strict";
// Writes what readme.md takes from the code: the table of interfaces, from the registry, between the two marker
// lines, and the text of a file into the code block that follows a line <!-- file: examples/lamp.js -->.
//
// npm run build && npm run docs write them
// node scripts/capability-table.js --check exit 1 when the readme has another text
//
// It reads the built registry (dist/cjs), so the build comes first. test/readme.test.js fails on a stale readme.
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.join(__dirname, "..");
const README = path.join(ROOT, "readme.md");
const BEGIN = "<!-- capabilities: written by scripts/capability-table.js, npm run docs -->";
const END = "<!-- /capabilities -->";
// What was driven from a real Alexa account through the public Alex2MQTT service, and when. The runs used
// alex2node 1.5.2; a row is added here when a run is recorded, not when a descriptor is written.
const DRIVEN_ON = "2026-09-28";
const DRIVEN_WITH = "alex2node 1.5.2";
const DRIVEN = {
"Alexa": "ReportState",
"Alexa.BrightnessController": "directives",
"Alexa.ColorController": "directives",
"Alexa.ColorTemperatureController": "directives",
"Alexa.ContactSensor": "change report",
"Alexa.LockController": "directives, change report",
"Alexa.ModeController": "directives",
"Alexa.MotionSensor": "change report",
"Alexa.PercentageController": "directives",
"Alexa.PowerController": "directives, change report",
"Alexa.PowerLevelController": "directives",
"Alexa.RangeController": "directives",
"Alexa.SceneController": "directives",
"Alexa.TemperatureSensor": "change report",
"Alexa.ThermostatController": "directives",
"Alexa.ToggleController": "directives",
};
const code = (names) => (names.length > 0 ? names.map((name) => `\`${name}\``).join(", ") : "-");
function row(descriptor) {
const { namespace, version, doc, tier, instanced, properties, directives, events = {} } = descriptor;
return [
`[${namespace}](${doc})${instanced ? " (instances)" : ""}`,
version,
code(Object.keys(properties)),
code(Object.keys(directives)),
code(Object.keys(events)),
String(tier),
DRIVEN[namespace] ?? "-",
];
}
/** The text between the markers, from the registry of the build. */
function table(registry = require(path.join(ROOT, "dist", "cjs", "index.js")).registry) {
const all = registry.list();
const unknown = Object.keys(DRIVEN).filter((namespace) => !registry.has(namespace));
if (unknown.length > 0) throw new Error(`DRIVEN names ${unknown.join(", ")}, which the registry does not have`);
const described = all.filter((descriptor) => descriptor.tier !== 3);
const named = all.filter((descriptor) => descriptor.tier === 3);
const header = ["Interface", "Version", "Properties", "Directives", "Events", "Tier", "Through Alexa"];
const lines = [header, header.map(() => "---"), ...described.map(row)].map((cells) => `| ${cells.join(" | ")} |`);
return [
...lines,
"",
`${described.length} interfaces are described (tier 1 and 2), ${named.length} are named (tier 3).`,
`"Through Alexa" is what a real Alexa account drove through the public Alex2MQTT service on ${DRIVEN_ON}, with`,
`${DRIVEN_WITH}; "-" is an interface that no such run has covered.`,
"",
`Tier 3: ${named.map(({ namespace, version, doc }) => `[${namespace}](${doc}) ${version}`).join(", ")}.`,
].join("\n");
}
// A line that names a file, and the code block after it
const FILE = /<!-- file: (\S+) -->\n```(\w+)\n[\s\S]*?\n```/g;
/** The readme with the text the named files have now in their code blocks. */
function files(readme) {
return readme.replace(FILE, (block, file, language) => {
const text = fs.readFileSync(path.join(ROOT, file), "utf8").trimEnd();
if (text.includes("```")) throw new Error(`${file} has a code fence, which would end its block in readme.md`);
return `<!-- file: ${file} -->\n\`\`\`${language}\n${text}\n\`\`\``;
});
}
/** The files the readme shows, as the marker lines name them. */
function shown(readme) {
return [...readme.matchAll(FILE)].map(([, file]) => file);
}
/** The readme with the table of now and the files of now. Throws when the markers are not there, each once and in this order. */
function update(readme, text = table()) {
const begin = readme.indexOf(BEGIN);
const end = readme.indexOf(END);
if (begin < 0 || end < begin || readme.indexOf(BEGIN, begin + 1) >= 0 || readme.indexOf(END, end + 1) >= 0) {
throw new Error(`readme.md needs the lines "${BEGIN}" and "${END}", once each and in this order`);
}
return files(`${readme.slice(0, begin)}${BEGIN}\n${text}\n${readme.slice(end)}`);
}
if (require.main === module) {
const before = fs.readFileSync(README, "utf8");
const after = update(before);
if (process.argv.includes("--check")) {
if (after !== before) {
console.error("readme.md has another table of interfaces or another text of a file than the code gives: npm run build && npm run docs");
process.exit(1);
}
} else if (after !== before) {
fs.writeFileSync(README, after);
console.log("readme.md was written");
} else {
console.log("readme.md is up to date");
}
}
module.exports = { table, update, files, shown, BEGIN, END, DRIVEN, README };