@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.
- package/CHANGELOG.md +200 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +322 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +38 -23
- package/instructions/visual-check.md +43 -0
- package/package.json +3 -3
- package/scripts/build-site.mjs +15 -3
- package/scripts/check.mjs +3 -1
- package/scripts/icon.mjs +47 -0
- package/scripts/lib/apps.mjs +56 -2
- package/scripts/lib/generate.mjs +55 -14
- package/scripts/lib/icon.mjs +192 -0
- package/scripts/lib/overview.mjs +5 -3
- package/scripts/lib/page.mjs +137 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +44 -14
- package/scripts/new-app.mjs +43 -10
- package/scripts/sdk-update.mjs +16 -1
- package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
- package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
- package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +20 -2
- package/skeleton/app/index.html +7 -3
- package/skeleton/app/package.json +6 -6
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/NewVersionNotice.tsx +6 -11
- package/skeleton/app/src/examples/ListScreen.tsx +176 -0
- package/skeleton/app/src/index.css +86 -13
- package/skeleton/app/src/main.tsx +3 -0
- package/skeleton/app/src/pwa.ts +1 -1
- package/skeleton/app/src/routes.test.ts +19 -0
- package/skeleton/app/src/routes.ts +139 -0
- package/skeleton/app/src/texts.test.ts +28 -0
- package/skeleton/app/src/texts.ts +35 -0
- package/skeleton/app/src/themes/calm.css +11 -0
- package/skeleton/app/src/themes/fresh.css +11 -0
- package/skeleton/app/src/themes/playful.css +11 -0
- package/skeleton/app/src/themes/warm.css +11 -0
- package/skeleton/app/src/ui/Composer.tsx +18 -0
- package/skeleton/app/src/ui/ContextSwitch.tsx +94 -0
- package/skeleton/app/src/ui/Dialog.tsx +120 -0
- package/skeleton/app/src/ui/Menu.tsx +118 -0
- package/skeleton/app/src/ui/Tabs.tsx +39 -0
- package/skeleton/app/src/ui/dismiss.ts +30 -0
- package/skeleton/app/src/ui/icons.tsx +53 -0
- package/skeleton/app/src/ui/ui.css +249 -0
- package/skeleton/app/vite.config.ts +4 -4
- package/skills/update/SKILL.md +4 -2
- package/skeleton/app/public/favicon.svg +0 -1
- package/skeleton/app/public/icon-192.png +0 -0
- package/skeleton/app/public/icon-512.png +0 -0
- package/skeleton/app/src/start.test.ts +0 -15
- 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
|
+
}
|
package/scripts/lib/overview.mjs
CHANGED
|
@@ -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)}"
|
|
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
|
|
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
|
+
}
|
package/scripts/migrate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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),
|
package/scripts/new-app.mjs
CHANGED
|
@@ -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>"] [--
|
|
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
|
-
|
|
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
|
-
{
|
|
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 (!
|
|
82
|
-
throw new Error(`
|
|
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
|
});
|