create-spark-html-app 0.5.6 → 0.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/README.md CHANGED
@@ -25,11 +25,30 @@ Run it with no name to be prompted:
25
25
  npm create spark-html-app@latest
26
26
  ```
27
27
 
28
- The scaffold is a **multi-page SPA** with client-side routing (`spark-html-router`),
29
- reactive counters, todo lists with two-way binding and keyed reconciliation,
30
- slot-based composition, async declarative loading states, and shared stores
31
- with derived values all in the same monospace dark/light design as the
32
- [Spark website](https://wilkinnovo.github.io/spark).
28
+ ## What you get
29
+
30
+ The scaffold comes with the **whole Spark ecosystem pre-wired** — you delete
31
+ what you don't need instead of wiring what you do:
32
+
33
+ | Always on | Optional (prompted) |
34
+ |-----------|---------------------|
35
+ | `spark-html` — the runtime | `spark-html-router` — multi-page SPA *(default yes)* |
36
+ | `spark-html-head` — reactive title/meta | `spark-html-theme` — dark/light toggle *(yes)* |
37
+ | `spark-html-persist` — localStorage store demo | `spark-html-image` — webp/avif + srcset at build *(yes)* |
38
+ | `spark-prerender` — SEO HTML + sitemap/robots | `spark-html-sri` — integrity checks *(yes)* |
39
+ | `spark-html-devtools` — dev-only inspector | `spark-html-manifest` — PWA manifest + icons + offline shell *(no)* |
40
+
41
+ Every included feature ships with a live demo component, ready to run.
42
+
43
+ Non-interactive? Pass flags instead of answering prompts:
44
+
45
+ ```bash
46
+ npx create-spark-html-app my-app --yes # accept the defaults
47
+ npx create-spark-html-app my-app --all # everything on
48
+ npx create-spark-html-app my-app --minimal # core only
49
+ npx create-spark-html-app my-app --pwa --no-image # per-feature
50
+ ```
51
+
33
52
  Everything is plain HTML and JavaScript — no compiler, no virtual DOM, no
34
53
  proprietary file format. Edit a component, save, and the page updates.
35
54
 
package/bin/index.js CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  readdirSync,
19
19
  readFileSync,
20
20
  renameSync,
21
+ rmSync,
22
+ statSync,
21
23
  writeFileSync,
22
24
  } from 'node:fs';
23
25
  import { createInterface } from 'node:readline/promises';
@@ -96,6 +98,86 @@ async function prompt(question, fallback) {
96
98
  }
97
99
  }
98
100
 
101
+ // ── optional features ──────────────────────────────────────────────────
102
+ // The template ships with EVERYTHING wired; excluded features are stripped
103
+ // out of the copied files via `@spark:<name>` … `@spark:end` marker blocks
104
+ // (`@spark:!name` blocks are kept only when the feature is OFF).
105
+ const FEATURES = [
106
+ { key: 'router', question: 'Include router (multi-page SPA)?', def: true, deps: ['spark-html-router'], files: ['public/components/about.html'] },
107
+ { key: 'theme', question: 'Include theme (dark/light toggle)?', def: true, deps: ['spark-html-theme'], files: [] },
108
+ { key: 'image', question: 'Include image optimization?', def: true, deps: ['spark-html-image'], files: ['public/components/demo-image.html', 'public/sample.jpg'] },
109
+ { key: 'sri', question: 'Include SRI integrity checks?', def: true, deps: ['spark-html-sri'], files: [] },
110
+ { key: 'pwa', question: 'Include PWA support (manifest + offline shell)?', def: false, deps: ['spark-html-manifest'], files: ['public/icon.png'] },
111
+ ];
112
+
113
+ async function pickFeatures() {
114
+ const flags = argv.slice(3);
115
+ const on = {};
116
+ for (const f of FEATURES) on[f.key] = f.def;
117
+ if (flags.includes('--all')) for (const f of FEATURES) on[f.key] = true;
118
+ if (flags.includes('--minimal')) for (const f of FEATURES) on[f.key] = false;
119
+ for (const f of FEATURES) {
120
+ if (flags.includes(`--${f.key}`)) on[f.key] = true;
121
+ if (flags.includes(`--no-${f.key}`)) on[f.key] = false;
122
+ }
123
+ // Interactive only on a TTY, and only when no preset flag was given.
124
+ const preset = flags.some((a) => /^--(yes|all|minimal|(no-)?(router|theme|image|sri|pwa))$/.test(a));
125
+ if (stdin.isTTY && !preset) {
126
+ for (const f of FEATURES) {
127
+ const hint = f.def ? '(Y/n)' : '(y/N)';
128
+ const a = (await prompt(`${c.accent('?')} ${f.question} ${c.dim(hint)} `, '')).toLowerCase();
129
+ if (a) on[f.key] = /^y(es)?$/.test(a);
130
+ }
131
+ }
132
+ return on;
133
+ }
134
+
135
+ // Remove `@spark:` marker blocks for excluded features (and the marker
136
+ // lines themselves for included ones) across every copied text file.
137
+ function stripMarkers(text, on) {
138
+ const out = [];
139
+ let skipDepth = 0;
140
+ for (const line of text.split('\n')) {
141
+ const open = line.match(/@spark:(!?)([a-z]+)\s*(-->)?\s*$/);
142
+ if (open && open[2] !== 'end') {
143
+ const keep = open[1] === '!' ? !on[open[2]] : !!on[open[2]];
144
+ if (skipDepth || !keep) skipDepth++;
145
+ continue; // marker lines never ship
146
+ }
147
+ if (/@spark:end/.test(line)) {
148
+ if (skipDepth) skipDepth--;
149
+ continue;
150
+ }
151
+ if (!skipDepth) out.push(line);
152
+ }
153
+ return out.join('\n');
154
+ }
155
+
156
+ function walkFiles(dir) {
157
+ const out = [];
158
+ for (const name of readdirSync(dir)) {
159
+ const full = join(dir, name);
160
+ if (statSync(full).isDirectory()) out.push(...walkFiles(full));
161
+ else out.push(full);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function applyFeatures(targetDir, on) {
167
+ // 1 ─ strip marker blocks in every text file.
168
+ for (const file of walkFiles(targetDir)) {
169
+ if (!/\.(js|html|css)$/.test(file)) continue;
170
+ const text = readFileSync(file, 'utf8');
171
+ if (!text.includes('@spark:')) continue;
172
+ writeFileSync(file, stripMarkers(text, on), 'utf8');
173
+ }
174
+ // 2 ─ delete files that belong to excluded features.
175
+ for (const f of FEATURES) {
176
+ if (on[f.key]) continue;
177
+ for (const rel of f.files) rmSync(join(targetDir, rel), { force: true });
178
+ }
179
+ }
180
+
99
181
  async function main() {
100
182
  stdout.write(`\n${BOLT} ${c.bold('create-spark-html-app')}\n`);
101
183
  stdout.write(`${c.dim(' HTML that reacts — no compiler, no virtual DOM.')}\n\n`);
@@ -123,9 +205,11 @@ async function main() {
123
205
  if (!/^y(es)?$/i.test(ok)) bail('Aborted — nothing was written.');
124
206
  }
125
207
 
126
- // 3 ─ copy the template ──────────────────────────────────────────────
208
+ // 3 ─ pick features, copy the template, strip what's excluded ────────
209
+ const features = await pickFeatures();
127
210
  mkdirSync(targetDir, { recursive: true });
128
211
  cpSync(templateDir, targetDir, { recursive: true });
212
+ applyFeatures(targetDir, features);
129
213
 
130
214
  // npm renames/strips dotfiles on publish, so the template ships them
131
215
  // with safe underscore prefixes. Restore the real names here.
@@ -142,6 +226,19 @@ async function main() {
142
226
  const pkgPath = join(targetDir, 'package.json');
143
227
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
144
228
  pkg.name = projectName;
229
+ // Drop dependencies that belong to excluded features.
230
+ for (const f of FEATURES) {
231
+ if (features[f.key]) continue;
232
+ for (const dep of f.deps) {
233
+ if (pkg.dependencies) delete pkg.dependencies[dep];
234
+ if (pkg.devDependencies) delete pkg.devDependencies[dep];
235
+ }
236
+ }
237
+ // PWA config carries the app's display name.
238
+ if (features.pwa) {
239
+ const vitePath = join(targetDir, 'vite.config.js');
240
+ writeFileSync(vitePath, readFileSync(vitePath, 'utf8').replace("name: 'Spark App'", `name: '${projectName}'`), 'utf8');
241
+ }
145
242
  // Always start on the newest published versions of the spark packages. If the
146
243
  // registry can't be reached (or a package isn't published yet), the template's
147
244
  // "latest" default still resolves on install.
@@ -161,7 +258,8 @@ async function main() {
161
258
 
162
259
  // 5 ─ celebrate + print next steps ───────────────────────────────────
163
260
  const rel = relative(process.cwd(), targetDir) || '.';
164
- stdout.write(`\n${c.green('✔')} Scaffolded ${c.bold(projectName)} in ${c.cyan(rel)}\n\n`);
261
+ const picked = FEATURES.filter((f) => features[f.key]).map((f) => f.key).join(', ') || 'core only';
262
+ stdout.write(`\n${c.green('✔')} Scaffolded ${c.bold(projectName)} in ${c.cyan(rel)} ${c.dim(`(head, persist, prerender, devtools + ${picked})`)}\n\n`);
165
263
  stdout.write(`${c.bold('Next steps:')}\n`);
166
264
  if (rel !== '.') stdout.write(` ${c.dim('1.')} cd ${rel}\n`);
167
265
  stdout.write(` ${c.dim(rel !== '.' ? '2.' : '1.')} npm install\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-spark-html-app",
3
- "version": "0.5.6",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold a Vite + spark-html",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,11 @@ HTML components with built-in reactivity. No compiler, no virtual DOM, no build
5
5
 
6
6
  The scaffold is a **multi-page SPA** with client-side routing, live demos, and a
7
7
  shared design system — edit any component and save to see it update instantly.
8
+ Depending on the options you picked at scaffold time it also wires up
9
+ `spark-html-theme` (dark/light), `spark-html-image` (build-time webp/avif),
10
+ `spark-html-sri` (integrity checks), and `spark-html-manifest` (PWA manifest +
11
+ icons + offline app shell). `spark-html-head`, `spark-html-persist`,
12
+ `spark-prerender`, and `spark-html-devtools` are always included.
8
13
 
9
14
  ## Develop
10
15
 
@@ -197,8 +197,13 @@
197
197
  <body>
198
198
  <div import="components/nav"></div>
199
199
  <main class="routes">
200
+ <!-- @spark:router -->
200
201
  <template route="/"><div import="components/home"></div></template>
201
202
  <template route="/about"><div import="components/about"></div></template>
203
+ <!-- @spark:end -->
204
+ <!-- @spark:!router -->
205
+ <div import="components/home"></div>
206
+ <!-- @spark:end -->
202
207
  </main>
203
208
  <div import="components/footer"></div>
204
209
 
@@ -12,11 +12,15 @@
12
12
  "spark-html": "latest",
13
13
  "spark-html-router": "latest",
14
14
  "spark-html-theme": "latest",
15
- "spark-html-head": "latest"
15
+ "spark-html-head": "latest",
16
+ "spark-html-persist": "latest",
17
+ "spark-html-sri": "latest",
18
+ "spark-html-manifest": "latest"
16
19
  },
17
20
  "devDependencies": {
18
21
  "spark-prerender": "latest",
19
22
  "spark-html-devtools": "latest",
23
+ "spark-html-image": "latest",
20
24
  "vite": "^8.1.0"
21
25
  }
22
26
  }
@@ -0,0 +1,21 @@
1
+ <section class="card">
2
+ <header class="card-header">
3
+ <h2>Images <span class="tag">spark-html-image</span></h2>
4
+ </header>
5
+ <p class="hint">
6
+ A plain <code>&lt;img&gt;</code> — at build time it becomes a
7
+ <code>&lt;picture&gt;</code> with webp/avif srcset, width/height, and lazy
8
+ loading. Run <code>npm run build</code> and inspect <code>dist/</code>.
9
+ </p>
10
+
11
+ <img class="shot" src="/sample.jpg" alt="Sample image — optimized at build time" />
12
+ </section>
13
+
14
+ <style>
15
+ .shot {
16
+ width: 100%;
17
+ border-radius: 8px;
18
+ border: 1px solid var(--border);
19
+ display: block;
20
+ }
21
+ </style>
@@ -0,0 +1,30 @@
1
+ <section class="card">
2
+ <header class="card-header">
3
+ <h2>Persist <span class="tag">spark-html-persist</span></h2>
4
+ </header>
5
+ <p class="hint">
6
+ This store lives in <code>localStorage</code> — change it, then <b>reload the page</b>.
7
+ </p>
8
+
9
+ <p class="visits">You've loaded this app <b>{visits}</b> {visits === 1 ? 'time' : 'times'}.</p>
10
+ <label>
11
+ A note that survives reloads
12
+ <input bind:value="prefs.name" placeholder="type something, then hit reload" />
13
+ </label>
14
+ </section>
15
+
16
+ <script>
17
+ // Created in src/main.js: persist('prefs', { name: '', visits: 0 })
18
+ const prefs = useStore('prefs');
19
+ let visits = 0;
20
+
21
+ onMount(() => {
22
+ prefs.visits++; // saved to localStorage immediately
23
+ visits = prefs.visits; // local mirror for this render
24
+ });
25
+ </script>
26
+
27
+ <style>
28
+ .visits { font-size: 13.5px; margin-bottom: 14px; }
29
+ .visits b { color: var(--spark-ink); }
30
+ </style>
@@ -1,7 +1,7 @@
1
1
  <section class="card">
2
2
  <header class="card-header">
3
3
  <h2>Todos <span class="tag">bind:value + each + $:</span></h2>
4
- <span class="badge">{doneCount}/{todos.length}</span>
4
+ <span class="badge">{doneCount}/{todos.length} {pluralize(todos.length, 'item')}</span>
5
5
  </header>
6
6
  <p class="hint">Two-way binding, keyed reconciliation, and reactive statements in action.</p>
7
7
 
@@ -26,6 +26,8 @@
26
26
  </section>
27
27
 
28
28
  <script>
29
+ import { pluralize } from '../lib/format.js';
30
+
29
31
  let draft = '';
30
32
  let todos = [
31
33
  { id: 1, text: 'Learn Spark', done: true },
@@ -11,18 +11,20 @@
11
11
  <button class="round" onclick="{count = Math.max(0, count - 1)}" :disabled="count <= 0" aria-label="decrement">–</button>
12
12
  <div class="readout">
13
13
  <span class="num">{count}</span>
14
- <span class="sub">doubled is {doubled} · {mood}</span>
14
+ <span class="sub">doubled is {doubled} · {capitalize(mood)}</span>
15
15
  </div>
16
16
  <button class="round plus" onclick="{count++}" aria-label="increment">+</button>
17
17
  </div>
18
18
 
19
19
  <p class="store-line">
20
20
  You've struck the bolt <strong>{app.sparks}</strong>
21
- time{app.sparks === 1 ? '' : 's'} — that lives in a shared store.
21
+ {pluralize(app.sparks, 'time')} — that lives in a shared store.
22
22
  </p>
23
23
  </header>
24
24
 
25
25
  <script>
26
+ import { capitalize, pluralize } from '../lib/format.js';
27
+
26
28
  let count = 0;
27
29
  let igniting = false;
28
30
 
@@ -11,6 +11,13 @@
11
11
  <div import="components/demo-props"></div>
12
12
  </div>
13
13
 
14
+ <div class="grid">
15
+ <div import="components/demo-persist"></div>
16
+ <!-- @spark:image -->
17
+ <div import="components/demo-image"></div>
18
+ <!-- @spark:end -->
19
+ </div>
20
+
14
21
  <div import="components/demo-await"></div>
15
22
  </section>
16
23
 
@@ -6,16 +6,22 @@
6
6
  </a>
7
7
  <div class="nav-links">
8
8
  <a href="/">Home</a>
9
+ <!-- @spark:router -->
9
10
  <a href="/about">About</a>
11
+ <!-- @spark:end -->
10
12
  </div>
13
+ <!-- @spark:theme -->
11
14
  <button class="theme" onclick="{theme.toggle}" title="Toggle theme">
12
15
  {theme.resolved === 'dark' ? '☾' : '☀'}
13
16
  </button>
17
+ <!-- @spark:end -->
14
18
  </div>
15
19
  </nav>
16
20
 
17
21
  <script>
22
+ // @spark:theme
18
23
  const theme = useStore('theme');
24
+ // @spark:end
19
25
  </script>
20
26
 
21
27
  <style>
Binary file
@@ -0,0 +1,13 @@
1
+ export function capitalize(str) {
2
+ return str.charAt(0).toUpperCase() + str.slice(1);
3
+ }
4
+
5
+ export function pluralize(count, singular, plural) {
6
+ return count === 1 ? singular : (plural || singular + 's');
7
+ }
8
+
9
+ export function formatDate(date) {
10
+ return date.toLocaleDateString('en-US', {
11
+ year: 'numeric', month: 'short', day: 'numeric',
12
+ });
13
+ }
Binary file
@@ -1,12 +1,30 @@
1
1
  import { store } from "spark-html";
2
+ // @spark:!router
3
+ import { mount } from "spark-html";
4
+ // @spark:end
5
+ // @spark:router
2
6
  import { router } from "spark-html-router";
7
+ // @spark:end
8
+ // @spark:theme
3
9
  import { theme } from "spark-html-theme";
10
+ // @spark:end
4
11
  import { head } from "spark-html-head";
12
+ import { persist } from "spark-html-persist";
13
+ // @spark:sri
14
+ import { sri } from "spark-html-sri";
15
+ // @spark:end
5
16
  import { devtools } from "spark-html-devtools";
6
17
 
7
18
  const dev = import.meta.env?.DEV;
8
19
  if (dev) devtools(); // dev only
9
20
 
21
+ // @spark:sri
22
+ // Subresource Integrity: verifies every component fetch against the
23
+ // manifest the build baked into the page, and allow-lists remote URL
24
+ // imports (TOFU). Fails open on localhost, enforces in production.
25
+ sri();
26
+ // @spark:end
27
+
10
28
  head({
11
29
  title: { "/": "Home", "/about": "About", "*": "Not found" },
12
30
  titleTemplate: (t) => `${t} · My Site`,
@@ -16,9 +34,28 @@ head({
16
34
  // Shared stores connect components without providers or prop drilling.
17
35
  store("app", { sparks: 0 });
18
36
 
37
+ // A store that survives reloads — hydrates from localStorage on boot,
38
+ // saves on every change (see components/demo-persist.html).
39
+ persist("prefs", { name: "", visits: 0 });
40
+
41
+ // @spark:theme
19
42
  // One-line dark/light/system theming (the ⚡ logo toggles it).
20
43
  theme();
44
+ // @spark:end
45
+
46
+ // @spark:pwa
47
+ // PWA: manifest.webmanifest, icons, and the offline app-shell worker are
48
+ // generated by spark-html-manifest/vite (see vite.config.js) — the worker
49
+ // registration is injected into every built page automatically.
50
+ // Want offline-capable CDN component imports too? See spark-html-offline
51
+ // (a page registers one worker per scope, so pick the one you need).
52
+ // @spark:end
21
53
 
54
+ // @spark:router
22
55
  // Client-side router: reads <template route> blocks, intercepts <a> clicks,
23
56
  // and manages SPA navigation. Call it once — replaces mount().
24
57
  router({ devOverlay: dev, quiet: !dev });
58
+ // @spark:end
59
+ // @spark:!router
60
+ mount(document.body);
61
+ // @spark:end
@@ -1,6 +1,15 @@
1
1
  import { defineConfig } from 'vite';
2
2
  import spark from 'spark-html/vite';
3
3
  import prerender from 'spark-prerender/vite';
4
+ // @spark:image
5
+ import image from 'spark-html-image/vite';
6
+ // @spark:end
7
+ // @spark:pwa
8
+ import manifest from 'spark-html-manifest/vite';
9
+ // @spark:end
10
+ // @spark:sri
11
+ import sri from 'spark-html-sri/vite';
12
+ // @spark:end
4
13
 
5
14
  // Spark needs no build step — Vite is just a convenient dev server and
6
15
  // bundler. The plugin serves component fragments raw and full-reloads
@@ -9,11 +18,38 @@ import prerender from 'spark-prerender/vite';
9
18
  //
10
19
  // `prerender()` makes `npm run build` SEO-friendly: it runs your app at
11
20
  // build time and writes fully-rendered HTML into dist/ (crawlers and AI
12
- // tools read real content; the browser still hydrates over it). Remove it
13
- // if you don't need SEO. List every page you ship in `pages`.
21
+ // tools read real content; the browser still hydrates over it), plus
22
+ // sitemap.xml + robots.txt. Remove it if you don't need SEO.
14
23
  export default defineConfig({
24
+ optimizeDeps: {
25
+ // Ensure spark-html is pre-bundled so all modules share the same stores Map.
26
+ // Without this, file: references can create duplicate runtime instances.
27
+ include: ['spark-html'],
28
+ },
15
29
  plugins: [
16
30
  spark(),
31
+ // @spark:image
32
+ // Every <img> in pages and components: converted to webp/avif at
33
+ // multiple widths, wrapped in <picture> with srcset, width/height
34
+ // added (no layout shift), loading="lazy". Zero config.
35
+ image(),
36
+ // @spark:end
17
37
  prerender({ pages: ['index.html'] }),
38
+ // @spark:pwa
39
+ // One config → manifest.webmanifest + resized icons + <head> tags +
40
+ // an offline app-shell service worker (registered automatically).
41
+ manifest({
42
+ name: 'Spark App',
43
+ themeColor: '#ffd24a',
44
+ icon: 'public/icon.png',
45
+ offline: true,
46
+ }),
47
+ // @spark:end
48
+ // @spark:sri
49
+ // Hashes every built asset + component, stamps integrity/crossorigin
50
+ // onto script/link tags, and bakes the verify manifest into each page.
51
+ // Keep it AFTER prerender() so it sees the final pages.
52
+ sri(),
53
+ // @spark:end
18
54
  ],
19
55
  });