@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.
- package/README.md +14 -5
- package/package.json +2 -1
- package/packages/registry/components/accordion.ts +20 -15
- package/packages/registry/components/alert-dialog.ts +21 -18
- package/packages/registry/components/alert.ts +20 -18
- package/packages/registry/components/aspect-ratio.ts +11 -9
- package/packages/registry/components/avatar.ts +20 -12
- package/packages/registry/components/badge.ts +7 -5
- package/packages/registry/components/breadcrumb.ts +17 -15
- package/packages/registry/components/button.ts +8 -6
- package/packages/registry/components/card.ts +18 -14
- package/packages/registry/components/checkbox.ts +6 -4
- package/packages/registry/components/collapsible.ts +14 -12
- package/packages/registry/components/dialog.ts +32 -25
- package/packages/registry/components/dropdown-menu.ts +24 -21
- package/packages/registry/components/hover-card.ts +20 -11
- package/packages/registry/components/input.ts +6 -4
- package/packages/registry/components/kbd.ts +11 -9
- package/packages/registry/components/label.ts +6 -4
- package/packages/registry/components/native-select.ts +12 -10
- package/packages/registry/components/pagination.ts +15 -11
- package/packages/registry/components/popover.ts +24 -16
- package/packages/registry/components/progress.ts +8 -6
- package/packages/registry/components/radio-group.ts +14 -12
- package/packages/registry/components/separator.ts +8 -6
- package/packages/registry/components/skeleton.ts +6 -4
- package/packages/registry/components/sonner.ts +17 -13
- package/packages/registry/components/switch.ts +13 -11
- package/packages/registry/components/table.ts +21 -19
- package/packages/registry/components/tabs.ts +16 -10
- package/packages/registry/components/textarea.ts +6 -4
- package/packages/registry/components/toggle-group.ts +15 -13
- package/packages/registry/components/toggle.ts +17 -14
- package/packages/registry/components/tooltip.ts +12 -9
- package/packages/registry/lib/dom.ts +18 -0
- package/packages/registry/lib/utils.ts +6 -105
- package/packages/registry/registry.json +25 -5
- package/src/commands/add.js +78 -14
- package/src/commands/diff.js +20 -3
- package/src/commands/init.js +41 -25
- package/src/commands/list.js +2 -2
- package/src/commands/view.js +18 -3
- package/src/registry/example.js +120 -0
- package/src/registry/extract.js +151 -0
- package/src/registry/fetcher.js +60 -2
- package/src/registry/local.js +207 -0
- package/src/registry/resolver.js +2 -2
- package/src/utils/detect-project.js +3 -1
- 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
|
+
}
|
package/src/registry/fetcher.js
CHANGED
|
@@ -1,12 +1,70 @@
|
|
|
1
1
|
import { registryItemSchema, registryIndexSchema } from './schema.js';
|
|
2
|
+
import { loadRegistryItem, loadRegistryIndex } from './local.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/registry/resolver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|