@webjskit/ui 0.1.0

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 ADDED
@@ -0,0 +1,105 @@
1
+ # @webjskit/ui
2
+
3
+ An **AI-first component library** for the web. Source-copied into your project ,
4
+ you own the code.
5
+
6
+ Two-tier composition designed for AI agents who reason about real HTML +
7
+ function calls, not for a layered React abstraction over every primitive:
8
+
9
+ - **Tier 1, class-helper functions** (`buttonClass`, `cardClass`,
10
+ `inputClass`, `labelClass`, `alertClass`, `popoverContentClass`,
11
+ `accordionItemClass`, `collapsibleTriggerClass`, …). Pure functions that
12
+ return Tailwind class strings. You spread them onto raw native elements
13
+ , including `<button class=${buttonClass({ variant: 'outline' })}>`,
14
+ `<details name="faq" class=${accordionItemClass()}>`, and
15
+ `<div popover class=${popoverContentClass()}>`, so real native elements
16
+ participate in form submission, autocomplete, screen readers, the
17
+ Popover API ancestry, and devtools as themselves.
18
+ - **Tier 2, stateful custom elements** (`<ui-dialog>`, `<ui-alert-dialog>`,
19
+ `<ui-tabs>`, `<ui-tooltip>`, `<ui-hover-card>`, `<ui-dropdown-menu>`,
20
+ `<ui-sonner>`, …). Reserved for the behavior the browser still doesn't
21
+ give you for free: hover-with-delay tooltips, roving-focus keyboard nav
22
+ for menus and tabs, toast queue with stack and dismiss. Dialog and
23
+ alert-dialog use a thin custom element on top of the native
24
+ `<dialog>.showModal()`, focus trap, Escape, and backdrop overlay all
25
+ come from the platform. Decorate the host, no shadow DOM.
26
+
27
+ Works with any project that uses Tailwind CSS v4 and supports custom elements:
28
+ webjs, Next, Astro, Vite, SvelteKit, Lit, vanilla HTML, as long as Tailwind
29
+ is configured, the components render correctly. Variant names, sizes, and
30
+ data-attribute conventions mirror shadcn's so an AI agent's existing
31
+ knowledge of shadcn maps directly.
32
+
33
+ Tier-2 elements extend `Base` (a Node-safe `HTMLElement` shim) from a small
34
+ shared `lib/utils.ts` the CLI writes into your project.
35
+
36
+ ## Install
37
+
38
+ ### Option A : Webjs users (already have `@webjskit/cli`)
39
+
40
+ Nothing to install. `@webjskit/ui` is a hard dependency of `@webjskit/cli`,
41
+ so a global webjs install already includes it. Apps scaffolded with
42
+ `webjs create` also have it pre-listed in `devDependencies`.
43
+
44
+ ```sh
45
+ webjs ui init
46
+ webjs ui add button card dialog
47
+ ```
48
+
49
+ ### Option B : Everyone else (Next, Astro, Vite, SvelteKit, Lit, vanilla, …)
50
+
51
+ Two npm installs, the CLI and the runtime base class, then run the CLI:
52
+
53
+ ```sh
54
+ npm install -D @webjskit/ui
55
+ npm install @webjskit/core
56
+ npx webjsui init
57
+ npx webjsui add button card dialog
58
+ ```
59
+
60
+ The `webjsui` binary is standalone, it doesn't require `@webjskit/cli`.
61
+ `init` auto-detects your project type (Next / Astro / Vite / Lit / plain)
62
+ and picks sensible defaults.
63
+
64
+ ## What `init` writes
65
+
66
+ - `components.json`, your project's UI config (aliases, base color, Tailwind path)
67
+ - `lib/utils.ts`, the `cn()` class-merge helper
68
+ - Tailwind tokens + CSS variables appended to your global stylesheet
69
+
70
+ ## What `add` does
71
+
72
+ Copies the component's `.ts` source into `components/ui/<name>.ts` (or your
73
+ configured alias). Resolves transitive deps via `registryDependencies` and
74
+ auto-installs npm deps like `@floating-ui/dom` for popover-style components.
75
+
76
+ ## Commands
77
+
78
+ | Command | Effect |
79
+ |---|---|
80
+ | `webjsui init` | Initialize a project (writes `components.json`, theme CSS, `lib/utils.ts`) |
81
+ | `webjsui add <names...>` | Add components to your project |
82
+ | `webjsui list` | List all available components |
83
+ | `webjsui view <name>` | Print a component's source to stdout |
84
+ | `webjsui diff [name]` | Show diff between your local copy and the registry |
85
+ | `webjsui info` | Print project diagnostics |
86
+ | `webjsui build` | (For registry authors) Compile a custom registry |
87
+
88
+ ## Tag convention
89
+
90
+ Every component uses a single `ui-` prefix:
91
+
92
+ ```html
93
+ <ui-button variant="default">Click me</ui-button>
94
+ <ui-card>
95
+ <ui-card-header>
96
+ <ui-card-title>Title</ui-card-title>
97
+ <ui-card-description>Description</ui-card-description>
98
+ </ui-card-header>
99
+ <ui-card-content>Content here</ui-card-content>
100
+ </ui-card>
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT
package/bin/webjsui.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/index.js';
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@webjskit/ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "An AI-first component library - class-helper functions for visuals, custom elements only where state matters. Source-copied into your repo, you own it. Works with any Tailwind v4 project.",
6
+ "bin": {
7
+ "webjsui": "bin/webjsui.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./registry/schema": "./src/registry/schema.js",
13
+ "./utils": "./src/utils/index.js"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "commander": "^14.0.0",
25
+ "kleur": "^4.1.5",
26
+ "prompts": "^2.4.2",
27
+ "zod": "^3.24.1"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/vivek7405/webjs.git",
35
+ "directory": "packages/ui"
36
+ },
37
+ "homepage": "https://ui.webjs.dev",
38
+ "bugs": "https://github.com/vivek7405/webjs/issues",
39
+ "license": "MIT",
40
+ "keywords": [
41
+ "shadcn",
42
+ "ui",
43
+ "web-components",
44
+ "tailwind",
45
+ "components",
46
+ "webjs"
47
+ ],
48
+ "engines": {
49
+ "node": ">=24.0.0"
50
+ }
51
+ }
@@ -0,0 +1,123 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, basename } from 'node:path';
4
+ import prompts from 'prompts';
5
+ import { execSync } from 'node:child_process';
6
+ import { getConfig } from '../utils/get-config.js';
7
+ import { logger } from '../utils/logger.js';
8
+ import { resolveTree, collectNpmDeps } from '../registry/resolver.js';
9
+ import { DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
10
+
11
+ export const add = new Command()
12
+ .name('add')
13
+ .description('Add one or more components to your project')
14
+ .argument('[components...]', 'component names (e.g. button card dialog)')
15
+ .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
16
+ .option('-y, --yes', 'skip overwrite prompts', false)
17
+ .option('-o, --overwrite', 'overwrite existing files without asking', false)
18
+ .option('--no-deps', 'skip installing npm dependencies')
19
+ .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
20
+ .action(async (components, opts) => {
21
+ const cwd = opts.cwd;
22
+ const config = getConfig(cwd);
23
+ if (!config) {
24
+ logger.error(`No ${logger.cyan('components.json')} found in ${cwd}.`);
25
+ logger.info(`Run ${logger.cyan('npx webjsui init')} first.`);
26
+ process.exit(1);
27
+ }
28
+
29
+ if (!components || components.length === 0) {
30
+ logger.error('No components specified.');
31
+ logger.info(`Try ${logger.cyan('npx webjsui add button')} or ${logger.cyan('npx webjsui list')}.`);
32
+ process.exit(1);
33
+ }
34
+
35
+ const tree = await resolveTree(components, opts.registry);
36
+ logger.info(`Installing ${logger.bold(components.join(', '))}…`);
37
+
38
+ for (const item of tree) {
39
+ for (const file of item.files || []) {
40
+ await writeRegistryFile(cwd, config, item, file, opts);
41
+ }
42
+ }
43
+
44
+ if (opts.deps !== false) {
45
+ const { dependencies, devDependencies } = collectNpmDeps(tree);
46
+ // @webjskit/core is always a runtime dep
47
+ if (!dependencies.includes('@webjskit/core')) dependencies.push('@webjskit/core');
48
+
49
+ if (dependencies.length) await installDeps(cwd, dependencies, false);
50
+ if (devDependencies.length) await installDeps(cwd, devDependencies, true);
51
+ }
52
+
53
+ logger.success('Done.');
54
+ });
55
+
56
+ async function writeRegistryFile(cwd, config, item, file, opts) {
57
+ const target = resolveTarget(cwd, config, item, file);
58
+ ensureDir(dirname(target));
59
+
60
+ if (existsSync(target) && !opts.overwrite && !opts.yes) {
61
+ const r = await prompts({
62
+ type: 'confirm',
63
+ name: 'overwrite',
64
+ message: `Overwrite ${basename(target)}?`,
65
+ initial: false,
66
+ });
67
+ if (!r.overwrite) {
68
+ logger.info(`Skipped ${basename(target)}`);
69
+ return;
70
+ }
71
+ }
72
+
73
+ writeFileSync(target, file.content || '', 'utf8');
74
+ logger.success(`Wrote ${relative(cwd, target)}`);
75
+ }
76
+
77
+ function resolveTarget(cwd, config, item, file) {
78
+ // explicit `target` wins
79
+ if (file.target) return join(cwd, file.target);
80
+
81
+ const fileName = basename(file.path);
82
+ const aliases = config.aliases;
83
+
84
+ switch (file.type) {
85
+ case 'registry:ui':
86
+ return join(cwd, (aliases.ui || 'components/ui').replace(/^@\//, ''), fileName);
87
+ case 'registry:component':
88
+ return join(cwd, aliases.components.replace(/^@\//, ''), fileName);
89
+ case 'registry:lib':
90
+ return join(cwd, (aliases.lib || 'lib').replace(/^@\//, ''), fileName);
91
+ case 'registry:hook':
92
+ return join(cwd, 'hooks', fileName);
93
+ default:
94
+ return join(cwd, fileName);
95
+ }
96
+ }
97
+
98
+ function relative(cwd, p) {
99
+ return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
100
+ }
101
+
102
+ function ensureDir(d) {
103
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
104
+ }
105
+
106
+ async function installDeps(cwd, deps, dev) {
107
+ const manager = detectPackageManager(cwd);
108
+ const flag = dev ? '-D' : '';
109
+ const cmd = `${manager.exec} ${manager.add} ${flag} ${deps.join(' ')}`.replace(/\s+/g, ' ').trim();
110
+ logger.info(`${logger.dim('$')} ${cmd}`);
111
+ try {
112
+ execSync(cmd, { cwd, stdio: 'inherit' });
113
+ } catch (e) {
114
+ logger.warn(`Dependency install failed. Run manually: ${logger.cyan(cmd)}`);
115
+ }
116
+ }
117
+
118
+ function detectPackageManager(cwd) {
119
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return { exec: 'pnpm', add: 'add' };
120
+ if (existsSync(join(cwd, 'yarn.lock'))) return { exec: 'yarn', add: 'add' };
121
+ if (existsSync(join(cwd, 'bun.lockb'))) return { exec: 'bun', add: 'add' };
122
+ return { exec: 'npm', add: 'install' };
123
+ }
@@ -0,0 +1,47 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { registrySchema, registryItemSchema } from '../registry/schema.js';
5
+ import { logger } from '../utils/logger.js';
6
+
7
+ export const build = new Command()
8
+ .name('build')
9
+ .description('Build a custom registry: read registry.json, inline file contents, emit r/*.json')
10
+ .argument('[file]', 'registry manifest path', 'registry.json')
11
+ .option('-o, --output <dir>', 'output directory', './r')
12
+ .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
13
+ .action((file, opts) => {
14
+ const cwd = opts.cwd;
15
+ const manifestPath = resolve(cwd, file);
16
+ if (!existsSync(manifestPath)) {
17
+ logger.error(`Manifest not found: ${manifestPath}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
22
+ const manifest = registrySchema.parse(raw);
23
+ const outDir = resolve(cwd, opts.output);
24
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
25
+
26
+ const flatIndex = [];
27
+ for (const item of manifest.items) {
28
+ const enriched = {
29
+ $schema: 'https://ui.webjs.dev/schema/registry-item.json',
30
+ ...item,
31
+ files: (item.files || []).map((f) => ({
32
+ ...f,
33
+ content: f.content ?? readFileSync(resolve(dirname(manifestPath), f.path), 'utf8'),
34
+ })),
35
+ };
36
+ registryItemSchema.parse(enriched);
37
+ const outPath = join(outDir, `${item.name}.json`);
38
+ mkdirSync(dirname(outPath), { recursive: true });
39
+ writeFileSync(outPath, JSON.stringify(enriched, null, 2) + '\n', 'utf8');
40
+ flatIndex.push({ name: item.name, type: item.type, description: item.description });
41
+ logger.success(`Built r/${item.name}.json`);
42
+ }
43
+
44
+ writeFileSync(join(outDir, 'index.json'), JSON.stringify(flatIndex, null, 2) + '\n', 'utf8');
45
+ writeFileSync(join(outDir, 'registry.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
46
+ logger.success(`Built ${flatIndex.length} items → ${outDir}`);
47
+ });
@@ -0,0 +1,39 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { getConfig } from '../utils/get-config.js';
5
+ import { fetchRegistryItem, fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
6
+ import { logger } from '../utils/logger.js';
7
+
8
+ export const diff = new Command()
9
+ .name('diff')
10
+ .description('Show differences between local components and the registry')
11
+ .argument('[name]', 'component to diff (omit to show all out-of-date)')
12
+ .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
13
+ .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
14
+ .action(async (name, opts) => {
15
+ const config = getConfig(opts.cwd);
16
+ if (!config) {
17
+ logger.error('No components.json. Run `webjsui init` first.');
18
+ process.exit(1);
19
+ }
20
+
21
+ const items = name ? [await fetchRegistryItem(name, opts.registry)] : (await fetchRegistryIndex(opts.registry)).filter((i) => i.type === 'registry:ui');
22
+ const uiDir = config.resolvedPaths.ui;
23
+ let changed = 0;
24
+
25
+ for (const item of items) {
26
+ for (const file of item.files || []) {
27
+ const target = join(uiDir, basename(file.path));
28
+ if (!existsSync(target)) continue;
29
+ const local = readFileSync(target, 'utf8');
30
+ if (local !== (file.content || '')) {
31
+ logger.info(`${logger.bold(item.name)}: ${basename(file.path)} differs from registry`);
32
+ changed++;
33
+ }
34
+ }
35
+ }
36
+
37
+ if (changed === 0) logger.success('All local components match the registry.');
38
+ else logger.info(`\n${changed} file${changed === 1 ? '' : 's'} differ. Re-add with ${logger.cyan('webjsui add <name> -o')} to overwrite.`);
39
+ });
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getConfig } from '../utils/get-config.js';
5
+ import { detectProject } from '../utils/detect-project.js';
6
+ import { DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ export const info = new Command()
10
+ .name('info')
11
+ .description('Print project diagnostics (config, project type, registry)')
12
+ .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
13
+ .action((opts) => {
14
+ const cwd = opts.cwd;
15
+ const { type } = detectProject(cwd);
16
+ const config = getConfig(cwd);
17
+
18
+ logger.info(`${logger.bold('Project')} ${type}`);
19
+ logger.info(`${logger.bold('cwd')} ${cwd}`);
20
+ logger.info(`${logger.bold('Registry')} ${DEFAULT_REGISTRY_URL}`);
21
+ logger.info(`${logger.bold('Config')} ${config ? 'components.json ✔' : 'components.json ✖ (run `webjsui init`)'}`);
22
+ if (config) {
23
+ logger.info(`${logger.bold('Base color')} ${config.tailwind.baseColor}`);
24
+ logger.info(`${logger.bold('Tailwind CSS')} ${config.tailwind.css}`);
25
+ logger.info(`${logger.bold('Aliases')} ${JSON.stringify(config.aliases)}`);
26
+ }
27
+ logger.info(`${logger.bold('Tailwind')} ${existsSync(join(cwd, 'tailwind.config.js')) || existsSync(join(cwd, 'tailwind.config.ts')) ? 'config detected' : '(none: Tailwind v4 uses CSS-only config)'}`);
28
+ });
@@ -0,0 +1,115 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import prompts from 'prompts';
5
+ import { defaultsForProject } from '../utils/detect-project.js';
6
+ import { writeConfig, CONFIG_FILE } from '../utils/get-config.js';
7
+ import { logger } from '../utils/logger.js';
8
+ import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
9
+
10
+ const BASE_COLORS = ['neutral', 'stone', 'zinc', 'mauve', 'olive', 'mist', 'taupe'];
11
+
12
+ export const init = new Command()
13
+ .name('init')
14
+ .description('Initialize @webjskit/ui in a project: writes components.json, theme CSS, lib/utils')
15
+ .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
16
+ .option('-y, --yes', 'skip confirmation prompts', false)
17
+ .option('--base-color <color>', `base color (${BASE_COLORS.join('|')})`)
18
+ .option('--css <path>', 'path to the project Tailwind CSS file')
19
+ .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
20
+ .action(async (opts) => {
21
+ const cwd = opts.cwd;
22
+ const defaults = defaultsForProject(cwd);
23
+
24
+ /** @type {{ baseColor: string, css: string }} */
25
+ let answers = {
26
+ baseColor: opts.baseColor || 'neutral',
27
+ css: opts.css || defaults.tailwindCss,
28
+ };
29
+
30
+ if (!opts.yes) {
31
+ const r = await prompts(
32
+ [
33
+ {
34
+ type: opts.baseColor ? null : 'select',
35
+ name: 'baseColor',
36
+ message: 'Base color?',
37
+ choices: BASE_COLORS.map((c) => ({ title: c, value: c })),
38
+ initial: 0,
39
+ },
40
+ {
41
+ type: opts.css ? null : 'text',
42
+ name: 'css',
43
+ message: 'Tailwind CSS file path?',
44
+ initial: defaults.tailwindCss,
45
+ },
46
+ ],
47
+ { onCancel: () => process.exit(1) },
48
+ );
49
+ answers = { ...answers, ...r };
50
+ }
51
+
52
+ const config = {
53
+ $schema: 'https://ui.webjs.dev/schema.json',
54
+ style: 'default',
55
+ tailwind: {
56
+ css: answers.css,
57
+ baseColor: answers.baseColor,
58
+ cssVariables: true,
59
+ },
60
+ aliases: defaults.aliases,
61
+ iconLibrary: 'lucide',
62
+ };
63
+
64
+ writeConfig(cwd, config);
65
+ logger.success(`Wrote ${CONFIG_FILE}`);
66
+
67
+ // Pull lib/utils + the chosen theme from the registry and write them in.
68
+ await writeLibUtils(cwd, defaults.aliases.utils, opts.registry);
69
+ await writeTheme(cwd, answers.baseColor, answers.css, opts.registry);
70
+
71
+ logger.break();
72
+ logger.success('Done.');
73
+ logger.info('');
74
+ logger.info(`Add components with: ${logger.cyan('npx webjsui add button card dialog')}`);
75
+ });
76
+
77
+ async function writeLibUtils(cwd, utilsAlias, registryUrl) {
78
+ try {
79
+ const item = await fetchRegistryItem('lib-utils', registryUrl);
80
+ if (!item.files) return;
81
+ for (const f of item.files) {
82
+ // `utils` alias points at e.g. "lib/utils" → we write to lib/utils.ts
83
+ const target = join(cwd, utilsAlias.replace(/^@\//, '') + '.ts');
84
+ ensureDir(dirname(target));
85
+ writeFileSync(target, f.content || '', 'utf8');
86
+ logger.success(`Wrote ${utilsAlias}.ts`);
87
+ }
88
+ } catch (e) {
89
+ logger.warn(`Could not fetch lib-utils from registry (${e.message}). You may need to write lib/utils.ts manually.`);
90
+ }
91
+ }
92
+
93
+ async function writeTheme(cwd, baseColor, cssPath, registryUrl) {
94
+ try {
95
+ const item = await fetchRegistryItem(`theme-${baseColor}`, registryUrl);
96
+ if (!item.files) return;
97
+ const target = join(cwd, cssPath);
98
+ ensureDir(dirname(target));
99
+ const existing = existsSync(target) ? readFileSync(target, 'utf8') : '';
100
+ const themeBlock = item.files[0]?.content || '';
101
+ // Idempotent: only append if our marker isn't already present.
102
+ if (existing.includes('/* @webjskit/ui theme */')) {
103
+ logger.info(`Theme already present in ${cssPath}: skipping.`);
104
+ return;
105
+ }
106
+ writeFileSync(target, existing + (existing && !existing.endsWith('\n') ? '\n' : '') + themeBlock, 'utf8');
107
+ logger.success(`Wrote theme into ${cssPath}`);
108
+ } catch (e) {
109
+ logger.warn(`Could not fetch theme-${baseColor} (${e.message}). Skipping theme install.`);
110
+ }
111
+ }
112
+
113
+ function ensureDir(d) {
114
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
115
+ }
@@ -0,0 +1,25 @@
1
+ import { Command } from 'commander';
2
+ import { fetchRegistryIndex, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export const list = new Command()
6
+ .name('list')
7
+ .alias('search')
8
+ .description('List components available in the registry')
9
+ .argument('[filter]', 'filter by substring')
10
+ .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
11
+ .action(async (filter, opts) => {
12
+ const items = await fetchRegistryIndex(opts.registry);
13
+ const ui = items.filter((i) => i.type === 'registry:ui');
14
+ const filtered = filter ? ui.filter((i) => i.name.includes(filter)) : ui;
15
+ if (!filtered.length) {
16
+ logger.info('No matches.');
17
+ return;
18
+ }
19
+ for (const i of filtered) {
20
+ const desc = i.description ? logger.dim(': ' + i.description) : '';
21
+ logger.info(` ${logger.cyan(i.name)}${desc}`);
22
+ }
23
+ logger.info('');
24
+ logger.info(`${filtered.length} component${filtered.length === 1 ? '' : 's'}.`);
25
+ });
@@ -0,0 +1,19 @@
1
+ import { Command } from 'commander';
2
+ import { fetchRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export const view = new Command()
6
+ .name('view')
7
+ .description("Print a registry item's source to stdout")
8
+ .argument('<name>', 'component name')
9
+ .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
10
+ .action(async (name, opts) => {
11
+ const item = await fetchRegistryItem(name, opts.registry);
12
+ logger.info(logger.dim(`# ${item.name}: ${item.type}`));
13
+ if (item.description) logger.info(logger.dim(`# ${item.description}`));
14
+ for (const f of item.files || []) {
15
+ logger.info('');
16
+ logger.info(logger.dim(`# ${f.path}`));
17
+ console.log(f.content || '');
18
+ }
19
+ });
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { init } from './commands/init.js';
6
+ import { add } from './commands/add.js';
7
+ import { list } from './commands/list.js';
8
+ import { view } from './commands/view.js';
9
+ import { diff } from './commands/diff.js';
10
+ import { info } from './commands/info.js';
11
+ import { build } from './commands/build.js';
12
+
13
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
14
+
15
+ process.on('SIGINT', () => process.exit(0));
16
+ process.on('SIGTERM', () => process.exit(0));
17
+
18
+ const program = new Command()
19
+ .name('webjsui')
20
+ .description('AI-first component library: class helpers + custom elements, copied into your project so you own the code')
21
+ .version(pkg.version, '-v, --version', 'display the version number');
22
+
23
+ program
24
+ .addCommand(init)
25
+ .addCommand(add)
26
+ .addCommand(list)
27
+ .addCommand(view)
28
+ .addCommand(diff)
29
+ .addCommand(info)
30
+ .addCommand(build);
31
+
32
+ program.parse();
@@ -0,0 +1,45 @@
1
+ import { registryItemSchema, registryIndexSchema } from './schema.js';
2
+
3
+ /**
4
+ * Default registry URL. Override via REGISTRY_URL env var, or per-call.
5
+ */
6
+ export const DEFAULT_REGISTRY_URL = process.env.REGISTRY_URL || 'https://ui.webjs.dev/registry';
7
+
8
+ const cache = new Map();
9
+
10
+ /**
11
+ * Fetch a registry item by name.
12
+ *
13
+ * @param {string} name
14
+ * @param {string} [baseUrl]
15
+ */
16
+ export async function fetchRegistryItem(name, baseUrl = DEFAULT_REGISTRY_URL) {
17
+ const url = `${baseUrl.replace(/\/$/, '')}/${name}.json`;
18
+ if (cache.has(url)) return cache.get(url);
19
+ const res = await fetch(url);
20
+ if (!res.ok) {
21
+ throw new Error(`Failed to fetch registry item "${name}" from ${url}: HTTP ${res.status}`);
22
+ }
23
+ const json = await res.json();
24
+ const item = registryItemSchema.parse(json);
25
+ cache.set(url, item);
26
+ return item;
27
+ }
28
+
29
+ /**
30
+ * Fetch the flat registry index (list of all items).
31
+ *
32
+ * @param {string} [baseUrl]
33
+ */
34
+ export async function fetchRegistryIndex(baseUrl = DEFAULT_REGISTRY_URL) {
35
+ const url = `${baseUrl.replace(/\/$/, '')}/index.json`;
36
+ if (cache.has(url)) return cache.get(url);
37
+ const res = await fetch(url);
38
+ if (!res.ok) {
39
+ throw new Error(`Failed to fetch registry index from ${url}: HTTP ${res.status}`);
40
+ }
41
+ const json = await res.json();
42
+ const items = registryIndexSchema.parse(json);
43
+ cache.set(url, items);
44
+ return items;
45
+ }
@@ -0,0 +1,40 @@
1
+ import { fetchRegistryItem } from './fetcher.js';
2
+
3
+ /**
4
+ * Walk `registryDependencies` transitively. Returns a flat array of items in
5
+ * install order (deepest dependency first). Cycles are guarded against.
6
+ *
7
+ * @param {string[]} names
8
+ * @param {string} baseUrl
9
+ */
10
+ export async function resolveTree(names, baseUrl) {
11
+ /** @type {Map<string, any>} */
12
+ const seen = new Map();
13
+ /** @type {any[]} */
14
+ const ordered = [];
15
+
16
+ async function visit(name) {
17
+ if (seen.has(name)) return;
18
+ seen.set(name, true);
19
+ const item = await fetchRegistryItem(name, baseUrl);
20
+ for (const dep of item.registryDependencies || []) await visit(dep);
21
+ ordered.push(item);
22
+ }
23
+
24
+ for (const name of names) await visit(name);
25
+ return ordered;
26
+ }
27
+
28
+ /**
29
+ * Collect all npm `dependencies` + `devDependencies` from a resolved tree,
30
+ * deduplicated. Returns { dependencies, devDependencies }.
31
+ */
32
+ export function collectNpmDeps(items) {
33
+ const deps = new Set();
34
+ const devDeps = new Set();
35
+ for (const item of items) {
36
+ for (const d of item.dependencies || []) deps.add(d);
37
+ for (const d of item.devDependencies || []) devDeps.add(d);
38
+ }
39
+ return { dependencies: [...deps], devDependencies: [...devDeps] };
40
+ }
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+
3
+ // Wire-compatible port of shadcn's registry schema.
4
+ // Source: ~/Documents/Projects/shadcn/packages/shadcn/src/registry/schema.ts
5
+
6
+ export const registryItemTypeSchema = z.enum([
7
+ 'registry:lib',
8
+ 'registry:block',
9
+ 'registry:component',
10
+ 'registry:ui',
11
+ 'registry:hook',
12
+ 'registry:page',
13
+ 'registry:file',
14
+ 'registry:theme',
15
+ 'registry:style',
16
+ 'registry:item',
17
+ 'registry:base',
18
+ 'registry:font',
19
+ 'registry:example',
20
+ 'registry:internal',
21
+ ]);
22
+
23
+ export const registryItemFileSchema = z.discriminatedUnion('type', [
24
+ z.object({
25
+ path: z.string(),
26
+ content: z.string().optional(),
27
+ type: z.enum(['registry:file', 'registry:page']),
28
+ target: z.string(),
29
+ }),
30
+ z.object({
31
+ path: z.string(),
32
+ content: z.string().optional(),
33
+ type: registryItemTypeSchema.exclude(['registry:file', 'registry:page']),
34
+ target: z.string().optional(),
35
+ }),
36
+ ]);
37
+
38
+ export const registryItemTailwindSchema = z.object({
39
+ config: z
40
+ .object({
41
+ content: z.array(z.string()).optional(),
42
+ theme: z.record(z.string(), z.any()).optional(),
43
+ plugins: z.array(z.string()).optional(),
44
+ })
45
+ .optional(),
46
+ });
47
+
48
+ export const registryItemCssVarsSchema = z.object({
49
+ theme: z.record(z.string(), z.string()).optional(),
50
+ light: z.record(z.string(), z.string()).optional(),
51
+ dark: z.record(z.string(), z.string()).optional(),
52
+ });
53
+
54
+ const cssValueSchema = z.lazy(() =>
55
+ z.union([
56
+ z.string(),
57
+ z.array(z.union([z.string(), z.record(z.string(), z.string())])),
58
+ z.record(z.string(), cssValueSchema),
59
+ ]),
60
+ );
61
+
62
+ export const registryItemCssSchema = z.record(z.string(), cssValueSchema);
63
+ export const registryItemEnvVarsSchema = z.record(z.string(), z.string());
64
+
65
+ export const registryItemCommonSchema = z.object({
66
+ $schema: z.string().optional(),
67
+ extends: z.string().optional(),
68
+ name: z.string(),
69
+ title: z.string().optional(),
70
+ author: z.string().min(2).optional(),
71
+ description: z.string().optional(),
72
+ dependencies: z.array(z.string()).optional(),
73
+ devDependencies: z.array(z.string()).optional(),
74
+ registryDependencies: z.array(z.string()).optional(),
75
+ files: z.array(registryItemFileSchema).optional(),
76
+ tailwind: registryItemTailwindSchema.optional(),
77
+ cssVars: registryItemCssVarsSchema.optional(),
78
+ css: registryItemCssSchema.optional(),
79
+ envVars: registryItemEnvVarsSchema.optional(),
80
+ meta: z.record(z.string(), z.any()).optional(),
81
+ docs: z.string().optional(),
82
+ categories: z.array(z.string()).optional(),
83
+ });
84
+
85
+ export const rawConfigSchema = z
86
+ .object({
87
+ $schema: z.string().optional(),
88
+ style: z.string().default('default'),
89
+ tailwind: z.object({
90
+ config: z.string().optional(),
91
+ css: z.string(),
92
+ baseColor: z.string().default('neutral'),
93
+ cssVariables: z.boolean().default(true),
94
+ prefix: z.string().default('').optional(),
95
+ }),
96
+ iconLibrary: z.string().optional().default('lucide'),
97
+ aliases: z.object({
98
+ components: z.string().default('components'),
99
+ utils: z.string().default('lib/utils'),
100
+ ui: z.string().optional().default('components/ui'),
101
+ lib: z.string().optional().default('lib'),
102
+ }),
103
+ registries: z.record(z.string(), z.any()).optional(),
104
+ })
105
+ .strict();
106
+
107
+ export const registryItemSchema = registryItemCommonSchema.extend({
108
+ type: registryItemTypeSchema,
109
+ });
110
+
111
+ export const registrySchema = z.object({
112
+ name: z.string(),
113
+ homepage: z.string().optional(),
114
+ items: z.array(registryItemSchema),
115
+ });
116
+
117
+ export const registryIndexSchema = z.array(registryItemSchema);
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Detect the host project type so we can pick sensible defaults for paths.
6
+ *
7
+ * @param {string} cwd
8
+ * @returns {{ type: 'webjs' | 'next' | 'vite' | 'astro' | 'plain', meta: any }}
9
+ */
10
+ export function detectProject(cwd = process.cwd()) {
11
+ const pkgPath = join(cwd, 'package.json');
12
+ if (!existsSync(pkgPath)) return { type: 'plain', meta: {} };
13
+
14
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
15
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
16
+
17
+ if (deps['@webjskit/server'] || deps['@webjskit/cli'] || existsSync(join(cwd, 'app', 'layout.ts')) || existsSync(join(cwd, 'app', 'layout.js'))) {
18
+ return { type: 'webjs', meta: { pkg } };
19
+ }
20
+ if (deps['next']) return { type: 'next', meta: { pkg } };
21
+ if (deps['vite'] || deps['@vitejs/plugin-react'] || deps['@vitejs/plugin-vue']) return { type: 'vite', meta: { pkg } };
22
+ if (deps['astro']) return { type: 'astro', meta: { pkg } };
23
+
24
+ return { type: 'plain', meta: { pkg } };
25
+ }
26
+
27
+ /**
28
+ * Pick default `aliases` + `tailwind.css` path based on the detected project.
29
+ *
30
+ * @param {string} cwd
31
+ */
32
+ export function defaultsForProject(cwd = process.cwd()) {
33
+ const { type } = detectProject(cwd);
34
+ switch (type) {
35
+ case 'webjs':
36
+ return {
37
+ tailwindCss: 'app/globals.css',
38
+ aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
39
+ };
40
+ case 'next':
41
+ return {
42
+ tailwindCss: 'app/globals.css',
43
+ aliases: { components: '@/components', utils: '@/lib/utils', ui: '@/components/ui', lib: '@/lib' },
44
+ };
45
+ case 'vite':
46
+ return {
47
+ tailwindCss: 'src/index.css',
48
+ aliases: { components: 'src/components', utils: 'src/lib/utils', ui: 'src/components/ui', lib: 'src/lib' },
49
+ };
50
+ case 'astro':
51
+ return {
52
+ tailwindCss: 'src/styles/globals.css',
53
+ aliases: { components: 'src/components', utils: 'src/lib/utils', ui: 'src/components/ui', lib: 'src/lib' },
54
+ };
55
+ default:
56
+ return {
57
+ tailwindCss: 'styles/globals.css',
58
+ aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
59
+ };
60
+ }
61
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { rawConfigSchema } from '../registry/schema.js';
4
+
5
+ export const CONFIG_FILE = 'components.json';
6
+
7
+ /**
8
+ * Read components.json from the given cwd. Returns null if missing.
9
+ *
10
+ * @param {string} cwd
11
+ */
12
+ export function getConfig(cwd = process.cwd()) {
13
+ const p = join(cwd, CONFIG_FILE);
14
+ if (!existsSync(p)) return null;
15
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
16
+ const parsed = rawConfigSchema.parse(raw);
17
+ return {
18
+ ...parsed,
19
+ resolvedPaths: resolvePaths(cwd, parsed),
20
+ };
21
+ }
22
+
23
+ function resolvePaths(cwd, config) {
24
+ return {
25
+ cwd: resolve(cwd),
26
+ tailwindCss: resolve(cwd, config.tailwind.css),
27
+ components: resolve(cwd, config.aliases.components.replace(/^@\//, '')),
28
+ utils: resolve(cwd, config.aliases.utils.replace(/^@\//, '') + '.ts'),
29
+ ui: resolve(cwd, (config.aliases.ui || 'components/ui').replace(/^@\//, '')),
30
+ lib: resolve(cwd, (config.aliases.lib || 'lib').replace(/^@\//, '')),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Write components.json.
36
+ *
37
+ * @param {string} cwd
38
+ * @param {any} config
39
+ */
40
+ export function writeConfig(cwd, config) {
41
+ const p = join(cwd, CONFIG_FILE);
42
+ writeFileSync(p, JSON.stringify(config, null, 2) + '\n', 'utf8');
43
+ }
@@ -0,0 +1,3 @@
1
+ export { getConfig, writeConfig, CONFIG_FILE } from './get-config.js';
2
+ export { detectProject, defaultsForProject } from './detect-project.js';
3
+ export { logger } from './logger.js';
@@ -0,0 +1,13 @@
1
+ import kleur from 'kleur';
2
+
3
+ export const logger = {
4
+ info: (...args) => console.log(...args),
5
+ success: (...args) => console.log(kleur.green('✔'), ...args),
6
+ warn: (...args) => console.log(kleur.yellow('⚠'), ...args),
7
+ error: (...args) => console.error(kleur.red('✖'), ...args),
8
+ break: () => console.log(''),
9
+ dim: (s) => kleur.dim(s),
10
+ bold: (s) => kleur.bold(s),
11
+ cyan: (s) => kleur.cyan(s),
12
+ green: (s) => kleur.green(s),
13
+ };