@webjsdev/ui 0.3.8 → 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 (45) 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 +19 -17
  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 +30 -24
  15. package/packages/registry/components/dropdown-menu.ts +22 -20
  16. package/packages/registry/components/hover-card.ts +18 -10
  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 +16 -12
  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 +10 -8
  35. package/src/commands/add.js +45 -1
  36. package/src/commands/diff.js +20 -3
  37. package/src/commands/init.js +17 -25
  38. package/src/commands/list.js +2 -2
  39. package/src/commands/view.js +18 -3
  40. package/src/registry/example.js +120 -0
  41. package/src/registry/extract.js +151 -0
  42. package/src/registry/fetcher.js +60 -2
  43. package/src/registry/local.js +207 -0
  44. package/src/registry/resolver.js +2 -2
  45. package/src/utils/theme.js +68 -0
@@ -7,6 +7,9 @@ import { getConfig } from '../utils/get-config.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
  import { resolveTree, collectNpmDeps } from '../registry/resolver.js';
9
9
  import { DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
10
+ import { isCustomElementSource } from '../registry/local.js';
11
+ import { stripExample } from '../registry/example.js';
12
+ import { ensureTheme } from '../utils/theme.js';
10
13
 
11
14
  export const add = new Command()
12
15
  .name('add')
@@ -35,6 +38,20 @@ export const add = new Command()
35
38
  const tree = await resolveTree(components, opts.registry);
36
39
  logger.info(`Installing ${logger.bold(components.join(', '))}…`);
37
40
 
41
+ // Self-heal missing theme tokens (#983): the helpers render against CSS
42
+ // design tokens; if the app never ran (or lost) the theme block, plant it
43
+ // so a copied component is not unstyled. Idempotent when already present.
44
+ // Skip if the config predates / lacks the tailwind fields (never crash add).
45
+ const tw = config.tailwind || {};
46
+ if (tw.css && tw.baseColor) {
47
+ const theme = await ensureTheme(cwd, tw.baseColor, tw.css, opts.registry);
48
+ if (theme.status === 'written') {
49
+ logger.success(`Planted missing theme tokens into ${tw.css}`);
50
+ } else if (theme.status === 'failed') {
51
+ logger.warn(`Could not verify theme tokens in ${tw.css}: ${theme.error}`);
52
+ }
53
+ }
54
+
38
55
  for (const item of tree) {
39
56
  for (const file of item.files || []) {
40
57
  await writeRegistryFile(cwd, config, item, file, opts);
@@ -70,11 +87,38 @@ async function writeRegistryFile(cwd, config, item, file, opts) {
70
87
  }
71
88
  }
72
89
 
73
- const content = rewriteUtilsImport(file.content || '', target, config);
90
+ const content = transformForProject(file.content || '', target, config, item);
74
91
  writeFileSync(target, content, 'utf8');
75
92
  logger.success(`Wrote ${relative(cwd, target)}`);
76
93
  }
77
94
 
95
+ /**
96
+ * The single transform that turns a registry file's raw content into what
97
+ * lands in the user's project. `add` writes this; `diff` compares against it,
98
+ * so the two cannot disagree (#983). Two steps:
99
+ *
100
+ * 1. Retarget the registry-relative `../lib/utils.ts` / `../lib/dom.ts` imports
101
+ * to the project's resolved helper paths (see {@link rewriteUtilsImport}).
102
+ * 2. For a Tier-1 helper component (a `registry:ui` file that is NOT a custom
103
+ * element), strip the worked `@example` and leave a one-line pointer, so the
104
+ * copied file keeps only the helpers + a lean header. Tier-2 custom-element
105
+ * files are left whole (the element IS the component), and lib/theme files
106
+ * have no example so the strip is a no-op.
107
+ *
108
+ * @param {string} content raw registry file content
109
+ * @param {string} target absolute path where the file will be written
110
+ * @param {{ resolvedPaths: { utils: string } }} config parsed components.json
111
+ * @param {{ name: string, type?: string }} [item] the registry item
112
+ * @returns {string}
113
+ */
114
+ export function transformForProject(content, target, config, item) {
115
+ let out = rewriteUtilsImport(content, target, config);
116
+ if (item && item.type === 'registry:ui' && !isCustomElementSource(content)) {
117
+ out = stripExample(out, item.name);
118
+ }
119
+ return out;
120
+ }
121
+
78
122
  /**
79
123
  * Rewrite the registry-relative `'../lib/utils.ts'` and `'../lib/dom.ts'`
80
124
  * imports to the paths that resolve correctly from the file's target
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join, basename } from 'node:path';
4
4
  import { getConfig } from '../utils/get-config.js';
5
5
  import { fetchRegistryItem, fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
6
+ import { transformForProject } from './add.js';
6
7
  import { logger } from '../utils/logger.js';
7
8
 
8
9
  export const diff = new Command()
@@ -18,16 +19,32 @@ export const diff = new Command()
18
19
  process.exit(1);
19
20
  }
20
21
 
21
- const items = name ? [await fetchRegistryItem(name, opts.registry)] : (await fetchRegistryIndex(opts.registry)).filter((i) => i.type === 'registry:ui');
22
+ // diff compares local files against the LIVE upstream, so it stays on the
23
+ // network path (NOT local-first): local-first would compare the package
24
+ // against itself. Resolve the names to compare, then fetch each item's full
25
+ // content (the index is metadata-only).
26
+ let names;
27
+ if (name) {
28
+ names = [name];
29
+ } else {
30
+ const index = await fetchRegistryIndex(opts.registry);
31
+ names = index.filter((i) => i.type === 'registry:ui').map((i) => i.name);
32
+ }
33
+
22
34
  const uiDir = config.resolvedPaths.ui;
23
35
  let changed = 0;
24
36
 
25
- for (const item of items) {
37
+ for (const n of names) {
38
+ const item = await fetchRegistryItem(n, opts.registry);
26
39
  for (const file of item.files || []) {
27
40
  const target = join(uiDir, basename(file.path));
28
41
  if (!existsSync(target)) continue;
29
42
  const local = readFileSync(target, 'utf8');
30
- if (local !== (file.content || '')) {
43
+ // Compare against what `add` WOULD write (import-rewrite + example
44
+ // strip), not the raw registry content: otherwise a pristine install
45
+ // reports every import-rewritten component as differing (#983).
46
+ const expected = transformForProject(file.content || '', target, config, item);
47
+ if (local !== expected) {
31
48
  logger.info(`${logger.bold(item.name)}: ${basename(file.path)} differs from registry`);
32
49
  changed++;
33
50
  }
@@ -1,11 +1,12 @@
1
1
  import { Command } from 'commander';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import prompts from 'prompts';
5
5
  import { defaultsForProject } from '../utils/detect-project.js';
6
6
  import { writeConfig, CONFIG_FILE } from '../utils/get-config.js';
7
7
  import { logger } from '../utils/logger.js';
8
- import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
8
+ import { getRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
9
+ import { ensureTheme } from '../utils/theme.js';
9
10
 
10
11
  const BASE_COLORS = ['neutral', 'stone', 'zinc', 'mauve', 'olive', 'mist', 'taupe'];
11
12
 
@@ -66,7 +67,18 @@ export const init = new Command()
66
67
 
67
68
  // Pull lib/utils + the chosen theme from the registry and write them in.
68
69
  await writeLibUtils(cwd, defaults.aliases.utils, opts.registry);
69
- await writeTheme(cwd, answers.baseColor, answers.css, opts.registry);
70
+
71
+ // The theme tokens are what the class helpers render against. A silent
72
+ // failure here (the old behaviour) left an unstyled install with a clean
73
+ // exit code, the exact trap for an autonomous agent, so hard-fail (#983).
74
+ const theme = await ensureTheme(cwd, answers.baseColor, answers.css, opts.registry);
75
+ if (theme.status === 'failed') {
76
+ logger.error(`Could not write theme tokens into ${answers.css}: ${theme.error}`);
77
+ logger.info('The class helpers render against these tokens, so this must succeed.');
78
+ process.exit(1);
79
+ }
80
+ if (theme.status === 'written') logger.success(`Wrote theme into ${answers.css}`);
81
+ else logger.info(`Theme already present in ${answers.css}: skipping.`);
70
82
 
71
83
  logger.break();
72
84
  logger.success('Done.');
@@ -79,7 +91,7 @@ async function writeLibUtils(cwd, utilsAlias, registryUrl) {
79
91
  const utilsRel = utilsAlias.replace(/^@\//, '') + '.ts';
80
92
  const utilsTarget = join(cwd, utilsRel);
81
93
  try {
82
- const item = await fetchRegistryItem('lib-utils', registryUrl);
94
+ const item = await getRegistryItem('lib-utils', registryUrl);
83
95
  if (item.files) {
84
96
  for (const f of item.files) {
85
97
  ensureDir(dirname(utilsTarget));
@@ -98,7 +110,7 @@ async function writeLibUtils(cwd, utilsAlias, registryUrl) {
98
110
  // sibling of the utils file (e.g. lib/utils/dom.ts next to cn.ts).
99
111
  const domTarget = join(dirname(utilsTarget), 'dom.ts');
100
112
  try {
101
- const item = await fetchRegistryItem('lib-dom', registryUrl);
113
+ const item = await getRegistryItem('lib-dom', registryUrl);
102
114
  if (!item.files) return;
103
115
  for (const f of item.files) {
104
116
  ensureDir(dirname(domTarget));
@@ -114,26 +126,6 @@ function relative(cwd, p) {
114
126
  return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
115
127
  }
116
128
 
117
- async function writeTheme(cwd, baseColor, cssPath, registryUrl) {
118
- try {
119
- const item = await fetchRegistryItem(`theme-${baseColor}`, registryUrl);
120
- if (!item.files) return;
121
- const target = join(cwd, cssPath);
122
- ensureDir(dirname(target));
123
- const existing = existsSync(target) ? readFileSync(target, 'utf8') : '';
124
- const themeBlock = item.files[0]?.content || '';
125
- // Idempotent: only append if our marker isn't already present.
126
- if (existing.includes('/* @webjsdev/ui theme */')) {
127
- logger.info(`Theme already present in ${cssPath}: skipping.`);
128
- return;
129
- }
130
- writeFileSync(target, existing + (existing && !existing.endsWith('\n') ? '\n' : '') + themeBlock, 'utf8');
131
- logger.success(`Wrote theme into ${cssPath}`);
132
- } catch (e) {
133
- logger.warn(`Could not fetch theme-${baseColor} (${e.message}). Skipping theme install.`);
134
- }
135
- }
136
-
137
129
  function ensureDir(d) {
138
130
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
139
131
  }
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
2
+ import { getRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
3
3
  import { logger } from '../utils/logger.js';
4
4
 
5
5
  export const list = new Command()
@@ -9,7 +9,7 @@ export const list = new Command()
9
9
  .argument('[filter]', 'filter by substring')
10
10
  .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
11
11
  .action(async (filter, opts) => {
12
- const items = await fetchRegistryIndex(opts.registry);
12
+ const items = await getRegistryIndex(opts.registry);
13
13
  const ui = items.filter((i) => i.type === 'registry:ui');
14
14
  const filtered = filter ? ui.filter((i) => i.name.includes(filter)) : ui;
15
15
  if (!filtered.length) {
@@ -1,14 +1,29 @@
1
1
  import { Command } from 'commander';
2
- import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
2
+ import { getRegistryItem, DEFAULT_REGISTRY_URL, isDefaultRegistry } from '../registry/fetcher.js';
3
+ import { uiComponent, renderComponentText } from '../registry/extract.js';
3
4
  import { logger } from '../utils/logger.js';
4
5
 
5
6
  export const view = new Command()
6
7
  .name('view')
7
- .description("Print a registry item's source to stdout")
8
+ .description("Print a registry item's source (and the paste-ready example) to stdout")
8
9
  .argument('<name>', 'component name')
9
10
  .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
11
+ .option('--source', 'print only the raw source, not the projected view')
10
12
  .action(async (name, opts) => {
11
- const item = await fetchRegistryItem(name, opts.registry);
13
+ // For a local registry:ui component, lead with the projected view (helper
14
+ // signatures + the paste-ready @example + deps). This is the human /
15
+ // offline path to the example that `add` strips from the copied file, and
16
+ // it shares ONE projector with the MCP `ui` tool (`registry/extract.js`).
17
+ // The projection reads the LOCAL packaged registry, so it only applies to
18
+ // the default registry (a custom --registry gets the raw fetched source).
19
+ const projected = opts.source || !isDefaultRegistry(opts.registry) ? null : uiComponent(name);
20
+ if (projected) {
21
+ console.log(renderComponentText(projected));
22
+ console.log('');
23
+ logger.info(logger.dim('# --- full source ---'));
24
+ }
25
+
26
+ const item = await getRegistryItem(name, opts.registry);
12
27
  logger.info(logger.dim(`# ${item.name}: ${item.type}`));
13
28
  if (item.description) logger.info(logger.dim(`# ${item.description}`));
14
29
  for (const f of item.files || []) {
@@ -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
  *