@svgrid/create 2.6.0 → 2.8.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 (44) hide show
  1. package/README.md +11 -5
  2. package/index.mjs +486 -466
  3. package/package.json +2 -2
  4. package/templates/headless/README.md +65 -0
  5. package/templates/headless/_gitignore +4 -0
  6. package/templates/headless/_package.json +22 -0
  7. package/templates/headless/index.html +12 -0
  8. package/templates/headless/src/App.svelte +159 -0
  9. package/templates/headless/src/app.css +153 -0
  10. package/templates/headless/src/main.ts +7 -0
  11. package/templates/headless/src/vite-env.d.ts +2 -0
  12. package/templates/headless/svelte.config.js +5 -0
  13. package/templates/headless/tsconfig.json +14 -0
  14. package/templates/headless/vite.config.js +6 -0
  15. package/templates/minimal/src/app.css +1 -1
  16. package/templates/pivot-dashboard/README.md +74 -0
  17. package/templates/pivot-dashboard/_gitignore +23 -0
  18. package/templates/pivot-dashboard/_package.json +27 -0
  19. package/templates/pivot-dashboard/src/app.css +15 -0
  20. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  21. package/templates/pivot-dashboard/src/app.html +22 -0
  22. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  23. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  24. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  25. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  26. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  27. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  28. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  29. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  30. package/templates/pivot-dashboard/tsconfig.json +20 -0
  31. package/templates/pivot-dashboard/vite.config.ts +20 -0
  32. package/templates/sveltekit/README.md +33 -3
  33. package/templates/sveltekit/src/app.css +1 -1
  34. package/templates/sveltekit/src/app.d.ts +10 -2
  35. package/templates/sveltekit/src/hooks.server.ts +49 -0
  36. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  37. package/templates/sveltekit/src/lib/theme.svelte.ts +1 -1
  38. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  39. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  40. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  41. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  42. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  43. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  44. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
package/index.mjs CHANGED
@@ -1,466 +1,486 @@
1
- #!/usr/bin/env node
2
- // @svgrid/create - scaffold a Svelte app powered by SvGrid.
3
- //
4
- // npm create @svgrid@latest # interactive
5
- // pnpm create @svgrid # interactive
6
- // npm create @svgrid@latest my-app -- --template admin-dashboard
7
- // npm create @svgrid@latest my-app -- -t minimal
8
- //
9
- // Zero runtime dependencies - Node built-ins only. Copies a bundled template,
10
- // renames `_`-prefixed dotfiles, and rewrites the project name.
11
-
12
- import { cp, mkdir, readdir, rename, stat, readFile, writeFile } from 'node:fs/promises'
13
- import { existsSync } from 'node:fs'
14
- import { dirname, join, basename, resolve, isAbsolute } from 'node:path'
15
- import { fileURLToPath } from 'node:url'
16
- import { createInterface } from 'node:readline/promises'
17
- import { stdin, stdout } from 'node:process'
18
-
19
- const __dirname = dirname(fileURLToPath(import.meta.url))
20
-
21
- const TEMPLATES = {
22
- minimal: {
23
- label: 'Minimal - Vite + Svelte 5 + SvGrid, one page',
24
- bundled: join(__dirname, 'templates', 'minimal'),
25
- },
26
- sveltekit: {
27
- label: 'SvelteKit - server load, URL-driven sort, form-action edits, theme picker',
28
- bundled: join(__dirname, 'templates', 'sveltekit'),
29
- },
30
- 'admin-dashboard': {
31
- label: 'Admin dashboard - SvelteKit shell, multiple grids, deploy to Vercel',
32
- bundled: join(__dirname, 'templates', 'admin-dashboard'),
33
- // When running from the monorepo before `prepack` has synced the bundled
34
- // copy, fall back to the canonical source.
35
- fallback: join(__dirname, '..', '..', 'templates', 'sveltekit-admin-dashboard'),
36
- },
37
- }
38
-
39
- const RENAME_BACK = new Map([
40
- ['_gitignore', '.gitignore'],
41
- ['_npmrc', '.npmrc'],
42
- ['_env.example', '.env.example'],
43
- ['_package.json', 'package.json'],
44
- ])
45
-
46
- const c = {
47
- reset: '\x1b[0m',
48
- bold: '\x1b[1m',
49
- dim: '\x1b[2m',
50
- green: '\x1b[32m',
51
- cyan: '\x1b[36m',
52
- red: '\x1b[31m',
53
- yellow: '\x1b[33m',
54
- }
55
- const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
56
-
57
- function parseArgs(argv) {
58
- const args = { _: [], template: null, force: false, help: false, theme: null, mode: null }
59
- for (let i = 0; i < argv.length; i++) {
60
- const a = argv[i]
61
- if (a === '--help' || a === '-h') args.help = true
62
- else if (a === '--force' || a === '-f') args.force = true
63
- else if (a === '--template' || a === '-t') args.template = argv[++i]
64
- else if (a.startsWith('--template=')) args.template = a.slice('--template='.length)
65
- else if (a === '--theme') args.theme = argv[++i]
66
- else if (a.startsWith('--theme=')) args.theme = a.slice('--theme='.length)
67
- else if (a === '--dark') args.mode = 'dark'
68
- else if (a === '--light') args.mode = 'light'
69
- else if (!a.startsWith('-')) args._.push(a)
70
- }
71
- return args
72
- }
73
-
74
- function printHelp() {
75
- stdout.write(`
76
- ${color('bold', '@svgrid/create')} - scaffold a Svelte app powered by SvGrid
77
-
78
- ${color('bold', 'Usage')}
79
- npm create @svgrid@latest [dir] -- [--template <name>] [--force]
80
-
81
- ${color('bold', 'Templates')}
82
- ${Object.entries(TEMPLATES)
83
- .map(([k, t]) => ` ${color('cyan', k.padEnd(16))} ${t.label.replace(/^\S+\s+-\s+/, '')}`)
84
- .join('\n')}
85
-
86
- ${color('bold', 'Options')}
87
- --theme <id> One of: ${THEME_IDS.join(', ')} (default: tailwind).
88
- --dark / --light Pin the starting mode. Left out, minimal and sveltekit
89
- follow the visitor's OS; admin-dashboard starts dark.
90
- --force Scaffold into a non-empty directory.
91
-
92
- ${color('bold', 'Examples')}
93
- npm create @svgrid@latest
94
- npm create @svgrid@latest my-app -- --template admin-dashboard
95
- npm create @svgrid@latest my-app -- -t admin-dashboard --theme material --light
96
- pnpm create @svgrid my-app -t minimal
97
- `)
98
- }
99
-
100
- const THEME_IDS = [
101
- 'ember', 'shadcn', 'tailwind', 'material', 'excel', 'fluent', 'carbon', 'sap',
102
- 'salesforce', 'atlassian', 'github', 'antd', 'ag-alpine', 'bootstrap',
103
- 'vercel', 'linear', 'notion', 'nord', 'dracula', 'catppuccin',
104
- ]
105
-
106
- /**
107
- * Resolve the theme + mode to apply: from `--theme` / `--dark` / `--light`
108
- * flags, or (in a TTY) an interactive prompt; otherwise left untouched (the
109
- * template's own default look stands). Reuses `@svgrid/grid/themes`'s own
110
- * preset list, so the picker can never drift from the canonical set.
111
- */
112
- async function promptTheme(args, ask, interactive) {
113
- let themes
114
- try {
115
- themes = await import('@svgrid/grid/themes')
116
- } catch {
117
- return null // theming is best-effort; the template's own default stands in.
118
- }
119
-
120
- let themeId = args.theme ? args.theme.trim().toLowerCase() : null
121
- if (themeId && !themes.getThemePreset(themeId)) {
122
- stdout.write(`${color('yellow', '!')} Unknown theme "${themeId}" - using tailwind. (${THEME_IDS.join(', ')})\n`)
123
- themeId = null
124
- }
125
- if (!themeId && interactive) {
126
- const list = themes.themePresets.map((t, i) => ` ${i + 1}. ${t.name} ${color('dim', `(${t.id})`)}`).join('\n')
127
- stdout.write(`\n${color('bold', 'Theme')}\n${list}\n`)
128
- const choice = await ask('Pick a number or id:', 'tailwind')
129
- const byIndex = themes.themePresets[Number(choice) - 1]
130
- themeId = byIndex ? byIndex.id : (themes.getThemePreset(choice.trim().toLowerCase())?.id ?? 'tailwind')
131
- }
132
- themeId ??= 'tailwind'
133
-
134
- let mode = args.mode
135
- if (!mode && interactive) {
136
- const a = await ask('Light or dark mode? (light/dark)', 'dark')
137
- mode = /^l(ight)?$/i.test(a) ? 'light' : 'dark'
138
- }
139
- // Whether the mode was actually chosen, as opposed to falling through to the
140
- // default below. minimal follows the OS when nobody said, which is only
141
- // distinguishable from an explicit --dark by keeping this flag.
142
- const explicitMode = mode != null
143
- mode ??= 'dark' // matches the template's existing default - only patched when 'light' is chosen.
144
-
145
- return { themeId, mode, explicitMode, name: themes.getThemePreset(themeId)?.name ?? themeId, themes }
146
- }
147
-
148
- /** Rewrite `app.css` between the `svgrid-theme` markers.
149
- *
150
- * The two templates want different things there. admin-dashboard inlines the
151
- * resolved `--sg-*` tokens, because its Tailwind layer aliases them to
152
- * `--app-*` and having the values on the page makes them editable. minimal
153
- * just points at the preset stylesheet the package already ships, which keeps
154
- * its `app.css` short enough to read in one go. Which one we are looking at
155
- * is decided by what the marked block currently holds, not by the template
156
- * name, so a hand-edited project still round-trips. */
157
- async function applyTheme(destDir, choice) {
158
- if (!choice) return
159
- const { themeId, themes } = choice
160
- const preset = themes.getThemePreset(themeId) ?? themes.defaultThemePreset
161
- const light = themes.resolveThemeTokens(preset, 'light')
162
- const dark = themes.resolveThemeTokens(preset, 'dark')
163
- const block = (selector, tokens, scheme) => {
164
- const lines = Object.entries(tokens).map(([k, v]) => ` ${k}: ${v};`).join('\n')
165
- return `${selector} {\n${lines}\n color-scheme: ${scheme};\n}`
166
- }
167
- const tokenBlocks = `${block(':root', light, 'light')}\n${block(":root[data-theme='dark']", dark, 'dark')}`
168
-
169
- const cssPath = join(destDir, 'src', 'app.css')
170
- const cssText = await readFile(cssPath, 'utf8').catch(() => null)
171
- if (cssText == null) return
172
- const marked = /\/\* svgrid-theme:start \*\/([\s\S]*?)\/\* svgrid-theme:end \*\//
173
- // A CSS @import has to stay at the top of the file, so where the block holds
174
- // one we swap the preset id in place instead of replacing it with token
175
- // declarations that would then sit above the import.
176
- const held = cssText.match(marked)
177
- const css = held && /@import\s/.test(held[1])
178
- ? `@import '@svgrid/grid/themes/${preset.id}.css';`
179
- : tokenBlocks
180
- if (marked.test(cssText)) {
181
- await writeFile(cssPath, cssText.replace(marked, `/* svgrid-theme:start */\n${css}\n/* svgrid-theme:end */`))
182
- }
183
-
184
- // Templates with a RUNTIME theme picker keep the starting selection in TS, so
185
- // the picker opens on the theme that was scaffolded rather than disagreeing
186
- // with the stylesheet above. Absent in templates without a picker, where the
187
- // missing file makes this a no-op.
188
- const tsPath = join(destDir, 'src', 'lib', 'theme.svelte.ts')
189
- const tsText = await readFile(tsPath, 'utf8').catch(() => null)
190
- if (tsText == null) return
191
- const tsMarked = /\/\* svgrid-initial-theme:start \*\/([\s\S]*?)\/\* svgrid-initial-theme:end \*\//
192
- if (!tsMarked.test(tsText)) return
193
- // Only the preset here. The mode is left to applyModeSvelteKit, which pins it
194
- // only when one was actually asked for.
195
- const body = tsText.match(tsMarked)[1].replace(
196
- /export const INITIAL_THEME = '[^']*'/,
197
- `export const INITIAL_THEME = '${preset.id}'`,
198
- )
199
- await writeFile(
200
- tsPath,
201
- tsText.replace(
202
- tsMarked,
203
- `/* svgrid-initial-theme:start */${body}/* svgrid-initial-theme:end */`,
204
- ),
205
- )
206
- }
207
-
208
- /** sveltekit settles its start mode in an inline script in `src/app.html`,
209
- * falling back to the OS preference, exactly as minimal does. Pin it only when
210
- * light or dark was actually asked for. The picker in the layout reads the
211
- * attribute that script sets, so both halves have to agree. */
212
- async function applyModeSvelteKit(destDir, choice) {
213
- if (!choice || !choice.explicitMode) return
214
-
215
- const htmlPath = join(destDir, 'src', 'app.html')
216
- const html = await readFile(htmlPath, 'utf8').catch(() => null)
217
- if (html != null) {
218
- await writeFile(
219
- htmlPath,
220
- html
221
- .replace(`<html lang="en" data-theme="light">`, `<html lang="en" data-theme="${choice.mode}">`)
222
- .replace(
223
- `var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`,
224
- `var fallback = '${choice.mode}'`,
225
- ),
226
- )
227
- }
228
-
229
- const tsPath = join(destDir, 'src', 'lib', 'theme.svelte.ts')
230
- const ts = await readFile(tsPath, 'utf8').catch(() => null)
231
- if (ts == null) return
232
- await writeFile(
233
- tsPath,
234
- ts.replace(
235
- /export const INITIAL_MODE: ThemeMode = '[^']*'/,
236
- `export const INITIAL_MODE: ThemeMode = '${choice.mode}'`,
237
- ),
238
- )
239
- }
240
-
241
- /** admin-dashboard defaults to dark (see app.html / src/lib/theme.ts /
242
- * +layout.svelte). Flip all three to light-by-default when 'light' is chosen
243
- * - a no-op when 'dark' is chosen, since that already matches the template. */
244
- async function applyMode(destDir, choice) {
245
- if (!choice || choice.mode !== 'light') return
246
-
247
- const htmlPath = join(destDir, 'src', 'app.html')
248
- const html = await readFile(htmlPath, 'utf8').catch(() => null)
249
- if (html != null) {
250
- await writeFile(
251
- htmlPath,
252
- html
253
- .replace(`data-theme="dark"`, `data-theme="light"`)
254
- .replace(`t === 'light' ? 'light' : 'dark'`, `t === 'dark' ? 'dark' : 'light'`),
255
- )
256
- }
257
-
258
- const themeTsPath = join(destDir, 'src', 'lib', 'theme.ts')
259
- const themeTs = await readFile(themeTsPath, 'utf8').catch(() => null)
260
- if (themeTs != null) {
261
- await writeFile(
262
- themeTsPath,
263
- themeTs
264
- .replace('Defaults to dark (matches app.html).', 'Defaults to light (matches app.html).')
265
- .replace(`if (!browser) return 'dark'`, `if (!browser) return 'light'`)
266
- .replace(
267
- `return localStorage.getItem('theme') === 'light' ? 'light' : 'dark'`,
268
- `return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'`,
269
- ),
270
- )
271
- }
272
-
273
- const layoutPath = join(destDir, 'src', 'routes', '+layout.svelte')
274
- const layout = await readFile(layoutPath, 'utf8').catch(() => null)
275
- if (layout != null) {
276
- await writeFile(
277
- layoutPath,
278
- layout
279
- .replace('// Theme: dark by default (see app.html), toggleable + persisted.', '// Theme: light by default (see app.html), toggleable + persisted.')
280
- .replace(`let theme = $state<Theme>('dark')`, `let theme = $state<Theme>('light')`),
281
- )
282
- }
283
- }
284
-
285
- /** minimal picks its start mode in an inline script in `index.html`, falling
286
- * back to the OS preference. Pin it only when light or dark was actually asked
287
- * for - following the OS is the better default for anyone who did not say. */
288
- async function applyModeMinimal(destDir, choice) {
289
- if (!choice || !choice.explicitMode) return
290
-
291
- const htmlPath = join(destDir, 'index.html')
292
- const html = await readFile(htmlPath, 'utf8').catch(() => null)
293
- if (html == null) return
294
-
295
- await writeFile(
296
- htmlPath,
297
- html
298
- .replace(`<html lang="en" data-theme="light">`, `<html lang="en" data-theme="${choice.mode}">`)
299
- .replace(
300
- `var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`,
301
- `var fallback = '${choice.mode}'`,
302
- ),
303
- )
304
- }
305
-
306
- function sanitizeName(name) {
307
- return (
308
- name
309
- .trim()
310
- .toLowerCase()
311
- .replace(/[^a-z0-9._-]+/g, '-')
312
- .replace(/^[-_.]+|[-_.]+$/g, '') || 'sv-grid-app'
313
- )
314
- }
315
-
316
- function resolveTemplateDir(key) {
317
- const t = TEMPLATES[key]
318
- if (existsSync(t.bundled)) return t.bundled
319
- if (t.fallback && existsSync(t.fallback)) return t.fallback
320
- return null
321
- }
322
-
323
- async function copyTemplate(srcDir, destDir) {
324
- // Match only on the path RELATIVE to the template root. The absolute `src`
325
- // includes the install location (e.g. `.../node_modules/@svgrid/create/...`),
326
- // so testing it directly would match `node_modules` and skip the whole copy.
327
- const skip = /(^|[\\/])(node_modules|\.svelte-kit|\.vercel|build|dist)([\\/]|$)/
328
- await cp(srcDir, destDir, {
329
- recursive: true,
330
- filter: (src) => !skip.test(src.slice(srcDir.length)),
331
- })
332
- // Rename `_`-prefixed files back to their real dotfile names.
333
- await renameBack(destDir)
334
- }
335
-
336
- async function renameBack(dir) {
337
- for (const entry of await readdir(dir)) {
338
- const full = join(dir, entry)
339
- if ((await stat(full)).isDirectory()) {
340
- await renameBack(full)
341
- } else if (RENAME_BACK.has(entry)) {
342
- await rename(full, join(dir, RENAME_BACK.get(entry)))
343
- }
344
- }
345
- }
346
-
347
- async function setProjectName(destDir, name) {
348
- const pkgPath = join(destDir, 'package.json')
349
- if (!existsSync(pkgPath)) return
350
- try {
351
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
352
- pkg.name = name
353
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
354
- } catch {
355
- // leave the template's name if it isn't valid JSON for some reason
356
- }
357
- }
358
-
359
- async function isEmptyDir(dir) {
360
- if (!existsSync(dir)) return true
361
- const entries = await readdir(dir)
362
- return entries.filter((e) => e !== '.git').length === 0
363
- }
364
-
365
- async function main() {
366
- const args = parseArgs(process.argv.slice(2))
367
- if (args.help) return printHelp()
368
-
369
- const interactive = stdin.isTTY && stdout.isTTY
370
- let rl = null
371
- const ask = async (q, def) => {
372
- if (!interactive) return def
373
- rl ??= createInterface({ input: stdin, output: stdout })
374
- const a = (await rl.question(`${q} ${color('dim', `(${def})`)} `)).trim()
375
- return a || def
376
- }
377
-
378
- stdout.write(`\n${color('bold', '◆ @svgrid/create')} ${color('dim', 'Svelte + SvGrid')}\n\n`)
379
-
380
- // 1. Target directory.
381
- let target = args._[0]
382
- if (!target) target = await ask('Project directory:', 'my-sv-grid-app')
383
- const destDir = resolve(process.cwd(), target)
384
- const projectName = sanitizeName(basename(destDir))
385
-
386
- // 2. Template.
387
- let template = args.template
388
- if (!template) {
389
- if (interactive) {
390
- stdout.write(`\n Templates:\n`)
391
- Object.entries(TEMPLATES).forEach(([, t], i) => {
392
- stdout.write(` ${color('cyan', String(i + 1))}. ${t.label}\n`)
393
- })
394
- const pick = await ask(`\nChoose a template (1-${Object.keys(TEMPLATES).length}):`, '1')
395
- template = Object.keys(TEMPLATES)[Number(pick) - 1] ?? 'minimal'
396
- } else {
397
- template = 'minimal'
398
- }
399
- }
400
- if (!TEMPLATES[template]) {
401
- if (rl) rl.close()
402
- stdout.write(
403
- `\n${color('red', '✖')} Unknown template "${template}". Choose: ${Object.keys(TEMPLATES).join(', ')}\n`,
404
- )
405
- process.exit(1)
406
- }
407
-
408
- // 3. Safety: don't clobber a non-empty directory.
409
- if (!(await isEmptyDir(destDir)) && !args.force) {
410
- const ok = await ask(
411
- `\n${color('yellow', '!')} ${target} is not empty. Continue and overwrite files? (y/N)`,
412
- 'N',
413
- )
414
- if (!/^y(es)?$/i.test(ok)) {
415
- if (rl) rl.close()
416
- stdout.write(`${color('red', '✖')} Aborted.\n`)
417
- process.exit(1)
418
- }
419
- }
420
-
421
- const srcDir = resolveTemplateDir(template)
422
- if (!srcDir) {
423
- if (rl) rl.close()
424
- stdout.write(
425
- `\n${color('red', '✖')} Template "${template}" is not available in this build.\n`,
426
- )
427
- process.exit(1)
428
- }
429
-
430
- // 3b. Theme + light/dark mode. Both templates carry the full --sg-* palette
431
- // and a toggle, so both get asked.
432
- const themeChoice = await promptTheme(args, ask, interactive)
433
- if (rl) rl.close()
434
-
435
- // 4. Scaffold.
436
- await mkdir(destDir, { recursive: true })
437
- await copyTemplate(srcDir, destDir)
438
- await setProjectName(destDir, projectName)
439
- await applyTheme(destDir, themeChoice)
440
- await applyMode(destDir, themeChoice)
441
- await applyModeMinimal(destDir, themeChoice)
442
- await applyModeSvelteKit(destDir, themeChoice)
443
-
444
- // 5. Next steps.
445
- const rel = isAbsolute(target) || target.startsWith('.') ? target : `./${target}`
446
- stdout.write(`\n${color('green', '✔')} Scaffolded ${color('bold', projectName)} (${template}) into ${rel}\n`)
447
- if (themeChoice) {
448
- // minimal and sveltekit only pin a mode when one was asked for; otherwise
449
- // they read the OS preference at load, so reporting "dark" here would be a
450
- // lie. admin-dashboard is dark by default either way.
451
- const pinned = themeChoice.explicitMode || template === 'admin-dashboard'
452
- const mode = pinned ? themeChoice.mode : 'follows your OS'
453
- stdout.write(` ${color('dim', 'theme')} ${themeChoice.name} (${mode})\n`)
454
- }
455
- stdout.write(`\n`)
456
- stdout.write(`${color('bold', 'Next steps')}\n`)
457
- stdout.write(` cd ${target}\n`)
458
- stdout.write(` npm install\n`)
459
- stdout.write(` npm run dev\n\n`)
460
- stdout.write(`${color('dim', 'Docs:')} https://svgrid.com/docs ${color('dim', 'Pro:')} https://svgrid.com/pricing\n\n`)
461
- }
462
-
463
- main().catch((err) => {
464
- console.error(err)
465
- process.exit(1)
466
- })
1
+ #!/usr/bin/env node
2
+ // @svgrid/create - scaffold a Svelte app powered by SvGrid.
3
+ //
4
+ // npm create @svgrid@latest # interactive
5
+ // pnpm create @svgrid # interactive
6
+ // npm create @svgrid@latest my-app -- --template admin-dashboard
7
+ // npm create @svgrid@latest my-app -- -t minimal
8
+ //
9
+ // Zero runtime dependencies - Node built-ins only. Copies a bundled template,
10
+ // renames `_`-prefixed dotfiles, and rewrites the project name.
11
+
12
+ import { cp, mkdir, readdir, rename, stat, readFile, writeFile } from 'node:fs/promises'
13
+ import { existsSync } from 'node:fs'
14
+ import { dirname, join, basename, resolve, isAbsolute } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { createInterface } from 'node:readline/promises'
17
+ import { stdin, stdout } from 'node:process'
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url))
20
+
21
+ const TEMPLATES = {
22
+ minimal: {
23
+ label: 'Minimal - Vite + Svelte 5 + SvGrid, one page',
24
+ bundled: join(__dirname, 'templates', 'minimal'),
25
+ },
26
+ sveltekit: {
27
+ label: 'SvelteKit - server load, URL-driven sort, form-action edits, cookie auth + roles',
28
+ bundled: join(__dirname, 'templates', 'sveltekit'),
29
+ },
30
+ 'pivot-dashboard': {
31
+ label: 'Pivot dashboard - pivot cube + linked chart + drill-through (needs @svgrid/enterprise)',
32
+ bundled: join(__dirname, 'templates', 'pivot-dashboard'),
33
+ },
34
+ 'admin-dashboard': {
35
+ label: 'Admin dashboard - SvelteKit shell, multiple grids, deploy to Vercel',
36
+ bundled: join(__dirname, 'templates', 'admin-dashboard'),
37
+ // When running from the monorepo before `prepack` has synced the bundled
38
+ // copy, fall back to the canonical source.
39
+ fallback: join(__dirname, '..', '..', 'templates', 'sveltekit-admin-dashboard'),
40
+ },
41
+ headless: {
42
+ label: 'Headless - createSvGrid engine, your own <table> and CSS',
43
+ bundled: join(__dirname, 'templates', 'headless'),
44
+ // No <SvGrid>, so no preset stylesheet and nothing for --theme to write to.
45
+ // Asking the theme questions here would collect two answers and apply
46
+ // neither.
47
+ theme: false,
48
+ },
49
+ }
50
+
51
+ const RENAME_BACK = new Map([
52
+ ['_gitignore', '.gitignore'],
53
+ ['_npmrc', '.npmrc'],
54
+ ['_env.example', '.env.example'],
55
+ ['_package.json', 'package.json'],
56
+ ])
57
+
58
+ const c = {
59
+ reset: '\x1b[0m',
60
+ bold: '\x1b[1m',
61
+ dim: '\x1b[2m',
62
+ green: '\x1b[32m',
63
+ cyan: '\x1b[36m',
64
+ red: '\x1b[31m',
65
+ yellow: '\x1b[33m',
66
+ }
67
+ const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
68
+
69
+ function parseArgs(argv) {
70
+ const args = { _: [], template: null, force: false, help: false, theme: null, mode: null }
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i]
73
+ if (a === '--help' || a === '-h') args.help = true
74
+ else if (a === '--force' || a === '-f') args.force = true
75
+ else if (a === '--template' || a === '-t') args.template = argv[++i]
76
+ else if (a.startsWith('--template=')) args.template = a.slice('--template='.length)
77
+ else if (a === '--theme') args.theme = argv[++i]
78
+ else if (a.startsWith('--theme=')) args.theme = a.slice('--theme='.length)
79
+ else if (a === '--dark') args.mode = 'dark'
80
+ else if (a === '--light') args.mode = 'light'
81
+ else if (!a.startsWith('-')) args._.push(a)
82
+ }
83
+ return args
84
+ }
85
+
86
+ function printHelp() {
87
+ stdout.write(`
88
+ ${color('bold', '@svgrid/create')} - scaffold a Svelte app powered by SvGrid
89
+
90
+ ${color('bold', 'Usage')}
91
+ npm create @svgrid@latest [dir] -- [--template <name>] [--force]
92
+
93
+ ${color('bold', 'Templates')}
94
+ ${Object.entries(TEMPLATES)
95
+ .map(([k, t]) => ` ${color('cyan', k.padEnd(16))} ${t.label.replace(/^\S+\s+-\s+/, '')}`)
96
+ .join('\n')}
97
+
98
+ ${color('bold', 'Options')}
99
+ --theme <id> One of: ${THEME_IDS.join(', ')} (default: ember).
100
+ Ignored by headless, which ships no stylesheet to theme.
101
+ --dark / --light Pin the starting mode. Left out, minimal and sveltekit
102
+ follow the visitor's OS; admin-dashboard starts dark.
103
+ --force Scaffold into a non-empty directory.
104
+
105
+ ${color('bold', 'Examples')}
106
+ npm create @svgrid@latest
107
+ npm create @svgrid@latest my-app -- --template admin-dashboard
108
+ npm create @svgrid@latest my-app -- -t admin-dashboard --theme material --light
109
+ pnpm create @svgrid my-app -t minimal
110
+ `)
111
+ }
112
+
113
+ const THEME_IDS = [
114
+ 'ember', 'shadcn', 'tailwind', 'material', 'excel', 'fluent', 'carbon', 'sap',
115
+ 'salesforce', 'atlassian', 'github', 'antd', 'ag-alpine', 'bootstrap',
116
+ 'vercel', 'linear', 'notion', 'nord', 'dracula', 'catppuccin',
117
+ ]
118
+
119
+ /**
120
+ * Resolve the theme + mode to apply: from `--theme` / `--dark` / `--light`
121
+ * flags, or (in a TTY) an interactive prompt; otherwise left untouched (the
122
+ * template's own default look stands). Reuses `@svgrid/grid/themes`'s own
123
+ * preset list, so the picker can never drift from the canonical set.
124
+ */
125
+ async function promptTheme(args, ask, interactive) {
126
+ let themes
127
+ try {
128
+ themes = await import('@svgrid/grid/themes')
129
+ } catch {
130
+ return null // theming is best-effort; the template's own default stands in.
131
+ }
132
+
133
+ let themeId = args.theme ? args.theme.trim().toLowerCase() : null
134
+ if (themeId && !themes.getThemePreset(themeId)) {
135
+ stdout.write(`${color('yellow', '!')} Unknown theme "${themeId}" - using ember. (${THEME_IDS.join(', ')})\n`)
136
+ themeId = null
137
+ }
138
+ if (!themeId && interactive) {
139
+ const list = themes.themePresets.map((t, i) => ` ${i + 1}. ${t.name} ${color('dim', `(${t.id})`)}`).join('\n')
140
+ stdout.write(`\n${color('bold', 'Theme')}\n${list}\n`)
141
+ const choice = await ask('Pick a number or id:', 'ember')
142
+ const byIndex = themes.themePresets[Number(choice) - 1]
143
+ themeId = byIndex ? byIndex.id : (themes.getThemePreset(choice.trim().toLowerCase())?.id ?? 'ember')
144
+ }
145
+ themeId ??= 'ember'
146
+
147
+ let mode = args.mode
148
+ if (!mode && interactive) {
149
+ const a = await ask('Light or dark mode? (light/dark)', 'dark')
150
+ mode = /^l(ight)?$/i.test(a) ? 'light' : 'dark'
151
+ }
152
+ // Whether the mode was actually chosen, as opposed to falling through to the
153
+ // default below. minimal follows the OS when nobody said, which is only
154
+ // distinguishable from an explicit --dark by keeping this flag.
155
+ const explicitMode = mode != null
156
+ mode ??= 'dark' // matches the template's existing default - only patched when 'light' is chosen.
157
+
158
+ return { themeId, mode, explicitMode, name: themes.getThemePreset(themeId)?.name ?? themeId, themes }
159
+ }
160
+
161
+ /** Rewrite `app.css` between the `svgrid-theme` markers.
162
+ *
163
+ * The two templates want different things there. admin-dashboard inlines the
164
+ * resolved `--sg-*` tokens, because its Tailwind layer aliases them to
165
+ * `--app-*` and having the values on the page makes them editable. minimal
166
+ * just points at the preset stylesheet the package already ships, which keeps
167
+ * its `app.css` short enough to read in one go. Which one we are looking at
168
+ * is decided by what the marked block currently holds, not by the template
169
+ * name, so a hand-edited project still round-trips. */
170
+ async function applyTheme(destDir, choice) {
171
+ if (!choice) return
172
+ const { themeId, themes } = choice
173
+ const preset = themes.getThemePreset(themeId) ?? themes.defaultThemePreset
174
+ const light = themes.resolveThemeTokens(preset, 'light')
175
+ const dark = themes.resolveThemeTokens(preset, 'dark')
176
+ const block = (selector, tokens, scheme) => {
177
+ const lines = Object.entries(tokens).map(([k, v]) => ` ${k}: ${v};`).join('\n')
178
+ return `${selector} {\n${lines}\n color-scheme: ${scheme};\n}`
179
+ }
180
+ const tokenBlocks = `${block(':root', light, 'light')}\n${block(":root[data-theme='dark']", dark, 'dark')}`
181
+
182
+ const cssPath = join(destDir, 'src', 'app.css')
183
+ const cssText = await readFile(cssPath, 'utf8').catch(() => null)
184
+ if (cssText == null) return
185
+ const marked = /\/\* svgrid-theme:start \*\/([\s\S]*?)\/\* svgrid-theme:end \*\//
186
+ // A CSS @import has to stay at the top of the file, so where the block holds
187
+ // one we swap the preset id in place instead of replacing it with token
188
+ // declarations that would then sit above the import.
189
+ const held = cssText.match(marked)
190
+ const css = held && /@import\s/.test(held[1])
191
+ ? `@import '@svgrid/grid/themes/${preset.id}.css';`
192
+ : tokenBlocks
193
+ if (marked.test(cssText)) {
194
+ await writeFile(cssPath, cssText.replace(marked, `/* svgrid-theme:start */\n${css}\n/* svgrid-theme:end */`))
195
+ }
196
+
197
+ // Templates with a RUNTIME theme picker keep the starting selection in TS, so
198
+ // the picker opens on the theme that was scaffolded rather than disagreeing
199
+ // with the stylesheet above. Absent in templates without a picker, where the
200
+ // missing file makes this a no-op.
201
+ const tsPath = join(destDir, 'src', 'lib', 'theme.svelte.ts')
202
+ const tsText = await readFile(tsPath, 'utf8').catch(() => null)
203
+ if (tsText == null) return
204
+ const tsMarked = /\/\* svgrid-initial-theme:start \*\/([\s\S]*?)\/\* svgrid-initial-theme:end \*\//
205
+ if (!tsMarked.test(tsText)) return
206
+ // Only the preset here. The mode is left to applyModeSvelteKit, which pins it
207
+ // only when one was actually asked for.
208
+ const body = tsText.match(tsMarked)[1].replace(
209
+ /export const INITIAL_THEME = '[^']*'/,
210
+ `export const INITIAL_THEME = '${preset.id}'`,
211
+ )
212
+ await writeFile(
213
+ tsPath,
214
+ tsText.replace(
215
+ tsMarked,
216
+ `/* svgrid-initial-theme:start */${body}/* svgrid-initial-theme:end */`,
217
+ ),
218
+ )
219
+ }
220
+
221
+ /** sveltekit settles its start mode in an inline script in `src/app.html`,
222
+ * falling back to the OS preference, exactly as minimal does. Pin it only when
223
+ * light or dark was actually asked for. The picker in the layout reads the
224
+ * attribute that script sets, so both halves have to agree. */
225
+ async function applyModeSvelteKit(destDir, choice) {
226
+ if (!choice || !choice.explicitMode) return
227
+
228
+ const htmlPath = join(destDir, 'src', 'app.html')
229
+ const html = await readFile(htmlPath, 'utf8').catch(() => null)
230
+ if (html != null) {
231
+ await writeFile(
232
+ htmlPath,
233
+ html
234
+ .replace(`<html lang="en" data-theme="light">`, `<html lang="en" data-theme="${choice.mode}">`)
235
+ .replace(
236
+ `var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`,
237
+ `var fallback = '${choice.mode}'`,
238
+ ),
239
+ )
240
+ }
241
+
242
+ const tsPath = join(destDir, 'src', 'lib', 'theme.svelte.ts')
243
+ const ts = await readFile(tsPath, 'utf8').catch(() => null)
244
+ if (ts == null) return
245
+ await writeFile(
246
+ tsPath,
247
+ ts.replace(
248
+ /export const INITIAL_MODE: ThemeMode = '[^']*'/,
249
+ `export const INITIAL_MODE: ThemeMode = '${choice.mode}'`,
250
+ ),
251
+ )
252
+ }
253
+
254
+ /** admin-dashboard defaults to dark (see app.html / src/lib/theme.ts /
255
+ * +layout.svelte). Flip all three to light-by-default when 'light' is chosen
256
+ * - a no-op when 'dark' is chosen, since that already matches the template. */
257
+ async function applyMode(destDir, choice) {
258
+ if (!choice || choice.mode !== 'light') return
259
+
260
+ const htmlPath = join(destDir, 'src', 'app.html')
261
+ const html = await readFile(htmlPath, 'utf8').catch(() => null)
262
+ if (html != null) {
263
+ await writeFile(
264
+ htmlPath,
265
+ html
266
+ .replace(`data-theme="dark"`, `data-theme="light"`)
267
+ .replace(`t === 'light' ? 'light' : 'dark'`, `t === 'dark' ? 'dark' : 'light'`),
268
+ )
269
+ }
270
+
271
+ const themeTsPath = join(destDir, 'src', 'lib', 'theme.ts')
272
+ const themeTs = await readFile(themeTsPath, 'utf8').catch(() => null)
273
+ if (themeTs != null) {
274
+ await writeFile(
275
+ themeTsPath,
276
+ themeTs
277
+ .replace('Defaults to dark (matches app.html).', 'Defaults to light (matches app.html).')
278
+ .replace(`if (!browser) return 'dark'`, `if (!browser) return 'light'`)
279
+ .replace(
280
+ `return localStorage.getItem('theme') === 'light' ? 'light' : 'dark'`,
281
+ `return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'`,
282
+ ),
283
+ )
284
+ }
285
+
286
+ const layoutPath = join(destDir, 'src', 'routes', '+layout.svelte')
287
+ const layout = await readFile(layoutPath, 'utf8').catch(() => null)
288
+ if (layout != null) {
289
+ await writeFile(
290
+ layoutPath,
291
+ layout
292
+ .replace('// Theme: dark by default (see app.html), toggleable + persisted.', '// Theme: light by default (see app.html), toggleable + persisted.')
293
+ .replace(`let theme = $state<Theme>('dark')`, `let theme = $state<Theme>('light')`),
294
+ )
295
+ }
296
+ }
297
+
298
+ /** minimal picks its start mode in an inline script in `index.html`, falling
299
+ * back to the OS preference. Pin it only when light or dark was actually asked
300
+ * for - following the OS is the better default for anyone who did not say. */
301
+ async function applyModeMinimal(destDir, choice) {
302
+ if (!choice || !choice.explicitMode) return
303
+
304
+ const htmlPath = join(destDir, 'index.html')
305
+ const html = await readFile(htmlPath, 'utf8').catch(() => null)
306
+ if (html == null) return
307
+
308
+ await writeFile(
309
+ htmlPath,
310
+ html
311
+ .replace(`<html lang="en" data-theme="light">`, `<html lang="en" data-theme="${choice.mode}">`)
312
+ .replace(
313
+ `var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`,
314
+ `var fallback = '${choice.mode}'`,
315
+ ),
316
+ )
317
+ }
318
+
319
+ function sanitizeName(name) {
320
+ return (
321
+ name
322
+ .trim()
323
+ .toLowerCase()
324
+ .replace(/[^a-z0-9._-]+/g, '-')
325
+ .replace(/^[-_.]+|[-_.]+$/g, '') || 'sv-grid-app'
326
+ )
327
+ }
328
+
329
+ function resolveTemplateDir(key) {
330
+ const t = TEMPLATES[key]
331
+ if (existsSync(t.bundled)) return t.bundled
332
+ if (t.fallback && existsSync(t.fallback)) return t.fallback
333
+ return null
334
+ }
335
+
336
+ async function copyTemplate(srcDir, destDir) {
337
+ // Match only on the path RELATIVE to the template root. The absolute `src`
338
+ // includes the install location (e.g. `.../node_modules/@svgrid/create/...`),
339
+ // so testing it directly would match `node_modules` and skip the whole copy.
340
+ const skip = /(^|[\\/])(node_modules|\.svelte-kit|\.vercel|build|dist)([\\/]|$)/
341
+ await cp(srcDir, destDir, {
342
+ recursive: true,
343
+ filter: (src) => !skip.test(src.slice(srcDir.length)),
344
+ })
345
+ // Rename `_`-prefixed files back to their real dotfile names.
346
+ await renameBack(destDir)
347
+ }
348
+
349
+ async function renameBack(dir) {
350
+ for (const entry of await readdir(dir)) {
351
+ const full = join(dir, entry)
352
+ if ((await stat(full)).isDirectory()) {
353
+ await renameBack(full)
354
+ } else if (RENAME_BACK.has(entry)) {
355
+ await rename(full, join(dir, RENAME_BACK.get(entry)))
356
+ }
357
+ }
358
+ }
359
+
360
+ async function setProjectName(destDir, name) {
361
+ const pkgPath = join(destDir, 'package.json')
362
+ if (!existsSync(pkgPath)) return
363
+ try {
364
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
365
+ pkg.name = name
366
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
367
+ } catch {
368
+ // leave the template's name if it isn't valid JSON for some reason
369
+ }
370
+ }
371
+
372
+ async function isEmptyDir(dir) {
373
+ if (!existsSync(dir)) return true
374
+ const entries = await readdir(dir)
375
+ return entries.filter((e) => e !== '.git').length === 0
376
+ }
377
+
378
+ async function main() {
379
+ const args = parseArgs(process.argv.slice(2))
380
+ if (args.help) return printHelp()
381
+
382
+ const interactive = stdin.isTTY && stdout.isTTY
383
+ let rl = null
384
+ const ask = async (q, def) => {
385
+ if (!interactive) return def
386
+ rl ??= createInterface({ input: stdin, output: stdout })
387
+ const a = (await rl.question(`${q} ${color('dim', `(${def})`)} `)).trim()
388
+ return a || def
389
+ }
390
+
391
+ stdout.write(`\n${color('bold', '◆ @svgrid/create')} ${color('dim', 'Svelte + SvGrid')}\n\n`)
392
+
393
+ // 1. Target directory.
394
+ let target = args._[0]
395
+ if (!target) target = await ask('Project directory:', 'my-sv-grid-app')
396
+ const destDir = resolve(process.cwd(), target)
397
+ const projectName = sanitizeName(basename(destDir))
398
+
399
+ // 2. Template.
400
+ let template = args.template
401
+ if (!template) {
402
+ if (interactive) {
403
+ stdout.write(`\n Templates:\n`)
404
+ Object.entries(TEMPLATES).forEach(([, t], i) => {
405
+ stdout.write(` ${color('cyan', String(i + 1))}. ${t.label}\n`)
406
+ })
407
+ const pick = await ask(`\nChoose a template (1-${Object.keys(TEMPLATES).length}):`, '1')
408
+ template = Object.keys(TEMPLATES)[Number(pick) - 1] ?? 'minimal'
409
+ } else {
410
+ template = 'minimal'
411
+ }
412
+ }
413
+ if (!TEMPLATES[template]) {
414
+ if (rl) rl.close()
415
+ stdout.write(
416
+ `\n${color('red', '✖')} Unknown template "${template}". Choose: ${Object.keys(TEMPLATES).join(', ')}\n`,
417
+ )
418
+ process.exit(1)
419
+ }
420
+
421
+ // 3. Safety: don't clobber a non-empty directory.
422
+ if (!(await isEmptyDir(destDir)) && !args.force) {
423
+ const ok = await ask(
424
+ `\n${color('yellow', '!')} ${target} is not empty. Continue and overwrite files? (y/N)`,
425
+ 'N',
426
+ )
427
+ if (!/^y(es)?$/i.test(ok)) {
428
+ if (rl) rl.close()
429
+ stdout.write(`${color('red', '✖')} Aborted.\n`)
430
+ process.exit(1)
431
+ }
432
+ }
433
+
434
+ const srcDir = resolveTemplateDir(template)
435
+ if (!srcDir) {
436
+ if (rl) rl.close()
437
+ stdout.write(
438
+ `\n${color('red', '✖')} Template "${template}" is not available in this build.\n`,
439
+ )
440
+ process.exit(1)
441
+ }
442
+
443
+ // 3b. Theme + light/dark mode - only for templates that render <SvGrid> and
444
+ // therefore carry a --sg-* palette. headless has no stylesheet to write to,
445
+ // so it is neither asked nor told a theme; say so if flags implied otherwise.
446
+ const themed = TEMPLATES[template].theme !== false
447
+ if (!themed && (args.theme || args.mode)) {
448
+ stdout.write(
449
+ `${color('yellow', '!')} ${template} has no stylesheet to theme - ignoring --theme/--dark/--light.\n`,
450
+ )
451
+ }
452
+ const themeChoice = themed ? await promptTheme(args, ask, interactive) : null
453
+ if (rl) rl.close()
454
+
455
+ // 4. Scaffold.
456
+ await mkdir(destDir, { recursive: true })
457
+ await copyTemplate(srcDir, destDir)
458
+ await setProjectName(destDir, projectName)
459
+ await applyTheme(destDir, themeChoice)
460
+ await applyMode(destDir, themeChoice)
461
+ await applyModeMinimal(destDir, themeChoice)
462
+ await applyModeSvelteKit(destDir, themeChoice)
463
+
464
+ // 5. Next steps.
465
+ const rel = isAbsolute(target) || target.startsWith('.') ? target : `./${target}`
466
+ stdout.write(`\n${color('green', '✔')} Scaffolded ${color('bold', projectName)} (${template}) into ${rel}\n`)
467
+ if (themeChoice) {
468
+ // minimal and sveltekit only pin a mode when one was asked for; otherwise
469
+ // they read the OS preference at load, so reporting "dark" here would be a
470
+ // lie. admin-dashboard is dark by default either way.
471
+ const pinned = themeChoice.explicitMode || template === 'admin-dashboard'
472
+ const mode = pinned ? themeChoice.mode : 'follows your OS'
473
+ stdout.write(` ${color('dim', 'theme')} ${themeChoice.name} (${mode})\n`)
474
+ }
475
+ stdout.write(`\n`)
476
+ stdout.write(`${color('bold', 'Next steps')}\n`)
477
+ stdout.write(` cd ${target}\n`)
478
+ stdout.write(` npm install\n`)
479
+ stdout.write(` npm run dev\n\n`)
480
+ stdout.write(`${color('dim', 'Docs:')} https://svgrid.com/docs ${color('dim', 'Pro:')} https://svgrid.com/pricing\n\n`)
481
+ }
482
+
483
+ main().catch((err) => {
484
+ console.error(err)
485
+ process.exit(1)
486
+ })