@sempods/apps 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +6 -3
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +322 -111
  5. package/instructions/publish.md +13 -7
  6. package/instructions/setup.md +38 -23
  7. package/instructions/visual-check.md +43 -0
  8. package/package.json +3 -3
  9. package/scripts/build-site.mjs +15 -3
  10. package/scripts/check.mjs +3 -1
  11. package/scripts/icon.mjs +47 -0
  12. package/scripts/lib/apps.mjs +56 -2
  13. package/scripts/lib/generate.mjs +55 -14
  14. package/scripts/lib/icon.mjs +192 -0
  15. package/scripts/lib/overview.mjs +5 -3
  16. package/scripts/lib/page.mjs +137 -0
  17. package/scripts/lib/themes.mjs +58 -0
  18. package/scripts/migrate.mjs +44 -14
  19. package/scripts/new-app.mjs +43 -10
  20. package/scripts/sdk-update.mjs +16 -1
  21. package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
  22. package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
  23. package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
  24. package/shared/snapshot.json +4 -4
  25. package/skeleton/app/NOTES.md +20 -2
  26. package/skeleton/app/index.html +7 -3
  27. package/skeleton/app/package.json +6 -6
  28. package/skeleton/app/src/App.tsx +53 -21
  29. package/skeleton/app/src/NewVersionNotice.tsx +6 -11
  30. package/skeleton/app/src/examples/ListScreen.tsx +176 -0
  31. package/skeleton/app/src/index.css +86 -13
  32. package/skeleton/app/src/main.tsx +3 -0
  33. package/skeleton/app/src/pwa.ts +1 -1
  34. package/skeleton/app/src/routes.test.ts +19 -0
  35. package/skeleton/app/src/routes.ts +139 -0
  36. package/skeleton/app/src/texts.test.ts +28 -0
  37. package/skeleton/app/src/texts.ts +35 -0
  38. package/skeleton/app/src/themes/calm.css +11 -0
  39. package/skeleton/app/src/themes/fresh.css +11 -0
  40. package/skeleton/app/src/themes/playful.css +11 -0
  41. package/skeleton/app/src/themes/warm.css +11 -0
  42. package/skeleton/app/src/ui/Composer.tsx +18 -0
  43. package/skeleton/app/src/ui/ContextSwitch.tsx +94 -0
  44. package/skeleton/app/src/ui/Dialog.tsx +120 -0
  45. package/skeleton/app/src/ui/Menu.tsx +118 -0
  46. package/skeleton/app/src/ui/Tabs.tsx +39 -0
  47. package/skeleton/app/src/ui/dismiss.ts +30 -0
  48. package/skeleton/app/src/ui/icons.tsx +53 -0
  49. package/skeleton/app/src/ui/ui.css +249 -0
  50. package/skeleton/app/vite.config.ts +4 -4
  51. package/skills/update/SKILL.md +4 -2
  52. package/skeleton/app/public/favicon.svg +0 -1
  53. package/skeleton/app/public/icon-192.png +0 -0
  54. package/skeleton/app/public/icon-512.png +0 -0
  55. package/skeleton/app/src/start.test.ts +0 -15
  56. package/skeleton/app/src/start.ts +0 -9
@@ -0,0 +1,192 @@
1
+ // The default app icon: a monogram, the first letter of the app's title on a
2
+ // rounded square in the theme's accent. new-app and icon write it as
3
+ // public/favicon.svg and as the PNG icons of the web app manifest, all from
4
+ // the same strokes, so no font or image tool is needed.
5
+ import { mkdirSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { crc32, deflateSync } from 'node:zlib';
8
+
9
+ const VIEW = 64;
10
+ const RADIUS = 14;
11
+ const STROKE = 5;
12
+ // Letters are drawn in a box 28 units high from y = 18, centred horizontally.
13
+ const TOP = 18;
14
+ // The calm theme's accent and the text on it, in light and dark mode; only
15
+ // the SVG can follow the page's mode.
16
+ export const CALM = {
17
+ light: { background: '#1f2933', letter: '#ffffff' },
18
+ dark: { background: '#e5e7eb', letter: '#111315' },
19
+ };
20
+
21
+ /** Points along an elliptical arc; angles in degrees, 0 = right, 90 = down. */
22
+ function arc(cx, cy, rx, ry, from, to) {
23
+ const steps = Math.max(2, Math.ceil(Math.abs(to - from) / 6));
24
+ return Array.from({ length: steps + 1 }, (_, i) => {
25
+ const angle = ((from + ((to - from) * i) / steps) * Math.PI) / 180;
26
+ return [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)];
27
+ });
28
+ }
29
+
30
+ // One entry per letter: strokes as lists of points in the letter's box.
31
+ const GLYPHS = {
32
+ A: [[[0, 28], [11, 0], [22, 28]], [[4.5, 18], [17.5, 18]]],
33
+ B: [
34
+ [[0, 28], [0, 0], [10, 0], ...arc(10, 6.5, 6.5, 6.5, -90, 90), [0, 13]],
35
+ [[0, 13], [11, 13], ...arc(11, 20.5, 7.5, 7.5, -90, 90), [0, 28]],
36
+ ],
37
+ C: [arc(12, 14, 12, 14, -40, -320)],
38
+ D: [[[0, 0], [0, 28], [7, 28], ...arc(7, 14, 12, 14, 90, -90), [0, 0]]],
39
+ E: [[[17, 0], [0, 0], [0, 28], [17, 28]], [[0, 14], [13, 14]]],
40
+ F: [[[17, 0], [0, 0], [0, 28]], [[0, 14], [13, 14]]],
41
+ G: [[...arc(12, 14, 12, 14, -40, -360), [14, 14]]],
42
+ H: [[[0, 0], [0, 28]], [[18, 0], [18, 28]], [[0, 14], [18, 14]]],
43
+ I: [[[0, 0], [0, 28]]],
44
+ J: [[[14, 0], [14, 20], ...arc(7, 20, 7, 8, 0, 180)]],
45
+ K: [[[0, 0], [0, 28]], [[20, 0], [3, 15], [20, 28]]],
46
+ L: [[[0, 0], [0, 28], [16, 28]]],
47
+ M: [[[0, 28], [0, 0], [11, 18], [22, 0], [22, 28]]],
48
+ N: [[[0, 28], [0, 0], [18, 28], [18, 0]]],
49
+ O: [arc(12, 14, 12, 14, 0, 360)],
50
+ P: [[[0, 28], [0, 0], [10, 0], ...arc(10, 7.5, 7.5, 7.5, -90, 90), [0, 15]]],
51
+ Q: [arc(12, 14, 12, 14, 0, 360), [[14, 20], [23, 28]]],
52
+ R: [
53
+ [[0, 28], [0, 0], [10, 0], ...arc(10, 7.5, 7.5, 7.5, -90, 90), [0, 15]],
54
+ [[9, 15], [18, 28]],
55
+ ],
56
+ S: [[...arc(9, 7, 8.5, 7, -20, -270), ...arc(9, 21, 9, 7, -90, 160)]],
57
+ T: [[[0, 0], [20, 0]], [[10, 0], [10, 28]]],
58
+ U: [[[0, 0], [0, 19], ...arc(9, 19, 9, 9, 180, 0), [18, 0]]],
59
+ V: [[[0, 0], [10, 28], [20, 0]]],
60
+ W: [[[0, 0], [6, 28], [13, 8], [20, 28], [26, 0]]],
61
+ X: [[[0, 0], [19, 28]], [[19, 0], [0, 28]]],
62
+ Y: [[[0, 0], [10, 13], [20, 0]], [[10, 13], [10, 28]]],
63
+ Z: [[[0, 0], [18, 0], [0, 28], [18, 28]]],
64
+ };
65
+
66
+ /** The monogram letter of a title: its first letter A–Z, accents removed. */
67
+ export function monogramLetter(title) {
68
+ const first = String(title).trim().normalize('NFD').charAt(0).toUpperCase();
69
+ return Object.hasOwn(GLYPHS, first) ? first : undefined;
70
+ }
71
+
72
+ /** The letter's strokes in icon coordinates, centred; none for no letter. */
73
+ function strokes(letter) {
74
+ const glyph = GLYPHS[letter] ?? [];
75
+ const xs = glyph.flat().map(([x]) => x);
76
+ const shift = VIEW / 2 - (Math.min(...xs) + Math.max(...xs)) / 2;
77
+ return glyph.map((stroke) =>
78
+ stroke.map(([x, y]) => [x + shift, y + TOP]),
79
+ );
80
+ }
81
+
82
+ const round = (n) => Math.round(n * 100) / 100;
83
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
84
+
85
+ /**
86
+ * The SVG icon. In dark mode it takes the theme's dark accent, so it keeps its
87
+ * contrast on a dark page.
88
+ */
89
+ export function monogramSvg(letter, colours = CALM) {
90
+ const d = strokes(letter)
91
+ .map((stroke) =>
92
+ stroke
93
+ .map(([x, y], i) => `${i ? 'L' : 'M'}${round(x)} ${round(y)}`)
94
+ .join(''),
95
+ )
96
+ .join('');
97
+ const path = d
98
+ ? `<path d="${d}" fill="none" stroke-width="${STROKE}" stroke-linecap="round" stroke-linejoin="round"/>`
99
+ : '';
100
+ const style = ({ background, letter }) =>
101
+ `rect{fill:${background}}path{stroke:${letter}}`;
102
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEW} ${VIEW}"><style>${style(colours.light)}@media (prefers-color-scheme:dark){${style(colours.dark)}}</style><rect width="${VIEW}" height="${VIEW}" rx="${RADIUS}"/>${path}</svg>\n`;
103
+ }
104
+
105
+ function segmentDistance([px, py], [ax, ay], [bx, by]) {
106
+ const dx = bx - ax;
107
+ const dy = by - ay;
108
+ const length = dx * dx + dy * dy;
109
+ const t =
110
+ length === 0
111
+ ? 0
112
+ : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length));
113
+ return Math.hypot(px - ax - t * dx, py - ay - t * dy);
114
+ }
115
+
116
+ /** Signed distance to the rounded square; negative inside. */
117
+ function squareDistance([x, y]) {
118
+ const qx = Math.abs(x - VIEW / 2) - (VIEW / 2 - RADIUS);
119
+ const qy = Math.abs(y - VIEW / 2) - (VIEW / 2 - RADIUS);
120
+ return (
121
+ Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) +
122
+ Math.min(Math.max(qx, qy), 0) -
123
+ RADIUS
124
+ );
125
+ }
126
+
127
+ function chunk(type, data) {
128
+ const head = Buffer.alloc(8);
129
+ head.writeUInt32BE(data.length, 0);
130
+ head.write(type, 4, 'latin1');
131
+ const crc = Buffer.alloc(4);
132
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
133
+ return Buffer.concat([head, data, crc]);
134
+ }
135
+
136
+ /** The PNG icon of `size` pixels, antialiased, with transparent corners. */
137
+ export function monogramPng(letter, size, colours = CALM) {
138
+ const segments = strokes(letter).flatMap((stroke) =>
139
+ stroke.slice(1).map((point, i) => [stroke[i], point]),
140
+ );
141
+ const square = rgb(colours.light.background);
142
+ const mark = rgb(colours.light.letter);
143
+ const scale = size / VIEW;
144
+ const coverage = (distance) =>
145
+ Math.max(0, Math.min(1, 0.5 - distance * scale));
146
+ const rows = Buffer.alloc(size * (size * 4 + 1));
147
+ for (let y = 0; y < size; y++) {
148
+ const row = y * (size * 4 + 1);
149
+ rows[row] = 0; // filter: none
150
+ for (let x = 0; x < size; x++) {
151
+ const point = [(x + 0.5) / scale, (y + 0.5) / scale];
152
+ const alpha = coverage(squareDistance(point));
153
+ let near = Infinity;
154
+ for (const [a, b] of segments)
155
+ near = Math.min(near, segmentDistance(point, a, b));
156
+ const ink = coverage(near - STROKE / 2);
157
+ const offset = row + 1 + x * 4;
158
+ for (let c = 0; c < 3; c++)
159
+ rows[offset + c] = Math.round(square[c] * (1 - ink) + mark[c] * ink);
160
+ rows[offset + 3] = Math.round(alpha * 255);
161
+ }
162
+ }
163
+ const header = Buffer.alloc(13);
164
+ header.writeUInt32BE(size, 0);
165
+ header.writeUInt32BE(size, 4);
166
+ header.set([8, 6, 0, 0, 0], 8); // 8 bits, RGBA, no interlace
167
+ return Buffer.concat([
168
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
169
+ chunk('IHDR', header),
170
+ chunk('IDAT', deflateSync(rows)),
171
+ chunk('IEND', Buffer.alloc(0)),
172
+ ]);
173
+ }
174
+
175
+ /**
176
+ * Writes an app's icons: public/favicon.svg and the manifest's icon-192.png
177
+ * and icon-512.png. Returns them as paths inside the app.
178
+ */
179
+ export function writeAppIcons(appDir, title, colours = CALM) {
180
+ const letter = monogramLetter(title);
181
+ const pub = join(appDir, 'public');
182
+ mkdirSync(pub, { recursive: true });
183
+ writeFileSync(join(pub, 'favicon.svg'), monogramSvg(letter, colours));
184
+ for (const size of [192, 512])
185
+ writeFileSync(
186
+ join(pub, `icon-${size}.png`),
187
+ monogramPng(letter, size, colours),
188
+ );
189
+ return ['favicon.svg', 'icon-192.png', 'icon-512.png'].map(
190
+ (file) => `public/${file}`,
191
+ );
192
+ }
@@ -62,11 +62,12 @@ function card(entry) {
62
62
  const icon = entry.icon
63
63
  ? `<img src="${escape(entry.icon)}" alt="" width="64" height="64" />`
64
64
  : `<span class="letter" aria-hidden="true">${escape(letter)}</span>`;
65
+ const lang = entry.language ? ` lang="${escape(entry.language)}"` : '';
65
66
  const description = entry.description
66
67
  ? `\n <span class="description">${escape(entry.description)}</span>`
67
68
  : '';
68
69
  return ` <li>
69
- <a href="${escape(entry.href)}" lang="${escape(entry.language)}">
70
+ <a href="${escape(entry.href)}"${lang}>
70
71
  ${icon}
71
72
  <span class="name">${escape(entry.title)}</span>${description}
72
73
  </a>
@@ -81,8 +82,9 @@ export function siteLanguage(entries) {
81
82
  }
82
83
 
83
84
  /**
84
- * The overview: one card per `{ id, title, language, href, icon?,
85
- * description? }`, in the given order.
85
+ * The overview: one card per `{ id, title, language?, href, icon?,
86
+ * description? }`, in the given order; an app without a language follows the
87
+ * browser.
86
88
  */
87
89
  export function renderOverview(entries) {
88
90
  const language = siteLanguage(entries);
@@ -0,0 +1,137 @@
1
+ // The page title comes from apps.json: the generated Vite plugin pageTitle
2
+ // adds it to the head, so an app's index.html has no <title> of its own and its
3
+ // vite.config.ts uses the plugin. check reports what stands in the way; the
4
+ // migration moves apps that still have the skeleton's files, and updates the
5
+ // skeleton's head lines an app has kept.
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { escapeHtml } from './html.mjs';
9
+ import { ifPresent } from './json.mjs';
10
+ import { SKELETON } from './paths.mjs';
11
+
12
+ export const PAGE = 'index.html';
13
+ export const VITE_CONFIG = 'vite.config.ts';
14
+
15
+ // The skeleton's vite.config.ts from 0.7.0 until the plugin, which the
16
+ // migration replaces with the current one.
17
+ const VITE_CONFIG_0_7 = `import react from '@vitejs/plugin-react';
18
+ import { defineConfig } from 'vite';
19
+ import { VitePWA } from 'vite-plugin-pwa';
20
+ import { base, pwa, server } from './vite.sempods.generated.ts';
21
+
22
+ // Base path, development port and PWA settings come from apps.json through the
23
+ // generated file; add your own plugins and options here.
24
+ export default defineConfig({
25
+ base,
26
+ server,
27
+ preview: server,
28
+ plugins: [react(), ...(pwa ? [VitePWA(pwa)] : [])],
29
+ });
30
+ `;
31
+
32
+ // A <title> element in the head; an SVG <title> in the body is not one.
33
+ const OWN_TITLE = /<title[\s>]/i;
34
+ const head = (html) => html.split(/<body[\s>]/i)[0];
35
+ const usesPlugin = (config) => /\bpageTitle\b/.test(config);
36
+
37
+ /** Why the page would not get its title from apps.json; empty when it does. */
38
+ export function pageProblems(appDir, id) {
39
+ const problems = [];
40
+ const html = ifPresent(() => readFileSync(join(appDir, PAGE), 'utf8'));
41
+ if (html !== undefined && OWN_TITLE.test(head(html)))
42
+ problems.push(
43
+ `apps/${id}/${PAGE} has its own <title>; remove it, the page title comes from apps.json`,
44
+ );
45
+ const config = ifPresent(() =>
46
+ readFileSync(join(appDir, VITE_CONFIG), 'utf8'),
47
+ );
48
+ if (config !== undefined && !usesPlugin(config))
49
+ problems.push(
50
+ `apps/${id}/${VITE_CONFIG} does not add pageTitle from vite.sempods.generated.ts to its plugins, so the page has no title`,
51
+ );
52
+ return problems;
53
+ }
54
+
55
+ /**
56
+ * Moves one app to the generated page title where that is unambiguous: a
57
+ * vite.config.ts unchanged from the skeleton gets the plugin, and a page
58
+ * whose only title is the app's title from apps.json loses it. Returns the
59
+ * changed files as apps/<id>/<file>; anything else is left to check.
60
+ */
61
+ export function adoptPageTitle(appDir, app) {
62
+ const changed = [];
63
+ const configFile = join(appDir, VITE_CONFIG);
64
+ const config = ifPresent(() => readFileSync(configFile, 'utf8'));
65
+ if (config === VITE_CONFIG_0_7) {
66
+ writeFileSync(
67
+ configFile,
68
+ readFileSync(join(SKELETON, VITE_CONFIG), 'utf8'),
69
+ );
70
+ changed.push(VITE_CONFIG);
71
+ }
72
+ const pageFile = join(appDir, PAGE);
73
+ const html = ifPresent(() => readFileSync(pageFile, 'utf8'));
74
+ const line = new RegExp(
75
+ `^[ \\t]*<title>${escapeRegExp(escapeHtml(app.title))}</title>[ \\t]*\\r?\\n`,
76
+ 'm',
77
+ );
78
+ if (
79
+ html !== undefined &&
80
+ (changed.length > 0 || usesPlugin(config ?? '')) &&
81
+ head(html).match(/<title[\s>]/gi)?.length === 1 &&
82
+ line.test(head(html))
83
+ ) {
84
+ writeFileSync(pageFile, html.replace(line, ''));
85
+ changed.push(PAGE);
86
+ }
87
+ return changed.map((file) => `apps/${app.id}/${file}`);
88
+ }
89
+
90
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+
92
+ // Head lines of the skeleton's index.html from 0.7.0 that the current
93
+ // skeleton replaced: the viewport, which now reaches the screen's edges and
94
+ // resizes with the on-screen keyboard, and the green theme colour, now one per
95
+ // colour scheme. Each matches with either line ending.
96
+ const HEAD_0_7 = {
97
+ viewport:
98
+ /^ {4}<meta name="viewport" content="width=device-width, initial-scale=1\.0" \/>(\r?\n)/gm,
99
+ themeColor: /^ {4}<meta name="theme-color" content="#176354" \/>(\r?\n)/gm,
100
+ };
101
+
102
+ /** The skeleton's current lines for each entry of HEAD_0_7. */
103
+ function currentHead() {
104
+ const html = readFileSync(join(SKELETON, PAGE), 'utf8');
105
+ const lines = (pattern) => {
106
+ const match = html.match(pattern);
107
+ if (!match) throw new Error(`skeleton ${PAGE} lacks ${pattern}`);
108
+ return match[0];
109
+ };
110
+ return {
111
+ viewport: lines(
112
+ /^[ \t]*<!-- The layout resizes[^\n]*\n[ \t]*<meta\s+name="viewport"[^>]*\/>\n/m,
113
+ ),
114
+ themeColor: lines(/^(?:[ \t]*<meta name="theme-color"[^\n]*\n)+/m),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Replaces the skeleton's 0.7 head lines that one app's index.html still has
120
+ * unchanged with the current ones. Returns the changed file as
121
+ * apps/<id>/index.html; changed lines stay as the owner left them.
122
+ */
123
+ export function adoptPageHead(appDir, app) {
124
+ const pageFile = join(appDir, PAGE);
125
+ const html = ifPresent(() => readFileSync(pageFile, 'utf8'));
126
+ if (html === undefined) return [];
127
+ const current = currentHead();
128
+ let next = html;
129
+ for (const [key, old] of Object.entries(HEAD_0_7))
130
+ if (head(next).match(old)?.length === 1)
131
+ next = next.replace(old, (_, eol) =>
132
+ current[key].replaceAll('\n', eol),
133
+ );
134
+ if (next === html) return [];
135
+ writeFileSync(pageFile, next);
136
+ return [`apps/${app.id}/${PAGE}`];
137
+ }
@@ -0,0 +1,58 @@
1
+ // The skeleton's themes: one CSS file per theme in src/themes/, which sets the
2
+ // --sempods-* tokens in light and dark mode. An app imports one of them in its
3
+ // src/main.tsx; its icon takes that theme's accent.
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { ifPresent } from './json.mjs';
7
+ import { SKELETON } from './paths.mjs';
8
+
9
+ export const DEFAULT_THEME = 'calm';
10
+ const THEMES_DIR = join('src', 'themes');
11
+ const IMPORT = /^import '\.\/themes\/([a-z-]+)\.css';$/m;
12
+
13
+ /** The theme names the skeleton ships, the default first. */
14
+ export function themeNames(skeleton = SKELETON) {
15
+ const names = readdirSync(join(skeleton, THEMES_DIR))
16
+ .filter((file) => file.endsWith('.css'))
17
+ .map((file) => file.slice(0, -'.css'.length))
18
+ .sort();
19
+ return [DEFAULT_THEME, ...names.filter((name) => name !== DEFAULT_THEME)];
20
+ }
21
+
22
+ /** A token's light and dark colour, from `--name: light-dark(#light, #dark)`. */
23
+ export function tokenColours(css, token) {
24
+ const match = css.match(
25
+ new RegExp(
26
+ `--sempods-${token}:\\s*light-dark\\((#[0-9a-f]{6}),\\s*(#[0-9a-f]{6})\\)`,
27
+ 'i',
28
+ ),
29
+ );
30
+ if (!match) throw new Error(`the theme sets no --sempods-${token}`);
31
+ return { light: match[1].toLowerCase(), dark: match[2].toLowerCase() };
32
+ }
33
+
34
+ /**
35
+ * The icon's colours for a theme file: the accent as the square and the text
36
+ * on the accent as the letter, in light and dark mode.
37
+ */
38
+ export function iconColours(css) {
39
+ const accent = tokenColours(css, 'accent');
40
+ const on = tokenColours(css, 'on-accent');
41
+ return {
42
+ light: { background: accent.light, letter: on.light },
43
+ dark: { background: accent.dark, letter: on.dark },
44
+ };
45
+ }
46
+
47
+ /** The theme an app imports in src/main.tsx, if it imports one of these. */
48
+ export function appTheme(appDir) {
49
+ const main = ifPresent(() =>
50
+ readFileSync(join(appDir, 'src', 'main.tsx'), 'utf8'),
51
+ );
52
+ return main?.match(IMPORT)?.[1];
53
+ }
54
+
55
+ /** The CSS of a theme file in an app or in the skeleton. */
56
+ export function themeCss(dir, name) {
57
+ return readFileSync(join(dir, THEMES_DIR, `${name}.css`), 'utf8');
58
+ }
@@ -32,6 +32,12 @@ import {
32
32
  replaceFile,
33
33
  } from './lib/files.mjs';
34
34
  import { GENERATED_FILES } from './lib/generate.mjs';
35
+ import {
36
+ adoptPageHead,
37
+ adoptPageTitle,
38
+ PAGE,
39
+ VITE_CONFIG,
40
+ } from './lib/page.mjs';
35
41
  import { formatJson, ifPresent, readJson, replaceJson } from './lib/json.mjs';
36
42
  import { mergeManifest, mergeText, withSection } from './lib/merge.mjs';
37
43
  import { repositoryRoot, TOOLING } from './lib/paths.mjs';
@@ -73,21 +79,23 @@ export function refuseUnfinishedSdkUpdate(root) {
73
79
  );
74
80
  }
75
81
 
82
+ // A broken apps.json names no apps here; regenerating reports it later.
83
+ function appsOf(root) {
84
+ try {
85
+ return readApps(root).apps;
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
76
91
  /**
77
92
  * Symbolic links where an update writes: the command's own files, the apps'
78
- * generated configuration and the given starter paths, each with its
79
- * ancestors, and anything inside the migration folder.
93
+ * generated configuration, page and Vite configuration and the given starter
94
+ * paths, each with its ancestors, and anything inside the migration folder.
80
95
  */
81
96
  export function linkedPaths(root, files = []) {
82
97
  const found = new Set();
83
- // A broken apps.json names no apps here; regenerating reports it later.
84
- const apps = (() => {
85
- try {
86
- return readApps(root).apps;
87
- } catch {
88
- return [];
89
- }
90
- })();
98
+ const apps = appsOf(root);
91
99
  for (const path of [
92
100
  'package.json',
93
101
  'pnpm-lock.yaml',
@@ -95,7 +103,9 @@ export function linkedPaths(root, files = []) {
95
103
  BASELINE_FILE,
96
104
  MIGRATION_DIR,
97
105
  ...apps.flatMap(({ id }) =>
98
- GENERATED_FILES.map((file) => `apps/${id}/${file}`),
106
+ [...GENERATED_FILES, PAGE, VITE_CONFIG].map(
107
+ (file) => `apps/${id}/${file}`,
108
+ ),
99
109
  ),
100
110
  ...files,
101
111
  ]) {
@@ -372,6 +382,8 @@ export function migrate(
372
382
  if (baseline.revision === target.snapshot.revision) {
373
383
  // A leftover of a completed migration.
374
384
  rmSync(work, { recursive: true, force: true });
385
+ // The tooling may still have changed what it generates for apps.
386
+ updateApps(root, report);
375
387
  return { ...report, unchanged: true };
376
388
  }
377
389
  }
@@ -479,7 +491,7 @@ export function migrate(
479
491
  throw new Error(
480
492
  `${unresolved.map(({ file }) => file).join(', ')} still contain an unresolved suggestion; resolve them, then run pnpm run migrate --done again`,
481
493
  );
482
- report.regenerated = regenerate(root);
494
+ updateApps(root, report);
483
495
  // A new starter can change the manifest or the workspace settings, so the
484
496
  // lockfile and node_modules follow before the migration counts as done.
485
497
  if (!install(root))
@@ -497,11 +509,29 @@ export function migrate(
497
509
  return report;
498
510
  }
499
511
 
512
+ /**
513
+ * The apps' part of a migration: files a release moves where that is
514
+ * unambiguous, then the generated configuration.
515
+ */
516
+ function updateApps(root, report) {
517
+ for (const app of appsOf(root)) {
518
+ const dir = join(root, 'apps', app.id);
519
+ if (!existsSync(dir)) continue;
520
+ const changed = [...adoptPageTitle(dir, app), ...adoptPageHead(dir, app)];
521
+ report.updated.push(...new Set(changed));
522
+ }
523
+ report.regenerated = regenerate(root);
524
+ }
525
+
500
526
  export function formatMigration(report) {
501
- if (report.unchanged)
502
- return `The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`;
503
527
  const list = (title, items) =>
504
528
  items.length ? [`${title}:`, ...items.map((item) => ` ${item}`)] : [];
529
+ if (report.unchanged)
530
+ return [
531
+ `The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`,
532
+ ...list('Updated', report.updated),
533
+ ...list('Regenerated', report.regenerated),
534
+ ].join('\n');
505
535
  const lines = [
506
536
  `Migration from ${PACKAGE} ${report.from} to ${report.to}${report.unfinished ? ' is open' : ' is complete'}.`,
507
537
  ...list('Updated', report.updated),
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // Creates apps/<id> from the skeleton with the root's SDK version and adds it
3
3
  // to apps.json.
4
- // Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]
4
+ // Usage: pnpm run new-app <id> [--title "<title>"] [--description "<text>"]
5
+ // [--language <auto|de|en>] [--theme <name>] [--no-pwa]
5
6
  import {
6
7
  cpSync,
7
8
  existsSync,
9
+ mkdirSync,
8
10
  readdirSync,
9
11
  readFileSync,
10
12
  rmSync,
@@ -14,14 +16,23 @@ import { extname, join, resolve } from 'node:path';
14
16
  import { fileURLToPath } from 'node:url';
15
17
  import { parseArgs } from 'node:util';
16
18
  import {
19
+ APP_LANGUAGES,
20
+ AUTO,
21
+ invalidDescription,
17
22
  invalidId,
18
- LANGUAGES,
19
23
  nextDevPort,
20
24
  readApps,
21
25
  writeApps,
22
26
  } from './lib/apps.mjs';
23
27
  import { writeGenerated } from './lib/generate.mjs';
24
28
  import { escapeHtml } from './lib/html.mjs';
29
+ import { writeAppIcons } from './lib/icon.mjs';
30
+ import {
31
+ DEFAULT_THEME,
32
+ iconColours,
33
+ themeCss,
34
+ themeNames,
35
+ } from './lib/themes.mjs';
25
36
  import { readJson, writeJson } from './lib/json.mjs';
26
37
  import { repositoryRoot, SKELETON } from './lib/paths.mjs';
27
38
  import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
@@ -29,14 +40,14 @@ import { EXACT_VERSION, SDK } from './lib/sdk.mjs';
29
40
 
30
41
  const TEXT = new Set(['.json', '.html', '.md', '.ts', '.tsx', '.css']);
31
42
 
32
- function fillTokens(dir, app) {
43
+ function fillTokens(dir, app, theme) {
33
44
  // Directory entries carry their type, so no separate stat precedes the read
34
45
  // and write of the same path.
35
46
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
47
  const { name } = entry;
37
48
  const path = join(dir, name);
38
49
  if (entry.isDirectory()) {
39
- fillTokens(path, app);
50
+ fillTokens(path, app, theme);
40
51
  continue;
41
52
  }
42
53
  if (!TEXT.has(extname(name))) continue;
@@ -44,8 +55,13 @@ function fillTokens(dir, app) {
44
55
  const source = readFileSync(path, 'utf8');
45
56
  const filled = source
46
57
  .replaceAll('__APP_ID__', app.id)
58
+ .replaceAll('__APP_THEME__', theme)
47
59
  .replaceAll('__APP_TITLE__', title)
48
- .replaceAll('__APP_LANGUAGE__', app.language);
60
+ // The page's language until the app sets the one it resolved.
61
+ .replaceAll(
62
+ '__APP_LANGUAGE__',
63
+ app.language === AUTO ? 'en' : app.language,
64
+ );
49
65
  if (filled !== source) writeFileSync(path, filled);
50
66
  }
51
67
  }
@@ -74,12 +90,23 @@ function rootSdk(root) {
74
90
  */
75
91
  export function createApp(
76
92
  root,
77
- { id, title = id, language = 'en', pwa = true },
93
+ {
94
+ id,
95
+ title = id,
96
+ description,
97
+ language = AUTO,
98
+ theme = DEFAULT_THEME,
99
+ pwa = true,
100
+ },
78
101
  ) {
79
102
  const reason = invalidId(id);
80
103
  if (reason) throw new Error(reason);
81
- if (!LANGUAGES.includes(language))
82
- throw new Error(`language must be one of ${LANGUAGES.join(', ')}`);
104
+ if (!themeNames().includes(theme))
105
+ throw new Error(`theme must be one of ${themeNames().join(', ')}`);
106
+ const problem = invalidDescription(description);
107
+ if (problem) throw new Error(problem);
108
+ if (!APP_LANGUAGES.includes(language))
109
+ throw new Error(`language must be one of ${APP_LANGUAGES.join(', ')}`);
83
110
  if (typeof title !== 'string' || title.trim() === '')
84
111
  throw new Error('the title must not be empty');
85
112
  const manifest = readApps(root);
@@ -91,6 +118,7 @@ export function createApp(
91
118
  const app = {
92
119
  id,
93
120
  title: title.trim(),
121
+ ...(description === undefined ? {} : { description: description.trim() }),
94
122
  language,
95
123
  path: `/${id}/`,
96
124
  devPort: nextDevPort(manifest),
@@ -98,7 +126,8 @@ export function createApp(
98
126
  };
99
127
  try {
100
128
  cpSync(SKELETON, appDir, { recursive: true, errorOnExist: true });
101
- fillTokens(appDir, app);
129
+ fillTokens(appDir, app, theme);
130
+ writeAppIcons(appDir, app.title, iconColours(themeCss(appDir, theme)));
102
131
  const pkgFile = join(appDir, 'package.json');
103
132
  const pkg = readJson(pkgFile);
104
133
  // Package managers keep dependency lists sorted.
@@ -123,14 +152,16 @@ function main() {
123
152
  allowPositionals: true,
124
153
  options: {
125
154
  title: { type: 'string' },
155
+ description: { type: 'string' },
126
156
  language: { type: 'string' },
157
+ theme: { type: 'string' },
127
158
  'no-pwa': { type: 'boolean' },
128
159
  'skip-install': { type: 'boolean' },
129
160
  },
130
161
  });
131
162
  if (positionals.length !== 1) {
132
163
  console.error(
133
- 'Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]',
164
+ 'Usage: pnpm run new-app <id> [--title "<title>"] [--description "<text>"] [--language <auto|de|en>] [--theme <name>] [--no-pwa]',
134
165
  );
135
166
  process.exit(2);
136
167
  }
@@ -140,6 +171,8 @@ function main() {
140
171
  app = createApp(root, {
141
172
  id: positionals[0],
142
173
  title: values.title,
174
+ description: values.description,
175
+ theme: values.theme,
143
176
  language: values.language,
144
177
  pwa: !values['no-pwa'],
145
178
  });