- tools/ui_map.mjs find <words on screen | key | /anomalous/route | file.js>, read from the code each time (tools/ui_map_scan.mjs: the words, what each web file shows and calls, its CSS classes, imports, the backend's routes); build writes the whole map to the untracked docs/ui-map.md, with the routes no page calls and the words no file uses. - docs/architecture/ui-flows.md: the hand-written part for the AI workbench (what each part is for, what a click does, why) and a glossary of one name per thing. The structure check fails when a word in it is no longer on screen, a file or route it names is gone, or a retired name is back on screen. - One name each on the AI page: 素材 (not 材料), 留言 (not 消息), 节点结构 (the / command too), 连接 for the last AI, 副本 for the task's tab, 撤销; English "Node map". - AGENTS.md: look a button up with the map before changing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
8.0 KiB
JavaScript
160 lines
8.0 KiB
JavaScript
// The UI map's index (tools/ui_map.mjs), read from the code each time so it cannot go stale:
|
|
// the words on screen (web/modules/locales.js), what each web file shows of them, the
|
|
// /anomalous/ addresses it calls and the CSS classes it makes, which files import it, the
|
|
// backend's routes with the function behind each, and which CSS files style each class.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const read = rel => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
const list = (dir, ext) => fs.readdirSync(path.join(ROOT, dir)).filter(f => f.endsWith(ext)).map(f => path.posix.join(dir, f));
|
|
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
|
|
|
|
/** `/anomalous/...` as written in the code, up to its query: each `${…}` or `{name}` as `{}`. */
|
|
export function routeShape(source, from = 0) {
|
|
let out = '';
|
|
for (let at = from; at < source.length; at += 1) {
|
|
const char = source[at];
|
|
if (char === '$' && source[at + 1] === '{') {
|
|
let depth = 0;
|
|
for (at += 1; at < source.length; at += 1) {
|
|
if (source[at] === '{') depth += 1;
|
|
else if (source[at] === '}' && --depth === 0) break;
|
|
}
|
|
out += '{}';
|
|
} else if (char === '{') {
|
|
const end = source.indexOf('}', at);
|
|
out += '{}';
|
|
at = end < 0 ? source.length : end;
|
|
} else if (/[A-Za-z0-9_\-./]/.test(char)) {
|
|
out += char;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return out.replace(/\/+$/, '');
|
|
}
|
|
|
|
/** Whether frontend address shape `front` can reach backend route shape `back` (each `{}` any part). */
|
|
export function reaches(front, back) {
|
|
if (front === back) return true;
|
|
const pattern = new RegExp(`^${front.split('{}').map(part => part.replace(/[.*+?^$()|[\]\\]/g, '\\$&')).join('.*')}$`);
|
|
return pattern.test(back.replaceAll('{}', 'X'));
|
|
}
|
|
|
|
async function words() {
|
|
const { i18n } = await import(pathToFileURL(path.join(ROOT, 'web/modules/locales.js')).href);
|
|
const text = read('web/modules/locales.js');
|
|
const lines = new Map();
|
|
let lang = null;
|
|
text.split('\n').forEach((line, index) => {
|
|
const head = line.match(/^ {4}(\w+): \{/);
|
|
if (head) lang = head[1];
|
|
const key = line.match(/^ {8}([A-Za-z_]\w*):/);
|
|
if (key && lang) lines.set(`${lang}:${key[1]}`, index + 1);
|
|
});
|
|
return { zh: i18n.zh || {}, en: i18n.en || {}, line: (lang, key) => lines.get(`${lang}:${key}`) || 0 };
|
|
}
|
|
|
|
function backendRoutes() {
|
|
const routes = [];
|
|
for (const file of ['__init__.py', ...list('api', '.py')]) {
|
|
const text = read(file);
|
|
const defs = new Map([...text.matchAll(/^(?:async )?def (\w+)\(/gm)].map(match => [match[1], lineAt(text, match.index)]));
|
|
const added = /router\.add_(?:(get|post|put|delete|patch)\(|route\(\s*['"]([*A-Z]+)['"]\s*,)\s*['"]([^'"]+)['"]\s*,\s*([\w.]+)/g;
|
|
for (const match of text.matchAll(added)) {
|
|
const [, verb, anyVerb, route, handler] = match;
|
|
const method = verb || (anyVerb === '*' ? 'any' : anyVerb);
|
|
const [module, func] = handler.includes('.') ? handler.split('.') : [path.posix.basename(file, '.py'), handler];
|
|
const owner = handler.includes('.') ? `api/${module}.py` : file;
|
|
const ownText = owner === file ? text : (fs.existsSync(path.join(ROOT, owner)) ? read(owner) : '');
|
|
const def = ownText.match(new RegExp(`^(?:async )?def ${func}\\(`, 'm'));
|
|
routes.push({
|
|
method: method.toUpperCase(), route, shape: routeShape(route), file: owner, func,
|
|
line: owner === file ? (defs.get(func) || 0) : (def ? lineAt(ownText, def.index) : 0),
|
|
registered: `${file}:${lineAt(text, match.index)}`,
|
|
});
|
|
}
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
function cssIndex() {
|
|
const where = new Map(); // class -> [file:line]
|
|
for (const file of list('web/styles', '.css')) {
|
|
read(file).split('\n').forEach((line, index) => {
|
|
for (const [, name] of line.matchAll(/\.(anomalous-[\w-]+|amb-[\w-]+)/g)) {
|
|
const at = `${file}:${index + 1}`;
|
|
const known = where.get(name) || [];
|
|
if (!known.includes(at)) known.push(at);
|
|
where.set(name, known);
|
|
}
|
|
});
|
|
}
|
|
return where;
|
|
}
|
|
|
|
const KEY_LIKE = /^[a-zA-Z][A-Za-z0-9_]{2,}$/;
|
|
const STRING = /'([^'\\\n]*)'|"([^"\\\n]*)"/g;
|
|
const TEMPLATE = /`([^`]*)`/g;
|
|
|
|
/** What one web file uses: words (key -> lines), addresses, classes (class -> lines), imports. */
|
|
function scanFile(file, keys) {
|
|
const text = read(file);
|
|
const uses = new Map();
|
|
const use = (key, line) => uses.set(key, [...(uses.get(key) || []), line]);
|
|
// A key in quotes: inside t(…) / translate(…), or a name no other string would have (aiCanvasGo).
|
|
for (const match of text.matchAll(STRING)) {
|
|
const value = match[1] ?? match[2];
|
|
if (!keys.has(value)) continue;
|
|
if (/[A-Z_]/.test(value) || /\b(?:t|translate)\(\s*$/.test(text.slice(Math.max(0, match.index - 14), match.index))) use(value, lineAt(text, match.index));
|
|
}
|
|
// A key put together in a template (`aiCanvas_${mode}`): every key it can be.
|
|
for (const match of text.matchAll(TEMPLATE)) {
|
|
const body = match[1];
|
|
if (!body.includes('${')) continue;
|
|
const parts = body.split(/\$\{[^}]*\}/);
|
|
if (!parts[0] || !KEY_LIKE.test(parts[0]) || parts.some(part => !/^[A-Za-z0-9_]*$/.test(part))) continue;
|
|
const pattern = new RegExp(`^${parts.join('[A-Za-z0-9_]+')}$`);
|
|
const line = lineAt(text, match.index);
|
|
for (const key of keys) if (pattern.test(key)) use(key, line);
|
|
}
|
|
const calls = [];
|
|
for (const match of text.matchAll(/['"`](\/anomalous\/)/g)) {
|
|
const shape = routeShape(text, match.index + 1);
|
|
const near = text.slice(match.index, match.index + 260);
|
|
const lead = text.slice(text.lastIndexOf('\n', match.index) + 1, match.index);
|
|
const method = /method:\s*['"](POST|PUT|DELETE|PATCH)['"]/.exec(near)?.[1]
|
|
|| (/post\w*\(\s*$/i.test(lead) ? 'POST' : /fetch\(\s*$/.test(lead) ? 'GET' : null);
|
|
calls.push({ shape, method, line: lineAt(text, match.index) });
|
|
}
|
|
const classes = new Map();
|
|
for (const match of text.matchAll(/\b(anomalous-[a-z0-9-]*[a-z0-9]|amb-[a-z0-9-]*[a-z0-9])\b/g)) {
|
|
const line = lineAt(text, match.index);
|
|
classes.set(match[1], [...(classes.get(match[1]) || []), line]);
|
|
}
|
|
const imports = [...text.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+\.js)['"]/g)]
|
|
.map(match => path.posix.normalize(path.posix.join(path.posix.dirname(file), match[1])));
|
|
const head = text.match(/^\s*\/\*\*([\s\S]*?)\*\//)?.[1].replace(/^\s*\* ?/gm, '').replace(/\s+/g, ' ').trim() || '';
|
|
return { file, uses, calls, classes, imports, about: head.split(/(?<=\.)\s+(?=[A-Z(`"])/)[0] || '' };
|
|
}
|
|
|
|
/** The whole index. */
|
|
export async function buildIndex() {
|
|
const text = await words();
|
|
const keys = new Set([...Object.keys(text.zh), ...Object.keys(text.en)]);
|
|
const files = [...list('web', '.js'), ...list('web/modules', '.js')].filter(file => file !== 'web/modules/locales.js').map(file => scanFile(file, keys));
|
|
const importers = new Map();
|
|
for (const one of files) for (const target of one.imports) importers.set(target, [...(importers.get(target) || []), one.file]);
|
|
const routes = backendRoutes();
|
|
for (const one of files) {
|
|
for (const call of one.calls) {
|
|
const exact = routes.filter(route => route.shape === call.shape);
|
|
call.routes = (exact.length ? exact : routes.filter(route => reaches(call.shape, route.shape)))
|
|
.filter(route => !call.method || exact.length < 2 || route.method === call.method);
|
|
}
|
|
}
|
|
return { text, keys, files, importers, routes, css: cssIndex() };
|
|
}
|