@svgrid/ui 0.3.4 → 2.4.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.
Files changed (88) hide show
  1. package/LICENSE +0 -25
  2. package/README.md +238 -218
  3. package/index.mjs +591 -591
  4. package/package.json +4 -4
  5. package/recipes/buttons/button-group.svelte +18 -18
  6. package/recipes/buttons/button.svelte +17 -17
  7. package/recipes/buttons/check-box.svelte +10 -10
  8. package/recipes/buttons/radio-group.svelte +18 -18
  9. package/recipes/buttons/rating.svelte +8 -8
  10. package/recipes/buttons/repeat-button.svelte +10 -10
  11. package/recipes/buttons/segmented.svelte +14 -0
  12. package/recipes/buttons/switch-button.svelte +12 -12
  13. package/recipes/buttons/toggle-button.svelte +8 -8
  14. package/recipes/date-time/calendar.svelte +15 -15
  15. package/recipes/date-time/date-range-input.svelte +15 -15
  16. package/recipes/date-time/date-time-picker.svelte +16 -16
  17. package/recipes/date-time/time-picker.svelte +15 -15
  18. package/recipes/feedback/alert.svelte +11 -11
  19. package/recipes/feedback/avatar-group.svelte +11 -11
  20. package/recipes/feedback/avatar.svelte +8 -8
  21. package/recipes/feedback/badge.svelte +8 -8
  22. package/recipes/feedback/carousel.svelte +10 -10
  23. package/recipes/feedback/chip.svelte +10 -10
  24. package/recipes/feedback/empty-state.svelte +8 -8
  25. package/recipes/feedback/loading-overlay.svelte +14 -0
  26. package/recipes/feedback/result.svelte +15 -0
  27. package/recipes/feedback/skeleton.svelte +8 -8
  28. package/recipes/feedback/spinner.svelte +8 -0
  29. package/recipes/feedback/timeline.svelte +10 -10
  30. package/recipes/inputs/color-input.svelte +8 -8
  31. package/recipes/inputs/duration-input.svelte +9 -9
  32. package/recipes/inputs/masked-input.svelte +14 -14
  33. package/recipes/inputs/number-input.svelte +17 -17
  34. package/recipes/inputs/otp-input.svelte +13 -13
  35. package/recipes/inputs/password-input.svelte +14 -14
  36. package/recipes/inputs/phone-input.svelte +8 -8
  37. package/recipes/inputs/tags-input.svelte +8 -8
  38. package/recipes/inputs/text-area.svelte +15 -15
  39. package/recipes/inputs/text-input.svelte +14 -14
  40. package/recipes/layout/accordion.svelte +17 -17
  41. package/recipes/layout/card.svelte +9 -9
  42. package/recipes/layout/collapsible.svelte +13 -0
  43. package/recipes/layout/display.svelte +15 -0
  44. package/recipes/layout/divider.svelte +9 -9
  45. package/recipes/layout/dock-layout.svelte +22 -22
  46. package/recipes/layout/dock-manager.svelte +24 -24
  47. package/recipes/layout/field.svelte +15 -15
  48. package/recipes/layout/file-upload.svelte +16 -16
  49. package/recipes/layout/form.svelte +14 -14
  50. package/recipes/layout/grid-chart.svelte +16 -16
  51. package/recipes/layout/layout.svelte +18 -0
  52. package/recipes/layout/scroll-area.svelte +10 -10
  53. package/recipes/layout/splitter.svelte +13 -13
  54. package/recipes/layout/tabs.svelte +17 -17
  55. package/recipes/layout/typography.svelte +17 -0
  56. package/recipes/navigation/breadcrumb.svelte +12 -12
  57. package/recipes/navigation/command.svelte +12 -12
  58. package/recipes/navigation/nav-pane.svelte +14 -14
  59. package/recipes/navigation/pagination.svelte +13 -13
  60. package/recipes/navigation/rich-text.svelte +8 -8
  61. package/recipes/navigation/stepper.svelte +13 -13
  62. package/recipes/navigation/tour.svelte +14 -14
  63. package/recipes/navigation/tree.svelte +14 -14
  64. package/recipes/overlays/context-menu.svelte +15 -15
  65. package/recipes/overlays/drawer.svelte +13 -13
  66. package/recipes/overlays/hover-card.svelte +15 -0
  67. package/recipes/overlays/menu.svelte +15 -15
  68. package/recipes/overlays/menubar.svelte +27 -0
  69. package/recipes/overlays/modal.svelte +13 -13
  70. package/recipes/overlays/popconfirm.svelte +18 -0
  71. package/recipes/overlays/popover.svelte +9 -9
  72. package/recipes/overlays/toaster.svelte +9 -9
  73. package/recipes/overlays/tooltip.svelte +8 -8
  74. package/recipes/range/circular-progress.svelte +9 -9
  75. package/recipes/range/gauge.svelte +16 -16
  76. package/recipes/range/progress.svelte +10 -10
  77. package/recipes/range/slider.svelte +19 -19
  78. package/recipes/range/sparkline.svelte +9 -9
  79. package/recipes/range/stat.svelte +9 -9
  80. package/recipes/registry.json +237 -4
  81. package/recipes/selection/auto-complete.svelte +10 -10
  82. package/recipes/selection/combo-box.svelte +13 -13
  83. package/recipes/selection/country-input.svelte +8 -8
  84. package/recipes/selection/drop-down-list.svelte +13 -13
  85. package/recipes/selection/grid-select.svelte +17 -17
  86. package/recipes/selection/list-box.svelte +13 -13
  87. package/recipes/selection/multi-select.svelte +13 -13
  88. package/recipes/selection/tree-select.svelte +15 -15
package/index.mjs CHANGED
@@ -1,591 +1,591 @@
1
- #!/usr/bin/env node
2
- // @svgrid/ui - add SvGrid UI components to your app, one command at a time.
3
- //
4
- // npx @svgrid/ui add calendar # add a component (installs the dep)
5
- // npx @svgrid/ui add button --preview # + a /preview/button route to see it
6
- // npx @svgrid/ui try button # zero-setup: open it in a sandbox
7
- // npx @svgrid/ui add calendar time-picker --dir src/lib/ui
8
- // npx @svgrid/ui add date-time # the whole date/time family
9
- // npx @svgrid/ui list
10
- //
11
- // Recipe-scaffolder model: `add` writes a minimal, ready-to-EDIT .svelte starter
12
- // that imports from `@svgrid/grid` (which you own and can change). Each recipe is
13
- // a self-contained demo, so `--preview` (in your app) and `try` (in a throwaway
14
- // sandbox) can render it immediately - "one command and see it".
15
- //
16
- // Zero runtime dependencies - Node built-ins only.
17
-
18
- import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
19
- import { existsSync } from 'node:fs'
20
- import { dirname, join, relative, resolve } from 'node:path'
21
- import { fileURLToPath } from 'node:url'
22
- import { tmpdir } from 'node:os'
23
- import { stdout } from 'node:process'
24
- import { spawnSync } from 'node:child_process'
25
-
26
- const __dirname = dirname(fileURLToPath(import.meta.url))
27
- const RECIPES_DIR = join(__dirname, 'recipes')
28
-
29
- const c = {
30
- reset: '\x1b[0m',
31
- bold: '\x1b[1m',
32
- dim: '\x1b[2m',
33
- green: '\x1b[32m',
34
- cyan: '\x1b[36m',
35
- red: '\x1b[31m',
36
- yellow: '\x1b[33m',
37
- }
38
- const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
39
-
40
- // Package managers: lockfile -> add command. Order matters (first match wins).
41
- const PMS = [
42
- { id: 'pnpm', lock: 'pnpm-lock.yaml', add: 'pnpm add' },
43
- { id: 'bun', lock: 'bun.lockb', add: 'bun add' },
44
- { id: 'yarn', lock: 'yarn.lock', add: 'yarn add' },
45
- { id: 'npm', lock: 'package-lock.json', add: 'npm install' },
46
- ]
47
-
48
- function parseArgs(argv) {
49
- // install defaults ON: an added component imports @svgrid/grid, so it should
50
- // work right away. `--no-install` opts out (just prints the command).
51
- const args = { _: [], dir: null, force: false, install: true, preview: false, help: false, js: false }
52
- for (let i = 0; i < argv.length; i++) {
53
- const a = argv[i]
54
- if (a === '--help' || a === '-h') args.help = true
55
- else if (a === '--force' || a === '-f') args.force = true
56
- else if (a === '--install') args.install = true
57
- else if (a === '--no-install') args.install = false
58
- else if (a === '--preview' || a === '-p') args.preview = true
59
- else if (a === '--js') args.js = true
60
- else if (a === '--ts') args.js = false
61
- else if (a === '--dir' || a === '-d') args.dir = argv[++i]
62
- else if (a.startsWith('--dir=')) args.dir = a.slice('--dir='.length)
63
- else if (!a.startsWith('-')) args._.push(a)
64
- }
65
- return args
66
- }
67
-
68
- async function loadRegistry() {
69
- const raw = await readFile(join(RECIPES_DIR, 'registry.json'), 'utf8')
70
- return JSON.parse(raw)
71
- }
72
-
73
- /** Match a user-typed id against the registry: exact id, alias, or lowercase. */
74
- function resolveItem(registry, token) {
75
- const key = token.trim().toLowerCase()
76
- return (
77
- registry.items.find((it) => it.id === key) ??
78
- registry.items.find((it) => (it.aliases ?? []).includes(key)) ??
79
- null
80
- )
81
- }
82
-
83
- /** Expand a token into one or more recipe items (a group alias -> its members). */
84
- function expand(registry, token) {
85
- const key = token.trim().toLowerCase()
86
- const group = registry.groups?.[key]
87
- if (group) return group.items.map((id) => resolveItem(registry, id)).filter(Boolean)
88
- const item = resolveItem(registry, key)
89
- return item ? [item] : []
90
- }
91
-
92
- /** Resolve tokens -> unique items in order; exits with a helpful error on unknowns. */
93
- function collectItems(registry, tokens) {
94
- const items = new Map()
95
- const unknown = []
96
- for (const tok of tokens) {
97
- const matched = expand(registry, tok)
98
- if (!matched.length) unknown.push(tok)
99
- for (const it of matched) items.set(it.id, it)
100
- }
101
- if (unknown.length) {
102
- stdout.write(
103
- `${color('red', '✖')} Unknown component(s): ${unknown.join(', ')}\n` +
104
- ` See ${color('cyan', 'npx @svgrid/ui list')} for the available set.\n`,
105
- )
106
- process.exit(1)
107
- }
108
- return [...items.values()]
109
- }
110
-
111
- /** Walk up from `start` to the nearest directory containing a package.json. */
112
- function findProjectRoot(start) {
113
- let dir = start
114
- while (true) {
115
- if (existsSync(join(dir, 'package.json'))) return dir
116
- const parent = dirname(dir)
117
- if (parent === dir) return null
118
- dir = parent
119
- }
120
- }
121
-
122
- /** A SvelteKit app has file-based routes, so we can drop in a /preview route. */
123
- function isSvelteKit(root) {
124
- return !!root && (existsSync(join(root, 'svelte.config.js')) || existsSync(join(root, 'src', 'routes')))
125
- }
126
-
127
- function detectPm(root) {
128
- if (!root) return PMS.find((p) => p.id === 'npm')
129
- return PMS.find((p) => existsSync(join(root, p.lock))) ?? PMS.find((p) => p.id === 'npm')
130
- }
131
-
132
- async function targetDir(args, projectRoot, cwd) {
133
- if (args.dir) return resolve(cwd, args.dir)
134
- // Optional project config: svgrid.json { componentsDir }
135
- if (projectRoot) {
136
- const cfgPath = join(projectRoot, 'svgrid.json')
137
- if (existsSync(cfgPath)) {
138
- try {
139
- const cfg = JSON.parse(await readFile(cfgPath, 'utf8'))
140
- if (cfg.componentsDir) return resolve(projectRoot, cfg.componentsDir)
141
- } catch {
142
- /* fall through to the default */
143
- }
144
- }
145
- return join(projectRoot, 'src', 'lib', 'components', 'ui')
146
- }
147
- return join(cwd, 'src', 'lib', 'components', 'ui')
148
- }
149
-
150
- /** Add any missing deps to the nearest package.json (dependencies). Returns the
151
- * list actually added so the caller can report / install them. */
152
- async function ensureDeps(projectRoot, deps) {
153
- if (!projectRoot) return deps
154
- const pkgPath = join(projectRoot, 'package.json')
155
- let pkg
156
- try {
157
- pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
158
- } catch {
159
- return deps
160
- }
161
- pkg.dependencies ??= {}
162
- const added = []
163
- for (const dep of deps) {
164
- const known = pkg.dependencies[dep] || pkg.devDependencies?.[dep]
165
- if (!known) {
166
- pkg.dependencies[dep] = 'latest'
167
- added.push(dep)
168
- }
169
- }
170
- if (added.length) await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
171
- return added
172
- }
173
-
174
- /** A valid JS identifier for a component id (time-picker -> C_time_picker). */
175
- function toIdent(id) {
176
- return 'C_' + id.replace(/[^a-zA-Z0-9]/g, '_')
177
- }
178
-
179
- function esc(s) {
180
- return String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;')
181
- }
182
-
183
- /** A SvelteKit `/preview/<id>` page that renders the recipe you just added, so
184
- * you can open it in your dev server. Import is relative to the recipe on disk,
185
- * so it works whether or not the recipe lives under $lib. */
186
- async function writePreviewRoutes(projectRoot, dest, items, force) {
187
- const routesRoot = join(projectRoot, 'src', 'routes', 'preview')
188
- const urls = []
189
- for (const it of items) {
190
- const file = it.files?.[0]
191
- if (!file) continue
192
- const routeDir = join(routesRoot, it.id)
193
- await mkdir(routeDir, { recursive: true })
194
- const routeFile = join(routeDir, '+page.svelte')
195
- urls.push('/preview/' + it.id)
196
- if (existsSync(routeFile) && !force) continue
197
- let rel = relative(routeDir, join(dest, file.write)).split('\\').join('/')
198
- if (!rel.startsWith('.')) rel = './' + rel
199
- await writeFile(
200
- routeFile,
201
- `<script lang="ts">
202
- // Auto-generated by @svgrid/ui to preview the ${it.id} recipe. Yours to edit.
203
- import Demo from '${rel}'
204
- </script>
205
-
206
- <div class="svui-preview">
207
- <a class="svui-preview__back" href="/preview">&larr; all components</a>
208
- <h1 class="svui-preview__title">${esc(it.title ?? it.id)}</h1>
209
- <p class="svui-preview__desc">${esc(it.description ?? '')}</p>
210
- <div class="svui-preview__stage">
211
- <Demo />
212
- </div>
213
- </div>
214
-
215
- <style>
216
- .svui-preview { max-width: 880px; margin: 0 auto; padding: 32px 24px; font-family: system-ui, sans-serif; }
217
- .svui-preview__back { font-size: 13px; color: #6366f1; text-decoration: none; }
218
- .svui-preview__title { margin: 12px 0 4px; font-size: 24px; }
219
- .svui-preview__desc { margin: 0 0 20px; color: #64748b; font-size: 14px; }
220
- .svui-preview__stage { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; padding: 28px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
221
- </style>
222
- `,
223
- )
224
- }
225
- await writePreviewIndex(routesRoot)
226
- return urls
227
- }
228
-
229
- /** (Re)generate /preview - an index of every component preview present on disk. */
230
- async function writePreviewIndex(routesRoot) {
231
- let entries = []
232
- try {
233
- entries = await readdir(routesRoot, { withFileTypes: true })
234
- } catch {
235
- return
236
- }
237
- const ids = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
238
- const links = ids.map((id) => ` <a class="svui-index__link" href="/preview/${id}">${esc(id)}</a>`).join('\n')
239
- await writeFile(
240
- join(routesRoot, '+page.svelte'),
241
- `<script lang="ts">
242
- // Auto-generated by @svgrid/ui. Lists the component previews you've added.
243
- </script>
244
-
245
- <div class="svui-index">
246
- <h1>Component previews</h1>
247
- <div class="svui-index__grid">
248
- ${links}
249
- </div>
250
- </div>
251
-
252
- <style>
253
- .svui-index { max-width: 880px; margin: 0 auto; padding: 32px 24px; font-family: system-ui, sans-serif; }
254
- .svui-index h1 { font-size: 22px; }
255
- .svui-index__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; margin-top: 16px; }
256
- .svui-index__link { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 10px; text-decoration: none; color: #0f172a; font-size: 14px; }
257
- .svui-index__link:hover { border-color: #6366f1; color: #6366f1; }
258
- </style>
259
- `,
260
- )
261
- }
262
-
263
- function printHelp() {
264
- stdout.write(`
265
- ${color('bold', '@svgrid/ui')} - add SvGrid UI components to your app
266
-
267
- ${color('bold', 'Usage')}
268
- npx @svgrid/ui add <component...> [--dir <path>] [--preview] [--force] [--no-install]
269
- npx @svgrid/ui try <component...>
270
- npx @svgrid/ui list
271
-
272
- ${color('bold', 'Commands')}
273
- ${color('cyan', 'add')} Write a ready-to-edit recipe for each component into your project
274
- (and install @svgrid/grid). Add ${color('cyan', '--preview')} to also drop a /preview route.
275
- ${color('cyan', 'try')} Open the component(s) in a throwaway sandbox - no project needed.
276
- ${color('cyan', 'list')} Show the components you can add.
277
-
278
- ${color('bold', 'Options')}
279
- --preview, -p (add) Also write a src/routes/preview/<id> route so you can see it
280
- in your running dev server. SvelteKit apps only.
281
- --dir <path> Where to write files (default: src/lib/components/ui, or the
282
- "componentsDir" in a project svgrid.json).
283
- --force Overwrite files that already exist.
284
- --no-install Do not run the package manager; just print the install command.
285
-
286
- ${color('bold', 'Examples')}
287
- npx @svgrid/ui try button
288
- npx @svgrid/ui add button --preview
289
- npx @svgrid/ui add calendar time-picker --dir src/lib/ui
290
- npx @svgrid/ui add date-time
291
- `)
292
- }
293
-
294
- async function cmdList(registry) {
295
- stdout.write(`\n${color('bold', 'Available components')}\n`)
296
- for (const it of registry.items) {
297
- stdout.write(` ${color('cyan', it.id.padEnd(18))} ${color('dim', it.description)}\n`)
298
- }
299
- const groups = Object.entries(registry.groups ?? {})
300
- if (groups.length) {
301
- stdout.write(`\n${color('bold', 'Groups')}\n`)
302
- for (const [id, g] of groups) {
303
- stdout.write(` ${color('cyan', id.padEnd(18))} ${color('dim', g.items.join(', '))}\n`)
304
- }
305
- }
306
- stdout.write(`\n${color('dim', 'Add one with:')} npx @svgrid/ui add ${registry.items[0]?.id ?? 'calendar'}\n`)
307
- stdout.write(`${color('dim', 'Or just see it:')} npx @svgrid/ui try ${registry.items[0]?.id ?? 'calendar'}\n\n`)
308
- }
309
-
310
- async function cmdAdd(registry, tokens, args) {
311
- if (!tokens.length) {
312
- stdout.write(`${color('red', '✖')} Nothing to add. Try: ${color('cyan', 'npx @svgrid/ui list')}\n`)
313
- process.exit(1)
314
- }
315
- const items = collectItems(registry, tokens)
316
-
317
- const cwd = process.cwd()
318
- const projectRoot = findProjectRoot(cwd)
319
- const dest = await targetDir(args, projectRoot, cwd)
320
- await mkdir(dest, { recursive: true })
321
-
322
- const written = []
323
- const skipped = []
324
- const deps = new Set()
325
- for (const it of items) {
326
- for (const d of it.deps ?? []) deps.add(d)
327
- for (const file of it.files ?? []) {
328
- const outPath = join(dest, file.write)
329
- if (existsSync(outPath) && !args.force) {
330
- skipped.push(file.write)
331
- continue
332
- }
333
- await cp(join(RECIPES_DIR, file.from), outPath)
334
- written.push(file.write)
335
- }
336
- }
337
-
338
- // Report writes.
339
- stdout.write(`\n`)
340
- for (const f of written) stdout.write(` ${color('green', '+')} ${relFromCwd(cwd, join(dest, f))}\n`)
341
- for (const f of skipped)
342
- stdout.write(` ${color('yellow', '•')} ${f} ${color('dim', 'already exists (use --force to overwrite)')}\n`)
343
- if (!written.length && !args.preview) {
344
- stdout.write(`\n${color('yellow', '!')} No files written.\n\n`)
345
- return
346
- }
347
-
348
- // Install the dependency - only meaningful inside a project (on by default;
349
- // --no-install just prints the command). `add` writes into an EXISTING app;
350
- // a no-project run is handled in the next-steps block below.
351
- if (projectRoot) {
352
- const pm = detectPm(projectRoot)
353
- const added = await ensureDeps(projectRoot, [...deps])
354
- if (added.length) {
355
- if (args.install) {
356
- stdout.write(`\n${color('dim', `Installing with ${pm.id}...`)}\n`)
357
- // Single shell string (not bin + args[]) so Node doesn't warn DEP0190
358
- // under shell:true; the tokens here are fixed pm commands + npm package ids.
359
- const res = spawnSync(`${pm.add} ${added.join(' ')}`, { cwd: projectRoot, stdio: 'inherit', shell: true })
360
- if (res.status !== 0) {
361
- stdout.write(`${color('yellow', '!')} Install failed - run it yourself: ${color('cyan', `${pm.add} ${added.join(' ')}`)}\n`)
362
- }
363
- } else {
364
- stdout.write(`\n${color('bold', 'Install the dependency')}\n ${color('cyan', `${pm.add} ${added.join(' ')}`)}\n`)
365
- }
366
- } else if ([...deps].length) {
367
- stdout.write(`\n${color('dim', `${[...deps].join(', ')} already in package.json.`)}\n`)
368
- }
369
- }
370
-
371
- // Optional preview route(s) - needs a SvelteKit project.
372
- if (args.preview) {
373
- if (isSvelteKit(projectRoot)) {
374
- const urls = await writePreviewRoutes(projectRoot, dest, items, args.force)
375
- stdout.write(`\n${color('green', '✔')} Preview route(s) written. Start your dev server and open:\n`)
376
- for (const u of urls) stdout.write(` ${color('cyan', u)}\n`)
377
- } else {
378
- stdout.write(
379
- `\n${color('yellow', '!')} --preview needs a SvelteKit app (src/routes). ` +
380
- `To see it with zero setup: ${color('cyan', `npx @svgrid/ui try ${items[0].id}`)}\n`,
381
- )
382
- }
383
- }
384
-
385
- // Next steps.
386
- const first = items[0]
387
- const ids = items.map((it) => it.id).join(' ')
388
- stdout.write(`\n${color('green', '✔')} Added ${written.length} file(s). They're yours - edit away.\n`)
389
- if (!projectRoot) {
390
- // A lone component file with no app to run it - `add` targets an existing
391
- // project. Point at the two real paths instead of leaving a stranded file.
392
- stdout.write(
393
- ` ${color('yellow', 'Heads up:')} no package.json here, so there's nothing to run this in yet.\n` +
394
- ` ${color('dim', 'See it now:')} ${color('cyan', `npx @svgrid/ui try ${ids}`)} ${color('dim', '(no project needed)')}\n` +
395
- ` ${color('dim', 'Start an app:')} ${color('cyan', 'npm create @svgrid@latest')} ${color('dim', '(then run add inside it)')}\n`,
396
- )
397
- } else {
398
- stdout.write(` ${color('dim', 'Use it:')} import { ${exportName(first.id)} } from '@svgrid/grid'\n`)
399
- // "See it" - skip when we already wrote preview routes just above.
400
- if (!(args.preview && isSvelteKit(projectRoot))) {
401
- stdout.write(` ${color('dim', 'See it:')} ${color('cyan', `npx @svgrid/ui try ${ids}`)} ${color('dim', '(opens in your browser)')}\n`)
402
- if (isSvelteKit(projectRoot)) {
403
- stdout.write(` ${color('dim', 'In app:')} re-run with ${color('cyan', '--preview')} to add a /preview/${first.id} route\n`)
404
- }
405
- }
406
- }
407
- stdout.write(`\n${color('dim', 'Docs:')} https://www.svgrid.com/docs/help/ui-components\n\n`)
408
- }
409
-
410
- /** `try` - render the component(s) in a throwaway Vite + Svelte sandbox and open
411
- * the browser. No project needed. The sandbox is cached under the OS temp dir so
412
- * repeat runs skip the install. */
413
- async function cmdTry(registry, tokens) {
414
- if (!tokens.length) {
415
- stdout.write(`${color('red', '✖')} Nothing to try. Try: ${color('cyan', 'npx @svgrid/ui try button')}\n`)
416
- process.exit(1)
417
- }
418
- const items = collectItems(registry, tokens)
419
- const sandbox = join(tmpdir(), 'svgrid-ui-try')
420
- const src = join(sandbox, 'src')
421
- await mkdir(src, { recursive: true })
422
-
423
- // Base app (mirrors the known-good minimal Vite + Svelte 5 setup).
424
- await writeFile(
425
- join(sandbox, 'package.json'),
426
- JSON.stringify(
427
- {
428
- name: 'svgrid-ui-try',
429
- private: true,
430
- version: '0.0.0',
431
- type: 'module',
432
- scripts: { dev: 'vite' },
433
- dependencies: { '@svgrid/grid': 'latest' },
434
- devDependencies: { '@sveltejs/vite-plugin-svelte': '^7.0.0', svelte: '^5.55.5', vite: '^8.0.10' },
435
- },
436
- null,
437
- 2,
438
- ) + '\n',
439
- )
440
- await writeFile(
441
- join(sandbox, 'vite.config.js'),
442
- `import { svelte } from '@sveltejs/vite-plugin-svelte'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [svelte()] })\n`,
443
- )
444
- await writeFile(
445
- join(sandbox, 'svelte.config.js'),
446
- `import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\nexport default { preprocess: vitePreprocess() }\n`,
447
- )
448
- await writeFile(
449
- join(sandbox, 'index.html'),
450
- `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>@svgrid/ui preview</title>\n </head>\n <body>\n <div id="app"></div>\n <script type="module" src="/src/main.js"></script>\n </body>\n</html>\n`,
451
- )
452
- await writeFile(
453
- join(src, 'main.js'),
454
- `import { mount } from 'svelte'\nimport App from './App.svelte'\n\nexport default mount(App, { target: document.getElementById('app') })\n`,
455
- )
456
-
457
- // Copy each recipe (all its files) and render the primary one.
458
- const imports = []
459
- const sections = []
460
- for (const it of items) {
461
- let primary
462
- for (const file of it.files ?? []) {
463
- await cp(join(RECIPES_DIR, file.from), join(src, file.write))
464
- primary ??= file.write
465
- }
466
- if (!primary) continue
467
- const ident = toIdent(it.id)
468
- imports.push(` import ${ident} from './${primary}'`)
469
- sections.push(` <section class="svui-try__item">\n <h2>${esc(it.title ?? it.id)}</h2>\n <${ident} />\n </section>`)
470
- }
471
- await writeFile(
472
- join(src, 'App.svelte'),
473
- `<script lang="ts">
474
- import { themePresets, resolveThemeTokens } from '@svgrid/grid/themes'
475
- ${imports.join('\n')}
476
-
477
- let themeId = $state(themePresets[0].id)
478
- let mode = $state<'light' | 'dark'>('light')
479
- const preset = $derived(themePresets.find((p) => p.id === themeId) ?? themePresets[0])
480
-
481
- // Apply the chosen preset + light/dark as --sg-* tokens on :root, the same way
482
- // the shipped themes/<id>.css files do (light in :root, dark under data-theme),
483
- // so both the components and this page follow the picker.
484
- $effect(() => {
485
- const tokens = resolveThemeTokens(preset, mode)
486
- const root = document.documentElement
487
- for (const [k, v] of Object.entries(tokens)) root.style.setProperty(k, v)
488
- root.setAttribute('data-theme', mode)
489
- root.style.colorScheme = mode
490
- })
491
- </script>
492
-
493
- <header class="svui-try__bar">
494
- <strong class="svui-try__brand">@svgrid/ui preview</strong>
495
- <div class="svui-try__controls">
496
- <label class="svui-try__field">
497
- Theme
498
- <select bind:value={themeId}>
499
- {#each themePresets as p (p.id)}<option value={p.id}>{p.name}</option>{/each}
500
- </select>
501
- </label>
502
- <button type="button" class="svui-try__toggle" onclick={() => (mode = mode === 'light' ? 'dark' : 'light')}>
503
- {mode === 'light' ? 'Dark' : 'Light'} mode
504
- </button>
505
- </div>
506
- </header>
507
-
508
- <main class="svui-try">
509
- <p class="svui-try__sub">${esc(items.map((i) => i.id).join(', '))}</p>
510
- ${sections.join('\n')}
511
- </main>
512
-
513
- <style>
514
- :global(body) { margin: 0; background: var(--sg-bg, #f8fafc); color: var(--sg-fg, #0f172a); transition: background 0.15s, color 0.15s; }
515
- .svui-try__bar { position: sticky; top: 0; z-index: 10; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 24px; background: var(--sg-header-bg, #fff); border-bottom: 1px solid var(--sg-border, #e2e8f0); }
516
- .svui-try__brand { font-size: 13px; color: var(--sg-muted, #64748b); }
517
- .svui-try__controls { display: flex; align-items: center; gap: 12px; }
518
- .svui-try__field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }
519
- .svui-try__field select { font: inherit; padding: 5px 8px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
520
- .svui-try__toggle { font: inherit; font-size: 12px; padding: 6px 12px; border-radius: 8px; cursor: pointer; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
521
- .svui-try__toggle:hover { border-color: var(--sg-accent, #6366f1); }
522
- .svui-try { max-width: 960px; margin: 0 auto; padding: 24px; font-family: system-ui, sans-serif; }
523
- .svui-try__sub { margin: 0 0 20px; font-size: 12px; color: var(--sg-muted, #64748b); }
524
- .svui-try__item { padding: 28px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 12px; background: var(--sg-bg, #fff); margin-bottom: 16px; }
525
- .svui-try__item h2 { margin: 0 0 16px; font-size: 15px; color: var(--sg-muted, #334155); }
526
- </style>
527
- `,
528
- )
529
-
530
- // Install only when the sandbox isn't already provisioned.
531
- const provisioned =
532
- existsSync(join(sandbox, 'node_modules', '@svgrid', 'grid')) && existsSync(join(sandbox, 'node_modules', 'vite'))
533
- if (!provisioned) {
534
- stdout.write(`\n${color('dim', 'Setting up preview sandbox (first run installs deps)...')}\n`)
535
- const res = spawnSync('npm install', { cwd: sandbox, stdio: 'inherit', shell: true })
536
- if (res.status !== 0) {
537
- stdout.write(`${color('red', '✖')} Sandbox install failed.\n`)
538
- process.exit(1)
539
- }
540
- }
541
-
542
- stdout.write(
543
- `\n${color('green', '▶')} Opening ${color('cyan', items.map((i) => i.id).join(', '))} ${color('dim', '(Ctrl+C to stop)')}\n`,
544
- )
545
- spawnSync('npx vite --open', { cwd: sandbox, stdio: 'inherit', shell: true })
546
- }
547
-
548
- /** Component export name from its id (calendar -> SvCalendar, time-picker ->
549
- * SvTimePicker). */
550
- function exportName(id) {
551
- return 'Sv' + id.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')
552
- }
553
-
554
- function relFromCwd(cwd, p) {
555
- const r = p.startsWith(cwd) ? p.slice(cwd.length).replace(/^[\\/]/, '') : p
556
- return r.split('\\').join('/')
557
- }
558
-
559
- async function main() {
560
- const argv = process.argv.slice(2)
561
- const args = parseArgs(argv)
562
- const [command, ...rest] = args._
563
-
564
- if (args.help || !command) return printHelp()
565
-
566
- const registry = await loadRegistry()
567
- stdout.write(`${color('bold', '◆ @svgrid/ui')}\n`)
568
-
569
- switch (command) {
570
- case 'list':
571
- case 'ls':
572
- return cmdList(registry)
573
- case 'add':
574
- return cmdAdd(registry, rest, args)
575
- case 'try':
576
- case 'preview':
577
- return cmdTry(registry, rest)
578
- default:
579
- // Treat a bare component id as `add <id>` for convenience.
580
- if (resolveItem(registry, command) || registry.groups?.[command]) {
581
- return cmdAdd(registry, [command, ...rest], args)
582
- }
583
- stdout.write(`${color('red', '✖')} Unknown command "${command}". Try ${color('cyan', 'add')}, ${color('cyan', 'try')} or ${color('cyan', 'list')}.\n`)
584
- process.exit(1)
585
- }
586
- }
587
-
588
- main().catch((err) => {
589
- console.error(err)
590
- process.exit(1)
591
- })
1
+ #!/usr/bin/env node
2
+ // @svgrid/ui - add SvGrid UI components to your app, one command at a time.
3
+ //
4
+ // npx @svgrid/ui add calendar # add a component (installs the dep)
5
+ // npx @svgrid/ui add button --preview # + a /preview/button route to see it
6
+ // npx @svgrid/ui try button # zero-setup: open it in a sandbox
7
+ // npx @svgrid/ui add calendar time-picker --dir src/lib/ui
8
+ // npx @svgrid/ui add date-time # the whole date/time family
9
+ // npx @svgrid/ui list
10
+ //
11
+ // Recipe-scaffolder model: `add` writes a minimal, ready-to-EDIT .svelte starter
12
+ // that imports from `@svgrid/grid` (which you own and can change). Each recipe is
13
+ // a self-contained demo, so `--preview` (in your app) and `try` (in a throwaway
14
+ // sandbox) can render it immediately - "one command and see it".
15
+ //
16
+ // Zero runtime dependencies - Node built-ins only.
17
+
18
+ import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
19
+ import { existsSync } from 'node:fs'
20
+ import { dirname, join, relative, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { tmpdir } from 'node:os'
23
+ import { stdout } from 'node:process'
24
+ import { spawnSync } from 'node:child_process'
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url))
27
+ const RECIPES_DIR = join(__dirname, 'recipes')
28
+
29
+ const c = {
30
+ reset: '\x1b[0m',
31
+ bold: '\x1b[1m',
32
+ dim: '\x1b[2m',
33
+ green: '\x1b[32m',
34
+ cyan: '\x1b[36m',
35
+ red: '\x1b[31m',
36
+ yellow: '\x1b[33m',
37
+ }
38
+ const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
39
+
40
+ // Package managers: lockfile -> add command. Order matters (first match wins).
41
+ const PMS = [
42
+ { id: 'pnpm', lock: 'pnpm-lock.yaml', add: 'pnpm add' },
43
+ { id: 'bun', lock: 'bun.lockb', add: 'bun add' },
44
+ { id: 'yarn', lock: 'yarn.lock', add: 'yarn add' },
45
+ { id: 'npm', lock: 'package-lock.json', add: 'npm install' },
46
+ ]
47
+
48
+ function parseArgs(argv) {
49
+ // install defaults ON: an added component imports @svgrid/grid, so it should
50
+ // work right away. `--no-install` opts out (just prints the command).
51
+ const args = { _: [], dir: null, force: false, install: true, preview: false, help: false, js: false }
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i]
54
+ if (a === '--help' || a === '-h') args.help = true
55
+ else if (a === '--force' || a === '-f') args.force = true
56
+ else if (a === '--install') args.install = true
57
+ else if (a === '--no-install') args.install = false
58
+ else if (a === '--preview' || a === '-p') args.preview = true
59
+ else if (a === '--js') args.js = true
60
+ else if (a === '--ts') args.js = false
61
+ else if (a === '--dir' || a === '-d') args.dir = argv[++i]
62
+ else if (a.startsWith('--dir=')) args.dir = a.slice('--dir='.length)
63
+ else if (!a.startsWith('-')) args._.push(a)
64
+ }
65
+ return args
66
+ }
67
+
68
+ async function loadRegistry() {
69
+ const raw = await readFile(join(RECIPES_DIR, 'registry.json'), 'utf8')
70
+ return JSON.parse(raw)
71
+ }
72
+
73
+ /** Match a user-typed id against the registry: exact id, alias, or lowercase. */
74
+ function resolveItem(registry, token) {
75
+ const key = token.trim().toLowerCase()
76
+ return (
77
+ registry.items.find((it) => it.id === key) ??
78
+ registry.items.find((it) => (it.aliases ?? []).includes(key)) ??
79
+ null
80
+ )
81
+ }
82
+
83
+ /** Expand a token into one or more recipe items (a group alias -> its members). */
84
+ function expand(registry, token) {
85
+ const key = token.trim().toLowerCase()
86
+ const group = registry.groups?.[key]
87
+ if (group) return group.items.map((id) => resolveItem(registry, id)).filter(Boolean)
88
+ const item = resolveItem(registry, key)
89
+ return item ? [item] : []
90
+ }
91
+
92
+ /** Resolve tokens -> unique items in order; exits with a helpful error on unknowns. */
93
+ function collectItems(registry, tokens) {
94
+ const items = new Map()
95
+ const unknown = []
96
+ for (const tok of tokens) {
97
+ const matched = expand(registry, tok)
98
+ if (!matched.length) unknown.push(tok)
99
+ for (const it of matched) items.set(it.id, it)
100
+ }
101
+ if (unknown.length) {
102
+ stdout.write(
103
+ `${color('red', '✖')} Unknown component(s): ${unknown.join(', ')}\n` +
104
+ ` See ${color('cyan', 'npx @svgrid/ui list')} for the available set.\n`,
105
+ )
106
+ process.exit(1)
107
+ }
108
+ return [...items.values()]
109
+ }
110
+
111
+ /** Walk up from `start` to the nearest directory containing a package.json. */
112
+ function findProjectRoot(start) {
113
+ let dir = start
114
+ while (true) {
115
+ if (existsSync(join(dir, 'package.json'))) return dir
116
+ const parent = dirname(dir)
117
+ if (parent === dir) return null
118
+ dir = parent
119
+ }
120
+ }
121
+
122
+ /** A SvelteKit app has file-based routes, so we can drop in a /preview route. */
123
+ function isSvelteKit(root) {
124
+ return !!root && (existsSync(join(root, 'svelte.config.js')) || existsSync(join(root, 'src', 'routes')))
125
+ }
126
+
127
+ function detectPm(root) {
128
+ if (!root) return PMS.find((p) => p.id === 'npm')
129
+ return PMS.find((p) => existsSync(join(root, p.lock))) ?? PMS.find((p) => p.id === 'npm')
130
+ }
131
+
132
+ async function targetDir(args, projectRoot, cwd) {
133
+ if (args.dir) return resolve(cwd, args.dir)
134
+ // Optional project config: svgrid.json { componentsDir }
135
+ if (projectRoot) {
136
+ const cfgPath = join(projectRoot, 'svgrid.json')
137
+ if (existsSync(cfgPath)) {
138
+ try {
139
+ const cfg = JSON.parse(await readFile(cfgPath, 'utf8'))
140
+ if (cfg.componentsDir) return resolve(projectRoot, cfg.componentsDir)
141
+ } catch {
142
+ /* fall through to the default */
143
+ }
144
+ }
145
+ return join(projectRoot, 'src', 'lib', 'components', 'ui')
146
+ }
147
+ return join(cwd, 'src', 'lib', 'components', 'ui')
148
+ }
149
+
150
+ /** Add any missing deps to the nearest package.json (dependencies). Returns the
151
+ * list actually added so the caller can report / install them. */
152
+ async function ensureDeps(projectRoot, deps) {
153
+ if (!projectRoot) return deps
154
+ const pkgPath = join(projectRoot, 'package.json')
155
+ let pkg
156
+ try {
157
+ pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
158
+ } catch {
159
+ return deps
160
+ }
161
+ pkg.dependencies ??= {}
162
+ const added = []
163
+ for (const dep of deps) {
164
+ const known = pkg.dependencies[dep] || pkg.devDependencies?.[dep]
165
+ if (!known) {
166
+ pkg.dependencies[dep] = 'latest'
167
+ added.push(dep)
168
+ }
169
+ }
170
+ if (added.length) await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
171
+ return added
172
+ }
173
+
174
+ /** A valid JS identifier for a component id (time-picker -> C_time_picker). */
175
+ function toIdent(id) {
176
+ return 'C_' + id.replace(/[^a-zA-Z0-9]/g, '_')
177
+ }
178
+
179
+ function esc(s) {
180
+ return String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;')
181
+ }
182
+
183
+ /** A SvelteKit `/preview/<id>` page that renders the recipe you just added, so
184
+ * you can open it in your dev server. Import is relative to the recipe on disk,
185
+ * so it works whether or not the recipe lives under $lib. */
186
+ async function writePreviewRoutes(projectRoot, dest, items, force) {
187
+ const routesRoot = join(projectRoot, 'src', 'routes', 'preview')
188
+ const urls = []
189
+ for (const it of items) {
190
+ const file = it.files?.[0]
191
+ if (!file) continue
192
+ const routeDir = join(routesRoot, it.id)
193
+ await mkdir(routeDir, { recursive: true })
194
+ const routeFile = join(routeDir, '+page.svelte')
195
+ urls.push('/preview/' + it.id)
196
+ if (existsSync(routeFile) && !force) continue
197
+ let rel = relative(routeDir, join(dest, file.write)).split('\\').join('/')
198
+ if (!rel.startsWith('.')) rel = './' + rel
199
+ await writeFile(
200
+ routeFile,
201
+ `<script lang="ts">
202
+ // Auto-generated by @svgrid/ui to preview the ${it.id} recipe. Yours to edit.
203
+ import Demo from '${rel}'
204
+ </script>
205
+
206
+ <div class="svui-preview">
207
+ <a class="svui-preview__back" href="/preview">&larr; all components</a>
208
+ <h1 class="svui-preview__title">${esc(it.title ?? it.id)}</h1>
209
+ <p class="svui-preview__desc">${esc(it.description ?? '')}</p>
210
+ <div class="svui-preview__stage">
211
+ <Demo />
212
+ </div>
213
+ </div>
214
+
215
+ <style>
216
+ .svui-preview { max-width: 880px; margin: 0 auto; padding: 32px 24px; font-family: system-ui, sans-serif; }
217
+ .svui-preview__back { font-size: 13px; color: #6366f1; text-decoration: none; }
218
+ .svui-preview__title { margin: 12px 0 4px; font-size: 24px; }
219
+ .svui-preview__desc { margin: 0 0 20px; color: #64748b; font-size: 14px; }
220
+ .svui-preview__stage { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; padding: 28px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
221
+ </style>
222
+ `,
223
+ )
224
+ }
225
+ await writePreviewIndex(routesRoot)
226
+ return urls
227
+ }
228
+
229
+ /** (Re)generate /preview - an index of every component preview present on disk. */
230
+ async function writePreviewIndex(routesRoot) {
231
+ let entries = []
232
+ try {
233
+ entries = await readdir(routesRoot, { withFileTypes: true })
234
+ } catch {
235
+ return
236
+ }
237
+ const ids = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
238
+ const links = ids.map((id) => ` <a class="svui-index__link" href="/preview/${id}">${esc(id)}</a>`).join('\n')
239
+ await writeFile(
240
+ join(routesRoot, '+page.svelte'),
241
+ `<script lang="ts">
242
+ // Auto-generated by @svgrid/ui. Lists the component previews you've added.
243
+ </script>
244
+
245
+ <div class="svui-index">
246
+ <h1>Component previews</h1>
247
+ <div class="svui-index__grid">
248
+ ${links}
249
+ </div>
250
+ </div>
251
+
252
+ <style>
253
+ .svui-index { max-width: 880px; margin: 0 auto; padding: 32px 24px; font-family: system-ui, sans-serif; }
254
+ .svui-index h1 { font-size: 22px; }
255
+ .svui-index__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; margin-top: 16px; }
256
+ .svui-index__link { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 10px; text-decoration: none; color: #0f172a; font-size: 14px; }
257
+ .svui-index__link:hover { border-color: #6366f1; color: #6366f1; }
258
+ </style>
259
+ `,
260
+ )
261
+ }
262
+
263
+ function printHelp() {
264
+ stdout.write(`
265
+ ${color('bold', '@svgrid/ui')} - add SvGrid UI components to your app
266
+
267
+ ${color('bold', 'Usage')}
268
+ npx @svgrid/ui add <component...> [--dir <path>] [--preview] [--force] [--no-install]
269
+ npx @svgrid/ui try <component...>
270
+ npx @svgrid/ui list
271
+
272
+ ${color('bold', 'Commands')}
273
+ ${color('cyan', 'add')} Write a ready-to-edit recipe for each component into your project
274
+ (and install @svgrid/grid). Add ${color('cyan', '--preview')} to also drop a /preview route.
275
+ ${color('cyan', 'try')} Open the component(s) in a throwaway sandbox - no project needed.
276
+ ${color('cyan', 'list')} Show the components you can add.
277
+
278
+ ${color('bold', 'Options')}
279
+ --preview, -p (add) Also write a src/routes/preview/<id> route so you can see it
280
+ in your running dev server. SvelteKit apps only.
281
+ --dir <path> Where to write files (default: src/lib/components/ui, or the
282
+ "componentsDir" in a project svgrid.json).
283
+ --force Overwrite files that already exist.
284
+ --no-install Do not run the package manager; just print the install command.
285
+
286
+ ${color('bold', 'Examples')}
287
+ npx @svgrid/ui try button
288
+ npx @svgrid/ui add button --preview
289
+ npx @svgrid/ui add calendar time-picker --dir src/lib/ui
290
+ npx @svgrid/ui add date-time
291
+ `)
292
+ }
293
+
294
+ async function cmdList(registry) {
295
+ stdout.write(`\n${color('bold', 'Available components')}\n`)
296
+ for (const it of registry.items) {
297
+ stdout.write(` ${color('cyan', it.id.padEnd(18))} ${color('dim', it.description)}\n`)
298
+ }
299
+ const groups = Object.entries(registry.groups ?? {})
300
+ if (groups.length) {
301
+ stdout.write(`\n${color('bold', 'Groups')}\n`)
302
+ for (const [id, g] of groups) {
303
+ stdout.write(` ${color('cyan', id.padEnd(18))} ${color('dim', g.items.join(', '))}\n`)
304
+ }
305
+ }
306
+ stdout.write(`\n${color('dim', 'Add one with:')} npx @svgrid/ui add ${registry.items[0]?.id ?? 'calendar'}\n`)
307
+ stdout.write(`${color('dim', 'Or just see it:')} npx @svgrid/ui try ${registry.items[0]?.id ?? 'calendar'}\n\n`)
308
+ }
309
+
310
+ async function cmdAdd(registry, tokens, args) {
311
+ if (!tokens.length) {
312
+ stdout.write(`${color('red', '✖')} Nothing to add. Try: ${color('cyan', 'npx @svgrid/ui list')}\n`)
313
+ process.exit(1)
314
+ }
315
+ const items = collectItems(registry, tokens)
316
+
317
+ const cwd = process.cwd()
318
+ const projectRoot = findProjectRoot(cwd)
319
+ const dest = await targetDir(args, projectRoot, cwd)
320
+ await mkdir(dest, { recursive: true })
321
+
322
+ const written = []
323
+ const skipped = []
324
+ const deps = new Set()
325
+ for (const it of items) {
326
+ for (const d of it.deps ?? []) deps.add(d)
327
+ for (const file of it.files ?? []) {
328
+ const outPath = join(dest, file.write)
329
+ if (existsSync(outPath) && !args.force) {
330
+ skipped.push(file.write)
331
+ continue
332
+ }
333
+ await cp(join(RECIPES_DIR, file.from), outPath)
334
+ written.push(file.write)
335
+ }
336
+ }
337
+
338
+ // Report writes.
339
+ stdout.write(`\n`)
340
+ for (const f of written) stdout.write(` ${color('green', '+')} ${relFromCwd(cwd, join(dest, f))}\n`)
341
+ for (const f of skipped)
342
+ stdout.write(` ${color('yellow', '•')} ${f} ${color('dim', 'already exists (use --force to overwrite)')}\n`)
343
+ if (!written.length && !args.preview) {
344
+ stdout.write(`\n${color('yellow', '!')} No files written.\n\n`)
345
+ return
346
+ }
347
+
348
+ // Install the dependency - only meaningful inside a project (on by default;
349
+ // --no-install just prints the command). `add` writes into an EXISTING app;
350
+ // a no-project run is handled in the next-steps block below.
351
+ if (projectRoot) {
352
+ const pm = detectPm(projectRoot)
353
+ const added = await ensureDeps(projectRoot, [...deps])
354
+ if (added.length) {
355
+ if (args.install) {
356
+ stdout.write(`\n${color('dim', `Installing with ${pm.id}...`)}\n`)
357
+ // Single shell string (not bin + args[]) so Node doesn't warn DEP0190
358
+ // under shell:true; the tokens here are fixed pm commands + npm package ids.
359
+ const res = spawnSync(`${pm.add} ${added.join(' ')}`, { cwd: projectRoot, stdio: 'inherit', shell: true })
360
+ if (res.status !== 0) {
361
+ stdout.write(`${color('yellow', '!')} Install failed - run it yourself: ${color('cyan', `${pm.add} ${added.join(' ')}`)}\n`)
362
+ }
363
+ } else {
364
+ stdout.write(`\n${color('bold', 'Install the dependency')}\n ${color('cyan', `${pm.add} ${added.join(' ')}`)}\n`)
365
+ }
366
+ } else if ([...deps].length) {
367
+ stdout.write(`\n${color('dim', `${[...deps].join(', ')} already in package.json.`)}\n`)
368
+ }
369
+ }
370
+
371
+ // Optional preview route(s) - needs a SvelteKit project.
372
+ if (args.preview) {
373
+ if (isSvelteKit(projectRoot)) {
374
+ const urls = await writePreviewRoutes(projectRoot, dest, items, args.force)
375
+ stdout.write(`\n${color('green', '✔')} Preview route(s) written. Start your dev server and open:\n`)
376
+ for (const u of urls) stdout.write(` ${color('cyan', u)}\n`)
377
+ } else {
378
+ stdout.write(
379
+ `\n${color('yellow', '!')} --preview needs a SvelteKit app (src/routes). ` +
380
+ `To see it with zero setup: ${color('cyan', `npx @svgrid/ui try ${items[0].id}`)}\n`,
381
+ )
382
+ }
383
+ }
384
+
385
+ // Next steps.
386
+ const first = items[0]
387
+ const ids = items.map((it) => it.id).join(' ')
388
+ stdout.write(`\n${color('green', '✔')} Added ${written.length} file(s). They're yours - edit away.\n`)
389
+ if (!projectRoot) {
390
+ // A lone component file with no app to run it - `add` targets an existing
391
+ // project. Point at the two real paths instead of leaving a stranded file.
392
+ stdout.write(
393
+ ` ${color('yellow', 'Heads up:')} no package.json here, so there's nothing to run this in yet.\n` +
394
+ ` ${color('dim', 'See it now:')} ${color('cyan', `npx @svgrid/ui try ${ids}`)} ${color('dim', '(no project needed)')}\n` +
395
+ ` ${color('dim', 'Start an app:')} ${color('cyan', 'npm create @svgrid@latest')} ${color('dim', '(then run add inside it)')}\n`,
396
+ )
397
+ } else {
398
+ stdout.write(` ${color('dim', 'Use it:')} import { ${exportName(first.id)} } from '@svgrid/grid'\n`)
399
+ // "See it" - skip when we already wrote preview routes just above.
400
+ if (!(args.preview && isSvelteKit(projectRoot))) {
401
+ stdout.write(` ${color('dim', 'See it:')} ${color('cyan', `npx @svgrid/ui try ${ids}`)} ${color('dim', '(opens in your browser)')}\n`)
402
+ if (isSvelteKit(projectRoot)) {
403
+ stdout.write(` ${color('dim', 'In app:')} re-run with ${color('cyan', '--preview')} to add a /preview/${first.id} route\n`)
404
+ }
405
+ }
406
+ }
407
+ stdout.write(`\n${color('dim', 'Docs:')} https://svgrid.com/docs/help/ui-components\n\n`)
408
+ }
409
+
410
+ /** `try` - render the component(s) in a throwaway Vite + Svelte sandbox and open
411
+ * the browser. No project needed. The sandbox is cached under the OS temp dir so
412
+ * repeat runs skip the install. */
413
+ async function cmdTry(registry, tokens) {
414
+ if (!tokens.length) {
415
+ stdout.write(`${color('red', '✖')} Nothing to try. Try: ${color('cyan', 'npx @svgrid/ui try button')}\n`)
416
+ process.exit(1)
417
+ }
418
+ const items = collectItems(registry, tokens)
419
+ const sandbox = join(tmpdir(), 'svgrid-ui-try')
420
+ const src = join(sandbox, 'src')
421
+ await mkdir(src, { recursive: true })
422
+
423
+ // Base app (mirrors the known-good minimal Vite + Svelte 5 setup).
424
+ await writeFile(
425
+ join(sandbox, 'package.json'),
426
+ JSON.stringify(
427
+ {
428
+ name: 'svgrid-ui-try',
429
+ private: true,
430
+ version: '0.0.0',
431
+ type: 'module',
432
+ scripts: { dev: 'vite' },
433
+ dependencies: { '@svgrid/grid': 'latest' },
434
+ devDependencies: { '@sveltejs/vite-plugin-svelte': '^7.0.0', svelte: '^5.55.5', vite: '^8.0.10' },
435
+ },
436
+ null,
437
+ 2,
438
+ ) + '\n',
439
+ )
440
+ await writeFile(
441
+ join(sandbox, 'vite.config.js'),
442
+ `import { svelte } from '@sveltejs/vite-plugin-svelte'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [svelte()] })\n`,
443
+ )
444
+ await writeFile(
445
+ join(sandbox, 'svelte.config.js'),
446
+ `import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\nexport default { preprocess: vitePreprocess() }\n`,
447
+ )
448
+ await writeFile(
449
+ join(sandbox, 'index.html'),
450
+ `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>@svgrid/ui preview</title>\n </head>\n <body>\n <div id="app"></div>\n <script type="module" src="/src/main.js"></script>\n </body>\n</html>\n`,
451
+ )
452
+ await writeFile(
453
+ join(src, 'main.js'),
454
+ `import { mount } from 'svelte'\nimport App from './App.svelte'\n\nexport default mount(App, { target: document.getElementById('app') })\n`,
455
+ )
456
+
457
+ // Copy each recipe (all its files) and render the primary one.
458
+ const imports = []
459
+ const sections = []
460
+ for (const it of items) {
461
+ let primary
462
+ for (const file of it.files ?? []) {
463
+ await cp(join(RECIPES_DIR, file.from), join(src, file.write))
464
+ primary ??= file.write
465
+ }
466
+ if (!primary) continue
467
+ const ident = toIdent(it.id)
468
+ imports.push(` import ${ident} from './${primary}'`)
469
+ sections.push(` <section class="svui-try__item">\n <h2>${esc(it.title ?? it.id)}</h2>\n <${ident} />\n </section>`)
470
+ }
471
+ await writeFile(
472
+ join(src, 'App.svelte'),
473
+ `<script lang="ts">
474
+ import { themePresets, resolveThemeTokens } from '@svgrid/grid/themes'
475
+ ${imports.join('\n')}
476
+
477
+ let themeId = $state(themePresets[0].id)
478
+ let mode = $state<'light' | 'dark'>('light')
479
+ const preset = $derived(themePresets.find((p) => p.id === themeId) ?? themePresets[0])
480
+
481
+ // Apply the chosen preset + light/dark as --sg-* tokens on :root, the same way
482
+ // the shipped themes/<id>.css files do (light in :root, dark under data-theme),
483
+ // so both the components and this page follow the picker.
484
+ $effect(() => {
485
+ const tokens = resolveThemeTokens(preset, mode)
486
+ const root = document.documentElement
487
+ for (const [k, v] of Object.entries(tokens)) root.style.setProperty(k, v)
488
+ root.setAttribute('data-theme', mode)
489
+ root.style.colorScheme = mode
490
+ })
491
+ </script>
492
+
493
+ <header class="svui-try__bar">
494
+ <strong class="svui-try__brand">@svgrid/ui preview</strong>
495
+ <div class="svui-try__controls">
496
+ <label class="svui-try__field">
497
+ Theme
498
+ <select bind:value={themeId}>
499
+ {#each themePresets as p (p.id)}<option value={p.id}>{p.name}</option>{/each}
500
+ </select>
501
+ </label>
502
+ <button type="button" class="svui-try__toggle" onclick={() => (mode = mode === 'light' ? 'dark' : 'light')}>
503
+ {mode === 'light' ? 'Dark' : 'Light'} mode
504
+ </button>
505
+ </div>
506
+ </header>
507
+
508
+ <main class="svui-try">
509
+ <p class="svui-try__sub">${esc(items.map((i) => i.id).join(', '))}</p>
510
+ ${sections.join('\n')}
511
+ </main>
512
+
513
+ <style>
514
+ :global(body) { margin: 0; background: var(--sg-bg, #f8fafc); color: var(--sg-fg, #0f172a); transition: background 0.15s, color 0.15s; }
515
+ .svui-try__bar { position: sticky; top: 0; z-index: 10; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 24px; background: var(--sg-header-bg, #fff); border-bottom: 1px solid var(--sg-border, #e2e8f0); }
516
+ .svui-try__brand { font-size: 13px; color: var(--sg-muted, #64748b); }
517
+ .svui-try__controls { display: flex; align-items: center; gap: 12px; }
518
+ .svui-try__field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }
519
+ .svui-try__field select { font: inherit; padding: 5px 8px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
520
+ .svui-try__toggle { font: inherit; font-size: 12px; padding: 6px 12px; border-radius: 8px; cursor: pointer; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
521
+ .svui-try__toggle:hover { border-color: var(--sg-accent, #6366f1); }
522
+ .svui-try { max-width: 960px; margin: 0 auto; padding: 24px; font-family: system-ui, sans-serif; }
523
+ .svui-try__sub { margin: 0 0 20px; font-size: 12px; color: var(--sg-muted, #64748b); }
524
+ .svui-try__item { padding: 28px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 12px; background: var(--sg-bg, #fff); margin-bottom: 16px; }
525
+ .svui-try__item h2 { margin: 0 0 16px; font-size: 15px; color: var(--sg-muted, #334155); }
526
+ </style>
527
+ `,
528
+ )
529
+
530
+ // Install only when the sandbox isn't already provisioned.
531
+ const provisioned =
532
+ existsSync(join(sandbox, 'node_modules', '@svgrid', 'grid')) && existsSync(join(sandbox, 'node_modules', 'vite'))
533
+ if (!provisioned) {
534
+ stdout.write(`\n${color('dim', 'Setting up preview sandbox (first run installs deps)...')}\n`)
535
+ const res = spawnSync('npm install', { cwd: sandbox, stdio: 'inherit', shell: true })
536
+ if (res.status !== 0) {
537
+ stdout.write(`${color('red', '✖')} Sandbox install failed.\n`)
538
+ process.exit(1)
539
+ }
540
+ }
541
+
542
+ stdout.write(
543
+ `\n${color('green', '▶')} Opening ${color('cyan', items.map((i) => i.id).join(', '))} ${color('dim', '(Ctrl+C to stop)')}\n`,
544
+ )
545
+ spawnSync('npx vite --open', { cwd: sandbox, stdio: 'inherit', shell: true })
546
+ }
547
+
548
+ /** Component export name from its id (calendar -> SvCalendar, time-picker ->
549
+ * SvTimePicker). */
550
+ function exportName(id) {
551
+ return 'Sv' + id.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')
552
+ }
553
+
554
+ function relFromCwd(cwd, p) {
555
+ const r = p.startsWith(cwd) ? p.slice(cwd.length).replace(/^[\\/]/, '') : p
556
+ return r.split('\\').join('/')
557
+ }
558
+
559
+ async function main() {
560
+ const argv = process.argv.slice(2)
561
+ const args = parseArgs(argv)
562
+ const [command, ...rest] = args._
563
+
564
+ if (args.help || !command) return printHelp()
565
+
566
+ const registry = await loadRegistry()
567
+ stdout.write(`${color('bold', '◆ @svgrid/ui')}\n`)
568
+
569
+ switch (command) {
570
+ case 'list':
571
+ case 'ls':
572
+ return cmdList(registry)
573
+ case 'add':
574
+ return cmdAdd(registry, rest, args)
575
+ case 'try':
576
+ case 'preview':
577
+ return cmdTry(registry, rest)
578
+ default:
579
+ // Treat a bare component id as `add <id>` for convenience.
580
+ if (resolveItem(registry, command) || registry.groups?.[command]) {
581
+ return cmdAdd(registry, [command, ...rest], args)
582
+ }
583
+ stdout.write(`${color('red', '✖')} Unknown command "${command}". Try ${color('cyan', 'add')}, ${color('cyan', 'try')} or ${color('cyan', 'list')}.\n`)
584
+ process.exit(1)
585
+ }
586
+ }
587
+
588
+ main().catch((err) => {
589
+ console.error(err)
590
+ process.exit(1)
591
+ })