@svgrid/ui 0.3.4 → 0.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.
- package/LICENSE +0 -25
- package/README.md +238 -218
- package/index.mjs +591 -591
- package/package.json +4 -4
- package/recipes/buttons/button-group.svelte +18 -18
- package/recipes/buttons/button.svelte +17 -17
- package/recipes/buttons/check-box.svelte +10 -10
- package/recipes/buttons/radio-group.svelte +18 -18
- package/recipes/buttons/rating.svelte +8 -8
- package/recipes/buttons/repeat-button.svelte +10 -10
- package/recipes/buttons/segmented.svelte +14 -0
- package/recipes/buttons/switch-button.svelte +12 -12
- package/recipes/buttons/toggle-button.svelte +8 -8
- package/recipes/date-time/calendar.svelte +15 -15
- package/recipes/date-time/date-range-input.svelte +15 -15
- package/recipes/date-time/date-time-picker.svelte +16 -16
- package/recipes/date-time/time-picker.svelte +15 -15
- package/recipes/feedback/alert.svelte +11 -11
- package/recipes/feedback/avatar-group.svelte +11 -11
- package/recipes/feedback/avatar.svelte +8 -8
- package/recipes/feedback/badge.svelte +8 -8
- package/recipes/feedback/carousel.svelte +10 -10
- package/recipes/feedback/chip.svelte +10 -10
- package/recipes/feedback/empty-state.svelte +8 -8
- package/recipes/feedback/loading-overlay.svelte +14 -0
- package/recipes/feedback/result.svelte +15 -0
- package/recipes/feedback/skeleton.svelte +8 -8
- package/recipes/feedback/spinner.svelte +8 -0
- package/recipes/feedback/timeline.svelte +10 -10
- package/recipes/inputs/color-input.svelte +8 -8
- package/recipes/inputs/duration-input.svelte +9 -9
- package/recipes/inputs/masked-input.svelte +14 -14
- package/recipes/inputs/number-input.svelte +17 -17
- package/recipes/inputs/otp-input.svelte +13 -13
- package/recipes/inputs/password-input.svelte +14 -14
- package/recipes/inputs/phone-input.svelte +8 -8
- package/recipes/inputs/tags-input.svelte +8 -8
- package/recipes/inputs/text-area.svelte +15 -15
- package/recipes/inputs/text-input.svelte +14 -14
- package/recipes/layout/accordion.svelte +17 -17
- package/recipes/layout/card.svelte +9 -9
- package/recipes/layout/collapsible.svelte +13 -0
- package/recipes/layout/display.svelte +15 -0
- package/recipes/layout/divider.svelte +9 -9
- package/recipes/layout/dock-layout.svelte +22 -22
- package/recipes/layout/dock-manager.svelte +24 -24
- package/recipes/layout/field.svelte +15 -15
- package/recipes/layout/file-upload.svelte +16 -16
- package/recipes/layout/form.svelte +14 -14
- package/recipes/layout/grid-chart.svelte +16 -16
- package/recipes/layout/layout.svelte +18 -0
- package/recipes/layout/scroll-area.svelte +10 -10
- package/recipes/layout/splitter.svelte +13 -13
- package/recipes/layout/tabs.svelte +17 -17
- package/recipes/layout/typography.svelte +17 -0
- package/recipes/navigation/breadcrumb.svelte +12 -12
- package/recipes/navigation/command.svelte +12 -12
- package/recipes/navigation/nav-pane.svelte +14 -14
- package/recipes/navigation/pagination.svelte +13 -13
- package/recipes/navigation/rich-text.svelte +8 -8
- package/recipes/navigation/stepper.svelte +13 -13
- package/recipes/navigation/tour.svelte +14 -14
- package/recipes/navigation/tree.svelte +14 -14
- package/recipes/overlays/context-menu.svelte +15 -15
- package/recipes/overlays/drawer.svelte +13 -13
- package/recipes/overlays/hover-card.svelte +15 -0
- package/recipes/overlays/menu.svelte +15 -15
- package/recipes/overlays/menubar.svelte +27 -0
- package/recipes/overlays/modal.svelte +13 -13
- package/recipes/overlays/popconfirm.svelte +18 -0
- package/recipes/overlays/popover.svelte +9 -9
- package/recipes/overlays/toaster.svelte +9 -9
- package/recipes/overlays/tooltip.svelte +8 -8
- package/recipes/range/circular-progress.svelte +9 -9
- package/recipes/range/gauge.svelte +16 -16
- package/recipes/range/progress.svelte +10 -10
- package/recipes/range/slider.svelte +19 -19
- package/recipes/range/sparkline.svelte +9 -9
- package/recipes/range/stat.svelte +9 -9
- package/recipes/registry.json +237 -4
- package/recipes/selection/auto-complete.svelte +10 -10
- package/recipes/selection/combo-box.svelte +13 -13
- package/recipes/selection/country-input.svelte +8 -8
- package/recipes/selection/drop-down-list.svelte +13 -13
- package/recipes/selection/grid-select.svelte +17 -17
- package/recipes/selection/list-box.svelte +13 -13
- package/recipes/selection/multi-select.svelte +13 -13
- 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, '<').replace(/>/g, '>')
|
|
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">← 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://
|
|
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, '<').replace(/>/g, '>')
|
|
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">← 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
|
+
})
|