@webjsdev/ui 0.3.7 → 0.3.9

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/README.md +14 -5
  2. package/package.json +2 -1
  3. package/packages/registry/components/accordion.ts +20 -15
  4. package/packages/registry/components/alert-dialog.ts +21 -18
  5. package/packages/registry/components/alert.ts +20 -18
  6. package/packages/registry/components/aspect-ratio.ts +11 -9
  7. package/packages/registry/components/avatar.ts +20 -12
  8. package/packages/registry/components/badge.ts +7 -5
  9. package/packages/registry/components/breadcrumb.ts +17 -15
  10. package/packages/registry/components/button.ts +8 -6
  11. package/packages/registry/components/card.ts +18 -14
  12. package/packages/registry/components/checkbox.ts +6 -4
  13. package/packages/registry/components/collapsible.ts +14 -12
  14. package/packages/registry/components/dialog.ts +32 -25
  15. package/packages/registry/components/dropdown-menu.ts +24 -21
  16. package/packages/registry/components/hover-card.ts +20 -11
  17. package/packages/registry/components/input.ts +6 -4
  18. package/packages/registry/components/kbd.ts +11 -9
  19. package/packages/registry/components/label.ts +6 -4
  20. package/packages/registry/components/native-select.ts +12 -10
  21. package/packages/registry/components/pagination.ts +15 -11
  22. package/packages/registry/components/popover.ts +24 -16
  23. package/packages/registry/components/progress.ts +8 -6
  24. package/packages/registry/components/radio-group.ts +14 -12
  25. package/packages/registry/components/separator.ts +8 -6
  26. package/packages/registry/components/skeleton.ts +6 -4
  27. package/packages/registry/components/sonner.ts +17 -13
  28. package/packages/registry/components/switch.ts +13 -11
  29. package/packages/registry/components/table.ts +21 -19
  30. package/packages/registry/components/tabs.ts +16 -10
  31. package/packages/registry/components/textarea.ts +6 -4
  32. package/packages/registry/components/toggle-group.ts +15 -13
  33. package/packages/registry/components/toggle.ts +17 -14
  34. package/packages/registry/components/tooltip.ts +12 -9
  35. package/packages/registry/lib/dom.ts +18 -0
  36. package/packages/registry/lib/utils.ts +6 -105
  37. package/packages/registry/registry.json +25 -5
  38. package/src/commands/add.js +78 -14
  39. package/src/commands/diff.js +20 -3
  40. package/src/commands/init.js +41 -25
  41. package/src/commands/list.js +2 -2
  42. package/src/commands/view.js +18 -3
  43. package/src/registry/example.js +120 -0
  44. package/src/registry/extract.js +151 -0
  45. package/src/registry/fetcher.js +60 -2
  46. package/src/registry/local.js +207 -0
  47. package/src/registry/resolver.js +2 -2
  48. package/src/utils/detect-project.js +3 -1
  49. package/src/utils/theme.js +68 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The registry component `@example` block: extract it, strip it (#983).
3
+ *
4
+ * A Tier-1 component file is a handful of class-helper functions plus a module
5
+ * JSDoc whose `@example` block carries the ACCESSIBLE STRUCTURE an author
6
+ * composes (the `<details name>` exclusive-open wiring, the `group-open`
7
+ * chevron, the `aria-*` attributes). That worked example is BUILD-TIME
8
+ * guidance, consumed once while composing, so it should NOT persist in the
9
+ * copied project file as dead boilerplate. This module is the single source of
10
+ * truth for that block:
11
+ *
12
+ * - {@link extractExample} pulls the paste-ready snippet out (for `webjsui view`
13
+ * and the MCP `ui` tool, which serve it on demand without copying it in).
14
+ * - {@link stripExample} removes it from the file `add` writes and leaves a
15
+ * one-line pointer, so the copied file keeps only the helpers + a lean header.
16
+ *
17
+ * Both key on the SAME `@example` delimiter in the module JSDoc, so the snippet
18
+ * has exactly one home (the JSDoc) and cannot drift from a parallel field.
19
+ * Hand-rolled (no JSDoc/markdown parser dependency: `@webjsdev/ui` ships no
20
+ * third-party runtime deps).
21
+ *
22
+ * @module registry/example
23
+ */
24
+
25
+ /**
26
+ * Locate the first block comment (`/** ... *\/`), i.e. the module JSDoc.
27
+ *
28
+ * @param {string} src
29
+ * @returns {{ start: number, end: number, text: string } | null}
30
+ */
31
+ function firstBlockComment(src) {
32
+ const start = src.indexOf('/**');
33
+ if (start === -1) return null;
34
+ const end = src.indexOf('*/', start + 3);
35
+ if (end === -1) return null;
36
+ return { start, end: end + 2, text: src.slice(start, end + 2) };
37
+ }
38
+
39
+ /**
40
+ * True when the module JSDoc carries an `@example` block.
41
+ *
42
+ * @param {string} src
43
+ * @returns {boolean}
44
+ */
45
+ export function hasExample(src) {
46
+ const block = firstBlockComment(src);
47
+ return !!block && /^\s*\*\s*@example\b/m.test(block.text);
48
+ }
49
+
50
+ /**
51
+ * Extract the paste-ready example snippet from the module JSDoc. Returns the
52
+ * fenced code (unwrapped) when the `@example` body is a ```` ```lang ```` block,
53
+ * else the de-indented text. Empty string when there is no `@example`.
54
+ *
55
+ * @param {string} src
56
+ * @returns {string}
57
+ */
58
+ export function extractExample(src) {
59
+ const block = firstBlockComment(src);
60
+ if (!block) return '';
61
+ const lines = block.text.split('\n');
62
+ const exIdx = lines.findIndex((l) => /^\s*\*\s*@example\b/.test(l));
63
+ if (exIdx === -1) return '';
64
+ // Everything after the @example line, up to the closing */ OR the next JSDoc
65
+ // tag (so a trailing @see / @module after the example is not captured).
66
+ const bodyLines = [];
67
+ for (let i = exIdx + 1; i < lines.length; i++) {
68
+ if (/^\s*\*\/\s*$/.test(lines[i])) break; // closing */
69
+ if (/^\s*\*\s*@\w+/.test(lines[i])) break; // next tag ends the example body
70
+ // Strip the leading ` * ` JSDoc gutter, preserving inner indentation.
71
+ bodyLines.push(lines[i].replace(/^\s*\*\s?/, ''));
72
+ }
73
+ let body = bodyLines.join('\n').replace(/\s+$/, '');
74
+ // Unwrap a fenced code block if the example is wrapped in one.
75
+ const fence = body.match(/^\s*```[A-Za-z0-9]*\n([\s\S]*?)\n```\s*$/);
76
+ if (fence) body = fence[1];
77
+ return body.replace(/^\n+/, '').replace(/\s+$/, '');
78
+ }
79
+
80
+ /**
81
+ * The one-line pointer left in place of a stripped example. Uses the EXPLICIT
82
+ * `npx @webjsdev/ui view` form (not the bare `webjsui` bin name, which npx would
83
+ * resolve to an unrelated `webjsui` package when it is not a direct dep, e.g. in
84
+ * a scaffolded app where `@webjsdev/ui` is only transitive). Works in a WebJs
85
+ * app and a standalone project alike.
86
+ */
87
+ export function pointerLine(name) {
88
+ return `Full usage example: npx @webjsdev/ui view ${name} (or the MCP tool: ui ${name})`;
89
+ }
90
+
91
+ /**
92
+ * Remove the `@example` block from the module JSDoc and leave a one-line
93
+ * pointer to `webjsui view` / the MCP `ui` tool. No-op when there is no
94
+ * `@example`. This runs at `add` write-time so the copied project file keeps
95
+ * only the helpers + a lean header, not the worked example.
96
+ *
97
+ * @param {string} src
98
+ * @param {string} name the component name, for the pointer
99
+ * @returns {string}
100
+ */
101
+ export function stripExample(src, name) {
102
+ const block = firstBlockComment(src);
103
+ if (!block) return src;
104
+ const lines = block.text.split('\n');
105
+ const exIdx = lines.findIndex((l) => /^\s*\*\s*@example\b/.test(l));
106
+ if (exIdx === -1) return src;
107
+ const head = lines.slice(0, exIdx);
108
+ // Preserve any JSDoc tags that follow the @example block (so a trailing
109
+ // @see / @module survives the strip). The example body ends at the next tag
110
+ // line or the closing */.
111
+ const tail = [];
112
+ for (let i = exIdx + 1; i < lines.length; i++) {
113
+ if (/^\s*\*\/\s*$/.test(lines[i])) break;
114
+ if (/^\s*\*\s*@\w+/.test(lines[i])) { tail.push(...lines.slice(i, lines.length - 1)); break; }
115
+ }
116
+ // Drop trailing blank JSDoc gutter lines (` *`) so we don't double the gap.
117
+ while (head.length && /^\s*\*\s*$/.test(head[head.length - 1])) head.pop();
118
+ const rebuilt = [...head, ' *', ` * ${pointerLine(name)}`, ...tail, ' */'].join('\n');
119
+ return src.slice(0, block.start) + rebuilt + src.slice(block.end);
120
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The shared ui-kit projector (#983).
3
+ *
4
+ * ONE leaf that turns the packaged registry into an agent-facing view of the
5
+ * kit, consumed by BOTH `webjsui view` (the CLI / offline path) and the MCP
6
+ * `ui` tool (the in-context agent path). Following the #979 shared-projector
7
+ * pattern (one module backs both the CLI and the MCP surface, guarded by a
8
+ * drift test), so the two cannot disagree. It lives in `@webjsdev/ui` (exported
9
+ * as `@webjsdev/ui/registry/extract`), NOT in `@webjsdev/mcp`, because the
10
+ * registry is THIS package's source of truth and mcp has no path to it.
11
+ *
12
+ * It reads the LOCAL packaged registry (via `local.js`): the kit inventory and
13
+ * per-component helper signatures + the paste-ready `@example` + the JSDoc
14
+ * header (description + a11y obligations) + npm deps. Pure over the on-disk
15
+ * registry; no network, no app scope (this is about the KIT, unlike the MCP
16
+ * `list_components` which is about the app).
17
+ *
18
+ * @module registry/extract
19
+ */
20
+
21
+ import { loadRegistryItem, loadRegistryIndex, isCustomElementSource } from './local.js';
22
+ import { extractExample, stripExample } from './example.js';
23
+
24
+ // Re-export the lean-copy primitives so the scaffold generator (via
25
+ // `packages/cli/lib/lean-copy.js`) has one import surface for the shared
26
+ // example-strip. (The CLI's own `add` imports them directly from local.js /
27
+ // example.js.)
28
+ export { isCustomElementSource, stripExample };
29
+
30
+ /**
31
+ * Extract the exported class-helper signatures from a Tier-1 source, e.g.
32
+ * `accordionTriggerClass(opts: { disabled?: boolean } = {})`. Best-effort
33
+ * lexical scan (no TS parser dependency); the parameter list is captured up to
34
+ * its closing paren, the return-type annotation dropped.
35
+ *
36
+ * @param {string} src
37
+ * @returns {string[]}
38
+ */
39
+ export function extractHelperSignatures(src) {
40
+ /** @type {string[]} */
41
+ const out = [];
42
+ // Two authored forms, both class-helper functions:
43
+ // export const NAME = (params): T => ... (arrow; the `=>` gates out
44
+ // non-function consts)
45
+ // export function NAME(params): T { ... }
46
+ // `export type` / `export interface` never match (no arrow, no `function`).
47
+ const re =
48
+ /export\s+(?:const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(\([^)]*\))\s*(?::[^=]+?)?=>|function\s+([A-Za-z_$][\w$]*)\s*(\([^)]*\)))/g;
49
+ let m;
50
+ while ((m = re.exec(src)) !== null) {
51
+ if (m[1]) out.push(`${m[1]}${m[2]}`);
52
+ else if (m[3]) out.push(`${m[3]}${m[4]}`);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * The JSDoc header text (description + a11y obligations + token notes), with the
59
+ * `@example` block and the `@module`/`@param`-style tags dropped. This is the
60
+ * "lean header" the copied file keeps; serving it lets an agent read the
61
+ * obligations without the worked example.
62
+ *
63
+ * @param {string} src
64
+ * @returns {string}
65
+ */
66
+ export function extractDocHeader(src) {
67
+ const start = src.indexOf('/**');
68
+ if (start === -1) return '';
69
+ const end = src.indexOf('*/', start + 3);
70
+ if (end === -1) return '';
71
+ const lines = src.slice(start + 3, end).split('\n');
72
+ /** @type {string[]} */
73
+ const out = [];
74
+ for (const raw of lines) {
75
+ const line = raw.replace(/^\s*\*\s?/, '');
76
+ if (/^\s*@\w+/.test(line)) break; // stop at the first tag (@example, @module, ...)
77
+ out.push(line);
78
+ }
79
+ return out.join('\n').trim();
80
+ }
81
+
82
+ /**
83
+ * Project one registry component into the agent-facing shape. Returns null when
84
+ * the name is not a `registry:ui` component.
85
+ *
86
+ * @param {string} name
87
+ * @returns {{
88
+ * name: string, tier: 1|2, type: string,
89
+ * description: string, helpers: string[], example: string,
90
+ * dependencies: string[], registryDependencies: string[],
91
+ * } | null}
92
+ */
93
+ export function uiComponent(name) {
94
+ const item = loadRegistryItem(name);
95
+ if (!item || item.type !== 'registry:ui') return null;
96
+ const src = (item.files || []).map((f) => f.content || '').join('\n');
97
+ const tier = isCustomElementSource(src) ? 2 : 1;
98
+ return {
99
+ name: item.name,
100
+ tier,
101
+ type: item.type,
102
+ description: extractDocHeader(src),
103
+ helpers: tier === 1 ? extractHelperSignatures(src) : [],
104
+ example: extractExample(src),
105
+ dependencies: item.dependencies || [],
106
+ registryDependencies: item.registryDependencies || [],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The kit inventory: one compact entry per `registry:ui` component (name, tier,
112
+ * helper signatures, npm deps). The no-args payload for the MCP `ui` tool and
113
+ * the `references/ui-kit.md` skill surface, so an agent reaches for a helper
114
+ * instead of expanding Tailwind by hand.
115
+ *
116
+ * @returns {Array<{ name: string, tier: 1|2, helpers: string[], dependencies: string[] }>}
117
+ */
118
+ export function uiInventory() {
119
+ return loadRegistryIndex()
120
+ .filter((i) => i.type === 'registry:ui')
121
+ .map((i) => {
122
+ const c = uiComponent(i.name);
123
+ return {
124
+ name: i.name,
125
+ tier: c ? c.tier : 1,
126
+ helpers: c ? c.helpers : [],
127
+ dependencies: c ? c.dependencies : [],
128
+ };
129
+ })
130
+ .sort((a, b) => a.name.localeCompare(b.name));
131
+ }
132
+
133
+ /**
134
+ * Render a component projection as human-readable text for `webjsui view`.
135
+ * Shares the SAME {@link uiComponent} data the MCP `ui` tool returns, so the
136
+ * two never drift.
137
+ *
138
+ * @param {ReturnType<typeof uiComponent>} c
139
+ * @returns {string}
140
+ */
141
+ export function renderComponentText(c) {
142
+ if (!c) return '';
143
+ const lines = [];
144
+ lines.push(`# ${c.name} (Tier ${c.tier})`);
145
+ if (c.description) lines.push('', c.description);
146
+ if (c.helpers.length) lines.push('', 'Helpers:', ...c.helpers.map((h) => ` ${h}`));
147
+ if (c.dependencies.length) lines.push('', `npm: ${c.dependencies.join(', ')}`);
148
+ if (c.registryDependencies.length) lines.push(`registry deps: ${c.registryDependencies.join(', ')}`);
149
+ if (c.example) lines.push('', 'Example:', '', c.example);
150
+ return lines.join('\n');
151
+ }
@@ -1,12 +1,70 @@
1
1
  import { registryItemSchema, registryIndexSchema } from './schema.js';
2
+ import { loadRegistryItem, loadRegistryIndex } from './local.js';
2
3
 
3
4
  /**
4
- * Default registry URL. Override via REGISTRY_URL env var, or per-call.
5
+ * The canonical hosted registry. Local-first resolution applies ONLY for this
6
+ * exact URL: it is the one registry whose sources ship inside the package.
5
7
  */
6
- export const DEFAULT_REGISTRY_URL = process.env.REGISTRY_URL || 'https://ui.webjs.dev/registry';
8
+ export const HOSTED_REGISTRY_URL = 'https://ui.webjs.dev/registry';
9
+
10
+ /**
11
+ * Default registry URL. A `REGISTRY_URL` env var points at a CUSTOM registry,
12
+ * which (like an explicit `--registry`) forces the NETWORK path, since its
13
+ * sources are not the packaged ones.
14
+ */
15
+ export const DEFAULT_REGISTRY_URL = process.env.REGISTRY_URL || HOSTED_REGISTRY_URL;
7
16
 
8
17
  const cache = new Map();
9
18
 
19
+ /**
20
+ * True when the effective registry is the hosted one whose sources ship in the
21
+ * package, i.e. local-first resolution applies. A custom registry (via
22
+ * `--registry <url>` OR a `REGISTRY_URL` env override) is NOT default and forces
23
+ * the network path, so a self-hosted registry is never silently shadowed by the
24
+ * packaged sources.
25
+ *
26
+ * @param {string} [url]
27
+ */
28
+ export function isDefaultRegistry(url) {
29
+ const effective = url || DEFAULT_REGISTRY_URL;
30
+ return effective === HOSTED_REGISTRY_URL;
31
+ }
32
+
33
+ /**
34
+ * Resolve one registry item LOCAL-FIRST (#983): read the packaged registry
35
+ * sources unless the caller pointed at a custom `--registry` URL, in which case
36
+ * fetch over the network. This is the resolver `add` / `init` / `view` / `list`
37
+ * use, so a scaffolded app installs components with no network dependency.
38
+ *
39
+ * NOTE: `webjsui diff` deliberately does NOT use this (it compares local files
40
+ * against the LIVE upstream, so it calls {@link fetchRegistryItem} directly).
41
+ *
42
+ * @param {string} name
43
+ * @param {string} [registryUrl]
44
+ */
45
+ export async function getRegistryItem(name, registryUrl) {
46
+ if (isDefaultRegistry(registryUrl)) {
47
+ const item = loadRegistryItem(name);
48
+ if (!item) {
49
+ throw new Error(
50
+ `Unknown registry item "${name}". Run \`webjsui list\` to see the available components.`,
51
+ );
52
+ }
53
+ return item;
54
+ }
55
+ return fetchRegistryItem(name, registryUrl);
56
+ }
57
+
58
+ /**
59
+ * Resolve the flat registry index LOCAL-FIRST (#983). See {@link getRegistryItem}.
60
+ *
61
+ * @param {string} [registryUrl]
62
+ */
63
+ export async function getRegistryIndex(registryUrl) {
64
+ if (isDefaultRegistry(registryUrl)) return loadRegistryIndex();
65
+ return fetchRegistryIndex(registryUrl);
66
+ }
67
+
10
68
  /**
11
69
  * Fetch a registry item by name.
12
70
  *
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Local registry composer (#983).
3
+ *
4
+ * The registry SOURCES ship inside this npm package (`packages/ui/package.json`
5
+ * `files` includes `packages/registry`), so `webjsui add` / `list` / `view` and
6
+ * the MCP `ui` tool can resolve a component with NO network round-trip. This
7
+ * module reads those on-disk sources and composes the same shadcn-compatible
8
+ * item shape the hosted registry serves, so a local read and a network fetch
9
+ * are interchangeable for every consumer.
10
+ *
11
+ * It is the plain-JS twin of the ui-website composer
12
+ * (`packages/ui/packages/website/app/_lib/registry.server.ts`): both read
13
+ * `packages/registry/registry.json` + the `components/*.ts` sources and
14
+ * synthesize the 6 non-neutral base-colour themes from `themes/base-colors.js`.
15
+ * Keeping the CLI on this module (rather than always hitting the network) is
16
+ * what makes an autonomous agent's `add` deterministic and offline-safe.
17
+ *
18
+ * Resolution is relative to THIS module (`import.meta.url`), i.e. the installed
19
+ * `@webjsdev/ui` package, NOT `process.cwd()`: `webjsui` runs inside a user's
20
+ * project but the registry lives in the installed package.
21
+ *
22
+ * @module registry/local
23
+ */
24
+
25
+ import { readFileSync, existsSync } from 'node:fs';
26
+ import { join, dirname, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import {
29
+ BASE_COLORS,
30
+ BASE_TITLES,
31
+ BASE_DESCRIPTIONS,
32
+ BASE_OVERRIDES,
33
+ mergeThemeCss,
34
+ } from '../../packages/registry/themes/base-colors.js';
35
+
36
+ /** Absolute path to the packaged registry root (`packages/ui/packages/registry`). */
37
+ export const REGISTRY_ROOT = resolve(
38
+ dirname(fileURLToPath(import.meta.url)),
39
+ '..',
40
+ '..',
41
+ 'packages',
42
+ 'registry',
43
+ );
44
+ const MANIFEST_PATH = join(REGISTRY_ROOT, 'registry.json');
45
+ const NEUTRAL_CSS_PATH = join(REGISTRY_ROOT, 'themes', 'index.css');
46
+ const SCHEMA = 'https://ui.webjs.dev/schema/registry-item.json';
47
+
48
+ let manifestCache = null;
49
+ let neutralCssCache = null;
50
+ const itemCache = new Map();
51
+ let indexCache = null;
52
+
53
+ function readManifest() {
54
+ if (manifestCache) return manifestCache;
55
+ manifestCache = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
56
+ return manifestCache;
57
+ }
58
+
59
+ function readNeutralCss() {
60
+ if (neutralCssCache) return neutralCssCache;
61
+ neutralCssCache = readFileSync(NEUTRAL_CSS_PATH, 'utf8');
62
+ return neutralCssCache;
63
+ }
64
+
65
+ /**
66
+ * Return the item with each declared file's on-disk content inlined. A file
67
+ * whose source is missing degrades to empty content rather than throwing.
68
+ *
69
+ * @param {any} item
70
+ * @returns {any}
71
+ */
72
+ function inlineFiles(item) {
73
+ return {
74
+ $schema: SCHEMA,
75
+ ...item,
76
+ files: (item.files || []).map((f) => {
77
+ const abs = resolve(REGISTRY_ROOT, f.path);
78
+ if (!existsSync(abs)) return { ...f, content: '' };
79
+ return { ...f, content: readFileSync(abs, 'utf8') };
80
+ }),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Synthesize a `theme-<color>` item by merging the colour's overrides into the
86
+ * canonical neutral CSS, matching the manifest's `theme-neutral` shape so
87
+ * `webjsui init --base-color <color>` handles all 7 colours via one path.
88
+ *
89
+ * @param {string} color
90
+ * @returns {any|null}
91
+ */
92
+ function synthesizeColorTheme(color) {
93
+ if (!BASE_COLORS.includes(color)) return null;
94
+ const overrides = BASE_OVERRIDES[color];
95
+ const content = mergeThemeCss(readNeutralCss(), overrides);
96
+ return {
97
+ $schema: SCHEMA,
98
+ name: `theme-${color}`,
99
+ type: 'registry:theme',
100
+ title: BASE_TITLES[color],
101
+ description: BASE_DESCRIPTIONS[color],
102
+ files: [
103
+ {
104
+ path: 'themes/index.css',
105
+ type: 'registry:file',
106
+ target: 'app/globals.css',
107
+ content,
108
+ },
109
+ ],
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Load one registry item by name with its file content inlined. Returns null
115
+ * when the name is neither a manifest item nor a synthesizable colour theme.
116
+ *
117
+ * @param {string} name
118
+ * @returns {any|null}
119
+ */
120
+ export function loadRegistryItem(name) {
121
+ if (itemCache.has(name)) return itemCache.get(name);
122
+
123
+ const manifestItem = readManifest().items.find((it) => it.name === name);
124
+ if (manifestItem) {
125
+ const composed = inlineFiles(manifestItem);
126
+ itemCache.set(name, composed);
127
+ return composed;
128
+ }
129
+
130
+ if (name.startsWith('theme-')) {
131
+ const color = name.slice('theme-'.length);
132
+ const synth = synthesizeColorTheme(color);
133
+ if (synth) {
134
+ itemCache.set(name, synth);
135
+ return synth;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Load the flat registry index (metadata only, no inlined content), including
144
+ * the synthesized non-neutral colour themes.
145
+ *
146
+ * @returns {any[]}
147
+ */
148
+ export function loadRegistryIndex() {
149
+ if (indexCache) return indexCache;
150
+ const fromManifest = readManifest().items.map((item) => ({
151
+ name: item.name,
152
+ type: item.type,
153
+ description: item.description,
154
+ dependencies: item.dependencies,
155
+ registryDependencies: item.registryDependencies,
156
+ }));
157
+ const manifestNames = new Set(fromManifest.map((it) => it.name));
158
+ const synthesized = BASE_COLORS.filter(
159
+ (color) => !manifestNames.has(`theme-${color}`),
160
+ ).map((color) => ({
161
+ name: `theme-${color}`,
162
+ type: 'registry:theme',
163
+ description: BASE_DESCRIPTIONS[color],
164
+ }));
165
+ indexCache = [...fromManifest, ...synthesized];
166
+ return indexCache;
167
+ }
168
+
169
+ /**
170
+ * Strip `/* *​/` block comments and `//` line comments so a token check keys on
171
+ * CODE, not prose. Rough (does not honour a `//` inside a string), but a
172
+ * component's JSDoc is the realistic false-positive source, and the leading
173
+ * module JSDoc is exactly what this removes.
174
+ *
175
+ * @param {string} src
176
+ * @returns {string}
177
+ */
178
+ function stripComments(src) {
179
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
180
+ }
181
+
182
+ /**
183
+ * True when a component source defines/registers a custom element (Tier-2). A
184
+ * Tier-1 helper file exports only class-string functions and matches none of
185
+ * these. Used to gate the example-strip (Tier-2 files are left whole) and to
186
+ * label the kit inventory. Comments are stripped first so a JSDoc that merely
187
+ * MENTIONS `.register(` or `WebComponent` does not misclassify a Tier-1 helper.
188
+ *
189
+ * @param {string} src
190
+ * @returns {boolean}
191
+ */
192
+ export function isCustomElementSource(src) {
193
+ const code = stripComments(src);
194
+ return (
195
+ /\bextends\s+WebComponent\b/.test(code) ||
196
+ /\bcustomElements\.define\b/.test(code) ||
197
+ /\.register\(/.test(code)
198
+ );
199
+ }
200
+
201
+ /** Reset the in-memory caches. Test-only. */
202
+ export function _resetCache() {
203
+ manifestCache = null;
204
+ neutralCssCache = null;
205
+ indexCache = null;
206
+ itemCache.clear();
207
+ }
@@ -1,4 +1,4 @@
1
- import { fetchRegistryItem } from './fetcher.js';
1
+ import { getRegistryItem } from './fetcher.js';
2
2
 
3
3
  /**
4
4
  * Walk `registryDependencies` transitively. Returns a flat array of items in
@@ -16,7 +16,7 @@ export async function resolveTree(names, baseUrl) {
16
16
  async function visit(name) {
17
17
  if (seen.has(name)) return;
18
18
  seen.set(name, true);
19
- const item = await fetchRegistryItem(name, baseUrl);
19
+ const item = await getRegistryItem(name, baseUrl);
20
20
  for (const dep of item.registryDependencies || []) await visit(dep);
21
21
  ordered.push(item);
22
22
  }
@@ -34,7 +34,9 @@ export function defaultsForProject(cwd = process.cwd()) {
34
34
  switch (type) {
35
35
  case 'webjs':
36
36
  return {
37
- tailwindCss: 'app/globals.css',
37
+ // styles/globals.css, NOT app/globals.css: in webjs, app/ is routing-only,
38
+ // so a non-routing stylesheet lives outside it.
39
+ tailwindCss: 'styles/globals.css',
38
40
  aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
39
41
  };
40
42
  case 'next':
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Theme-token install, shared by `init` and `add` (#983).
3
+ *
4
+ * The class helpers render against CSS design tokens (`--background`,
5
+ * `--foreground`, `--destructive`, ...) that the app must define. `init` plants
6
+ * them; `add` self-heals if they are missing (an app that copied a component
7
+ * without the tokens paints unstyled). Both go through {@link ensureTheme} so
8
+ * the contract is one place: a token block is written exactly once, keyed by
9
+ * the {@link THEME_MARKER}, and a genuine failure to write is reported (never
10
+ * swallowed) so `init` can exit non-zero instead of leaving a clean exit code
11
+ * on an unstyled install.
12
+ *
13
+ * @module utils/theme
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { getRegistryItem } from '../registry/fetcher.js';
19
+
20
+ /** Idempotency marker at the top of the theme block. */
21
+ export const THEME_MARKER = '/* @webjsdev/ui theme */';
22
+
23
+ function ensureDir(d) {
24
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
25
+ }
26
+
27
+ /**
28
+ * Ensure the theme tokens exist in the project's Tailwind CSS file. Idempotent:
29
+ * if the marker is already present it is a no-op. Returns a status so the caller
30
+ * decides how to react (`init` fails hard on `'failed'`; `add` warns).
31
+ *
32
+ * @param {string} cwd
33
+ * @param {string} baseColor one of the base-colour names (`neutral`, ...)
34
+ * @param {string} cssPath project-relative path to the Tailwind CSS file
35
+ * @param {string} [registryUrl]
36
+ * @returns {Promise<{ status: 'written'|'present'|'failed', cssPath: string, error?: string }>}
37
+ */
38
+ export async function ensureTheme(cwd, baseColor, cssPath, registryUrl) {
39
+ if (!cssPath || !baseColor) {
40
+ return { status: 'failed', cssPath: cssPath || '', error: 'missing tailwind.css / tailwind.baseColor in components.json' };
41
+ }
42
+ const target = join(cwd, cssPath);
43
+ const existing = existsSync(target) ? readFileSync(target, 'utf8') : '';
44
+ if (existing.includes(THEME_MARKER)) {
45
+ return { status: 'present', cssPath };
46
+ }
47
+ let item;
48
+ try {
49
+ item = await getRegistryItem(`theme-${baseColor}`, registryUrl);
50
+ } catch (e) {
51
+ return { status: 'failed', cssPath, error: e && e.message ? e.message : String(e) };
52
+ }
53
+ const themeBlock = item && item.files && item.files[0] ? item.files[0].content || '' : '';
54
+ if (!themeBlock) {
55
+ return { status: 'failed', cssPath, error: `theme-${baseColor} has no content` };
56
+ }
57
+ try {
58
+ ensureDir(dirname(target));
59
+ writeFileSync(
60
+ target,
61
+ existing + (existing && !existing.endsWith('\n') ? '\n' : '') + themeBlock,
62
+ 'utf8',
63
+ );
64
+ } catch (e) {
65
+ return { status: 'failed', cssPath, error: e && e.message ? e.message : String(e) };
66
+ }
67
+ return { status: 'written', cssPath };
68
+ }