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