automatica11y 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +39 -11
  3. package/package.json +5 -3
  4. package/skills/automatica11y/SKILL.md +3 -1
  5. package/skills/automatica11y-runner/SKILL.md +47 -14
  6. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  7. package/src/commands/common.js +5 -2
  8. package/src/frameworks/index.js +42 -0
  9. package/src/frameworks/react.js +77 -0
  10. package/src/frameworks/vue.js +90 -0
  11. package/src/frameworks/wc.js +55 -0
  12. package/src/globals.d.ts +1 -0
  13. package/src/harness/bundle.js +7 -6
  14. package/src/harness/generate/dialects.js +64 -0
  15. package/src/harness/generate/index.js +12 -0
  16. package/src/harness/generate/jsx-recipes.js +224 -0
  17. package/src/harness/generate/jsx.js +76 -0
  18. package/src/harness/generate/kit.js +50 -0
  19. package/src/harness/generate/marking.js +64 -0
  20. package/src/harness/generate/probe.js +97 -0
  21. package/src/harness/generate/shared.js +12 -0
  22. package/src/harness/generate/wc-recipes.js +132 -0
  23. package/src/harness/npm-install.js +38 -7
  24. package/src/harness/settle.js +17 -0
  25. package/src/harness/storybook.js +1 -0
  26. package/src/harness/url.js +13 -3
  27. package/src/plan/classify.js +11 -4
  28. package/src/plan/mapping.js +10 -7
  29. package/src/plan/resolve-npm.js +15 -9
  30. package/src/plan/subpath.js +133 -0
  31. package/src/report/comparison.js +14 -9
  32. package/src/report/parts.js +36 -12
  33. package/src/run/audit-npm.js +118 -28
  34. package/src/run/generate-fixture.js +74 -0
  35. package/src/run/run-plan.js +19 -4
  36. package/src/run/summary.js +5 -0
  37. package/src/schema.js +30 -6
  38. package/src/tiers/computed/checks.js +44 -5
  39. package/src/tiers/computed/color.js +8 -0
  40. package/src/tiers/computed/index.js +2 -2
  41. package/src/tiers/computed/measure-kit.js +30 -10
  42. package/src/tiers/conditions/checks.js +283 -0
  43. package/src/tiers/conditions/index.js +30 -0
  44. package/src/tiers/conditions/kit.js +133 -0
  45. package/src/tiers/interactions/archetypes.js +111 -0
  46. package/src/tiers/interactions/helpers.js +56 -0
  47. package/src/tiers/interactions/index.js +16 -3
  48. package/src/harness/npm-react.js +0 -39
  49. package/src/harness/npm-wc.js +0 -30
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The Vue adapter, for Vue 3 packages. Libraries ship compiled components, so nothing here compiles single-file components.
3
+ * Fixtures are JSX that esbuild turns into `h()` calls (a small shim supplies Vue's `h` and `Fragment`, so a fixture doesn't import them),
4
+ * and a fixture's default export is a component. An optional `setup(app)` export installs plugins before the app mounts.
5
+ * Every framework adapter has this shape (see index.js).
6
+ */
7
+ import { writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { vueDialect } from "../harness/generate/dialects.js";
10
+ import { generateJsx } from "../harness/generate/jsx.js";
11
+
12
+ /** Mounts a fixture's default export as the root component, after its optional setup(app) has installed what it needs. */
13
+ export const entry = (fixturePath, _pkg) => `import { createApp } from "vue";
14
+ import * as fixture from ${JSON.stringify(fixturePath)};
15
+ const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
16
+ const app = createApp(fixture.default, { libA11y });
17
+ if (typeof fixture.setup === "function") await fixture.setup(app);
18
+ app.mount(document.getElementById("root"));
19
+ `;
20
+
21
+ /** Loads the whole package, lists its exports, and records compound parts such as Dialog.Trigger. Same as for React. */
22
+ export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
23
+ const out = [];
24
+ for (const name of Object.keys(lib)) {
25
+ const value = lib[name];
26
+ const type = typeof value;
27
+ if (value === null || (type !== "function" && type !== "object")) continue;
28
+ const parts = Object.keys(value).filter((key) => /^[A-Z]/.test(key)).slice(0, 30);
29
+ out.push({ name, type, parts });
30
+ }
31
+ window.__a11yExports = out;
32
+ `;
33
+
34
+ /** A fixture for the simple archetypes, from the export name alone. A function default export is a functional component. */
35
+ export function template(archetype, pkg, exportName) {
36
+ const body = {
37
+ button: `<Component data-a11y-trigger data-a11y-root type="button">Save</Component>`,
38
+ link: `<Component data-a11y-trigger data-a11y-root href="#top">Read more</Component>`,
39
+ }[archetype];
40
+ if (!body) return null;
41
+ return `import { ${exportName} as Component } from ${JSON.stringify(pkg)};
42
+ export default function Fixture() {
43
+ return ${body};
44
+ }
45
+ `;
46
+ }
47
+
48
+ /** Vue 3 or later? A range like "^3.2.0 || ^2.7" counts when any part allows 3. A bare "*" counts too. */
49
+ function allowsVue3(range) {
50
+ return String(range)
51
+ .split("||")
52
+ .some((part) => {
53
+ const major = /(\d+)/.exec(part);
54
+ return !major || Number(major[1]) >= 3 || /^[\s*x]*$/.test(part);
55
+ });
56
+ }
57
+
58
+ export default {
59
+ id: "vue",
60
+ label: "Vue",
61
+ kind: "npm-vue",
62
+ noun: "export",
63
+ extension: "jsx",
64
+ runtime: ["vue"],
65
+ detect(meta) {
66
+ const peers = meta.peerDependencies ?? {};
67
+ const deps = meta.dependencies ?? {};
68
+ const range = peers.vue ?? deps.vue;
69
+ if (range === undefined) return null;
70
+ if (!allowsVue3(range)) return { kind: "npm-unsupported", framework: "Vue 2", reason: `The package needs Vue ${range}. Only Vue 3 is supported.` };
71
+ return { kind: "npm-vue", framework: "Vue", reason: `The package lists vue as a ${"vue" in peers ? "peer dependency" : "dependency"}.` };
72
+ },
73
+ /** Writes the shim that supplies `h` and `Fragment`, then returns the settings. */
74
+ bundle(workDir) {
75
+ const shim = join(workDir, "a11y-vue-jsx-shim.js");
76
+ writeFileSync(shim, 'export { h, Fragment } from "vue";\n');
77
+ return {
78
+ // One copy of Vue for the library and the fixture, or reactivity breaks.
79
+ alias: { vue: join(workDir, "node_modules", "vue") },
80
+ define: { __VUE_OPTIONS_API__: "true", __VUE_PROD_DEVTOOLS__: "false", __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false" },
81
+ // Vue has no JSX runtime of its own, so JSX becomes h() calls. The shim is injected wherever h or Fragment is used without being declared.
82
+ esbuild: { jsx: "transform", jsxFactory: "h", jsxFragment: "Fragment", inject: [shim] },
83
+ };
84
+ },
85
+ entry,
86
+ discoverEntry,
87
+ template,
88
+ generate: (input) => generateJsx(vueDialect, input),
89
+ describe: (npm) => `Vue${npm.vue ? ` (vue ${npm.vue})` : ""}`,
90
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The web component adapter: framework-free entries, templates, and generation from what an element says about itself.
3
+ * Every framework adapter has this shape (see index.js).
4
+ */
5
+ import { generateWc } from "../harness/generate/wc-recipes.js";
6
+
7
+
8
+ /** Loads the package so its custom elements get defined, then calls the fixture's default export, `mount(container)`. */
9
+ export const entry = (fixturePath, pkg) => `// A package's own "sideEffects" list can mark its entry as removable, which would drop a bare import and leave its elements undefined.
10
+ // Using the namespace keeps the package and its registration code.
11
+ import * as library from ${JSON.stringify(pkg)};
12
+ globalThis.__a11yLibrary = library;
13
+ import mount from ${JSON.stringify(fixturePath)};
14
+ const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
15
+ await mount(document.getElementById("root"), { libA11y });
16
+ `;
17
+
18
+ /** Loads the whole package. The page's init script records every custom element the package defines. */
19
+ export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
20
+ window.__a11yExports = Object.keys(lib).map((name) => ({ name, type: typeof lib[name], parts: [] }));
21
+ `;
22
+
23
+ /** A fixture for the simple archetypes, from the tag name alone. */
24
+ export function template(archetype, _pkg, tag) {
25
+ const attrs = {
26
+ button: `el.textContent = "Save";`,
27
+ link: `el.setAttribute("href", "#top");\n el.textContent = "Read more";`,
28
+ }[archetype];
29
+ if (!attrs) return null;
30
+ return `export default function mount(container) {
31
+ const el = document.createElement(${JSON.stringify(tag)});
32
+ el.setAttribute("data-a11y-trigger", "");
33
+ el.setAttribute("data-a11y-root", "");
34
+ ${attrs}
35
+ container.append(el);
36
+ }
37
+ `;
38
+ }
39
+
40
+ export default {
41
+ id: "wc",
42
+ label: "Web components",
43
+ kind: "npm-wc",
44
+ noun: "custom element",
45
+ extension: "js",
46
+ runtime: [],
47
+ /** A package is detected as web components by a manifest or a base library, which index.js orders against the other adapters. */
48
+ detect: () => null,
49
+ bundle: () => ({ alias: {}, esbuild: {} }),
50
+ entry,
51
+ discoverEntry,
52
+ template,
53
+ generate: (input) => generateWc(input),
54
+ describe: (npm) => `web components (${npm.tags?.length ? npm.tags.slice(0, 6).join(", ") : "no tags found"})`,
55
+ };
package/src/globals.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  interface Window {
3
3
  __a11y: any;
4
4
  __a11yMeasure: any;
5
+ __a11yConditions: any;
5
6
  __vsr: any;
6
7
  __a11yClicks: number;
7
8
  __a11yLast: any;
@@ -22,10 +22,12 @@ export function unresolvedPackages(errors) {
22
22
  /**
23
23
  * Bundle fixture entries into one folder of browser-ready ES modules, plus one HTML shell per entry.
24
24
  * esbuild loads here and only here. It doesn't type-check, and fixtures don't need it to.
25
- * @param {{ entries: Record<string, string>, outdir: string, workDir: string, react?: boolean }} options
25
+ * @param {{ entries: Record<string, string>, outdir: string, workDir: string, framework?: import("../frameworks/index.js").Adapter | null }} options
26
+ * `framework` is the adapter whose bundle settings apply: which packages to keep to one copy, how JSX is turned into calls, and what to define.
26
27
  * @returns {Promise<Record<string, string>>} Entry name to page path, for example `{ dialog: "/dialog.html" }`.
27
28
  */
28
- export async function bundleEntries({ entries, outdir, workDir, react = false }) {
29
+ export async function bundleEntries({ entries, outdir, workDir, framework = null }) {
30
+ const settings = framework?.bundle(workDir) ?? { alias: {}, esbuild: { jsx: "automatic" } };
29
31
  const esbuild = await import("esbuild");
30
32
  mkdirSync(outdir, { recursive: true });
31
33
  try {
@@ -36,13 +38,12 @@ export async function bundleEntries({ entries, outdir, workDir, react = false })
36
38
  format: "esm",
37
39
  platform: "browser",
38
40
  target: "es2022",
39
- jsx: "automatic",
41
+ ...settings.esbuild,
40
42
  absWorkingDir: workDir,
41
43
  nodePaths: [join(workDir, "node_modules")],
42
- // One copy of React for the library and the fixture, or hooks break.
43
- alias: react ? { react: join(workDir, "node_modules", "react"), "react-dom": join(workDir, "node_modules", "react-dom") } : {},
44
+ alias: settings.alias,
44
45
  loader: ASSET_LOADERS,
45
- define: { "process.env.NODE_ENV": '"development"' },
46
+ define: { "process.env.NODE_ENV": '"development"', ...(settings.define ?? {}) },
46
47
  logLevel: "silent",
47
48
  });
48
49
  } catch (error) {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What differs between JSX frameworks when a fixture is generated: how it holds state, how it starts the marking code
3
+ * and wraps the result, and how a prop is spelled. The recipes in jsx-recipes.js are shared and ask the dialect for these.
4
+ */
5
+ import { markingSource } from "./marking.js";
6
+
7
+ const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
8
+ const indentBy = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
9
+
10
+ /** @typedef {{ id: string, extension: string, openProps: string[][], labelFor: string, declare: (name: string, init: string) => string, read: (name: string) => string, write: (name: string, value: string) => string, attr: (name: string, expr: string) => string, frame: (archetype: string, pkg: string, body: string, hooks?: string) => string }} Dialect */
11
+
12
+ /** @type {Dialect} */
13
+ export const reactDialect = {
14
+ id: "react",
15
+ extension: "jsx",
16
+ openProps: [["open", "onClose"], ["open", "onOpenChange"], ["isOpen", "onOpenChange"], ["isOpen", "onClose"], ["opened", "onClose"]],
17
+ labelFor: "htmlFor",
18
+ declare: (name, init) => `const [${name}, set${cap(name)}] = useState(${init});`,
19
+ read: (name) => name,
20
+ write: (name, value) => `set${cap(name)}(${value})`,
21
+ attr: (name, expr) => `${name}={${expr}}`,
22
+ frame(archetype, pkg, body, hooks = "") {
23
+ return `import * as Lib from ${JSON.stringify(pkg)};
24
+ import { useEffect, useState } from "react";
25
+ ${markingSource(archetype)}
26
+ export default function Fixture() {
27
+ useEffect(() => startMarking(), []);
28
+ ${hooks ? `${indentBy(hooks, 2)}\n` : ""} return (
29
+ ${indentBy(body, 4)}
30
+ );
31
+ }
32
+ `;
33
+ },
34
+ };
35
+
36
+ /** @type {Dialect} */
37
+ export const vueDialect = {
38
+ id: "vue",
39
+ extension: "jsx",
40
+ // Vue components usually take a model value and say they changed it with an update event, or take `open` and emit `update:open`.
41
+ openProps: [["open", "onUpdate:open"], ["modelValue", "onUpdate:modelValue"], ["visible", "onUpdate:visible"], ["show", "onUpdate:show"], ["open", "onClose"], ["isOpen", "onClose"]],
42
+ labelFor: "for",
43
+ declare: (name, init) => `const ${name} = ref(${init});`,
44
+ read: (name) => `${name}.value`,
45
+ write: (name, value) => `(${name}.value = ${value})`,
46
+ // A name with a colon (onUpdate:open) can't be written as a JSX attribute name, so it goes in through a spread.
47
+ attr: (name, expr) => (/^[\w-]+$/.test(name) ? `${name}={${expr}}` : `{...{ ${JSON.stringify(name)}: ${expr} }}`),
48
+ frame(archetype, pkg, body, hooks = "") {
49
+ return `import * as Lib from ${JSON.stringify(pkg)};
50
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
51
+ ${markingSource(archetype)}
52
+ export default defineComponent({
53
+ setup() {
54
+ let stop = null;
55
+ onMounted(() => { stop = startMarking(); });
56
+ onBeforeUnmount(() => { if (stop) stop(); });
57
+ ${hooks ? `${indentBy(hooks, 4)}\n` : ""} return () => (
58
+ ${indentBy(body, 6)}
59
+ );
60
+ },
61
+ });
62
+ `;
63
+ },
64
+ };
@@ -0,0 +1,12 @@
1
+ import { adapterFor } from "../../frameworks/index.js";
2
+
3
+ export { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
4
+
5
+ /**
6
+ * Build the candidate fixtures for one archetype of one package, most likely first, using the framework's adapter.
7
+ * @param {{ flavor: string, archetype: string, pkg: string, entry: { export?: string, tag?: string }, exports: Array<{ name: string, type: string, parts?: string[] }>, facts: Record<string, { attributes: string[], members: string[], slots: string[] }>, explicit?: boolean }} input
8
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
9
+ */
10
+ export function generateCandidates({ flavor, ...input }) {
11
+ return adapterFor(flavor).generate(input);
12
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Candidate fixtures for JSX frameworks (React and Vue), built only from the part names a package exports.
3
+ *
4
+ * Each builder takes a function that makes a fresh kit (see kit.js), picks the parts it needs by common names, and returns
5
+ * a candidate `{ id, summary, source, used }`, or null when the package doesn't have the parts. A candidate is a guess.
6
+ * It only counts once the probe has bundled it, loaded it, and seen the trigger and root behave (see probe.js).
7
+ *
8
+ * The recipes follow how compound components are usually put together (a root, a trigger, a content part, and a title,
9
+ * description, or close part), and how single components are usually switched on (an `open` prop and a close handler).
10
+ * None of them names a library. What differs between frameworks is only how a fixture holds state, starts its marking code,
11
+ * and spells a prop, so a dialect carries those three things (see dialects.js) and the recipes are shared.
12
+ */
13
+
14
+ const indent = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
15
+ /** A JSX element. Short text stays on one line, and anything longer goes on its own lines. */
16
+ const tag = (ref, props, children) => {
17
+ if (!children) return `<${ref}${props} />`;
18
+ return !children.includes("\n") && children.length < 40 && !children.startsWith("<") ? `<${ref}${props}>${children}</${ref}>` : `<${ref}${props}>\n${indent(children, 2)}\n</${ref}>`;
19
+ };
20
+
21
+ /** Wrap a layer in a portal and a positioner when the package has them. */
22
+ function layered(kit, content) {
23
+ const positioner = kit.pick("positioner");
24
+ const positioned = positioner ? tag(positioner, "", content) : content;
25
+ const portal = kit.pick("portal");
26
+ return portal ? tag(portal, "", positioned) : positioned;
27
+ }
28
+
29
+ // ---- dialog ----
30
+
31
+ function dialogContent(kit) {
32
+ const title = kit.pick("title", "heading");
33
+ const description = kit.pick("description", "desc", "contenttext");
34
+ const close = kit.pick("close", "closebutton", "closetrigger", "dismiss");
35
+ return [
36
+ title ? tag(title, "", "Edit profile") : "<h2>Edit profile</h2>",
37
+ description ? tag(description, "", "Update your details.") : "<p>Update your details.</p>",
38
+ close ? tag(close, "", "Close") : '<button type="button">Close</button>',
39
+ ].join("\n");
40
+ }
41
+
42
+ function dialogCompound(makeKit, pkg, d) {
43
+ const kit = makeKit();
44
+ const root = kit.pick("root") ?? kit.self;
45
+ const trigger = kit.pick("trigger", "opener", "activator");
46
+ const content = kit.pick("content", "popup", "window", "panel", "surface");
47
+ if (!root || !trigger || !content) return null;
48
+ const overlay = kit.pick("overlay", "backdrop");
49
+ const layer = `${overlay ? `<${overlay} />\n` : ""}${tag(content, "", dialogContent(kit))}`;
50
+ const body = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Open dialog")}\n${layered(kit, layer)}`);
51
+ return { id: "dialog-compound", summary: "a root, a trigger, and a content part that opens on its own", source: d.frame("dialog", pkg, body), used: kit.used() };
52
+ }
53
+
54
+ function dialogControlled([openProp, closeProp]) {
55
+ return (makeKit, pkg, d) => {
56
+ const kit = makeKit();
57
+ const root = kit.pick("root") ?? kit.self;
58
+ if (!root) return null;
59
+ const content = kit.pick("content", "panel", "popup", "window", "surface");
60
+ const inner = content ? tag(content, "", dialogContent(kit)) : dialogContent(kit);
61
+ const overlay = kit.pick("overlay", "backdrop");
62
+ const controls = ` ${d.attr(openProp, d.read("open"))} ${d.attr(closeProp, `(next) => ${d.write("open", "next === true")}`)}`;
63
+ const body = `<>
64
+ <button type="button" data-a11y-trigger onClick={() => ${d.write("open", "true")}}>Open dialog</button>
65
+ ${indent(tag(root, controls, `${overlay ? `<${overlay} />\n` : ""}${inner}`), 2)}
66
+ </>`;
67
+ return { id: `dialog-controlled-${openProp}-${closeProp}`.replace(/[^\w-]+/g, "-"), summary: `a root controlled with ${openProp} and ${closeProp}`, source: d.frame("dialog", pkg, body, ` ${d.declare("open", "false")}`), used: kit.used() };
68
+ };
69
+ }
70
+
71
+ // ---- menu ----
72
+
73
+ /** How a menu item is written: bare, with a value, or rendered as a div (some libraries render items as a template by default). */
74
+ const MENU_ITEMS = [
75
+ { id: "", summary: "", props: () => "" },
76
+ { id: "-values", summary: " that have values", props: (name) => ` value="${name}"` },
77
+ { id: "-as-div", summary: " rendered as divs", props: () => ' as="div"' },
78
+ ];
79
+
80
+ function menu(variant) {
81
+ return (makeKit, pkg, d) => {
82
+ const kit = makeKit();
83
+ const root = kit.pick("root") ?? kit.self;
84
+ const trigger = kit.pick("trigger", "button", "opener");
85
+ const content = kit.pick("content", "popup", "list", "items");
86
+ const item = kit.pick("item", "menuitem", "option");
87
+ if (!root || !trigger || !content || !item) return null;
88
+ const items = [tag(item, variant.props("edit"), "Edit"), tag(item, variant.props("delete"), "Delete")].join("\n");
89
+ const body = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Open menu")}\n${layered(kit, tag(content, "", items))}`);
90
+ return { id: `menu-compound${variant.id}`, summary: `a root, a trigger, and a content part with items${variant.summary}`, source: d.frame("menu", pkg, body), used: kit.used() };
91
+ };
92
+ }
93
+
94
+ // ---- tooltip ----
95
+
96
+ function tooltipCompound(makeKit, pkg, d) {
97
+ const kit = makeKit();
98
+ const root = kit.pick("root") ?? kit.self;
99
+ const trigger = kit.pick("trigger");
100
+ const content = kit.pick("content", "popup", "bubble");
101
+ if (!root || !trigger || !content) return null;
102
+ const provider = kit.pick("provider");
103
+ const inner = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Save")}\n${layered(kit, tag(content, "", "Saves your work."))}`);
104
+ const body = provider ? tag(provider, "", inner) : inner;
105
+ return { id: "tooltip-compound", summary: "a root, a trigger, and a content part", source: d.frame("tooltip", pkg, body), used: kit.used() };
106
+ }
107
+
108
+ const tooltipSingle = (prop) => (makeKit, pkg, d) => {
109
+ const kit = makeKit();
110
+ if (!kit.self) return null;
111
+ const body = `<${kit.self} ${prop}="Saves your work.">\n <button type="button" data-a11y-trigger>Save</button>\n</${kit.self}>`;
112
+ return { id: `tooltip-single-${prop}`, summary: `one component that wraps the trigger and takes ${prop}`, source: d.frame("tooltip", pkg, body), used: [kit.base] };
113
+ };
114
+
115
+ // ---- tabs ----
116
+
117
+ const TAB_VARIANTS = [
118
+ { id: "value", rootProps: ' defaultValue="one"', item: (v) => ` value="${v}"`, summary: "tabs matched to panels by value" },
119
+ { id: "index", rootProps: "", item: () => "", summary: "tabs matched to panels by position" },
120
+ { id: "default-index", rootProps: " defaultIndex={0}", item: () => "", summary: "tabs matched by position, starting at index 0" },
121
+ ];
122
+
123
+ const tabs = (variant) => (makeKit, pkg, d) => {
124
+ const kit = makeKit();
125
+ const root = kit.pick("root") ?? kit.self;
126
+ const tab = kit.pick("trigger", "tab");
127
+ const panel = kit.pick("content", "panel", "tabpanel");
128
+ if (!root || !tab || !panel) return null;
129
+ const list = kit.pick("list", "tablist", "tabs");
130
+ const tabsMarkup = `${tag(tab, `${variant.item("one")} data-a11y-trigger`, "One")}\n${tag(tab, variant.item("two"), "Two")}`;
131
+ const body = tag(root, variant.rootProps, `${list ? tag(list, "", tabsMarkup) : tabsMarkup}\n${tag(panel, variant.item("one"), "First panel")}\n${tag(panel, variant.item("two"), "Second panel")}`);
132
+ return { id: `tabs-${variant.id}`, summary: variant.summary, source: d.frame("tabs", pkg, body), used: kit.used() };
133
+ };
134
+
135
+ // ---- accordion ----
136
+
137
+ const ACCORDION_ROOTS = [["", "no root props"], [' type="single" collapsible', "single, collapsible"], [" collapsible", "collapsible"], [" multiple", "multiple"]];
138
+
139
+ const accordion = ([rootProps, label]) => (makeKit, pkg, d) => {
140
+ const kit = makeKit();
141
+ const root = kit.pick("root") ?? kit.self;
142
+ const item = kit.pick("item");
143
+ const trigger = kit.pick("trigger", "button", "summary");
144
+ const content = kit.pick("content", "panel", "itempanel", "body");
145
+ if (!root || !trigger || !content) return null;
146
+ const header = kit.pick("header", "itemheader", "heading");
147
+ const section = (name) => {
148
+ const button = tag(trigger, name === "one" ? " data-a11y-trigger" : "", `Section ${name}`);
149
+ return `${header ? tag(header, "", button) : button}\n${tag(content, "", "More information.")}`;
150
+ };
151
+ // Some libraries have no item part: a disclosure is just a root with a button and a panel.
152
+ if (!item) {
153
+ if (rootProps) return null;
154
+ return { id: "accordion-disclosure", summary: "a root with a button and a panel", source: d.frame("accordion", pkg, tag(root, "", section("one"))), used: kit.used() };
155
+ }
156
+ const one = (name, value) => tag(item, ` value="${value}"`, section(name));
157
+ const body = tag(root, rootProps, `${one("one", "one")}\n${one("two", "two")}`);
158
+ return { id: `accordion-${label.replace(/[^a-z]+/g, "-")}`, summary: `an accordion with ${label}`, source: d.frame("accordion", pkg, body), used: kit.used() };
159
+ };
160
+
161
+ // ---- combobox ----
162
+
163
+ const combobox = (itemProps) => (makeKit, pkg, d) => {
164
+ const kit = makeKit();
165
+ const root = kit.pick("root") ?? kit.self;
166
+ const input = kit.pick("input", "control");
167
+ const content = kit.pick("content", "list", "popup", "listbox");
168
+ const item = kit.pick("item", "option");
169
+ if (!root || !input || !content || !item) return null;
170
+ const items = [tag(item, itemProps ? ' value="apple"' : "", "Apple"), tag(item, itemProps ? ' value="banana"' : "", "Banana")].join("\n");
171
+ const body = tag(root, "", `${tag(input, ' data-a11y-trigger aria-label="Fruit"', "")}\n${layered(kit, tag(content, "", items))}`);
172
+ return { id: `combobox-compound${itemProps ? "-values" : ""}`, summary: `a root, an input, and a list of options${itemProps ? " that have values" : ""}`, source: d.frame("combobox", pkg, body), used: kit.used() };
173
+ };
174
+
175
+ // ---- form field ----
176
+
177
+ function fieldCompound(makeKit, pkg, d) {
178
+ const kit = makeKit();
179
+ const root = kit.pick("root", "field");
180
+ const label = kit.pick("label");
181
+ const input = kit.pick("input", "control");
182
+ if (!root || !label || !input) return null;
183
+ const body = tag(root, "", `${tag(label, "", "Name")}\n${tag(input, " data-a11y-trigger", "")}`);
184
+ return { id: "field-compound", summary: "a root, a label, and an input part", source: d.frame("form-field", pkg, body), used: kit.used() };
185
+ }
186
+
187
+ const fieldSingle = (id, summary, markup) => (makeKit, pkg, d) => {
188
+ const kit = makeKit();
189
+ if (!kit.self) return null;
190
+ return { id, summary, source: d.frame("form-field", pkg, markup(kit.self, d)), used: [kit.base] };
191
+ };
192
+
193
+ // ---- live region ----
194
+
195
+ const messageConditional = (makeKit, pkg, d) => {
196
+ const kit = makeKit();
197
+ if (!kit.self) return null;
198
+ const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <div>{${d.read("on")} && <${kit.self}>Saved.</${kit.self}>}</div>\n</div>`;
199
+ return { id: "message-mounted", summary: "a message that is mounted when the trigger is pressed", source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
200
+ };
201
+
202
+ const messageControlled = (prop) => (makeKit, pkg, d) => {
203
+ const kit = makeKit();
204
+ if (!kit.self) return null;
205
+ const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <${kit.self} ${d.attr(prop, d.read("on"))}>Saved.</${kit.self}>\n</div>`;
206
+ return { id: `message-${prop}`, summary: `a message shown with its ${prop} prop`, source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
207
+ };
208
+
209
+ /** The builders for each archetype, most likely first. */
210
+ export const buildRecipes = (d) => ({
211
+ dialog: [dialogCompound, ...d.openProps.map(dialogControlled)],
212
+ menu: MENU_ITEMS.map(menu),
213
+ tooltip: [tooltipCompound, ...["title", "label", "content", "text", "tip"].map(tooltipSingle)],
214
+ tabs: TAB_VARIANTS.map(tabs),
215
+ accordion: ACCORDION_ROOTS.map(accordion),
216
+ combobox: [combobox(true), combobox(false)],
217
+ "form-field": [
218
+ fieldCompound,
219
+ fieldSingle("field-wrapped-label", "an input inside a wrapping label", (self) => `<label>\n Name\n <${self} data-a11y-trigger />\n</label>`),
220
+ fieldSingle("field-label-prop", "an input that takes a label prop", (self) => `<${self} label="Name" data-a11y-trigger />`),
221
+ fieldSingle("field-label-for", "an input matched to a label by id", (self, d) => `<div>\n <label ${d.labelFor}="name-field">Name</label>\n <${self} id="name-field" data-a11y-trigger />\n</div>`),
222
+ ],
223
+ "live-region": [messageConditional, ...["open", "isOpen", "visible", "show"].map(messageControlled)],
224
+ });
@@ -0,0 +1,76 @@
1
+ import { ARCHETYPE_PATTERNS } from "../storybook.js";
2
+ import { score } from "../../plan/mapping.js";
3
+ import { buildKit } from "./kit.js";
4
+ import { buildRecipes } from "./jsx-recipes.js";
5
+ import { ATTEMPT_LIMIT, GENERATABLE, nameSaysSo } from "./shared.js";
6
+
7
+ /** The names a part of a compound component ends with. A name that ends in one belongs to a family that shares the rest. */
8
+ const PART_SUFFIX = /(Root|Trigger|Portal|Overlay|Backdrop|Positioner|Content|Popup|Panel|Title|Description|Close|Header|Item|List|Input|Label|Control|Provider)$/;
9
+
10
+ /**
11
+ * The prefix a family of flat exports shares. `DialogClose` and `DialogRoot` belong to `Dialog`, even when no export is
12
+ * named `Dialog`. It counts only when at least two exports start with it.
13
+ */
14
+ export function inferBase(name, exports) {
15
+ if (!name) return null;
16
+ const base = name.replace(PART_SUFFIX, "");
17
+ if (!base || base === name) return null;
18
+ return exports.filter((e) => e.name.startsWith(base) && e.name !== base).length >= 2 ? base : null;
19
+ }
20
+
21
+ const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
22
+
23
+ /**
24
+ * The component families in a package that look like this archetype, best first. A family is a base name and the parts that share
25
+ * it (`DropdownMenuRoot`, `DropdownMenuItem`), a namespaced export with parts, or a single export. A family ranks by how well its
26
+ * base fits the archetype's own name, then by how many parts it has. At most three are tried, so a library with a context menu,
27
+ * a dropdown menu, and a menubar gets the plainest ones first.
28
+ */
29
+ export function familyBases(archetype, exports) {
30
+ const families = new Map();
31
+ for (const e of exports) {
32
+ if (!/^[A-Z]/.test(e.name) || !ARCHETYPE_PATTERNS[archetype].test(words(e.name))) continue;
33
+ const base = inferBase(e.name, exports) ?? e.name;
34
+ if (families.has(base)) continue;
35
+ const size = exports.filter((other) => other.name !== base && other.name.startsWith(base)).length + (exports.find((other) => other.name === base)?.parts?.length ?? 0);
36
+ families.set(base, { base, fit: score(archetype, base), size });
37
+ }
38
+ return [...families.values()].sort((a, b) => b.fit - a.fit || b.size - a.size || a.base.localeCompare(b.base)).slice(0, 3).map((f) => f.base);
39
+ }
40
+
41
+ /**
42
+ * Candidate fixtures for a JSX framework: the recipes, run over the parts of the component the mapping found,
43
+ * then over the package itself when its own name says it is this archetype.
44
+ * @param {import("./dialects.js").Dialect} dialect
45
+ * @param {{ archetype: string, pkg: string, entry: { export?: string }, exports: Array<{ name: string, type: string, parts?: string[] }>, explicit?: boolean }} input
46
+ * `explicit` means a person named the export in a mapping, so only that component is tried.
47
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
48
+ */
49
+ export function generateJsx(dialect, { archetype, pkg, entry, exports, explicit = false }) {
50
+ if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
51
+ const recipes = buildRecipes(dialect)[archetype] ?? [];
52
+ const bases = [];
53
+ if (explicit && entry.export) {
54
+ bases.push(inferBase(entry.export, exports) ?? entry.export);
55
+ } else {
56
+ bases.push(...familyBases(archetype, exports));
57
+ if (bases.length === 0 && entry.export) bases.push(inferBase(entry.export, exports) ?? entry.export);
58
+ }
59
+ if (nameSaysSo(archetype, pkg)) bases.push(null);
60
+ const candidates = [];
61
+ const seen = new Set();
62
+ for (const base of [...new Set(bases)]) {
63
+ const makeKit = () => buildKit(exports, base);
64
+ for (const recipe of recipes) {
65
+ const candidate = recipe(makeKit, pkg, dialect);
66
+ if (candidate && !seen.has(candidate.source)) {
67
+ seen.add(candidate.source);
68
+ candidates.push(candidate);
69
+ }
70
+ }
71
+ }
72
+ if (candidates.length === 0) {
73
+ return { candidates: [], reason: `The package's exports don't have the parts a ${archetype} recipe needs${entry.export ? ` (looked at ${entry.export} and the exports that start with it)` : ""}.` };
74
+ }
75
+ return { candidates: candidates.slice(0, ATTEMPT_LIMIT), reason: null };
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Find the parts of a compound component from the names a package exports.
3
+ *
4
+ * Two shapes are common. A namespaced export carries its parts as properties (`Dialog.Root`, `Dialog.Trigger`).
5
+ * A flat set of exports shares a prefix (`DialogRoot`, `DialogTrigger`, or `Dialog` plus `DialogContent`).
6
+ * A package that is one component's parts, such as a `Root` and a `Trigger` with no `Dialog` in front, works too.
7
+ * Parts are found by name only. Nothing here knows any one library.
8
+ */
9
+
10
+ /**
11
+ * @param {Array<{ name: string, type: string, parts?: string[] }>} exports
12
+ * @param {string | null} base The export that names the component, or null when the package itself is the component.
13
+ */
14
+ export function buildKit(exports, base) {
15
+ /** @type {Map<string, string>} lower-case part name to the expression that reaches it */
16
+ const parts = new Map();
17
+ const baseInfo = base ? exports.find((e) => e.name === base) : null;
18
+ if (base) {
19
+ for (const part of baseInfo?.parts ?? []) parts.set(part.toLowerCase(), `Lib.${base}.${part}`);
20
+ for (const e of exports) {
21
+ if (e.name === base || !e.name.startsWith(base)) continue;
22
+ const rest = e.name.slice(base.length);
23
+ if (/^[A-Z]/.test(rest) && !parts.has(rest.toLowerCase())) parts.set(rest.toLowerCase(), `Lib.${e.name}`);
24
+ }
25
+ } else {
26
+ for (const e of exports) if (/^[A-Z]/.test(e.name) && !parts.has(e.name.toLowerCase())) parts.set(e.name.toLowerCase(), `Lib.${e.name}`);
27
+ }
28
+ const used = new Set();
29
+ return {
30
+ base,
31
+ /** The component itself (`Lib.Dialog`), or null for a package with no single base. */
32
+ self: base && baseInfo ? `Lib.${base}` : null,
33
+ /** True when any of these part names exists. */
34
+ has: (...names) => names.some((name) => parts.has(name)),
35
+ /** The first part that exists, as a JSX tag. Records what was used. */
36
+ pick(...names) {
37
+ for (const name of names) {
38
+ const ref = parts.get(name);
39
+ if (ref) {
40
+ used.add(ref.replace(/^Lib\./, ""));
41
+ return ref;
42
+ }
43
+ }
44
+ return null;
45
+ },
46
+ /** What the candidates used, for the report. */
47
+ used: () => [...used],
48
+ names: () => [...parts.keys()],
49
+ };
50
+ }