@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
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|