@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Local registry composer (#983).
3
+ *
4
+ * The registry SOURCES ship inside this npm package (`packages/ui/package.json`
5
+ * `files` includes `packages/registry`), so `webjsui add` / `list` / `view` and
6
+ * the MCP `ui` tool can resolve a component with NO network round-trip. This
7
+ * module reads those on-disk sources and composes the same shadcn-compatible
8
+ * item shape the hosted registry serves, so a local read and a network fetch
9
+ * are interchangeable for every consumer.
10
+ *
11
+ * It is the plain-JS twin of the ui-website composer
12
+ * (`packages/ui/packages/website/app/_lib/registry.server.ts`): both read
13
+ * `packages/registry/registry.json` + the `components/*.ts` sources and
14
+ * synthesize the 6 non-neutral base-colour themes from `themes/base-colors.js`.
15
+ * Keeping the CLI on this module (rather than always hitting the network) is
16
+ * what makes an autonomous agent's `add` deterministic and offline-safe.
17
+ *
18
+ * Resolution is relative to THIS module (`import.meta.url`), i.e. the installed
19
+ * `@webjsdev/ui` package, NOT `process.cwd()`: `webjsui` runs inside a user's
20
+ * project but the registry lives in the installed package.
21
+ *
22
+ * @module registry/local
23
+ */
24
+
25
+ import { readFileSync, existsSync } from 'node:fs';
26
+ import { join, dirname, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import {
29
+ BASE_COLORS,
30
+ BASE_TITLES,
31
+ BASE_DESCRIPTIONS,
32
+ BASE_OVERRIDES,
33
+ mergeThemeCss,
34
+ } from '../../packages/registry/themes/base-colors.js';
35
+
36
+ /** Absolute path to the packaged registry root (`packages/ui/packages/registry`). */
37
+ export const REGISTRY_ROOT = resolve(
38
+ dirname(fileURLToPath(import.meta.url)),
39
+ '..',
40
+ '..',
41
+ 'packages',
42
+ 'registry',
43
+ );
44
+ const MANIFEST_PATH = join(REGISTRY_ROOT, 'registry.json');
45
+ const NEUTRAL_CSS_PATH = join(REGISTRY_ROOT, 'themes', 'index.css');
46
+ const SCHEMA = 'https://ui.webjs.dev/schema/registry-item.json';
47
+
48
+ let manifestCache = null;
49
+ let neutralCssCache = null;
50
+ const itemCache = new Map();
51
+ let indexCache = null;
52
+
53
+ function readManifest() {
54
+ if (manifestCache) return manifestCache;
55
+ manifestCache = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
56
+ return manifestCache;
57
+ }
58
+
59
+ function readNeutralCss() {
60
+ if (neutralCssCache) return neutralCssCache;
61
+ neutralCssCache = readFileSync(NEUTRAL_CSS_PATH, 'utf8');
62
+ return neutralCssCache;
63
+ }
64
+
65
+ /**
66
+ * Return the item with each declared file's on-disk content inlined. A file
67
+ * whose source is missing degrades to empty content rather than throwing.
68
+ *
69
+ * @param {any} item
70
+ * @returns {any}
71
+ */
72
+ function inlineFiles(item) {
73
+ return {
74
+ $schema: SCHEMA,
75
+ ...item,
76
+ files: (item.files || []).map((f) => {
77
+ const abs = resolve(REGISTRY_ROOT, f.path);
78
+ if (!existsSync(abs)) return { ...f, content: '' };
79
+ return { ...f, content: readFileSync(abs, 'utf8') };
80
+ }),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Synthesize a `theme-<color>` item by merging the colour's overrides into the
86
+ * canonical neutral CSS, matching the manifest's `theme-neutral` shape so
87
+ * `webjsui init --base-color <color>` handles all 7 colours via one path.
88
+ *
89
+ * @param {string} color
90
+ * @returns {any|null}
91
+ */
92
+ function synthesizeColorTheme(color) {
93
+ if (!BASE_COLORS.includes(color)) return null;
94
+ const overrides = BASE_OVERRIDES[color];
95
+ const content = mergeThemeCss(readNeutralCss(), overrides);
96
+ return {
97
+ $schema: SCHEMA,
98
+ name: `theme-${color}`,
99
+ type: 'registry:theme',
100
+ title: BASE_TITLES[color],
101
+ description: BASE_DESCRIPTIONS[color],
102
+ files: [
103
+ {
104
+ path: 'themes/index.css',
105
+ type: 'registry:file',
106
+ target: 'app/globals.css',
107
+ content,
108
+ },
109
+ ],
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Load one registry item by name with its file content inlined. Returns null
115
+ * when the name is neither a manifest item nor a synthesizable colour theme.
116
+ *
117
+ * @param {string} name
118
+ * @returns {any|null}
119
+ */
120
+ export function loadRegistryItem(name) {
121
+ if (itemCache.has(name)) return itemCache.get(name);
122
+
123
+ const manifestItem = readManifest().items.find((it) => it.name === name);
124
+ if (manifestItem) {
125
+ const composed = inlineFiles(manifestItem);
126
+ itemCache.set(name, composed);
127
+ return composed;
128
+ }
129
+
130
+ if (name.startsWith('theme-')) {
131
+ const color = name.slice('theme-'.length);
132
+ const synth = synthesizeColorTheme(color);
133
+ if (synth) {
134
+ itemCache.set(name, synth);
135
+ return synth;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Load the flat registry index (metadata only, no inlined content), including
144
+ * the synthesized non-neutral colour themes.
145
+ *
146
+ * @returns {any[]}
147
+ */
148
+ export function loadRegistryIndex() {
149
+ if (indexCache) return indexCache;
150
+ const fromManifest = readManifest().items.map((item) => ({
151
+ name: item.name,
152
+ type: item.type,
153
+ description: item.description,
154
+ dependencies: item.dependencies,
155
+ registryDependencies: item.registryDependencies,
156
+ }));
157
+ const manifestNames = new Set(fromManifest.map((it) => it.name));
158
+ const synthesized = BASE_COLORS.filter(
159
+ (color) => !manifestNames.has(`theme-${color}`),
160
+ ).map((color) => ({
161
+ name: `theme-${color}`,
162
+ type: 'registry:theme',
163
+ description: BASE_DESCRIPTIONS[color],
164
+ }));
165
+ indexCache = [...fromManifest, ...synthesized];
166
+ return indexCache;
167
+ }
168
+
169
+ /**
170
+ * Strip `/* *​/` block comments and `//` line comments so a token check keys on
171
+ * CODE, not prose. Rough (does not honour a `//` inside a string), but a
172
+ * component's JSDoc is the realistic false-positive source, and the leading
173
+ * module JSDoc is exactly what this removes.
174
+ *
175
+ * @param {string} src
176
+ * @returns {string}
177
+ */
178
+ function stripComments(src) {
179
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
180
+ }
181
+
182
+ /**
183
+ * True when a component source defines/registers a custom element (Tier-2). A
184
+ * Tier-1 helper file exports only class-string functions and matches none of
185
+ * these. Used to gate the example-strip (Tier-2 files are left whole) and to
186
+ * label the kit inventory. Comments are stripped first so a JSDoc that merely
187
+ * MENTIONS `.register(` or `WebComponent` does not misclassify a Tier-1 helper.
188
+ *
189
+ * @param {string} src
190
+ * @returns {boolean}
191
+ */
192
+ export function isCustomElementSource(src) {
193
+ const code = stripComments(src);
194
+ return (
195
+ /\bextends\s+WebComponent\b/.test(code) ||
196
+ /\bcustomElements\.define\b/.test(code) ||
197
+ /\.register\(/.test(code)
198
+ );
199
+ }
200
+
201
+ /** Reset the in-memory caches. Test-only. */
202
+ export function _resetCache() {
203
+ manifestCache = null;
204
+ neutralCssCache = null;
205
+ indexCache = null;
206
+ itemCache.clear();
207
+ }
@@ -1,4 +1,4 @@
1
- import { fetchRegistryItem } from './fetcher.js';
1
+ import { getRegistryItem } from './fetcher.js';
2
2
 
3
3
  /**
4
4
  * Walk `registryDependencies` transitively. Returns a flat array of items in
@@ -16,7 +16,7 @@ export async function resolveTree(names, baseUrl) {
16
16
  async function visit(name) {
17
17
  if (seen.has(name)) return;
18
18
  seen.set(name, true);
19
- const item = await fetchRegistryItem(name, baseUrl);
19
+ const item = await getRegistryItem(name, baseUrl);
20
20
  for (const dep of item.registryDependencies || []) await visit(dep);
21
21
  ordered.push(item);
22
22
  }
@@ -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
+ }