330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import test from "node:test";
|
|
|
|
// Every built-in component is a Handlebars template in sqlpage/templates/, and
|
|
// its properties are documented as rows of the `parameter` table of the
|
|
// official site, built by the SQL migrations in examples/official-site/.
|
|
// Nothing links the two, so they drift: properties get added to a template and
|
|
// never documented, and documented properties outlive the code that read them.
|
|
//
|
|
// This test builds the official site's documentation database, reads the
|
|
// property names out of the templates, and asserts that the two agree.
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const REPO_ROOT = path.resolve(
|
|
path.dirname(require.resolve("../../package.json")),
|
|
);
|
|
const TEMPLATES_DIR = path.join(REPO_ROOT, "sqlpage", "templates");
|
|
const MIGRATIONS_DIR = path.join(
|
|
REPO_ROOT,
|
|
"examples",
|
|
"official-site",
|
|
"sqlpage",
|
|
"migrations",
|
|
);
|
|
|
|
/** Components rendered by src/render.rs rather than by a Handlebars template. */
|
|
const COMPONENTS_WITHOUT_A_TEMPLATE = new Set([
|
|
"authentication",
|
|
"cookie",
|
|
"download",
|
|
"dynamic",
|
|
"http_header",
|
|
"json",
|
|
"log",
|
|
"redirect",
|
|
"status_code",
|
|
]);
|
|
|
|
/**
|
|
* Templates that are not user-invocable components, and so have no row in the
|
|
* `component` table: `error` is rendered by SQLPage when a query fails,
|
|
* `default` is the fallback for an unknown component name, and `shell-empty`
|
|
* is selected through the `shell-empty` component rather than documented as
|
|
* one.
|
|
*/
|
|
const TEMPLATES_WITHOUT_DOCUMENTATION = new Set([
|
|
"default",
|
|
"error",
|
|
"shell-empty",
|
|
]);
|
|
|
|
/**
|
|
* Documented properties that a template cannot be expected to mention, with
|
|
* the reason why. Keep this list short: an entry here is documentation that
|
|
* nothing verifies.
|
|
*/
|
|
const DOCUMENTED_WITHOUT_A_TEMPLATE_REFERENCE = new Map([
|
|
[
|
|
"shell.target",
|
|
// `target` belongs to the json objects passed as `menu_item`, and the
|
|
// `parameter` table has no level for the sub-properties of a property.
|
|
// It is documented at the top level, and says so in its description.
|
|
"documented as a menu_item sub-property",
|
|
],
|
|
]);
|
|
|
|
/** Handlebars built-ins plus the helpers registered in src/template_helpers.rs. */
|
|
const HELPERS = new Set([
|
|
// handlebars-rust built-ins
|
|
"and",
|
|
"each",
|
|
"eq",
|
|
"gt",
|
|
"gte",
|
|
"if",
|
|
"len",
|
|
"log",
|
|
"lookup",
|
|
"lt",
|
|
"lte",
|
|
"ne",
|
|
"not",
|
|
"or",
|
|
"raw",
|
|
"unless",
|
|
"with",
|
|
// SQLPage helpers
|
|
"all",
|
|
"any",
|
|
"app_config",
|
|
"array_contains",
|
|
"array_contains_case_insensitive",
|
|
"buildinfo",
|
|
"csv_escape",
|
|
"default",
|
|
"delay",
|
|
"entries",
|
|
"flush_delayed",
|
|
"icon_img",
|
|
"loose_eq",
|
|
"markdown",
|
|
"minus",
|
|
"parse_json",
|
|
"plus",
|
|
"replace",
|
|
"rfc2822_date",
|
|
"starts_with",
|
|
"static_path",
|
|
"stringify",
|
|
"sum",
|
|
"to_array",
|
|
"typeof",
|
|
"url_encode",
|
|
]);
|
|
|
|
const LITERALS = new Set([
|
|
"else",
|
|
"true",
|
|
"false",
|
|
"null",
|
|
"NULL",
|
|
"undefined",
|
|
]);
|
|
|
|
type Level = "top" | "row";
|
|
type TemplateProperties = { top: Set<string>; row: Set<string> };
|
|
|
|
/**
|
|
* The context a Handlebars expression is evaluated in. `top` is the first row
|
|
* of the result set, `row` is one of the following rows, and `nested` is an
|
|
* object reached through `{{#each}}` or `{{#with}}` — the sub-properties of a
|
|
* json property, which are documented in the parent property's description
|
|
* rather than as rows of their own.
|
|
*/
|
|
type Context = "top" | "row" | "nested";
|
|
|
|
function properties_of_template(source: string): TemplateProperties {
|
|
const top = new Set<string>();
|
|
const row = new Set<string>();
|
|
const contexts: Context[] = ["top"];
|
|
|
|
for (const [, raw_body] of source.matchAll(/\{\{([^}]*)\}\}/g)) {
|
|
let body = raw_body
|
|
.replace(/^[{~]+/, "")
|
|
.replace(/[~}]+$/, "")
|
|
.trim();
|
|
if (body.startsWith("!")) continue; // comment
|
|
|
|
if (/^#each_row\b/.test(body)) {
|
|
contexts.push("row");
|
|
continue;
|
|
}
|
|
if (/^\/each_row\b/.test(body)) {
|
|
contexts.pop();
|
|
continue;
|
|
}
|
|
|
|
const is_closing = body.startsWith("/");
|
|
const opens_a_nested_context = /^#(each|with)\b/.test(body);
|
|
body = body.replace(/^[#^/>&]+/, "").trim();
|
|
if (is_closing) {
|
|
if (/^(each|with)\b/.test(body)) contexts.pop();
|
|
continue;
|
|
}
|
|
|
|
// `{{else if (...)}}` chains an inner `if`, which is not a property.
|
|
body = body.replace(/^else\s+/, "");
|
|
// String literals may contain anything, including what looks like a path.
|
|
body = body.replace(/"[^"]*"/g, " ").replace(/'[^']*'/g, " ");
|
|
// Block parameters (`{{#each x as |y|}}`) name the inner context.
|
|
const block_params = body.indexOf(" as |");
|
|
if (block_params >= 0) body = body.slice(0, block_params);
|
|
|
|
// A bare name is a helper call when it is the first token of the mustache
|
|
// or of a subexpression, and a property reference anywhere else.
|
|
let is_callee = true;
|
|
for (const token of body.split(/(\s+|\(|\))/)) {
|
|
if (!token.trim()) continue;
|
|
if (token === "(") {
|
|
is_callee = true;
|
|
continue;
|
|
}
|
|
if (token === ")") {
|
|
is_callee = false;
|
|
continue;
|
|
}
|
|
const in_callee_position = is_callee;
|
|
is_callee = false;
|
|
|
|
let name = token;
|
|
if (/^[0-9]/.test(name)) continue; // numeric literal
|
|
if (name.includes("=")) name = name.slice(name.indexOf("=") + 1); // hash argument
|
|
let parents = 0;
|
|
while (name.startsWith("../")) {
|
|
parents += 1;
|
|
name = name.slice(3);
|
|
}
|
|
if (name.startsWith("@") || name.startsWith(".") || name.startsWith("|"))
|
|
continue;
|
|
if (name.startsWith("this.")) name = name.slice(5);
|
|
if (name === "this" || name === "") continue;
|
|
name = name.split(/[.[]/)[0]; // `a.b` and `a.[0]` are reads of `a`
|
|
if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(name)) continue;
|
|
if (LITERALS.has(name)) continue;
|
|
if (parents === 0 && in_callee_position && HELPERS.has(name)) continue;
|
|
|
|
const context = contexts[Math.max(0, contexts.length - 1 - parents)];
|
|
if (context === "top") top.add(name);
|
|
else if (context === "row") row.add(name);
|
|
}
|
|
|
|
if (opens_a_nested_context) contexts.push("nested");
|
|
}
|
|
|
|
return { top, row };
|
|
}
|
|
|
|
function read_templates(): Map<string, TemplateProperties> {
|
|
const templates = new Map<string, TemplateProperties>();
|
|
for (const file of fs.readdirSync(TEMPLATES_DIR)) {
|
|
if (!file.endsWith(".handlebars")) continue;
|
|
const name = file.slice(0, -".handlebars".length);
|
|
if (TEMPLATES_WITHOUT_DOCUMENTATION.has(name)) continue;
|
|
const source = fs.readFileSync(path.join(TEMPLATES_DIR, file), "utf8");
|
|
templates.set(name, properties_of_template(source));
|
|
}
|
|
return templates;
|
|
}
|
|
|
|
function read_documentation(): Map<string, TemplateProperties> {
|
|
const migrations = fs
|
|
.readdirSync(MIGRATIONS_DIR)
|
|
.filter((file) => file.endsWith(".sql"))
|
|
// SQLPage applies migrations in the order of their numeric prefix.
|
|
.sort(
|
|
(a, b) =>
|
|
Number.parseInt(a, 10) - Number.parseInt(b, 10) || a.localeCompare(b),
|
|
);
|
|
|
|
const db = new DatabaseSync(":memory:");
|
|
for (const file of migrations) {
|
|
db.exec(fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"));
|
|
}
|
|
|
|
const documented = new Map<string, TemplateProperties>();
|
|
const rows = db
|
|
.prepare("select component, name, top_level from parameter")
|
|
.all() as { component: string; name: string; top_level: number }[];
|
|
for (const { component, name, top_level } of rows) {
|
|
let entry = documented.get(component);
|
|
if (!entry) {
|
|
entry = { top: new Set(), row: new Set() };
|
|
documented.set(component, entry);
|
|
}
|
|
(top_level ? entry.top : entry.row).add(name);
|
|
}
|
|
db.close();
|
|
return documented;
|
|
}
|
|
|
|
const templates = read_templates();
|
|
const documented = read_documentation();
|
|
|
|
test("every component documented by the official site has a template", () => {
|
|
const missing = [...documented.keys()].filter(
|
|
(component) =>
|
|
!templates.has(component) &&
|
|
!COMPONENTS_WITHOUT_A_TEMPLATE.has(component),
|
|
);
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`These components are documented but have no sqlpage/templates/*.handlebars file. ` +
|
|
`If they are rendered by src/render.rs, add them to COMPONENTS_WITHOUT_A_TEMPLATE.`,
|
|
);
|
|
});
|
|
|
|
test("every property read by a template is documented", () => {
|
|
const undocumented: string[] = [];
|
|
for (const [component, properties] of templates) {
|
|
const docs = documented.get(component) ?? {
|
|
top: new Set(),
|
|
row: new Set(),
|
|
};
|
|
for (const level of ["top", "row"] satisfies Level[]) {
|
|
for (const name of properties[level]) {
|
|
if (!docs[level].has(name))
|
|
undocumented.push(`${component}.${name} (${level}-level)`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
undocumented.sort(),
|
|
[],
|
|
`These properties are read by a template but not documented. Add a row to ` +
|
|
`the component's migration in examples/official-site/sqlpage/migrations/.`,
|
|
);
|
|
});
|
|
|
|
test("every documented property is read by its template", () => {
|
|
const unimplemented: string[] = [];
|
|
for (const [component, docs] of documented) {
|
|
const properties = templates.get(component);
|
|
if (!properties) continue; // rendered by src/, checked by the test above
|
|
for (const level of ["top", "row"] satisfies Level[]) {
|
|
for (const name of docs[level]) {
|
|
if (properties[level].has(name)) continue;
|
|
if (DOCUMENTED_WITHOUT_A_TEMPLATE_REFERENCE.has(`${component}.${name}`))
|
|
continue;
|
|
const other_level = level === "top" ? "row" : "top";
|
|
unimplemented.push(
|
|
properties[other_level].has(name)
|
|
? `${component}.${name} is documented as ${level}-level but the template reads it at the ${other_level} level`
|
|
: `${component}.${name} (${level}-level) is documented but no template reads it`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
unimplemented.sort(),
|
|
[],
|
|
`Remove the documentation, or fix its top_level, in ` +
|
|
`examples/official-site/sqlpage/migrations/.`,
|
|
);
|
|
});
|