Both tabs were rendering Explore. They now take the whole screen, as the
reference does by hiding the unit on each: a list or a page of prose
wants the room more than the panel does. Picking a search result jumps
to Explore with that combo lit, which is the reference's data-go.
The rules text is extracted verbatim rather than retyped or reworded.
Two bugs on the way there, both worth recording:
- RULES is a template literal that itself contains template literals
inside a ${} interpolation, so the bracket scanner used for the combo
table stops at the first inner backtick. The nine title and body pairs
are matched out of the source instead, with a count assertion so drift
fails loudly rather than silently dropping a rule.
- PADMAP was read with g() but never added to the list of declarations
evaluated into the vm, so it was simply not in scope.
The extractor now asserts 9 rules and 4 pad-map rows alongside the 159
combos.
141 tests, typecheck clean. Both tabs verified by rendering them, Find
with a seeded query since headless Firefox cannot type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
5.0 KiB
JavaScript
122 lines
5.0 KiB
JavaScript
// Extracts the combo table and label maps from reference/index.html into TypeScript.
|
|
//
|
|
// The reference is the data spec, so the 159 entries are never retyped by hand:
|
|
// this lifts the literals straight out of it and re-runs whenever it changes.
|
|
// It reads only pure-data declarations, evaluated in a vm with no DOM.
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import vm from "node:vm";
|
|
|
|
const SRC = "reference/index.html";
|
|
const src = readFileSync(SRC, "utf8");
|
|
|
|
// Balance brackets from a declaration's first opener, skipping string literals so
|
|
// that braces or quotes inside descriptions cannot end the scan early.
|
|
function literalAfter(name) {
|
|
const at = src.indexOf(`const ${name}=`);
|
|
if (at < 0) throw new Error(`declaration not found: ${name}`);
|
|
let i = src.indexOf("=", at) + 1;
|
|
while (/\s/.test(src[i])) i++;
|
|
const start = i;
|
|
let depth = 0, quote = null;
|
|
for (; i < src.length; i++) {
|
|
const c = src[i];
|
|
if (quote) {
|
|
if (c === "\\") { i++; continue; }
|
|
if (c === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
if (c === "{" || c === "[" || c === "(") depth++;
|
|
else if (c === "}" || c === "]" || c === ")") {
|
|
depth--;
|
|
if (depth === 0) return src.slice(start, i + 1);
|
|
}
|
|
}
|
|
throw new Error(`unbalanced literal: ${name}`);
|
|
}
|
|
|
|
const NAMES = [
|
|
"L",
|
|
"PL",
|
|
"GROUPS",
|
|
"GNAME",
|
|
"NOLATCH",
|
|
"MODORDER",
|
|
"RAW",
|
|
"CTX",
|
|
"CTXHINT",
|
|
"PADMAP",
|
|
];
|
|
const ctx = vm.createContext({});
|
|
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
|
|
// The reference adds the 16 pad labels in a loop right after declaring L.
|
|
vm.runInContext(`for (let i = 1; i <= 16; i++) L["p" + i] = "PAD " + i;`, ctx);
|
|
|
|
const g = (n) => vm.runInContext(n, ctx);
|
|
const header = (what) =>
|
|
`// GENERATED from ${SRC} by scripts/extract-reference-data.mjs — do not edit by hand.\n` +
|
|
`// ${what}\n\n`;
|
|
|
|
const j = (v) => JSON.stringify(v, null, 2);
|
|
|
|
writeFileSync(
|
|
"src/domain/raw.ts",
|
|
header("The combo table, exactly as the reference declares it.") +
|
|
`import type { RawEntry } from "./types";\n\nexport const RAW: RawEntry[] = ${j(g("RAW"))};\n`
|
|
);
|
|
|
|
writeFileSync(
|
|
"src/domain/labels.ts",
|
|
header("Control labels, groupings and context names.") +
|
|
`import type { ControlId, ContextCode, GroupId } from "./types";\n\n` +
|
|
`/** Full name printed on each control. */\n` +
|
|
`export const L: Record<ControlId, string> = ${j(g("L"))};\n\n` +
|
|
`/** Shorter labels used on the panel where the full one will not fit. */\n` +
|
|
`export const PL: Partial<Record<ControlId, string>> = ${j(g("PL"))};\n\n` +
|
|
`/** A target may name a whole group of controls rather than one. */\n` +
|
|
`export const GROUPS: Record<GroupId, ControlId[]> = ${j(g("GROUPS"))};\n\n` +
|
|
`export const GNAME: Record<GroupId, string> = ${j(g("GNAME"))};\n\n` +
|
|
`/** Controls that never latch when held: the knobs and the pads. */\n` +
|
|
`export const NOLATCH: ControlId[] = ${j([...g("NOLATCH")])};\n\n` +
|
|
`/** Canonical modifier order, so a layer key is stable however it was pressed. */\n` +
|
|
`export const MODORDER: ControlId[] = ${j(g("MODORDER"))};\n\n` +
|
|
`export const CTX: Record<ContextCode, string> = ${j(g("CTX"))};\n\n` +
|
|
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
|
|
);
|
|
|
|
// The nine rules live in a template literal that itself contains template
|
|
// literals inside a ${...} interpolation, so the bracket scanner above would
|
|
// stop at the first inner backtick. Match the pairs out of the source instead.
|
|
const rulePairs = [...src.matchAll(/<h3>(.*?)<\/h3><p>(.*?)<\/p>/gs)].map((m) => ({
|
|
title: m[1].trim(),
|
|
body: m[2].trim(),
|
|
}));
|
|
if (rulePairs.length !== 9) {
|
|
throw new Error(`expected 9 rules, found ${rulePairs.length}`);
|
|
}
|
|
|
|
const padMap = g("PADMAP").map(([label, pads]) => ({ label, pads }));
|
|
if (padMap.length !== 4) {
|
|
throw new Error(`expected 4 pad-map rows, found ${padMap.length}`);
|
|
}
|
|
|
|
writeFileSync(
|
|
"src/domain/rules.ts",
|
|
header("The Rules tab: nine rules, and the SHIFT + pad map.") +
|
|
`export interface Rule {\n readonly title: string;\n readonly body: string;\n}\n\n` +
|
|
`export interface PadMapRow {\n readonly label: string;\n readonly pads: readonly string[];\n}\n\n` +
|
|
`export const RULES: readonly Rule[] = ${j(rulePairs)};\n\n` +
|
|
`export const PAD_MAP: readonly PadMapRow[] = ${j(padMap)};\n`
|
|
);
|
|
|
|
const raw = g("RAW");
|
|
console.log(`rules ${rulePairs.length}`);
|
|
console.log(`pad-map rows ${padMap.length}`);
|
|
console.log(`RAW entries ${raw.length}`);
|
|
console.log(`control labels ${Object.keys(g("L")).length}`);
|
|
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);
|
|
console.log(`groups ${Object.keys(g("GROUPS")).join(", ")}`);
|
|
console.log(`entries with how ${raw.filter((r) => r.length > 7).length}`);
|
|
console.log(`group targets ${raw.filter((r) => typeof r[1] === "string" && g("GROUPS")[r[1]]).length}`);
|
|
console.log(`array targets ${raw.filter((r) => Array.isArray(r[1])).length}`);
|