@wells-nelson/build-tools 0.0.0-stage → 0.0.1

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
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # @wells-nelson/build-tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Build and deployment tooling for SvelteKit PWAs deployed to GitHub Pages.
4
+
5
+ ## CLIs
6
+
7
+ ```json
8
+ "scripts": {
9
+ "build": "vite build && wn-postbuild --forbid-from seed/demo.json#people.name",
10
+ "icons": "wn-icons --letters MA --background \"#1f5fa8\"",
11
+ "manifest": "wn-manifest --name \"My App\" --short-name MA --background \"#f3f5f8\" --theme \"#1f5fa8\" --description \"…\""
12
+ }
13
+ ```
14
+
15
+ - `wn-postbuild`: copies `build/404.html` to `index.html` and writes `.nojekyll`. Optional
16
+ leak check: `--forbid <text>` or `--forbid-from <file.json>#<path>` fails the build if those
17
+ strings appear in it (a missing file is skipped, e.g. a git-ignored seed in CI).
18
+ - `wn-icons`: `static/icons/icon-192.png`, `icon-512.png`, `icon-maskable-512.png` and
19
+ `static/favicon.svg` from one to three letters (A–Z) and colors. No dependencies.
20
+ - `wn-manifest`: `static/manifest.webmanifest`.
21
+
22
+ The same functions are exported: `postbuild`, `collectStrings`, `writeIcons`, `renderPng`,
23
+ `renderSvg`, `renderManifest`.
24
+
25
+ ## Vite (`@wells-nelson/build-tools/vite`)
26
+
27
+ ```js
28
+ import { basePath, devJsonModule, optimizeDeps } from '@wells-nelson/build-tools/vite';
29
+
30
+ export default defineConfig(({ mode, command }) => ({
31
+ plugins: [
32
+ devJsonModule({ id: 'virtual:seed', file: 'seed/demo.json', enabled: command === 'serve' }),
33
+ sveltekit({ adapter: adapter({ fallback: '404.html' }), paths: { base: basePath({ mode, env: loadEnv(mode, process.cwd(), ''), fallback: '/my-app' }) } })
34
+ ],
35
+ optimizeDeps: optimizeDeps(['some-lazily-loaded-dep'])
36
+ }));
37
+ ```
38
+
39
+ ## Service worker (`@wells-nelson/build-tools/service-worker`)
40
+
41
+ ```ts
42
+ /// <reference types="@sveltejs/kit" />
43
+ import { version } from '$app/env';
44
+ import { assets, immutable } from '$app/manifest';
45
+ import { self } from '$app/service-worker';
46
+ import { registerAppShellCache } from '@wells-nelson/build-tools/service-worker';
47
+
48
+ registerAppShellCache({ sw: self, version, files: [...immutable, ...assets], cachePrefix: 'my-app' });
49
+ ```
50
+
51
+ ## Templates
52
+
53
+ `templates/deploy-pages.yml` (replace `__REPO__`) and `templates/manifest.webmanifest`.
54
+
55
+ Try the icons, manifest, `basePath` and `postbuild` in the sandbox's Build tools page.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wn-icons --letters WN --background "#1f5fa8" [--foreground "#ffffff"] [--out static]
4
+ *
5
+ * Writes the PWA icons and favicon; commit the output under static/.
6
+ */
7
+ import { parseArgs } from 'node:util';
8
+ import { writeIcons } from '../icons.js';
9
+ const { values } = parseArgs({
10
+ options: {
11
+ letters: { type: 'string' },
12
+ background: { type: 'string' },
13
+ foreground: { type: 'string', default: '#ffffff' },
14
+ out: { type: 'string', default: 'static' }
15
+ }
16
+ });
17
+ if (!values.letters || !values.background) {
18
+ console.error('Usage: wn-icons --letters WN --background "#1f5fa8" [--foreground "#ffffff"] [--out static]');
19
+ process.exit(1);
20
+ }
21
+ try {
22
+ const files = writeIcons({
23
+ letters: values.letters,
24
+ background: values.background,
25
+ foreground: values.foreground,
26
+ outDir: values.out
27
+ });
28
+ console.log(`icons written: ${files.join(', ')}`);
29
+ }
30
+ catch (err) {
31
+ console.error(err instanceof Error ? err.message : err);
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wn-manifest --name "My App" --short-name MA --description "…"
4
+ * --background "#f3f5f8" --theme "#1f5fa8" [--out static/manifest.webmanifest]
5
+ */
6
+ import { writeFileSync } from 'node:fs';
7
+ import { parseArgs } from 'node:util';
8
+ import { renderManifest } from '../manifest.js';
9
+ const { values } = parseArgs({
10
+ options: {
11
+ name: { type: 'string' },
12
+ 'short-name': { type: 'string' },
13
+ description: { type: 'string', default: '' },
14
+ background: { type: 'string' },
15
+ theme: { type: 'string' },
16
+ out: { type: 'string', default: 'static/manifest.webmanifest' }
17
+ }
18
+ });
19
+ const { name, 'short-name': shortName, background, theme } = values;
20
+ if (!name || !shortName || !background || !theme) {
21
+ console.error('Usage: wn-manifest --name <name> --short-name <short> --background <#hex> --theme <#hex> [--description <text>] [--out <path>]');
22
+ process.exit(1);
23
+ }
24
+ writeFileSync(values.out, renderManifest({ name, shortName, description: values.description, backgroundColor: background, themeColor: theme }));
25
+ console.log(`manifest written: ${values.out}`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wn-postbuild [--dir build] [--shell 404.html] [--forbid <text>]... [--forbid-from <file.json>#<path>]...
4
+ *
5
+ * Example: wn-postbuild --forbid-from seed/demo.json#people.name
6
+ * A missing --forbid-from file is skipped (the seed is git-ignored, so CI doesn't have it).
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { parseArgs } from 'node:util';
10
+ import { collectStrings, postbuild } from '../postbuild.js';
11
+ const { values } = parseArgs({
12
+ options: {
13
+ dir: { type: 'string', default: 'build' },
14
+ shell: { type: 'string', default: '404.html' },
15
+ forbid: { type: 'string', multiple: true, default: [] },
16
+ 'forbid-from': { type: 'string', multiple: true, default: [] }
17
+ }
18
+ });
19
+ const forbid = [...values.forbid];
20
+ for (const spec of values['forbid-from']) {
21
+ const [file, path = ''] = spec.split('#');
22
+ if (!existsSync(file))
23
+ continue;
24
+ forbid.push(...collectStrings(JSON.parse(readFileSync(file, 'utf8')), path));
25
+ }
26
+ try {
27
+ postbuild({ dir: values.dir, shell: values.shell, forbid });
28
+ }
29
+ catch (err) {
30
+ console.error(err instanceof Error ? err.message : err);
31
+ process.exit(1);
32
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PWA icons (PNG) and favicon (SVG) drawn from one to three letters, without dependencies.
3
+ * Letters are polylines with round caps in a 100x100 design space (y down), 50 units tall.
4
+ */
5
+ type Point = [number, number];
6
+ type Path = Point[];
7
+ export declare const SUPPORTED_LETTERS: string;
8
+ /** The mark's strokes in the 100x100 design space. */
9
+ export declare function markPaths(letters: string): Path[];
10
+ export declare function parseHex(hex: string): [number, number, number];
11
+ export interface IconOptions {
12
+ /** One to three letters, e.g. "WN". */
13
+ letters: string;
14
+ /** Background color, #rrggbb. */
15
+ background: string;
16
+ /** Letter color, #rrggbb (default white). */
17
+ foreground?: string;
18
+ }
19
+ /** Render the mark as a PNG. Maskable icons are full-bleed with the mark inside the safe zone. */
20
+ export declare function renderPng(size: number, { letters, background, foreground }: IconOptions, { maskable }?: {
21
+ maskable?: boolean | undefined;
22
+ }): Buffer<ArrayBuffer>;
23
+ /** The favicon: the same mark as an SVG with a rounded background. */
24
+ export declare function renderSvg({ letters, background, foreground }: IconOptions): string;
25
+ /**
26
+ * Write `icons/icon-192.png`, `icons/icon-512.png`, `icons/icon-maskable-512.png` and
27
+ * `favicon.svg` under `outDir` (SvelteKit's `static/`). Returns the written paths.
28
+ */
29
+ export declare function writeIcons(options: IconOptions & {
30
+ outDir?: string;
31
+ }): string[];
32
+ export {};
package/dist/icons.js ADDED
@@ -0,0 +1,197 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { deflateSync } from 'node:zlib';
4
+ const STROKE = 11;
5
+ /** Points along an ellipse from angle a0 to a1 (degrees, y down: -90 is up). */
6
+ function arc(cx, cy, rx, ry, a0, a1, step = 5) {
7
+ const pts = [];
8
+ const dir = a1 >= a0 ? 1 : -1;
9
+ for (let a = a0; dir > 0 ? a <= a1 : a >= a1; a += dir * step) {
10
+ const t = (a * Math.PI) / 180;
11
+ pts.push([cx + rx * Math.cos(t), cy + ry * Math.sin(t)]);
12
+ }
13
+ return pts;
14
+ }
15
+ const P = (...pts) => pts;
16
+ /** Glyphs centered on x = 0, from y = 0 (top) to y = 50 (baseline), about 21 units wide. */
17
+ const GLYPHS = {
18
+ A: [P([-10, 50], [0, 0], [10, 50]), P([-5.6, 30], [5.6, 30])],
19
+ B: [
20
+ [[-9, 50], [-9, 0], [-1, 0], ...arc(-1, 11.5, 11.5, 11.5, -90, 90), [-9, 23]],
21
+ [[-9, 23], [-3.5, 23], ...arc(-3.5, 36.5, 13.5, 13.5, -90, 90), [-9, 50]]
22
+ ],
23
+ C: [arc(0, 25, 10.5, 25, -45, -315)],
24
+ D: [[[-9, 0], [-9, 50]], [[-9, 0], ...arc(-9, 25, 19.5, 25, -90, 90)]],
25
+ E: [P([10, 0], [-9, 0], [-9, 50], [10, 50]), P([-9, 25], [6, 25])],
26
+ F: [P([10, 0], [-9, 0], [-9, 50]), P([-9, 25], [6, 25])],
27
+ G: [[...arc(0, 25, 10.5, 25, -45, -360), [2, 25]]],
28
+ H: [P([-10, 0], [-10, 50]), P([10, 0], [10, 50]), P([-10, 25], [10, 25])],
29
+ I: [P([0, 0], [0, 50]), P([-6, 0], [6, 0]), P([-6, 50], [6, 50])],
30
+ J: [[[8, 0], [8, 38], ...arc(-1, 38, 9, 12, 0, 160)]],
31
+ K: [P([-9, 0], [-9, 50]), P([10, 0], [-9, 30]), P([-2.5, 22], [10, 50])],
32
+ L: [P([-10, 0], [-10, 50], [10, 50])],
33
+ M: [P([-11, 50], [-11, 0], [0, 30], [11, 0], [11, 50])],
34
+ N: [P([-10, 50], [-10, 0], [10, 50], [10, 0])],
35
+ O: [arc(0, 25, 10.5, 25, 0, 360)],
36
+ P: [[[-9, 50], [-9, 0], [0, 0], ...arc(0, 13.5, 10, 13.5, -90, 90), [-9, 27]]],
37
+ Q: [arc(0, 25, 10.5, 25, 0, 360), P([3, 38], [11, 52])],
38
+ R: [[[-9, 50], [-9, 0], [0, 0], ...arc(0, 13.5, 10, 13.5, -90, 90), [-9, 27]], P([0, 27], [10, 50])],
39
+ S: [[...arc(0, 12.5, 12.5, 12.5, -20, -270), ...arc(0, 37.5, 12.5, 12.5, -90, 160)]],
40
+ T: [P([-11, 0], [11, 0]), P([0, 0], [0, 50])],
41
+ U: [[[-10, 0], ...arc(0, 40, 10, 10, 180, 0), [10, 0]]],
42
+ V: [P([-10, 0], [0, 50], [10, 0])],
43
+ W: [P([-12, 0], [-6, 50], [0, 18], [6, 50], [12, 0])],
44
+ X: [P([-10, 0], [10, 50]), P([10, 0], [-10, 50])],
45
+ Y: [P([-10, 0], [0, 25], [10, 0]), P([0, 25], [0, 50])],
46
+ Z: [P([-10, 0], [10, 0], [-10, 50], [10, 50])]
47
+ };
48
+ export const SUPPORTED_LETTERS = Object.keys(GLYPHS).join('');
49
+ /** Horizontal centers of each letter for a mark of 1, 2 or 3 letters. */
50
+ const SLOTS = { 1: [50], 2: [32, 68], 3: [21, 50, 79] };
51
+ /** The mark's strokes in the 100x100 design space. */
52
+ export function markPaths(letters) {
53
+ const chars = [...letters.toUpperCase()];
54
+ const slots = SLOTS[chars.length];
55
+ if (!slots)
56
+ throw new Error(`Icons need 1 to 3 letters, got "${letters}".`);
57
+ return chars.flatMap((ch, i) => {
58
+ const glyph = GLYPHS[ch];
59
+ if (!glyph)
60
+ throw new Error(`No glyph for "${ch}". Supported: ${SUPPORTED_LETTERS}.`);
61
+ return glyph.map((path) => path.map(([x, y]) => [slots[i] + x, 25 + y]));
62
+ });
63
+ }
64
+ export function parseHex(hex) {
65
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
66
+ if (!m)
67
+ throw new Error(`Expected a #rrggbb color, got "${hex}".`);
68
+ const n = parseInt(m[1], 16);
69
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
70
+ }
71
+ function distToSegment(px, py, [ax, ay], [bx, by]) {
72
+ const dx = bx - ax;
73
+ const dy = by - ay;
74
+ const len2 = dx * dx + dy * dy;
75
+ const t = len2 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
76
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
77
+ }
78
+ function distToGlyph(paths, x, y) {
79
+ let min = Infinity;
80
+ for (const path of paths) {
81
+ for (let i = 0; i < path.length - 1; i++)
82
+ min = Math.min(min, distToSegment(x, y, path[i], path[i + 1]));
83
+ }
84
+ return min;
85
+ }
86
+ function inRoundedRect(x, y, size, radius) {
87
+ const cx = Math.min(Math.max(x, radius), size - radius);
88
+ const cy = Math.min(Math.max(y, radius), size - radius);
89
+ return Math.hypot(x - cx, y - cy) <= radius;
90
+ }
91
+ /** Render the mark as a PNG. Maskable icons are full-bleed with the mark inside the safe zone. */
92
+ export function renderPng(size, { letters, background, foreground = '#ffffff' }, { maskable = false } = {}) {
93
+ const paths = markPaths(letters);
94
+ const BG = parseHex(background);
95
+ const FG = parseHex(foreground);
96
+ const SS = 4; // supersampling per axis
97
+ const glyphScale = maskable ? 0.62 : 0.82;
98
+ const offset = (1 - glyphScale) * 50;
99
+ const radius = maskable ? 0 : size * 0.22;
100
+ const toGlyph = (v) => ((v / size) * 100 - offset) / glyphScale;
101
+ // Half a pixel's diagonal in design units: pixels further than this from the stroke edge are
102
+ // entirely inside or outside it, so only edge pixels need supersampling.
103
+ const halfDiagonal = (Math.SQRT2 / 2) * (100 / size / glyphScale);
104
+ const px = Buffer.alloc(size * size * 4);
105
+ for (let y = 0; y < size; y++) {
106
+ for (let x = 0; x < size; x++) {
107
+ const d = distToGlyph(paths, toGlyph(x + 0.5), toGlyph(y + 0.5));
108
+ const coverage = d > STROKE / 2 + halfDiagonal ? 0 : d < STROKE / 2 - halfDiagonal ? 1 : -1;
109
+ let bg = 0;
110
+ let fg = 0;
111
+ for (let sy = 0; sy < SS; sy++) {
112
+ for (let sx = 0; sx < SS; sx++) {
113
+ const fx = x + (sx + 0.5) / SS;
114
+ const fy = y + (sy + 0.5) / SS;
115
+ if (!maskable && !inRoundedRect(fx, fy, size, radius))
116
+ continue;
117
+ bg++;
118
+ if (coverage === 1 || (coverage === -1 && distToGlyph(paths, toGlyph(fx), toGlyph(fy)) <= STROKE / 2))
119
+ fg++;
120
+ }
121
+ }
122
+ const i = (y * size + x) * 4;
123
+ const f = bg ? fg / bg : 0;
124
+ for (let c = 0; c < 3; c++)
125
+ px[i + c] = Math.round(BG[c] * (1 - f) + FG[c] * f);
126
+ px[i + 3] = Math.round((bg / (SS * SS)) * 255);
127
+ }
128
+ }
129
+ return encodePng(size, size, px);
130
+ }
131
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
132
+ let c = n;
133
+ for (let k = 0; k < 8; k++)
134
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
135
+ return c >>> 0;
136
+ });
137
+ function crc32(buf) {
138
+ let c = 0xffffffff;
139
+ for (const b of buf)
140
+ c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
141
+ return (c ^ 0xffffffff) >>> 0;
142
+ }
143
+ function chunk(type, data) {
144
+ const len = Buffer.alloc(4);
145
+ len.writeUInt32BE(data.length);
146
+ const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
147
+ const crc = Buffer.alloc(4);
148
+ crc.writeUInt32BE(crc32(td));
149
+ return Buffer.concat([len, td, crc]);
150
+ }
151
+ function encodePng(w, h, rgba) {
152
+ const ihdr = Buffer.alloc(13);
153
+ ihdr.writeUInt32BE(w, 0);
154
+ ihdr.writeUInt32BE(h, 4);
155
+ ihdr[8] = 8; // bit depth
156
+ ihdr[9] = 6; // RGBA
157
+ const raw = Buffer.alloc((w * 4 + 1) * h);
158
+ for (let y = 0; y < h; y++)
159
+ rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
160
+ return Buffer.concat([
161
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
162
+ chunk('IHDR', ihdr),
163
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
164
+ chunk('IEND', Buffer.alloc(0))
165
+ ]);
166
+ }
167
+ /** The favicon: the same mark as an SVG with a rounded background. */
168
+ export function renderSvg({ letters, background, foreground = '#ffffff' }) {
169
+ const s = 0.82;
170
+ const o = (1 - s) * 50;
171
+ const pt = ([x, y]) => `${(o + x * s).toFixed(2)} ${(o + y * s).toFixed(2)}`;
172
+ const paths = markPaths(letters).map((p) => `<path d="M${p.map(pt).join(' L')}"/>`);
173
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
174
+ <rect width="100" height="100" rx="22" fill="${background}"/>
175
+ <g fill="none" stroke="${foreground}" stroke-width="${(STROKE * s).toFixed(2)}" stroke-linecap="round" stroke-linejoin="round">
176
+ ${paths.join('\n')}
177
+ </g>
178
+ </svg>
179
+ `;
180
+ }
181
+ /**
182
+ * Write `icons/icon-192.png`, `icons/icon-512.png`, `icons/icon-maskable-512.png` and
183
+ * `favicon.svg` under `outDir` (SvelteKit's `static/`). Returns the written paths.
184
+ */
185
+ export function writeIcons(options) {
186
+ const outDir = options.outDir ?? 'static';
187
+ mkdirSync(join(outDir, 'icons'), { recursive: true });
188
+ const files = [
189
+ [join(outDir, 'icons/icon-192.png'), renderPng(192, options)],
190
+ [join(outDir, 'icons/icon-512.png'), renderPng(512, options)],
191
+ [join(outDir, 'icons/icon-maskable-512.png'), renderPng(512, options, { maskable: true })],
192
+ [join(outDir, 'favicon.svg'), renderSvg(options)]
193
+ ];
194
+ for (const [path, data] of files)
195
+ writeFileSync(path, data);
196
+ return files.map(([path]) => path);
197
+ }
@@ -0,0 +1,3 @@
1
+ export { SUPPORTED_LETTERS, markPaths, parseHex, renderPng, renderSvg, writeIcons, type IconOptions } from './icons.js';
2
+ export { renderManifest, type ManifestOptions } from './manifest.js';
3
+ export { collectStrings, postbuild, type PostbuildOptions } from './postbuild.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { SUPPORTED_LETTERS, markPaths, parseHex, renderPng, renderSvg, writeIcons } from './icons.js';
2
+ export { renderManifest } from './manifest.js';
3
+ export { collectStrings, postbuild } from './postbuild.js';
@@ -0,0 +1,16 @@
1
+ export interface ManifestOptions {
2
+ /** e.g. "My App — Projects" */
3
+ name: string;
4
+ /** e.g. "MA" */
5
+ shortName: string;
6
+ description: string;
7
+ /** Splash background, usually the theme's --bg. */
8
+ backgroundColor: string;
9
+ /** Browser UI color, usually the theme's --accent. */
10
+ themeColor: string;
11
+ }
12
+ /**
13
+ * `static/manifest.webmanifest` for an installable PWA served from any base path (relative
14
+ * start_url and scope), using the icons `writeIcons` produces.
15
+ */
16
+ export declare function renderManifest(o: ManifestOptions): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `static/manifest.webmanifest` for an installable PWA served from any base path (relative
3
+ * start_url and scope), using the icons `writeIcons` produces.
4
+ */
5
+ export function renderManifest(o) {
6
+ const manifest = {
7
+ name: o.name,
8
+ short_name: o.shortName,
9
+ description: o.description,
10
+ start_url: './',
11
+ scope: './',
12
+ display: 'standalone',
13
+ background_color: o.backgroundColor,
14
+ theme_color: o.themeColor,
15
+ icons: [
16
+ { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
17
+ { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
18
+ { src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
19
+ { src: 'favicon.svg', sizes: 'any', type: 'image/svg+xml' }
20
+ ]
21
+ };
22
+ return `${JSON.stringify(manifest, null, '\t')}\n`;
23
+ }
@@ -0,0 +1,26 @@
1
+ export interface PostbuildOptions {
2
+ /** adapter-static output directory. */
3
+ dir?: string;
4
+ /** The SPA shell adapter-static writes (its `fallback`). */
5
+ shell?: string;
6
+ /**
7
+ * Strings that must never appear in the build, e.g. names from dev-only seed data. The build
8
+ * fails if any text file contains one.
9
+ */
10
+ forbid?: string[];
11
+ log?: (message: string) => void;
12
+ }
13
+ /**
14
+ * GitHub Pages fixups for a SvelteKit adapter-static build:
15
+ * - copy the SPA shell (404.html, which Pages serves for every unknown path) to index.html, so
16
+ * the root URL and the service worker's offline shell exist too;
17
+ * - write .nojekyll so Pages' Jekyll doesn't hide the _app directory;
18
+ * - optionally check that forbidden strings (e.g. dev seed data) didn't leak into the build.
19
+ */
20
+ export declare function postbuild({ dir, shell, forbid, log }?: PostbuildOptions): void;
21
+ /**
22
+ * Collect strings from a JSON value along a dotted path, walking through arrays, e.g.
23
+ * `collectStrings(seed, 'people.name')`. Strings shorter than `minLength` are skipped so
24
+ * common short words don't cause false alarms.
25
+ */
26
+ export declare function collectStrings(value: unknown, path: string, minLength?: number): string[];
@@ -0,0 +1,55 @@
1
+ import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const TEXT_FILE = /\.(js|html|json|css|webmanifest|txt|xml|svg)$/;
4
+ /**
5
+ * GitHub Pages fixups for a SvelteKit adapter-static build:
6
+ * - copy the SPA shell (404.html, which Pages serves for every unknown path) to index.html, so
7
+ * the root URL and the service worker's offline shell exist too;
8
+ * - write .nojekyll so Pages' Jekyll doesn't hide the _app directory;
9
+ * - optionally check that forbidden strings (e.g. dev seed data) didn't leak into the build.
10
+ */
11
+ export function postbuild({ dir = 'build', shell = '404.html', forbid = [], log = console.log } = {}) {
12
+ const shellPath = join(dir, shell);
13
+ if (!existsSync(shellPath))
14
+ throw new Error(`${shellPath} not found; did the build run?`);
15
+ copyFileSync(shellPath, join(dir, 'index.html'));
16
+ writeFileSync(join(dir, '.nojekyll'), '');
17
+ log(`postbuild: wrote ${join(dir, 'index.html')} and ${join(dir, '.nojekyll')}`);
18
+ const needles = forbid.filter(Boolean);
19
+ if (needles.length === 0)
20
+ return;
21
+ const files = [];
22
+ const walk = (d) => {
23
+ for (const name of readdirSync(d)) {
24
+ const path = join(d, name);
25
+ if (statSync(path).isDirectory())
26
+ walk(path);
27
+ else if (TEXT_FILE.test(name))
28
+ files.push(path);
29
+ }
30
+ };
31
+ walk(dir);
32
+ for (const file of files) {
33
+ const text = readFileSync(file, 'utf8');
34
+ const hit = needles.find((n) => text.includes(n));
35
+ if (hit !== undefined)
36
+ throw new Error(`postbuild: forbidden content found in ${file}. It must never be deployed.`);
37
+ }
38
+ log(`postbuild: checked ${files.length} files, no forbidden content in the build`);
39
+ }
40
+ /**
41
+ * Collect strings from a JSON value along a dotted path, walking through arrays, e.g.
42
+ * `collectStrings(seed, 'people.name')`. Strings shorter than `minLength` are skipped so
43
+ * common short words don't cause false alarms.
44
+ */
45
+ export function collectStrings(value, path, minLength = 4) {
46
+ let current = [value];
47
+ for (const key of path.split('.').filter(Boolean)) {
48
+ current = current
49
+ .flatMap((v) => (Array.isArray(v) ? v : [v]))
50
+ .map((v) => (v && typeof v === 'object' ? v[key] : undefined));
51
+ }
52
+ return current
53
+ .flatMap((v) => (Array.isArray(v) ? v : [v]))
54
+ .filter((v) => typeof v === 'string' && v.length >= minLength);
55
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Offline-first app shell for a SvelteKit service worker. Everything the app needs is precached
3
+ * on install; navigations fall back to the cached shell (the app root) when the network is
4
+ * unavailable; cross-origin requests (Supabase, Google avatars) are left to the network.
5
+ *
6
+ * ```ts
7
+ * // src/service-worker/index.ts
8
+ * /// <reference types="@sveltejs/kit" />
9
+ * import { version } from '$app/env';
10
+ * import { assets, immutable } from '$app/manifest';
11
+ * import { self } from '$app/service-worker';
12
+ * import { registerAppShellCache } from '@wells-nelson/build-tools/service-worker';
13
+ *
14
+ * registerAppShellCache({ sw: self, version, files: [...immutable, ...assets], cachePrefix: 'my-app' });
15
+ * ```
16
+ */
17
+ /** The parts of ServiceWorkerGlobalScope this uses (so it type-checks without the WebWorker lib). */
18
+ export interface ServiceWorkerScopeLike {
19
+ registration: {
20
+ scope: string;
21
+ };
22
+ clients: {
23
+ claim(): Promise<void>;
24
+ };
25
+ skipWaiting(): Promise<void>;
26
+ addEventListener(type: string, listener: (event: any) => void): void;
27
+ }
28
+ export interface AppShellCacheOptions {
29
+ sw: ServiceWorkerScopeLike;
30
+ /** Build version; each deploy gets a fresh cache and old ones are deleted. */
31
+ version: string;
32
+ /** Files to precache, relative to the scope (SvelteKit's `immutable` and `assets`). */
33
+ files: Array<string | {
34
+ path: string;
35
+ }>;
36
+ /** Cache name prefix (default 'app'). */
37
+ cachePrefix?: string;
38
+ }
39
+ export declare function registerAppShellCache({ sw, version, files, cachePrefix }: AppShellCacheOptions): {
40
+ cacheName: string;
41
+ precache: string[];
42
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Offline-first app shell for a SvelteKit service worker. Everything the app needs is precached
3
+ * on install; navigations fall back to the cached shell (the app root) when the network is
4
+ * unavailable; cross-origin requests (Supabase, Google avatars) are left to the network.
5
+ *
6
+ * ```ts
7
+ * // src/service-worker/index.ts
8
+ * /// <reference types="@sveltejs/kit" />
9
+ * import { version } from '$app/env';
10
+ * import { assets, immutable } from '$app/manifest';
11
+ * import { self } from '$app/service-worker';
12
+ * import { registerAppShellCache } from '@wells-nelson/build-tools/service-worker';
13
+ *
14
+ * registerAppShellCache({ sw: self, version, files: [...immutable, ...assets], cachePrefix: 'my-app' });
15
+ * ```
16
+ */
17
+ export function registerAppShellCache({ sw, version, files, cachePrefix = 'app' }) {
18
+ const CACHE = `${cachePrefix}-cache-${version}`;
19
+ const scope = new URL(sw.registration.scope);
20
+ // The app root serves the SPA shell (index.html on GitHub Pages, the fallback page elsewhere).
21
+ const SHELL = scope.href;
22
+ const PRECACHE = files.map((f) => new URL(typeof f === 'string' ? f : f.path, scope).href).concat(SHELL);
23
+ sw.addEventListener('install', (event) => {
24
+ event.waitUntil((async () => {
25
+ const cache = await caches.open(CACHE);
26
+ await cache.addAll(PRECACHE);
27
+ await sw.skipWaiting();
28
+ })());
29
+ });
30
+ sw.addEventListener('activate', (event) => {
31
+ event.waitUntil((async () => {
32
+ for (const key of await caches.keys())
33
+ if (key !== CACHE)
34
+ await caches.delete(key);
35
+ await sw.clients.claim();
36
+ })());
37
+ });
38
+ sw.addEventListener('fetch', (event) => {
39
+ const { request } = event;
40
+ if (request.method !== 'GET')
41
+ return;
42
+ const url = new URL(request.url);
43
+ if (url.origin !== scope.origin)
44
+ return;
45
+ if (request.mode === 'navigate') {
46
+ event.respondWith((async () => {
47
+ try {
48
+ const response = await fetch(request);
49
+ // GitHub Pages serves the SPA shell as 404.html with a 404 status for deep links.
50
+ if (response.ok || response.status === 404)
51
+ return response;
52
+ throw new Error(`HTTP ${response.status}`);
53
+ }
54
+ catch {
55
+ const cached = await caches.match(SHELL);
56
+ return cached ?? Response.error();
57
+ }
58
+ })());
59
+ return;
60
+ }
61
+ event.respondWith((async () => {
62
+ const cache = await caches.open(CACHE);
63
+ const cached = await cache.match(request);
64
+ if (cached)
65
+ return cached;
66
+ const response = await fetch(request);
67
+ if (response.ok && url.pathname.startsWith(scope.pathname))
68
+ cache.put(request, response.clone());
69
+ return response;
70
+ })());
71
+ });
72
+ return { cacheName: CACHE, precache: PRECACHE };
73
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * SvelteKit `paths.base` from the BASE_PATH env var: the one place the deploy path is configured.
4
+ * GitHub project pages are served from /<repo-name> (the `fallback`); set BASE_PATH to "/" for
5
+ * a custom domain. Development always serves from the root.
6
+ */
7
+ export declare function basePath({ mode, env, fallback }: {
8
+ mode: string;
9
+ env: Record<string, string | undefined>;
10
+ /** e.g. '/my-app' */
11
+ fallback: string;
12
+ }): '' | `/${string}`;
13
+ /**
14
+ * Runtime dependencies of `@wells-nelson/data-storage`. Pre-bundle them when the dev server
15
+ * starts; otherwise Vite discovers some mid-session, re-bundles, and the already-loaded modules
16
+ * break ("Cannot read properties of undefined (reading 'call')" / a 500 page) until a full reload.
17
+ */
18
+ export declare const SHARED_OPTIMIZE_DEPS: string[];
19
+ /** `optimizeDeps` with the shared list plus the app's own lazily loaded deps (e.g. a search library). */
20
+ export declare function optimizeDeps(extra?: string[]): {
21
+ include: string[];
22
+ };
23
+ /**
24
+ * Serve a git-ignored JSON file (e.g. seed data with real names) as a virtual module to the dev
25
+ * server only. Builds always get `null`, so the data can never be bundled or deployed.
26
+ *
27
+ * ```js
28
+ * plugins: [devJsonModule({ id: 'virtual:seed', file: 'seed/demo.json', enabled: command === 'serve' }), sveltekit()]
29
+ * ```
30
+ */
31
+ export declare function devJsonModule({ id, file, enabled }: {
32
+ id: string;
33
+ file: string;
34
+ enabled: boolean;
35
+ }): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ /**
4
+ * SvelteKit `paths.base` from the BASE_PATH env var: the one place the deploy path is configured.
5
+ * GitHub project pages are served from /<repo-name> (the `fallback`); set BASE_PATH to "/" for
6
+ * a custom domain. Development always serves from the root.
7
+ */
8
+ export function basePath({ mode, env, fallback }) {
9
+ const raw = mode === 'development' ? '/' : env.BASE_PATH || fallback;
10
+ return raw === '/' ? '' : `/${raw.replace(/^\/+|\/+$/g, '')}`;
11
+ }
12
+ /**
13
+ * Runtime dependencies of `@wells-nelson/data-storage`. Pre-bundle them when the dev server
14
+ * starts; otherwise Vite discovers some mid-session, re-bundles, and the already-loaded modules
15
+ * break ("Cannot read properties of undefined (reading 'call')" / a 500 page) until a full reload.
16
+ */
17
+ export const SHARED_OPTIMIZE_DEPS = [
18
+ '@supabase/supabase-js',
19
+ '@tanstack/db',
20
+ '@tanstack/rxdb-db-collection',
21
+ '@tanstack/svelte-db',
22
+ 'rxdb/plugins/core',
23
+ 'rxdb/plugins/replication-supabase',
24
+ 'rxdb/plugins/storage-dexie'
25
+ ];
26
+ /** `optimizeDeps` with the shared list plus the app's own lazily loaded deps (e.g. a search library). */
27
+ export function optimizeDeps(extra = []) {
28
+ return { include: [...extra, ...SHARED_OPTIMIZE_DEPS] };
29
+ }
30
+ /**
31
+ * Serve a git-ignored JSON file (e.g. seed data with real names) as a virtual module to the dev
32
+ * server only. Builds always get `null`, so the data can never be bundled or deployed.
33
+ *
34
+ * ```js
35
+ * plugins: [devJsonModule({ id: 'virtual:seed', file: 'seed/demo.json', enabled: command === 'serve' }), sveltekit()]
36
+ * ```
37
+ */
38
+ export function devJsonModule({ id, file, enabled }) {
39
+ const path = resolve(file);
40
+ const resolved = `\0${id}`;
41
+ return {
42
+ name: `dev-json:${id}`,
43
+ resolveId: (source) => (source === id ? resolved : undefined),
44
+ load(source) {
45
+ if (source !== resolved)
46
+ return;
47
+ if (!enabled || !existsSync(path))
48
+ return 'export default null;';
49
+ this.addWatchFile(path);
50
+ return `export default ${readFileSync(path, 'utf8')};`;
51
+ }
52
+ };
53
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@wells-nelson/build-tools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Build and deployment tooling for Wells Nelson SvelteKit PWAs: postbuild, PWA icon generator, Vite config pieces, service worker, and templates.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/bnelson729/wells-nelson-components.git",
9
+ "directory": "src/npm/build-tools"
10
+ },
11
+ "homepage": "https://github.com/bnelson729/wells-nelson-components/tree/main/src/npm/build-tools#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/bnelson729/wells-nelson-components/issues"
14
+ },
15
+ "type": "module",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./vite": {
23
+ "types": "./dist/vite.d.ts",
24
+ "default": "./dist/vite.js"
25
+ },
26
+ "./service-worker": {
27
+ "types": "./dist/service-worker.d.ts",
28
+ "default": "./dist/service-worker.js"
29
+ },
30
+ "./templates/*": "./templates/*"
31
+ },
32
+ "bin": {
33
+ "wn-postbuild": "./dist/cli/postbuild.js",
34
+ "wn-icons": "./dist/cli/icons.js",
35
+ "wn-manifest": "./dist/cli/manifest.js"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "templates"
40
+ ],
41
+ "sideEffects": false,
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "check": "tsc -p tsconfig.json",
45
+ "test": "vitest run",
46
+ "lint:package": "publint"
47
+ },
48
+ "peerDependencies": {
49
+ "vite": ">=6"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "vite": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "provenance": true
62
+ }
63
+ }
@@ -0,0 +1,49 @@
1
+ # GitHub Pages deploy for a SvelteKit adapter-static app.
2
+ # Copy to .github/workflows/deploy.yml and replace __REPO__ with the repository name.
3
+ name: Deploy to GitHub Pages
4
+
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+
15
+ concurrency:
16
+ group: pages
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: actions/setup-node@v4
25
+ with:
26
+ node-version: 24
27
+ cache: npm
28
+ - run: npm ci
29
+ - run: npm run build
30
+ env:
31
+ # Repository variables (Settings > Secrets and variables > Actions > Variables).
32
+ # Leave the Supabase ones unset to deploy a local-only build.
33
+ VITE_SUPABASE_URL: ${{ vars.SUPABASE_URL }}
34
+ VITE_SUPABASE_ANON_KEY: ${{ vars.SUPABASE_ANON_KEY }}
35
+ # "/__REPO__" for <user>.github.io/__REPO__; set BASE_PATH to "/" for a custom domain.
36
+ BASE_PATH: ${{ vars.BASE_PATH || '/__REPO__' }}
37
+ - uses: actions/upload-pages-artifact@v3
38
+ with:
39
+ path: build
40
+
41
+ deploy:
42
+ needs: build
43
+ runs-on: ubuntu-latest
44
+ environment:
45
+ name: github-pages
46
+ url: ${{ steps.deployment.outputs.page_url }}
47
+ steps:
48
+ - id: deployment
49
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "short_name": "__SHORT_NAME__",
4
+ "description": "__DESCRIPTION__",
5
+ "start_url": "./",
6
+ "scope": "./",
7
+ "display": "standalone",
8
+ "background_color": "#f3f5f8",
9
+ "theme_color": "#1f5fa8",
10
+ "icons": [
11
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
12
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
13
+ { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
14
+ { "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml" }
15
+ ]
16
+ }