@webjsdev/ui 0.3.8 → 0.3.10
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 +19 -17
- 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 +30 -24
- package/packages/registry/components/dropdown-menu.ts +22 -20
- package/packages/registry/components/hover-card.ts +18 -10
- 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 +16 -12
- package/packages/registry/components/switch.ts +13 -11
- package/packages/registry/components/table.ts +21 -19
- package/packages/registry/components/tabs.ts +30 -12
- 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 +10 -8
- package/packages/registry/lib/utils.ts +34 -8
- package/packages/registry/themes/index.css +11 -0
- package/src/commands/add.js +45 -1
- package/src/commands/diff.js +20 -3
- package/src/commands/init.js +17 -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 +145 -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/theme.js +68 -0
|
@@ -73,22 +73,48 @@ const GROUPS: Array<[RegExp, string]> = [
|
|
|
73
73
|
[/^grid(-|$)/, 'grid'],
|
|
74
74
|
];
|
|
75
75
|
|
|
76
|
+
// Directional shorthand conflicts (the tailwind-merge model). A shorthand
|
|
77
|
+
// utility invalidates the axis / side utilities it SUBSUMES, because Tailwind's
|
|
78
|
+
// cascade makes the shorthand win: `p-0` after `px-4 py-2` drops both, but
|
|
79
|
+
// `px-4` after `p-2` refines only the x-axis so BOTH survive (the conflict is
|
|
80
|
+
// directional, not symmetric). Keyed by the group a NEWLY-seen token belongs to;
|
|
81
|
+
// the value lists the EARLIER groups whose survivors it removes (its own group
|
|
82
|
+
// is always removed too, handled below).
|
|
83
|
+
const CONFLICTS: Record<string, string[]> = {
|
|
84
|
+
p: ['px', 'py', 'pt', 'pr', 'pb', 'pl'],
|
|
85
|
+
px: ['pl', 'pr'],
|
|
86
|
+
py: ['pt', 'pb'],
|
|
87
|
+
m: ['mx', 'my', 'mt', 'mr', 'mb', 'ml'],
|
|
88
|
+
mx: ['ml', 'mr'],
|
|
89
|
+
my: ['mt', 'mb'],
|
|
90
|
+
size: ['w', 'h'],
|
|
91
|
+
};
|
|
92
|
+
|
|
76
93
|
function dedupeUtilities(input: string): string {
|
|
77
94
|
const tokens = input.split(/\s+/).filter(Boolean);
|
|
78
|
-
|
|
95
|
+
// `${prefix}::${group}` -> index of the last SURVIVING token in that group.
|
|
96
|
+
const lastByKey = new Map<string, number>();
|
|
79
97
|
const result: Array<string | null> = [];
|
|
80
98
|
|
|
81
99
|
for (const token of tokens) {
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
100
|
+
// Strip variant prefix (`hover:`, `dark:md:`, …) before testing each dedupe
|
|
101
|
+
// regex so `hover:bg-red-500` still matches the `bg-color` group. Conflicts
|
|
102
|
+
// only apply WITHIN the same variant (`px-4 hover:p-0` keeps both).
|
|
85
103
|
const prefix = variantPrefix(token);
|
|
86
104
|
const bare = prefix ? token.slice(prefix.length) : token;
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
let gk: string | null = null;
|
|
106
|
+
for (const [re, g] of GROUPS) {
|
|
107
|
+
if (re.test(bare)) { gk = g; break; }
|
|
108
|
+
}
|
|
109
|
+
if (gk) {
|
|
110
|
+
// Remove earlier survivors in this group AND in every group it subsumes.
|
|
111
|
+
for (const g of [gk, ...(CONFLICTS[gk] ?? [])]) {
|
|
112
|
+
const k = `${prefix}::${g}`;
|
|
113
|
+
const idx = lastByKey.get(k);
|
|
114
|
+
if (idx !== undefined) { result[idx] = null; lastByKey.delete(k); }
|
|
115
|
+
}
|
|
116
|
+
lastByKey.set(`${prefix}::${gk}`, result.length);
|
|
89
117
|
}
|
|
90
|
-
if (key && seen.has(key)) result[seen.get(key)!] = null;
|
|
91
|
-
if (key) seen.set(key, result.length);
|
|
92
118
|
result.push(token);
|
|
93
119
|
}
|
|
94
120
|
return result.filter(Boolean).join(' ');
|
|
@@ -135,7 +135,18 @@
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
@layer base {
|
|
138
|
+
/* Focus: a themed, keyboard-only (:focus-visible), never-removed outline, in
|
|
139
|
+
the --ring colour, applied globally. `outline-ring/50` themes only the
|
|
140
|
+
COLOUR and leaves outline-style: auto, which the browser draws as its OWN
|
|
141
|
+
ring (it can look thick/white and ignore the colour). So force a SOLID
|
|
142
|
+
outline on focus, at a consistent width + offset, so the themed --ring/50
|
|
143
|
+
colour actually renders the same on every element. A shadow-DOM component
|
|
144
|
+
sets its own focus (a document rule cannot cross the shadow boundary). */
|
|
138
145
|
* {
|
|
139
146
|
@apply border-border outline-ring/50;
|
|
140
147
|
}
|
|
148
|
+
:focus-visible {
|
|
149
|
+
outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent);
|
|
150
|
+
outline-offset: 2px;
|
|
151
|
+
}
|
|
141
152
|
}
|
package/src/commands/add.js
CHANGED
|
@@ -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 =
|
|
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
|
package/src/commands/diff.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/commands/list.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import {
|
|
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
|
|
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) {
|
package/src/commands/view.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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,145 @@
|
|
|
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 } from './example.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the exported class-helper signatures from a Tier-1 source, e.g.
|
|
26
|
+
* `accordionTriggerClass(opts: { disabled?: boolean } = {})`. Best-effort
|
|
27
|
+
* lexical scan (no TS parser dependency); the parameter list is captured up to
|
|
28
|
+
* its closing paren, the return-type annotation dropped.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} src
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function extractHelperSignatures(src) {
|
|
34
|
+
/** @type {string[]} */
|
|
35
|
+
const out = [];
|
|
36
|
+
// Two authored forms, both class-helper functions:
|
|
37
|
+
// export const NAME = (params): T => ... (arrow; the `=>` gates out
|
|
38
|
+
// non-function consts)
|
|
39
|
+
// export function NAME(params): T { ... }
|
|
40
|
+
// `export type` / `export interface` never match (no arrow, no `function`).
|
|
41
|
+
const re =
|
|
42
|
+
/export\s+(?:const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(\([^)]*\))\s*(?::[^=]+?)?=>|function\s+([A-Za-z_$][\w$]*)\s*(\([^)]*\)))/g;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(src)) !== null) {
|
|
45
|
+
if (m[1]) out.push(`${m[1]}${m[2]}`);
|
|
46
|
+
else if (m[3]) out.push(`${m[3]}${m[4]}`);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The JSDoc header text (description + a11y obligations + token notes), with the
|
|
53
|
+
* `@example` block and the `@module`/`@param`-style tags dropped. This is the
|
|
54
|
+
* "lean header" the copied file keeps; serving it lets an agent read the
|
|
55
|
+
* obligations without the worked example.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} src
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function extractDocHeader(src) {
|
|
61
|
+
const start = src.indexOf('/**');
|
|
62
|
+
if (start === -1) return '';
|
|
63
|
+
const end = src.indexOf('*/', start + 3);
|
|
64
|
+
if (end === -1) return '';
|
|
65
|
+
const lines = src.slice(start + 3, end).split('\n');
|
|
66
|
+
/** @type {string[]} */
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const raw of lines) {
|
|
69
|
+
const line = raw.replace(/^\s*\*\s?/, '');
|
|
70
|
+
if (/^\s*@\w+/.test(line)) break; // stop at the first tag (@example, @module, ...)
|
|
71
|
+
out.push(line);
|
|
72
|
+
}
|
|
73
|
+
return out.join('\n').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Project one registry component into the agent-facing shape. Returns null when
|
|
78
|
+
* the name is not a `registry:ui` component.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} name
|
|
81
|
+
* @returns {{
|
|
82
|
+
* name: string, tier: 1|2, type: string,
|
|
83
|
+
* description: string, helpers: string[], example: string,
|
|
84
|
+
* dependencies: string[], registryDependencies: string[],
|
|
85
|
+
* } | null}
|
|
86
|
+
*/
|
|
87
|
+
export function uiComponent(name) {
|
|
88
|
+
const item = loadRegistryItem(name);
|
|
89
|
+
if (!item || item.type !== 'registry:ui') return null;
|
|
90
|
+
const src = (item.files || []).map((f) => f.content || '').join('\n');
|
|
91
|
+
const tier = isCustomElementSource(src) ? 2 : 1;
|
|
92
|
+
return {
|
|
93
|
+
name: item.name,
|
|
94
|
+
tier,
|
|
95
|
+
type: item.type,
|
|
96
|
+
description: extractDocHeader(src),
|
|
97
|
+
helpers: tier === 1 ? extractHelperSignatures(src) : [],
|
|
98
|
+
example: extractExample(src),
|
|
99
|
+
dependencies: item.dependencies || [],
|
|
100
|
+
registryDependencies: item.registryDependencies || [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The kit inventory: one compact entry per `registry:ui` component (name, tier,
|
|
106
|
+
* helper signatures, npm deps). The no-args payload for the MCP `ui` tool and
|
|
107
|
+
* the `references/ui-kit.md` skill surface, so an agent reaches for a helper
|
|
108
|
+
* instead of expanding Tailwind by hand.
|
|
109
|
+
*
|
|
110
|
+
* @returns {Array<{ name: string, tier: 1|2, helpers: string[], dependencies: string[] }>}
|
|
111
|
+
*/
|
|
112
|
+
export function uiInventory() {
|
|
113
|
+
return loadRegistryIndex()
|
|
114
|
+
.filter((i) => i.type === 'registry:ui')
|
|
115
|
+
.map((i) => {
|
|
116
|
+
const c = uiComponent(i.name);
|
|
117
|
+
return {
|
|
118
|
+
name: i.name,
|
|
119
|
+
tier: c ? c.tier : 1,
|
|
120
|
+
helpers: c ? c.helpers : [],
|
|
121
|
+
dependencies: c ? c.dependencies : [],
|
|
122
|
+
};
|
|
123
|
+
})
|
|
124
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render a component projection as human-readable text for `webjsui view`.
|
|
129
|
+
* Shares the SAME {@link uiComponent} data the MCP `ui` tool returns, so the
|
|
130
|
+
* two never drift.
|
|
131
|
+
*
|
|
132
|
+
* @param {ReturnType<typeof uiComponent>} c
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function renderComponentText(c) {
|
|
136
|
+
if (!c) return '';
|
|
137
|
+
const lines = [];
|
|
138
|
+
lines.push(`# ${c.name} (Tier ${c.tier})`);
|
|
139
|
+
if (c.description) lines.push('', c.description);
|
|
140
|
+
if (c.helpers.length) lines.push('', 'Helpers:', ...c.helpers.map((h) => ` ${h}`));
|
|
141
|
+
if (c.dependencies.length) lines.push('', `npm: ${c.dependencies.join(', ')}`);
|
|
142
|
+
if (c.registryDependencies.length) lines.push(`registry deps: ${c.registryDependencies.join(', ')}`);
|
|
143
|
+
if (c.example) lines.push('', 'Example:', '', c.example);
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
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
|
*
|