@webjsdev/ui 0.3.10 → 0.3.11

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 (35) hide show
  1. package/README.md +21 -13
  2. package/package.json +3 -3
  3. package/packages/registry/README.md +14 -10
  4. package/packages/registry/components/accordion.ts +18 -0
  5. package/packages/registry/components/alert-dialog.ts +203 -22
  6. package/packages/registry/components/aspect-ratio.ts +12 -0
  7. package/packages/registry/components/card.ts +20 -1
  8. package/packages/registry/components/checkbox.ts +45 -3
  9. package/packages/registry/components/collapsible.ts +15 -0
  10. package/packages/registry/components/dialog.ts +205 -22
  11. package/packages/registry/components/dropdown-menu.ts +390 -22
  12. package/packages/registry/components/hover-card.ts +162 -4
  13. package/packages/registry/components/input.ts +34 -2
  14. package/packages/registry/components/kbd.ts +35 -2
  15. package/packages/registry/components/label.ts +21 -0
  16. package/packages/registry/components/native-select.ts +28 -7
  17. package/packages/registry/components/popover.ts +43 -7
  18. package/packages/registry/components/radio-group.ts +37 -9
  19. package/packages/registry/components/sonner.ts +53 -5
  20. package/packages/registry/components/switch.ts +23 -1
  21. package/packages/registry/components/tabs.ts +15 -0
  22. package/packages/registry/components/textarea.ts +21 -2
  23. package/packages/registry/components/toggle-group.ts +81 -8
  24. package/packages/registry/components/toggle.ts +82 -3
  25. package/packages/registry/components/tooltip.ts +57 -0
  26. package/packages/registry/lib/utils.ts +147 -8
  27. package/packages/registry/registry.json +1 -1
  28. package/packages/registry/themes/base-colors.js +1 -1
  29. package/src/commands/add.js +50 -0
  30. package/src/commands/info.js +1 -4
  31. package/src/commands/init.js +126 -24
  32. package/src/registry/fetcher.js +9 -1
  33. package/src/registry/local.js +1 -1
  34. package/src/utils/index.js +0 -1
  35. package/src/utils/detect-project.js +0 -63
@@ -52,6 +52,10 @@ const GROUPS: Array<[RegExp, string]> = [
52
52
  [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'],
53
53
  [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'],
54
54
  [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'],
55
+ // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified
56
+ // by its FUNCTION, since it carries no type hint to classify it by. The
57
+ // hinted forms are handled centrally, in `hintedGroup`.
58
+ [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'],
55
59
  [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'],
56
60
  [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'],
57
61
  [/^bg-(fixed|local|scroll)$/, 'bg-attach'],
@@ -62,17 +66,127 @@ const GROUPS: Array<[RegExp, string]> = [
62
66
  [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
63
67
  // Text color: anything else starting with text- that isn't alignment / wrap / overflow.
64
68
  [/^text-(?!align-|left$|right$|center$|justify$|start$|end$|wrap$|nowrap$|balance$|pretty$|clip$|ellipsis$|xs$|sm$|base$|lg$|xl$|\d?xl$)/, 'text-color'],
65
- [/^border(-[trblxy])?-?\d/, 'border-w'],
69
+ // Border sub-properties that are neither a width nor a colour. These come
70
+ // FIRST so the width / colour classifier below never sees them.
71
+ [/^border-(collapse|separate)$/, 'border-collapse'],
72
+ [/^border-spacing(-[xy])?-/, 'border-spacing'],
73
+ [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
74
+ ...borderGroups(),
66
75
  [/^rounded(-[a-z]+)?$/, 'rounded'],
67
76
  [/^rounded-/, 'rounded'],
68
77
  [/^opacity-/, 'opacity'],
69
78
  [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'],
70
79
  [/^shadow(-|$)/, 'shadow'],
71
80
  [/^z-/, 'z'],
72
- [/^flex(-|$)/, 'flex'],
73
- [/^grid(-|$)/, 'grid'],
81
+ // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid
82
+ // sub-property groups below, so it must never dedupe against them: an element
83
+ // can be both a flex container and a flex child (`class="flex flex-1"`), and
84
+ // collapsing the two silently drops `display:flex`. It still belongs to a
85
+ // group of its own, alongside every other display keyword, so a repeated one
86
+ // collapses and `cn('hidden', open && 'flex')` resolves to one display.
87
+ [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'],
88
+ // Each sub-utility below gets the group of the real CSS property it sets, so
89
+ // none of them collapses against the display value or against each other.
90
+ [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'],
91
+ [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'],
92
+ [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'],
93
+ [/^grid-cols-/, 'grid-cols'],
94
+ [/^grid-rows-/, 'grid-rows'],
95
+ [/^grid-flow-/, 'grid-flow'],
74
96
  ];
75
97
 
98
+ /**
99
+ * Border WIDTH and border COLOUR share the `border-` prefix but are different
100
+ * CSS properties, so they need different groups: `cn('border-2',
101
+ * 'border-primary')` must keep BOTH, while `cn('border-border',
102
+ * 'border-accent')` must keep only the later colour (today the winner is
103
+ * decided by compiled stylesheet order, which silently loses whenever the
104
+ * override sorts alphabetically earlier).
105
+ *
106
+ * Classification, after an optional side / axis segment: a bare or numeric
107
+ * value is a width (`border`, `border-2`, `border-t-4`), an arbitrary value
108
+ * that opens with a digit, a dot, or `length:` is a width (`border-[3px]`,
109
+ * `border-[length:var(--w)]`), and everything else is a colour
110
+ * (`border-primary`, `border-red-500/50`, `border-[#fff]`). An ambiguous
111
+ * `border-[var(--x)]` falls to colour, which is the far more common intent.
112
+ *
113
+ * Each side is its own group so a per-side utility only overrides its own side,
114
+ * and the shorthand / axis subsumption is declared in CONFLICTS below, exactly
115
+ * like padding and margin. The logical inline sides (`border-s-*`,
116
+ * `border-e-*`) get their own groups but are subsumed only by the all-sides
117
+ * shorthand, never by `border-x-*`: which physical side they land on depends on
118
+ * the writing direction, so an axis override there would be a guess.
119
+ */
120
+ function borderGroups(): Array<[RegExp, string]> {
121
+ const width = '(\\d+(\\.\\d+)?|\\[(length:|\\d|\\.)[^\\]]*\\])';
122
+ const out: Array<[RegExp, string]> = [];
123
+ // Sides before the bare form: `border-t-primary` must match the `t` colour
124
+ // group, not be swallowed by the side-less `^border-` colour pattern.
125
+ const sides = ['x', 'y', 't', 'r', 'b', 'l', 's', 'e', ''];
126
+ for (const side of sides) {
127
+ const seg = side ? `-${side}` : '';
128
+ out.push([new RegExp(`^border${seg}(-${width})?$`), `border-w${seg}`]);
129
+ }
130
+ for (const side of sides) {
131
+ const seg = side ? `-${side}` : '';
132
+ out.push([new RegExp(`^border${seg}-`), `border-color${seg}`]);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * An arbitrary value may carry a Tailwind TYPE HINT: `bg-[image:var(--g)]`,
139
+ * `text-[length:14px]`, `shadow-[color:red]`. The hint exists precisely because
140
+ * the utility prefix is ambiguous, so the PREFIX cannot decide the group and
141
+ * the hint must. Letting a hinted value fall into the prefix's default group
142
+ * evicts a class that sets an entirely different property (`shadow-[color:red]`
143
+ * would eat `shadow-lg`).
144
+ *
145
+ * Every `<prefix>:<hint>` pair listed here names the group it belongs to; an
146
+ * empty string means the hint names the prefix's OWN default property, so the
147
+ * GROUPS table above is already right for it and matching falls through. A pair
148
+ * that is NOT listed gets a group of its own, so it can only ever collide with
149
+ * the identical hint under the identical prefix, never with the prefix's
150
+ * default. That is the safe direction to fail: an extra class renders, a
151
+ * dropped one does not.
152
+ *
153
+ * This is central rather than per-prefix on purpose. Handling only the prefixes
154
+ * that came to mind is how `shadow-[color:red]` was left evicting `shadow-lg`
155
+ * while `bg-` and `text-` were correct.
156
+ *
157
+ * These reach the matcher at all only because `variantPrefix` splits on the
158
+ * last colon OUTSIDE brackets; see the comment there.
159
+ */
160
+ const HINTED_GROUPS: Record<string, string> = {
161
+ 'bg:color': '',
162
+ 'bg:image': 'bg-image',
163
+ 'bg:position': 'bg-position',
164
+ 'bg:size': 'bg-size',
165
+ 'bg:length': 'bg-size',
166
+ 'text:color': '',
167
+ 'text:length': 'text-size',
168
+ };
169
+
170
+ /**
171
+ * The group for a hinted arbitrary value, `null` when the normal GROUPS table
172
+ * should decide it, `undefined` when the token carries no hint at all.
173
+ */
174
+ function hintedGroup(bare: string): string | null | undefined {
175
+ const m = /^([a-z][a-z-]*)-\[([a-z][a-z-]*):/.exec(bare);
176
+ if (!m) return undefined;
177
+ const prefix = m[1];
178
+ const hint = m[2];
179
+ const key = `${prefix}:${hint}`;
180
+ if (key in HINTED_GROUPS) return HINTED_GROUPS[key] || null;
181
+ // A border width or colour hint is read by `borderGroups()`'s value parser,
182
+ // which already classifies `border-[length:2px]` as a width and everything
183
+ // else after `border-` as a colour. The side list MUST match the one
184
+ // `borderGroups()` enumerates, logical inline sides included, or the hinted
185
+ // and plain spellings of one utility land in different groups.
186
+ if (/^border(-[xytrbles])?$/.test(prefix) && (hint === 'length' || hint === 'color')) return null;
187
+ return `hint:${key}`;
188
+ }
189
+
76
190
  // Directional shorthand conflicts (the tailwind-merge model). A shorthand
77
191
  // utility invalidates the axis / side utilities it SUBSUMES, because Tailwind's
78
192
  // cascade makes the shorthand win: `p-0` after `px-4 py-2` drops both, but
@@ -88,6 +202,12 @@ const CONFLICTS: Record<string, string[]> = {
88
202
  mx: ['ml', 'mr'],
89
203
  my: ['mt', 'mb'],
90
204
  size: ['w', 'h'],
205
+ 'border-w': ['border-w-x', 'border-w-y', 'border-w-t', 'border-w-r', 'border-w-b', 'border-w-l', 'border-w-s', 'border-w-e'],
206
+ 'border-w-x': ['border-w-l', 'border-w-r'],
207
+ 'border-w-y': ['border-w-t', 'border-w-b'],
208
+ 'border-color': ['border-color-x', 'border-color-y', 'border-color-t', 'border-color-r', 'border-color-b', 'border-color-l', 'border-color-s', 'border-color-e'],
209
+ 'border-color-x': ['border-color-l', 'border-color-r'],
210
+ 'border-color-y': ['border-color-t', 'border-color-b'],
91
211
  };
92
212
 
93
213
  function dedupeUtilities(input: string): string {
@@ -102,9 +222,15 @@ function dedupeUtilities(input: string): string {
102
222
  // only apply WITHIN the same variant (`px-4 hover:p-0` keeps both).
103
223
  const prefix = variantPrefix(token);
104
224
  const bare = prefix ? token.slice(prefix.length) : token;
105
- let gk: string | null = null;
106
- for (const [re, g] of GROUPS) {
107
- if (re.test(bare)) { gk = g; break; }
225
+ // A type-hinted arbitrary value is classified by its HINT, which overrides
226
+ // the prefix-keyed table below (`hintedGroup` returns null when the hint
227
+ // names the prefix's own default property and the table is already right).
228
+ const hinted = hintedGroup(bare);
229
+ let gk: string | null = hinted ?? null;
230
+ if (hinted === undefined || hinted === null) {
231
+ for (const [re, g] of GROUPS) {
232
+ if (re.test(bare)) { gk = g; break; }
233
+ }
108
234
  }
109
235
  if (gk) {
110
236
  // Remove earlier survivors in this group AND in every group it subsumes.
@@ -121,8 +247,21 @@ function dedupeUtilities(input: string): string {
121
247
  }
122
248
 
123
249
  function variantPrefix(token: string): string {
124
- // capture leading variants like `hover:`, `dark:`, `md:`: overrides only conflict within the same variant set
125
- const i = token.lastIndexOf(':');
250
+ // Capture leading variants like `hover:`, `dark:`, `md:`: overrides only
251
+ // conflict within the same variant set. The split is the last colon OUTSIDE
252
+ // square brackets, because an arbitrary value can contain one of its own
253
+ // (`border-[length:2px]`, `bg-[url(https://x/y.png)]`, `text-[color:var(--c)]`).
254
+ // Splitting on the last colon anywhere hands the group matcher a fragment
255
+ // like `2px]`, which matches nothing, so the utility silently stops
256
+ // deduping against its own property.
257
+ let depth = 0;
258
+ let i = -1;
259
+ for (let n = 0; n < token.length; n += 1) {
260
+ const c = token[n];
261
+ if (c === '[') depth += 1;
262
+ else if (c === ']') depth -= 1;
263
+ else if (c === ':' && depth === 0) i = n;
264
+ }
126
265
  return i === -1 ? '' : token.slice(0, i + 1);
127
266
  }
128
267
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://ui.webjs.dev/schema/registry.json",
3
3
  "name": "@webjsdev/ui",
4
- "homepage": "https://ui.webjs.dev",
4
+ "homepage": "https://webjs.dev/ui",
5
5
  "items": [
6
6
  {
7
7
  "name": "lib-utils",
@@ -5,7 +5,7 @@
5
5
  * preserved; only hue / chroma shift.
6
6
  *
7
7
  * Pure ESM data module: imported at request time by the website's registry
8
- * composer (`app/_lib/registry.server.ts`) to synthesize `theme-<color>` items
8
+ * composer (`website/modules/ui/queries/registry.server.ts`) to synthesize `theme-<color>` items
9
9
  * on demand. No build step, no committed output. Numbers match the values
10
10
  * shadcn emits in `apps/v4/public/r/themes/*.json`.
11
11
  */
@@ -74,6 +74,16 @@ async function writeRegistryFile(cwd, config, item, file, opts) {
74
74
  const target = resolveTarget(cwd, config, item, file);
75
75
  ensureDir(dirname(target));
76
76
 
77
+ // The shared helpers are yours to edit, and since #1129 they live at the
78
+ // `utils` alias, which is the file `webjs create` generates and the one the
79
+ // docs tell you to retune. `--yes` means "do not ask me about overwrites",
80
+ // which is the normal non-interactive invocation, so honouring it here would
81
+ // replace edited source on every `add`. Only an explicit `--overwrite` may.
82
+ if (existsSync(target) && !opts.overwrite && helperTarget(config, item, file)) {
83
+ logger.info(`${relative(cwd, target)} already exists: keeping it.`);
84
+ return;
85
+ }
86
+
77
87
  if (existsSync(target) && !opts.overwrite && !opts.yes) {
78
88
  const r = await prompts({
79
89
  type: 'confirm',
@@ -168,6 +178,17 @@ function replaceImportSpecifier(content, spec, destAbs, target) {
168
178
  }
169
179
 
170
180
  function resolveTarget(cwd, config, item, file) {
181
+ // The two shared helpers are pinned to `lib/utils.ts` / `lib/dom.ts` by the
182
+ // registry manifest, but where they actually BELONG is wherever the project's
183
+ // `utils` alias points, because that is the path `rewriteUtilsImport` writes
184
+ // into every component's import. Honour the alias for them, ahead of the
185
+ // manifest target, or `add` writes a second copy at the pinned path that
186
+ // nothing imports while the components resolve to the aliased one.
187
+ // The dom helper is the utils file's SIBLING, the same relationship the
188
+ // rewrite assumes, so the two cannot disagree.
189
+ const helper = helperTarget(config, item, file);
190
+ if (helper) return helper;
191
+
171
192
  // explicit `target` wins
172
193
  if (file.target) return join(cwd, file.target);
173
194
 
@@ -188,6 +209,35 @@ function resolveTarget(cwd, config, item, file) {
188
209
  }
189
210
  }
190
211
 
212
+ /**
213
+ * Absolute target for the `lib-utils` / `lib-dom` registry items, derived from
214
+ * the project's configured `utils` alias, or null for anything else.
215
+ *
216
+ * Kept as the single place that answers "where do the shared helpers live",
217
+ * so `add`'s WRITE and {@link rewriteUtilsImport}'s REWRITE read the same
218
+ * answer. `init` writes the same two paths (see `init.js` `writeLibUtils`).
219
+ *
220
+ * @param {{ resolvedPaths?: { utils?: string } }} config parsed components.json
221
+ * @param {{ name?: string }} [item] the registry item
222
+ * @param {{ path?: string }} [file] the file within that item
223
+ * @returns {string | null}
224
+ */
225
+ function helperTarget(config, item, file) {
226
+ const utilsAbs = config?.resolvedPaths?.utils;
227
+ if (!utilsAbs || !item) return null;
228
+ // Matched per FILE, not per item. `rewriteUtilsImport` always retargets
229
+ // component imports to `resolvedPaths.utils`, so the ONE file those imports
230
+ // resolve to has to be written there or it is an orphan. Any other file a
231
+ // custom registry ships under the same item is not that file, so it keeps
232
+ // its manifest target: routing them all here would collapse them onto one
233
+ // path and lose every file but the last.
234
+ if (!file) return null;
235
+ const name = basename(file.path || '');
236
+ if (item.name === 'lib-utils' && name === 'utils.ts') return utilsAbs;
237
+ if (item.name === 'lib-dom' && name === 'dom.ts') return join(dirname(utilsAbs), 'dom.ts');
238
+ return null;
239
+ }
240
+
191
241
  function relative(cwd, p) {
192
242
  return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
193
243
  }
@@ -2,20 +2,17 @@ import { Command } from 'commander';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { getConfig } from '../utils/get-config.js';
5
- import { detectProject } from '../utils/detect-project.js';
6
5
  import { DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
7
6
  import { logger } from '../utils/logger.js';
8
7
 
9
8
  export const info = new Command()
10
9
  .name('info')
11
- .description('Print project diagnostics (config, project type, registry)')
10
+ .description('Print project diagnostics (config, registry)')
12
11
  .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
13
12
  .action((opts) => {
14
13
  const cwd = opts.cwd;
15
- const { type } = detectProject(cwd);
16
14
  const config = getConfig(cwd);
17
15
 
18
- logger.info(`${logger.bold('Project')} ${type}`);
19
16
  logger.info(`${logger.bold('cwd')} ${cwd}`);
20
17
  logger.info(`${logger.bold('Registry')} ${DEFAULT_REGISTRY_URL}`);
21
18
  logger.info(`${logger.bold('Config')} ${config ? 'components.json ✔' : 'components.json ✖ (run `webjsui init`)'}`);
@@ -1,8 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import prompts from 'prompts';
5
- import { defaultsForProject } from '../utils/detect-project.js';
6
5
  import { writeConfig, CONFIG_FILE } from '../utils/get-config.js';
7
6
  import { logger } from '../utils/logger.js';
8
7
  import { getRegistryItem, DEFAULT_REGISTRY_URL } from '../registry/fetcher.js';
@@ -10,22 +9,96 @@ import { ensureTheme } from '../utils/theme.js';
10
9
 
11
10
  const BASE_COLORS = ['neutral', 'stone', 'zinc', 'mauve', 'olive', 'mist', 'taupe'];
12
11
 
12
+ /**
13
+ * The stylesheet `init` defaults to. It is `styles/globals.css`, NOT
14
+ * `app/globals.css`, because in WebJs `app/` is routing-only, so a
15
+ * non-routing stylesheet lives outside it.
16
+ *
17
+ * Exported so `webjs create` can be checked against it rather than against a
18
+ * hand-copied literal (`test/scaffolds/scaffold-ui-integration.test.js`).
19
+ */
20
+ export const DEFAULT_TAILWIND_CSS = 'styles/globals.css';
21
+
22
+ /**
23
+ * The alias map `init` writes into `components.json`. These are the paths a
24
+ * WebJs app uses, and they match byte for byte what `webjs create` scaffolds
25
+ * (see `packages/cli/lib/create.js`), so `init` on a bare app and a freshly
26
+ * scaffolded one land on the same layout.
27
+ *
28
+ * `utils` is `lib/utils/cn`, not `lib/utils`: `get-config.js` appends `'.ts'`
29
+ * when resolving the alias, so this resolves to `lib/utils/cn.ts`. Writing
30
+ * the helper into the `lib/utils/` DIRECTORY is what lets the client-only
31
+ * `onBeforeCache()` helper sit beside it at `lib/utils/dom.ts` (#819), which
32
+ * is where `add` rewrites the registry's `'../lib/dom.ts'` import to point.
33
+ *
34
+ * Exported for the same drift check as {@link DEFAULT_TAILWIND_CSS}. The two
35
+ * generators used to disagree here (init said `lib/utils`, the scaffold said
36
+ * `lib/utils/cn`) with only a comment claiming they matched, so the agreement
37
+ * is asserted now rather than described.
38
+ */
39
+ export const DEFAULT_ALIASES = {
40
+ components: 'components',
41
+ utils: 'lib/utils/cn',
42
+ ui: 'components/ui',
43
+ lib: 'lib',
44
+ };
45
+
13
46
  export const init = new Command()
14
47
  .name('init')
15
- .description('Initialize @webjsdev/ui in a project: writes components.json, theme CSS, lib/utils')
48
+ .description('Initialize @webjsdev/ui in a project: writes components.json, theme CSS, lib/utils/cn.ts')
16
49
  .option('-c, --cwd <cwd>', 'the working directory', process.cwd())
17
50
  .option('-y, --yes', 'skip confirmation prompts', false)
18
51
  .option('--base-color <color>', `base color (${BASE_COLORS.join('|')})`)
19
52
  .option('--css <path>', 'path to the project Tailwind CSS file')
53
+ .option('-o, --overwrite', 'replace an existing alias map and existing helper files', false)
20
54
  .option('--registry <url>', 'registry base URL', DEFAULT_REGISTRY_URL)
21
55
  .action(async (opts) => {
22
56
  const cwd = opts.cwd;
23
- const defaults = defaultsForProject(cwd);
57
+
58
+ // Re-running `init` on a project that already has one must not RELOCATE it.
59
+ // The alias map decides where cn() lives and what every already-added
60
+ // component imports, and `tailwind.css` decides which stylesheet the app
61
+ // actually builds, so adopting the current defaults over an existing
62
+ // project's values would strand those imports and append the tokens to a
63
+ // file nothing compiles. Keep what the project declared; `--overwrite`
64
+ // opts into replacing it.
65
+ //
66
+ // Read the RAW json rather than going through `getConfig`: its schema is
67
+ // `.strict()`, so a config carrying any unknown key (a shadcn-shaped one
68
+ // with `rsc` / `tsx`, or a hand-added field) throws, and treating that as
69
+ // "no config" is exactly what would relocate a real project and then
70
+ // overwrite the file. Only a file we cannot parse AT ALL is a refusal,
71
+ // because silently replacing unreadable config is the same data loss.
72
+ const cfgPath = join(cwd, CONFIG_FILE);
73
+ /** @type {any} */
74
+ let existing = null;
75
+ if (existsSync(cfgPath)) {
76
+ try {
77
+ existing = JSON.parse(readFileSync(cfgPath, 'utf8'));
78
+ } catch {
79
+ if (!opts.overwrite) {
80
+ logger.error(`${CONFIG_FILE} exists but is not valid JSON, so its settings cannot be preserved.`);
81
+ logger.info('Fix or delete it, or re-run with --overwrite to replace it.');
82
+ process.exit(1);
83
+ }
84
+ }
85
+ }
86
+ const keep = existing && !opts.overwrite;
87
+ if (keep) {
88
+ logger.info(`${CONFIG_FILE} already exists: keeping its settings (use --overwrite to reset them).`);
89
+ }
90
+ // Layer over the defaults rather than replacing them: the schema gives
91
+ // several alias keys defaults, so a hand-written map may legitimately omit
92
+ // one, and reading it raw (above) skips that default-filling. Taking the
93
+ // map wholesale left `utils` undefined and crashed on the write.
94
+ const aliases = { ...DEFAULT_ALIASES, ...((keep && existing.aliases) || null) };
95
+ const initialCss = (keep && existing.tailwind && existing.tailwind.css) || DEFAULT_TAILWIND_CSS;
96
+ const initialBaseColor = (keep && existing.tailwind && existing.tailwind.baseColor) || 'neutral';
24
97
 
25
98
  /** @type {{ baseColor: string, css: string }} */
26
99
  let answers = {
27
- baseColor: opts.baseColor || 'neutral',
28
- css: opts.css || defaults.tailwindCss,
100
+ baseColor: opts.baseColor || initialBaseColor,
101
+ css: opts.css || initialCss,
29
102
  };
30
103
 
31
104
  if (!opts.yes) {
@@ -36,13 +109,13 @@ export const init = new Command()
36
109
  name: 'baseColor',
37
110
  message: 'Base color?',
38
111
  choices: BASE_COLORS.map((c) => ({ title: c, value: c })),
39
- initial: 0,
112
+ initial: Math.max(0, BASE_COLORS.indexOf(initialBaseColor)),
40
113
  },
41
114
  {
42
115
  type: opts.css ? null : 'text',
43
116
  name: 'css',
44
117
  message: 'Tailwind CSS file path?',
45
- initial: defaults.tailwindCss,
118
+ initial: initialCss,
46
119
  },
47
120
  ],
48
121
  { onCancel: () => process.exit(1) },
@@ -50,23 +123,29 @@ export const init = new Command()
50
123
  answers = { ...answers, ...r };
51
124
  }
52
125
 
126
+ // Built from KNOWN keys only. The schema is `.strict()`, so carrying an
127
+ // unrecognised key through here would write a file that `getConfig` (and
128
+ // therefore `add` / `diff` / `info`) throws on. Preserving a project's
129
+ // settings means its VALUES, not arbitrary extra fields.
53
130
  const config = {
54
131
  $schema: 'https://ui.webjs.dev/schema.json',
55
- style: 'default',
132
+ style: (keep && existing.style) || 'default',
56
133
  tailwind: {
57
134
  css: answers.css,
58
135
  baseColor: answers.baseColor,
59
- cssVariables: true,
136
+ cssVariables: keep && existing.tailwind && existing.tailwind.cssVariables !== undefined
137
+ ? existing.tailwind.cssVariables
138
+ : true,
60
139
  },
61
- aliases: defaults.aliases,
62
- iconLibrary: 'lucide',
140
+ aliases,
141
+ iconLibrary: (keep && existing.iconLibrary) || 'lucide',
63
142
  };
64
143
 
65
144
  writeConfig(cwd, config);
66
145
  logger.success(`Wrote ${CONFIG_FILE}`);
67
146
 
68
147
  // Pull lib/utils + the chosen theme from the registry and write them in.
69
- await writeLibUtils(cwd, defaults.aliases.utils, opts.registry);
148
+ await writeLibUtils(cwd, aliases.utils, opts.registry, opts.overwrite);
70
149
 
71
150
  // The theme tokens are what the class helpers render against. A silent
72
151
  // failure here (the old behaviour) left an unstyled install with a clean
@@ -86,30 +165,53 @@ export const init = new Command()
86
165
  logger.info(`Add components with: ${logger.cyan('npx webjsui add button card dialog')}`);
87
166
  });
88
167
 
89
- async function writeLibUtils(cwd, utilsAlias, registryUrl) {
90
- // `utils` alias points at e.g. "lib/utils" so we write to lib/utils.ts
168
+ /**
169
+ * Write the two shared helpers, WITHOUT clobbering a copy the project already
170
+ * has. The kit is source-copied and you-own-it, so `lib/utils/cn.ts` is a file
171
+ * users are told to edit ("change one helper to retune the entire app"), and
172
+ * `webjs create` puts its own copy there. An unguarded write was harmless while
173
+ * init targeted `lib/utils.ts`, a path nothing else owned, but the moment it
174
+ * writes where the scaffold and the user's edits live it becomes silent data
175
+ * loss, and re-running init is the documented fix for an unstyled install. So
176
+ * an existing file is kept unless `--overwrite` says otherwise. `add` keeps
177
+ * the same two files on the same terms (its `--yes` skips prompts but does not
178
+ * license replacing them), and the theme is idempotent, so every path into a
179
+ * project leaves an edited helper alone.
180
+ */
181
+ async function writeLibUtils(cwd, utilsAlias, registryUrl, overwrite) {
182
+ // The `utils` alias omits the extension ("lib/utils/cn"), matching how
183
+ // get-config.js resolves it, so append '.ts' to get the target path.
91
184
  const utilsRel = utilsAlias.replace(/^@\//, '') + '.ts';
92
185
  const utilsTarget = join(cwd, utilsRel);
93
186
  try {
94
- const item = await getRegistryItem('lib-utils', registryUrl);
95
- if (item.files) {
96
- for (const f of item.files) {
97
- ensureDir(dirname(utilsTarget));
98
- writeFileSync(utilsTarget, f.content || '', 'utf8');
99
- logger.success(`Wrote ${utilsAlias}.ts`);
187
+ if (existsSync(utilsTarget) && !overwrite) {
188
+ logger.info(`${utilsRel} already exists: keeping it.`);
189
+ } else {
190
+ const item = await getRegistryItem('lib-utils', registryUrl);
191
+ if (item.files) {
192
+ for (const f of item.files) {
193
+ ensureDir(dirname(utilsTarget));
194
+ writeFileSync(utilsTarget, f.content || '', 'utf8');
195
+ logger.success(`Wrote ${utilsRel}`);
196
+ }
100
197
  }
101
198
  }
102
199
  } catch (e) {
103
- logger.warn(`Could not fetch lib-utils from registry (${e.message}). You may need to write lib/utils.ts manually.`);
200
+ logger.warn(`Could not fetch lib-utils from registry (${e.message}). You may need to write ${utilsRel} manually.`);
104
201
  }
105
202
 
106
203
  // The onBeforeCache() DOM helper lives in a SEPARATE module (#819) because
107
204
  // it references `document`, so keeping it out of cn()'s file prevents the
108
205
  // elision analyzer pinning every page that imports cn to the browser.
109
206
  // Overlay components import it from `../lib/dom.ts`, so write it as a
110
- // sibling of the utils file (e.g. lib/utils/dom.ts next to cn.ts).
207
+ // sibling of the utils file, which lands it at lib/utils/dom.ts next to
208
+ // cn.ts. `add` reads that same adjacency when it rewrites the import.
111
209
  const domTarget = join(dirname(utilsTarget), 'dom.ts');
112
210
  try {
211
+ if (existsSync(domTarget) && !overwrite) {
212
+ logger.info(`${relative(cwd, domTarget)} already exists: keeping it.`);
213
+ return;
214
+ }
113
215
  const item = await getRegistryItem('lib-dom', registryUrl);
114
216
  if (!item.files) return;
115
217
  for (const f of item.files) {
@@ -118,7 +220,7 @@ async function writeLibUtils(cwd, utilsAlias, registryUrl) {
118
220
  logger.success(`Wrote ${relative(cwd, domTarget)}`);
119
221
  }
120
222
  } catch (e) {
121
- logger.warn(`Could not fetch lib-dom from registry (${e.message}). You may need to write lib/dom.ts manually.`);
223
+ logger.warn(`Could not fetch lib-dom from registry (${e.message}). You may need to write ${relative(cwd, domTarget)} manually.`);
122
224
  }
123
225
  }
124
226
 
@@ -4,8 +4,16 @@ import { loadRegistryItem, loadRegistryIndex } from './local.js';
4
4
  /**
5
5
  * The canonical hosted registry. Local-first resolution applies ONLY for this
6
6
  * exact URL: it is the one registry whose sources ship inside the package.
7
+ *
8
+ * This moved from `https://ui.webjs.dev/registry` when the gallery merged into
9
+ * the marketing site (#1099). The OLD URL still works and must keep working
10
+ * forever, because every already-published version of this package carries it
11
+ * and cannot be corrected: `ui.webjs.dev` now permanently redirects here, and
12
+ * `fetch` follows redirects by default (verified against the real published
13
+ * 0.3.1 and 0.3.8 tarballs before the move). Pointing new releases straight at
14
+ * the destination just saves them the hop.
7
15
  */
8
- export const HOSTED_REGISTRY_URL = 'https://ui.webjs.dev/registry';
16
+ export const HOSTED_REGISTRY_URL = 'https://webjs.dev/ui/registry';
9
17
 
10
18
  /**
11
19
  * Default registry URL. A `REGISTRY_URL` env var points at a CUSTOM registry,
@@ -9,7 +9,7 @@
9
9
  * are interchangeable for every consumer.
10
10
  *
11
11
  * It is the plain-JS twin of the ui-website composer
12
- * (`packages/ui/packages/website/app/_lib/registry.server.ts`): both read
12
+ * (`website/modules/ui/queries/registry.server.ts`): both read
13
13
  * `packages/registry/registry.json` + the `components/*.ts` sources and
14
14
  * synthesize the 6 non-neutral base-colour themes from `themes/base-colors.js`.
15
15
  * Keeping the CLI on this module (rather than always hitting the network) is
@@ -1,3 +1,2 @@
1
1
  export { getConfig, writeConfig, CONFIG_FILE } from './get-config.js';
2
- export { detectProject, defaultsForProject } from './detect-project.js';
3
2
  export { logger } from './logger.js';
@@ -1,63 +0,0 @@
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['@webjsdev/server'] || deps['@webjsdev/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
- // styles/globals.css, NOT app/globals.css: in webjs, app/ is routing-only,
38
- // so a non-routing stylesheet lives outside it.
39
- tailwindCss: 'styles/globals.css',
40
- aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
41
- };
42
- case 'next':
43
- return {
44
- tailwindCss: 'app/globals.css',
45
- aliases: { components: '@/components', utils: '@/lib/utils', ui: '@/components/ui', lib: '@/lib' },
46
- };
47
- case 'vite':
48
- return {
49
- tailwindCss: 'src/index.css',
50
- aliases: { components: 'src/components', utils: 'src/lib/utils', ui: 'src/components/ui', lib: 'src/lib' },
51
- };
52
- case 'astro':
53
- return {
54
- tailwindCss: 'src/styles/globals.css',
55
- aliases: { components: 'src/components', utils: 'src/lib/utils', ui: 'src/components/ui', lib: 'src/lib' },
56
- };
57
- default:
58
- return {
59
- tailwindCss: 'styles/globals.css',
60
- aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
61
- };
62
- }
63
- }