mirror of
https://github.com/aphrody-labs/aphrody-m3-react.git
synced 2026-10-13 20:07:22 +00:00
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
/**
|
|
* Generator for @aphrody/m3-react.
|
|
*
|
|
* Reads migration/scripts/md-elements.txt (119 tags), resolves each tag to its
|
|
* real entry file + exported element class inside material-web/ by scanning the
|
|
* `@customElement('md-x') export class Foo` pattern, and emits one wrapper file
|
|
* per category directory using @lit/react `createComponent`.
|
|
*
|
|
* Registry archives contain compiled files only; pass the standalone fork sources.
|
|
* Run: bun run generate.ts --source /path/to/material-web [--output /tmp/preview]
|
|
*/
|
|
import { mkdirSync, readdirSync, statSync } from "node:fs";
|
|
import { join, relative, resolve } from "node:path";
|
|
|
|
const HERE = import.meta.dir;
|
|
const args = process.argv.slice(2);
|
|
let sourceRoot: string | undefined;
|
|
let outputRoot = HERE;
|
|
for (let i = 0; i < args.length; i++) {
|
|
const flag = args[i];
|
|
const value = args[++i];
|
|
if ((flag !== "--source" && flag !== "--output") || !value || value.startsWith("--")) {
|
|
throw new Error(
|
|
"Usage: bun run generate.ts --source /path/to/material-web [--output /tmp/preview]",
|
|
);
|
|
}
|
|
if (flag === "--source") sourceRoot = resolve(value);
|
|
else outputRoot = resolve(value);
|
|
}
|
|
if (!sourceRoot) {
|
|
throw new Error(
|
|
"Material Web npm archives contain compiled files only. Pass --source /path/to/material-web (the standalone fork checkout).",
|
|
);
|
|
}
|
|
const MW = sourceRoot;
|
|
const BUNDLES = ["all.ts", "aphrody-components.ts", "aphrody-labs.ts"];
|
|
const bundlePresent = await Promise.all(
|
|
BUNDLES.map((bundle) => Bun.file(join(MW, bundle)).exists()),
|
|
);
|
|
for (const [index, bundle] of BUNDLES.entries()) {
|
|
if (!bundlePresent[index]) {
|
|
throw new Error(`Material Web source checkout is missing ${bundle}: ${MW}`);
|
|
}
|
|
}
|
|
const ELEMENTS_TXT = join(HERE, "md-elements.txt");
|
|
const OUT_DIR = join(outputRoot, "wrappers");
|
|
|
|
// ── 1. Tag → PascalCase wrapper name (CONVENTIONS §2) ──────────────────────
|
|
const pascal = (tag: string): string =>
|
|
tag
|
|
.split("-")
|
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
.join("");
|
|
|
|
// ── 2. Walk material-web for every `@customElement('md-…')` entry file ──────
|
|
// Skip tests, internal/, demos, catalog. Collect tag → [{file, className}].
|
|
const SKIP_DIRS = new Set([
|
|
"node_modules",
|
|
"internal",
|
|
"demo",
|
|
"demos",
|
|
"catalog",
|
|
"dist-aphrody",
|
|
"sass",
|
|
"testing",
|
|
]);
|
|
const RE = /@customElement\(\s*['"]([a-z0-9-]+)['"]\s*\)\s*\nexport class (\w+)/g;
|
|
|
|
interface Def {
|
|
file: string;
|
|
className: string;
|
|
}
|
|
|
|
type EventMap = Record<string, string>;
|
|
|
|
interface Resolved {
|
|
tag: string;
|
|
name: string;
|
|
file: string;
|
|
className: string;
|
|
events: EventMap;
|
|
}
|
|
|
|
interface Unresolved {
|
|
tag: string;
|
|
reason: string;
|
|
}
|
|
|
|
const defs: Map<string, Def[]> = new Map();
|
|
|
|
async function walk(dir: string): Promise<void> {
|
|
for (const name of readdirSync(dir)) {
|
|
const full = join(dir, name);
|
|
const st = statSync(full);
|
|
if (st.isDirectory()) {
|
|
if (SKIP_DIRS.has(name)) continue;
|
|
// oxlint-disable-next-line no-await-in-loop -- depth-first walk in readdir order: the defs lists it appends to feed the generated output order
|
|
await walk(full);
|
|
} else if (name.endsWith(".ts") && !name.endsWith("_test.ts") && !name.endsWith(".d.ts")) {
|
|
// oxlint-disable-next-line no-await-in-loop -- files are registered in walk order (defs lists feed the generated output), so reads stay sequential
|
|
const src = await Bun.file(full).text();
|
|
let m;
|
|
RE.lastIndex = 0;
|
|
while ((m = RE.exec(src))) {
|
|
const [, tag, className] = m;
|
|
if (!tag.startsWith("md-")) continue;
|
|
const rel = relative(MW, full).replace(/\.ts$/, ".js"); // import as .js (side-effect register)
|
|
if (!defs.has(tag)) defs.set(tag, []);
|
|
defs.get(tag)!.push({ file: rel, className });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await walk(MW);
|
|
|
|
// ── 3. Canonical import path preference ────────────────────────────────────
|
|
// Tags re-exported by the three bundles must import from the bundled entry
|
|
// path. Build the set of canonical relative paths from those bundles.
|
|
const bundleImports = new Set<string>();
|
|
const bundleSources = await Promise.all(BUNDLES.map((bundle) => Bun.file(join(MW, bundle)).text()));
|
|
for (const src of bundleSources) {
|
|
for (const m of src.matchAll(/from ['"]\.\/([^'"]+\.js)['"]/g)) bundleImports.add(m[1]);
|
|
}
|
|
|
|
function pickDef(tag: string, candidates: Def[]): Def {
|
|
// 1. Prefer a candidate whose path is referenced by a bundle entry.
|
|
const bundled = candidates.find((c) => bundleImports.has(c.file));
|
|
if (bundled) return bundled;
|
|
// 2. Otherwise prefer a non-labs/gb path (canonical source dir).
|
|
const nonGb = candidates.find((c) => !c.file.startsWith("labs/gb/"));
|
|
if (nonGb) return nonGb;
|
|
// 3. Fall back to the first (labs/gb only — e.g. md-button, md-split-button).
|
|
return candidates[0];
|
|
}
|
|
|
|
// ── 4. Event maps per category (verified against new Event/CustomEvent in MW) ─
|
|
// Generic UI events React already types; the md elements re-dispatch the native
|
|
// `input`/`change`. Component-specific events use their real names.
|
|
// Every name below is the REAL event string verified by grepping
|
|
// `@fires` / `new (Custom)?Event('…')` in material-web/<dir>/internal/*.ts.
|
|
// Upstream components use bare names (input/change/opening/…); aphrody fork
|
|
// components namespace theirs (paginator:page, table:sort, side-sheet:opening…).
|
|
const EVENTS_BY_TAG: Record<string, EventMap> = {
|
|
// ── fork: parity additions (event names verified against source dispatch) ──
|
|
"md-rating": { onChange: "change", onInput: "input" },
|
|
"md-carousel": { onChange: "carousel-change" },
|
|
"md-scheduler": {
|
|
onNavigate: "scheduler:navigate",
|
|
onSelect: "scheduler:select",
|
|
onEventClick: "scheduler:event-click",
|
|
},
|
|
"md-date-range-picker": {
|
|
onInput: "input",
|
|
onChange: "change",
|
|
onDateRangeChange: "date-range-picker:change",
|
|
},
|
|
"md-date-time-picker": {
|
|
onInput: "input",
|
|
onChange: "change",
|
|
onDateTimeChange: "date-time-picker:change",
|
|
},
|
|
"md-popover": { onOpened: "opened", onClosed: "closed" },
|
|
"md-alert": { onClose: "close" },
|
|
"md-avatar": { onError: "error" },
|
|
// ── Form inputs (upstream: re-dispatched native input/change) ──────────
|
|
"md-checkbox": { onChange: "change", onInput: "input" },
|
|
"md-radio": { onChange: "change", onInput: "input" },
|
|
"md-switch": { onChange: "change", onInput: "input" },
|
|
"md-slider": { onChange: "change", onInput: "input" },
|
|
"md-filled-text-field": { onChange: "change", onInput: "input" },
|
|
"md-outlined-text-field": { onChange: "change", onInput: "input" },
|
|
"md-filled-select": {
|
|
onChange: "change",
|
|
onInput: "input",
|
|
onOpening: "opening",
|
|
onOpened: "opened",
|
|
onClosing: "closing",
|
|
onClosed: "closed",
|
|
},
|
|
"md-outlined-select": {
|
|
onChange: "change",
|
|
onInput: "input",
|
|
onOpening: "opening",
|
|
onOpened: "opened",
|
|
onClosing: "closing",
|
|
onClosed: "closed",
|
|
},
|
|
// fork: search-bar fires input + search + search:open/close
|
|
"md-search-bar": {
|
|
onInput: "input",
|
|
onSearch: "search",
|
|
onSearchOpen: "search:open",
|
|
onSearchClose: "search:close",
|
|
},
|
|
// fork: autocomplete fires input + autocomplete:select
|
|
"md-autocomplete": { onInput: "input", onSelect: "autocomplete:select" },
|
|
// ── Overlays (upstream) ────────────────────────────────────────────────
|
|
"md-dialog": {
|
|
onOpen: "open",
|
|
onOpened: "opened",
|
|
onClose: "close",
|
|
onClosed: "closed",
|
|
onCancel: "cancel",
|
|
},
|
|
"md-menu": {
|
|
onOpening: "opening",
|
|
onOpened: "opened",
|
|
onClosing: "closing",
|
|
onClosed: "closed",
|
|
},
|
|
"md-menu-item": { onCloseMenu: "close-menu" },
|
|
"md-sub-menu": {
|
|
onOpening: "opening",
|
|
onOpened: "opened",
|
|
onClosing: "closing",
|
|
onClosed: "closed",
|
|
},
|
|
// fork: snackbar (bare opening/opened/closing/closed)
|
|
"md-snackbar": {
|
|
onOpening: "opening",
|
|
onOpened: "opened",
|
|
onClosing: "closing",
|
|
onClosed: "closed",
|
|
},
|
|
// fork: sheets namespace their lifecycle events
|
|
"md-bottom-sheet": {
|
|
onOpening: "bottom-sheet:opening",
|
|
onOpened: "bottom-sheet:opened",
|
|
onClosing: "bottom-sheet:closing",
|
|
onClosed: "bottom-sheet:closed",
|
|
},
|
|
"md-side-sheet": {
|
|
onOpening: "side-sheet:opening",
|
|
onOpened: "side-sheet:opened",
|
|
onClosing: "side-sheet:closing",
|
|
onClosed: "side-sheet:closed",
|
|
},
|
|
// labs navigation drawer
|
|
"md-navigation-drawer": {
|
|
onNavigationDrawerChanged: "navigation-drawer-changed",
|
|
},
|
|
"md-navigation-drawer-modal": {
|
|
onNavigationDrawerChanged: "navigation-drawer-changed",
|
|
},
|
|
// ── Selection / navigation ─────────────────────────────────────────────
|
|
"md-tabs": { onChange: "change" }, // upstream: bubbling 'change'
|
|
"md-navigation-bar": { onActivated: "navigation-bar-activated" }, // labs
|
|
"md-navigation-rail": {
|
|
onChange: "navigation-rail:change",
|
|
onItemActivate: "navigation-rail-item:activate",
|
|
}, // fork
|
|
"md-input-chip": { onRemove: "remove", onUpdateFocus: "update-focus" },
|
|
"md-button-group": { onChange: "button-group:change" }, // fork
|
|
"md-fab-menu": { onOpen: "fab-menu:open", onClose: "fab-menu:close" }, // fork
|
|
"md-fab-menu-item": { onClick: "fab-menu-item:click" }, // fork
|
|
// ── Disclosure (fork) ──────────────────────────────────────────────────
|
|
"md-expansion-panel": { onToggle: "expansion:toggle" },
|
|
"md-stepper": { onChange: "stepper:change" },
|
|
// ── Pickers (fork) ─────────────────────────────────────────────────────
|
|
"md-date-picker": { onChange: "date-picker:change" },
|
|
"md-time-picker": { onChange: "time-picker:change" },
|
|
// ── Data (fork) ────────────────────────────────────────────────────────
|
|
"md-table": {
|
|
onSort: "table:sort",
|
|
onSelectionChange: "table:selection-change",
|
|
},
|
|
"md-paginator": { onPage: "paginator:page" },
|
|
"md-tree": { onSelect: "tree:select" },
|
|
"md-tree-item": {
|
|
onToggle: "tree-item:toggle",
|
|
onActivate: "tree-item:activate",
|
|
},
|
|
"md-virtual-scroller": { onRange: "virtual-scroll:range" },
|
|
// ── Misc (fork) ────────────────────────────────────────────────────────
|
|
"md-tooltip": { onShow: "tooltip-show", onHide: "tooltip-hide" },
|
|
};
|
|
|
|
// ── 5. Resolve every requested tag ─────────────────────────────────────────
|
|
const tags = (await Bun.file(ELEMENTS_TXT).text())
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
const resolved: Resolved[] = [];
|
|
const unresolved: Unresolved[] = [];
|
|
|
|
for (const tag of tags) {
|
|
const candidates = defs.get(tag);
|
|
if (!candidates || candidates.length === 0) {
|
|
unresolved.push({
|
|
tag,
|
|
reason: "no @customElement entry file found in material-web/",
|
|
});
|
|
continue;
|
|
}
|
|
const def = pickDef(tag, candidates);
|
|
resolved.push({
|
|
tag,
|
|
name: pascal(tag),
|
|
file: def.file,
|
|
className: def.className,
|
|
events: EVENTS_BY_TAG[tag] ?? {},
|
|
});
|
|
}
|
|
|
|
// ── 6. Group by top-level category dir, emit one wrapper file per group ─────
|
|
const groupOf = (file: string): string => file.split("/")[0].replace(/\..*$/, "");
|
|
const groups: Map<string, Resolved[]> = new Map();
|
|
for (const r of resolved) {
|
|
const g = groupOf(r.file);
|
|
if (!groups.has(g)) groups.set(g, []);
|
|
groups.get(g)!.push(r);
|
|
}
|
|
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
const HEADER = `// AUTO-GENERATED by generate.ts — do not edit by hand. Run \`bun run generate\`.\n`;
|
|
const fmtEvents = (events: EventMap): string => {
|
|
const keys = Object.keys(events);
|
|
if (keys.length === 0) return "{}";
|
|
const body = keys.map((k) => `${k}: '${events[k]}'`).join(", ");
|
|
return `{${body}}`;
|
|
};
|
|
|
|
const indexLines = [
|
|
HEADER,
|
|
`import * as React from 'react';`,
|
|
`import {createComponent} from '@lit/react';`,
|
|
"",
|
|
];
|
|
// Group elements that share the same import file (e.g. several tags per module is rare; one file = one element here).
|
|
const writes: Promise<number>[] = [];
|
|
for (const [group, items] of [...groups.entries()].toSorted()) {
|
|
const fileName = `${group}.ts`;
|
|
const lines = [
|
|
HEADER,
|
|
`import * as React from 'react';`,
|
|
`import {createComponent} from '@lit/react';`,
|
|
"",
|
|
];
|
|
// imports — alias element class to <Name>Element to avoid clashing with wrapper const.
|
|
for (const r of items) {
|
|
lines.push(
|
|
`import {${r.className} as ${r.name}Element} from '@aphrody/material-web/${r.file}';`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
for (const r of items) {
|
|
lines.push(`export const ${r.name} = createComponent({`);
|
|
lines.push(` react: React,`);
|
|
lines.push(` tagName: '${r.tag}',`);
|
|
lines.push(` elementClass: ${r.name}Element,`);
|
|
lines.push(` events: ${fmtEvents(r.events)},`);
|
|
lines.push(`});`);
|
|
lines.push("");
|
|
}
|
|
writes.push(Bun.write(join(OUT_DIR, fileName), lines.join("\n")));
|
|
indexLines.push(`export * from './wrappers/${group}.js';`.replace("./wrappers", "./"));
|
|
}
|
|
await Promise.all(writes);
|
|
|
|
// index.ts at package root re-exports all groups.
|
|
const idx = [HEADER];
|
|
for (const [group] of [...groups.entries()].toSorted()) {
|
|
idx.push(`export * from './wrappers/${group}.js';`);
|
|
}
|
|
idx.push("");
|
|
await Bun.write(join(outputRoot, "index.ts"), idx.join("\n"));
|
|
|
|
// ── 7. Report ──────────────────────────────────────────────────────────────
|
|
console.log(`Tags requested: ${tags.length}`);
|
|
console.log(`Wrappers generated: ${resolved.length} across ${groups.size} files`);
|
|
if (unresolved.length) {
|
|
console.log(`Unresolved (${unresolved.length}):`);
|
|
for (const u of unresolved) console.log(` - ${u.tag}: ${u.reason}`);
|
|
}
|
|
// Emit a machine-readable manifest for the README.
|
|
await Bun.write(
|
|
join(outputRoot, ".manifest.json"),
|
|
JSON.stringify(
|
|
{
|
|
resolved: resolved.map(({ tag, name, file, className, events }) => ({
|
|
tag,
|
|
name,
|
|
file,
|
|
className,
|
|
events,
|
|
})),
|
|
unresolved,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
// Keep the README's component list in sync (it is the last section). This makes
|
|
// the wrapper count + table self-maintaining — never stale after a `generate`.
|
|
const README = join(outputRoot, "README.md");
|
|
const rows = resolved
|
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
.map((r) => {
|
|
const ev = Object.keys(r.events ?? {}).length || "—";
|
|
return `| \`${r.name}\` | \`${r.tag}\` | \`@aphrody/material-web/${r.file}\` | ${ev} |`;
|
|
})
|
|
.join("\n");
|
|
const listSection =
|
|
`## Liste complète des wrappers (${resolved.length})\n\n` +
|
|
`> Régénérée par \`generate.ts\` (\`.manifest.json\`). ${unresolved.length} non résolu(s).\n\n` +
|
|
`| Wrapper | Tag | Import élément | # events |\n| --- | --- | --- | --- |\n${rows}\n`;
|
|
try {
|
|
const readme = await Bun.file(README).text();
|
|
const updated = readme.replace(/## Liste complète des wrappers \(\d+\)[\s\S]*$/, listSection);
|
|
if (updated !== readme) await Bun.write(README, updated);
|
|
} catch {
|
|
/* README optional */
|
|
}
|