@sempods/apps 0.7.0 → 0.8.0-dev.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +6 -3
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +254 -111
  5. package/instructions/publish.md +13 -7
  6. package/instructions/setup.md +33 -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 +89 -0
  17. package/scripts/lib/themes.mjs +58 -0
  18. package/scripts/migrate.mjs +37 -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 → f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102} +8 -2
  24. package/shared/snapshot.json +4 -4
  25. package/skeleton/app/NOTES.md +11 -1
  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 +167 -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 +85 -0
  44. package/skeleton/app/src/ui/Dialog.tsx +103 -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 +240 -0
  50. package/skeleton/app/vite.config.ts +4 -4
  51. package/skills/update/SKILL.md +19 -10
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sempods/apps",
3
- "version": "0.7.0",
3
+ "version": "0.8.0-dev.1",
4
4
  "description": "Tooling for a sempods apps repository: create, check, update and publish several sempods apps with one shared SDK version.",
5
5
  "keywords": [
6
6
  "sempods",
@@ -55,8 +55,8 @@
55
55
  "unified-engine": "11.2.2"
56
56
  },
57
57
  "peerDependencies": {
58
- "@sempods/app-sdk": "^0.5.0",
59
- "@sempods/client-sdk": "^0.5.0"
58
+ "@sempods/app-sdk": "^0.6.0",
59
+ "@sempods/client-sdk": "^0.6.0"
60
60
  },
61
61
  "scripts": {}
62
62
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Builds every app with a published profile into its path of one static site
3
- // folder, site-dist/, and writes the overview and the host's routing files.
3
+ // folder, site-dist/, with a page for each route, and writes the overview and
4
+ // the host's routing files.
4
5
  // apps/<id>/dist keeps the app's local build.
5
6
  // Usage: pnpm run build-site [--profile production|preview]
6
7
  // (or SEMPODS_SITE_PROFILE=<profile> in the host's environment)
@@ -15,7 +16,7 @@ import {
15
16
  import { join, relative, resolve } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import { parseArgs } from 'node:util';
18
- import { readApps, SITE_PROFILES, siteIdentity } from './lib/apps.mjs';
19
+ import { AUTO, readApps, SITE_PROFILES, siteIdentity } from './lib/apps.mjs';
19
20
  import { PROFILE_MODES, staleGenerated } from './lib/generate.mjs';
20
21
  import { ifPresent, writeJson } from './lib/json.mjs';
21
22
  import { renderNotFound, renderOverview } from './lib/overview.mjs';
@@ -79,7 +80,9 @@ function overviewEntry(app, outDir) {
79
80
  const entry = {
80
81
  id: app.id,
81
82
  title: app.title,
82
- language: app.language,
83
+ ...(app.description ? { description: app.description } : {}),
84
+ // An app that follows the browser has no one language to announce.
85
+ ...(app.language === AUTO ? {} : { language: app.language }),
83
86
  href: app.path,
84
87
  };
85
88
  if (existsSync(join(outDir, 'icon-192.png')))
@@ -143,6 +146,15 @@ export function buildSite(root, { profile = 'production', build = appBuild } = {
143
146
  // so without a redirect.
144
147
  const callback = new URL(redirectUri).pathname;
145
148
  writeFileSync(join(out, `${callback.slice(1)}.html`), html);
149
+ // Each route answers the same way from a copy of the page, so the host
150
+ // needs no rewrite and unknown paths still show the not-found page.
151
+ for (const route of app.routes ?? []) {
152
+ if (['', '.html'].some((ext) => existsSync(join(appOut, route + ext))))
153
+ throw new Error(
154
+ `apps/${app.id}: the route ${route} collides with ${route} in the app's build output; rename one of them.`,
155
+ );
156
+ writeFileSync(join(appOut, `${route}.html`), html);
157
+ }
146
158
 
147
159
  entries.push(overviewEntry(app, appOut));
148
160
  }
package/scripts/check.mjs CHANGED
@@ -21,6 +21,7 @@ import { parseArgs } from 'node:util';
21
21
  import semver from 'semver';
22
22
  import { readApps } from './lib/apps.mjs';
23
23
  import { staleGenerated } from './lib/generate.mjs';
24
+ import { pageProblems } from './lib/page.mjs';
24
25
  import { migrationState } from './migrate.mjs';
25
26
  import { readJson, writeJson } from './lib/json.mjs';
26
27
  import { repositoryRoot, TOOLING } from './lib/paths.mjs';
@@ -120,6 +121,7 @@ export function staticProblems(root, { tooling = TOOLING } = {}) {
120
121
  problems.push(
121
122
  `apps/${app.id}/${file} does not match apps.json; regenerate it instead of editing`,
122
123
  );
124
+ problems.push(...pageProblems(dir, app.id));
123
125
  const pkg = readJson(join(dir, 'package.json'));
124
126
  for (const sdk of SDK)
125
127
  expect(`apps/${app.id}`, sdk, pkg.dependencies?.[sdk]);
@@ -156,7 +158,7 @@ export function staticProblems(root, { tooling = TOOLING } = {}) {
156
158
  !semver.satisfies(version, range, { includePrerelease: true })
157
159
  )
158
160
  problems.push(
159
- `${sdk} ${version} is outside the range ${toolingManifest.name} ${toolingManifest.version} supports (${range}); update ${toolingManifest.name} or use a supported SDK version`,
161
+ `${sdk} ${version} is outside the range ${toolingManifest.name} ${toolingManifest.version} supports (${range}); move the SDK with pnpm run sdk-update <version> in that range, or update ${toolingManifest.name}`,
160
162
  );
161
163
  }
162
164
  // The app-author reference ships inside the installed SDK.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Draws an app's icon again: the monogram of its title in apps.json, in the
3
+ // accent of the theme its src/main.tsx imports, for example after a rename or
4
+ // a theme change. It replaces public/favicon.svg, icon-192.png and
5
+ // icon-512.png. Usage: pnpm run icon <id>
6
+ import { existsSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { readApps } from './lib/apps.mjs';
10
+ import { writeAppIcons } from './lib/icon.mjs';
11
+ import { repositoryRoot } from './lib/paths.mjs';
12
+ import { scriptArgs } from './lib/pnpm.mjs';
13
+ import { appTheme, iconColours, themeCss } from './lib/themes.mjs';
14
+
15
+ /** Writes the icons of app `id`; returns them as apps/<id>/<file>. */
16
+ export function drawIcon(root, id) {
17
+ const app = readApps(root).apps.find((entry) => entry.id === id);
18
+ if (!app) throw new Error(`apps.json lists no app "${id}"`);
19
+ const dir = join(root, 'apps', id);
20
+ if (!existsSync(dir)) throw new Error(`apps/${id} is missing`);
21
+ const theme = appTheme(dir);
22
+ if (!theme || !existsSync(join(dir, 'src', 'themes', `${theme}.css`)))
23
+ throw new Error(
24
+ `apps/${id}/src/main.tsx imports no theme from src/themes/; import one as the skeleton does, then run this again`,
25
+ );
26
+ return writeAppIcons(dir, app.title, iconColours(themeCss(dir, theme))).map(
27
+ (file) => `apps/${id}/${file}`,
28
+ );
29
+ }
30
+
31
+ if (
32
+ process.argv[1] &&
33
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
34
+ ) {
35
+ const [id, ...rest] = scriptArgs();
36
+ if (!id || rest.length > 0) {
37
+ console.error('Usage: pnpm run icon <id>');
38
+ process.exit(2);
39
+ }
40
+ try {
41
+ const files = drawIcon(repositoryRoot(), id);
42
+ console.log(`Drew:\n${files.map((file) => ` ${file}`).join('\n')}`);
43
+ } catch (error) {
44
+ console.error(`icon: ${error.message}`);
45
+ process.exitCode = 1;
46
+ }
47
+ }
@@ -5,7 +5,10 @@ import { readJson, writeJson } from './json.mjs';
5
5
 
6
6
  export const SCHEMA_VERSION = 1;
7
7
  export const FIRST_DEV_PORT = 5174;
8
+ // The UI languages of the SDK; "auto" follows the browser among them.
8
9
  export const LANGUAGES = ['en', 'de'];
10
+ export const AUTO = 'auto';
11
+ export const APP_LANGUAGES = [AUTO, ...LANGUAGES];
9
12
  // Where the site is published (configure-site): one origin per profile and the
10
13
  // host whose routing build-site writes for.
11
14
  export const SITE_PROFILES = ['production', 'preview'];
@@ -46,6 +49,45 @@ export function invalidId(id) {
46
49
  return undefined;
47
50
  }
48
51
 
52
+ // Routes answer as <id>/<route>.html next to the app's own build output.
53
+ const ROUTE_RESERVED = new Set([
54
+ 'assets',
55
+ 'callback',
56
+ 'did',
57
+ 'index',
58
+ 'licenses',
59
+ ]);
60
+
61
+ /**
62
+ * Returns a reason when `description` cannot describe an app, otherwise
63
+ * undefined. It is optional: one short sentence for the sign-in screen, the
64
+ * installed app and the overview.
65
+ */
66
+ export function invalidDescription(description) {
67
+ if (description === undefined) return undefined;
68
+ if (typeof description !== 'string' || description.trim() === '')
69
+ return 'description must be text';
70
+ if (description.trim().length > 120)
71
+ return 'description must be one short sentence, at most 120 characters';
72
+ return undefined;
73
+ }
74
+
75
+ /** Returns a reason when `route` cannot name a route, otherwise undefined. */
76
+ export function invalidRoute(route) {
77
+ if (
78
+ typeof route !== 'string' ||
79
+ !ID_PATTERN.test(route) ||
80
+ route.endsWith('-')
81
+ )
82
+ return 'a route is one lowercase segment: a letter, then up to 39 letters, digits or hyphens';
83
+ if (ROUTE_RESERVED.has(route))
84
+ return `"${route}" is reserved by the site layout`;
85
+ // build-site writes <route>.html, which Windows refuses for device names.
86
+ if (WINDOWS_DEVICES.has(route))
87
+ return `"${route}" is a reserved device name on Windows`;
88
+ return undefined;
89
+ }
90
+
49
91
  /**
50
92
  * Returns a reason when `value` is not an origin as apps.json records it,
51
93
  * otherwise undefined. configure-site also lets the SDK check the identity.
@@ -134,9 +176,11 @@ export function validateApps(manifest) {
134
176
  ids.add(app.id);
135
177
  if (typeof app.title !== 'string' || app.title.trim() === '')
136
178
  problems.push(`${app.id}: title must be text`);
137
- if (!LANGUAGES.includes(app.language))
179
+ const description = invalidDescription(app.description);
180
+ if (description) problems.push(`${app.id}: ${description}`);
181
+ if (!APP_LANGUAGES.includes(app.language))
138
182
  problems.push(
139
- `${app.id}: language must be one of ${LANGUAGES.join(', ')}`,
183
+ `${app.id}: language must be one of ${APP_LANGUAGES.join(', ')}`,
140
184
  );
141
185
  if (app.path !== `/${app.id}/`)
142
186
  problems.push(`${app.id}: path must be "/${app.id}/"`);
@@ -151,6 +195,16 @@ export function validateApps(manifest) {
151
195
  ports.add(app.devPort);
152
196
  if (typeof app.pwa !== 'boolean')
153
197
  problems.push(`${app.id}: pwa must be true or false`);
198
+ if (app.routes !== undefined && !Array.isArray(app.routes))
199
+ problems.push(`${app.id}: routes must be a list`);
200
+ else if (app.routes)
201
+ app.routes.forEach((route, i) => {
202
+ const reason = invalidRoute(route);
203
+ if (reason)
204
+ problems.push(`${app.id}: route ${JSON.stringify(route)}: ${reason}`);
205
+ else if (app.routes.indexOf(route) !== i)
206
+ problems.push(`${app.id}: route ${route} is listed twice`);
207
+ });
154
208
  }
155
209
  return problems;
156
210
  }
@@ -3,7 +3,8 @@
3
3
  // apps.json, so tooling updates reach existing apps.
4
4
  import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { SITE_PROFILES, siteIdentity } from './apps.mjs';
6
+ import { AUTO, SITE_PROFILES, siteIdentity } from './apps.mjs';
7
+ import { escapeHtml } from './html.mjs';
7
8
 
8
9
  const HEADER = `// Generated from apps.json by @sempods/apps. Do not edit: change apps.json
9
10
  // and run pnpm run regenerate.`;
@@ -33,11 +34,31 @@ const ${name}: BrowserRuntimeOptions = {
33
34
  clientId: ${json(clientId)},
34
35
  redirectUri: ${json(redirectUri)},
35
36
  },
36
- returnTo: app.basePath,
37
+ ${RETURN_TO[Boolean(app.routes?.length)]},
37
38
  };
38
39
  `;
39
40
  }
40
41
 
42
+ // With routes, sign-in returns to the route it started from: the runtime
43
+ // calls returnPath when each sign-in starts.
44
+ const RETURN_TO = {
45
+ false: 'returnTo: app.basePath',
46
+ true: 'returnTo: returnPath',
47
+ };
48
+
49
+ const RETURN_PATH = `
50
+ // The page sign-in returns to: the current route, otherwise the start page.
51
+ function returnPath(): string {
52
+ const routes: readonly string[] = app.routes;
53
+ const route = location.pathname
54
+ .slice(app.basePath.length)
55
+ .replace(/\\/$/, '');
56
+ return location.pathname.startsWith(app.basePath) && routes.includes(route)
57
+ ? app.basePath + route
58
+ : app.basePath;
59
+ }
60
+ `;
61
+
41
62
  /**
42
63
  * Runtime settings per profile: local development with a dynamic identity on
43
64
  * loopback HTTP, and the did:web identities of the configured site origins.
@@ -47,17 +68,22 @@ export function runtimeSource(app, site) {
47
68
  const modes = SITE_PROFILES.map(
48
69
  (name) => ` ${json(PROFILE_MODES[name])}: ${json(name)},`,
49
70
  ).join('\n');
71
+ // "auto" stays a literal: the app resolves it from the browser.
72
+ const language =
73
+ app.language === AUTO ? json(AUTO) : `${json(app.language)} as Language`;
50
74
  return `${HEADER}
51
- import type { BrowserRuntimeOptions, Language } from '@sempods/app-sdk';
75
+ import type { BrowserRuntimeOptions${app.language === AUTO ? '' : ', Language'} } from '@sempods/app-sdk';
52
76
 
53
77
  export const app = {
54
78
  id: ${json(app.id)},
55
79
  title: ${json(app.title)},
56
- language: ${json(app.language)} as Language,
80
+ description: ${json(app.description ?? null)},
81
+ language: ${language},
57
82
  basePath: ${json(app.path)},
58
83
  pwa: ${app.pwa},
84
+ routes: ${json(app.routes ?? [])},
59
85
  } as const;
60
-
86
+ ${app.routes?.length ? RETURN_PATH : ''}
61
87
  // Local development: a dynamic identity, local HTTP only.
62
88
  const local: BrowserRuntimeOptions = {
63
89
  identity: {
@@ -69,7 +95,7 @@ const local: BrowserRuntimeOptions = {
69
95
  return \`\${location.origin}${app.path}callback\`;
70
96
  },
71
97
  },
72
- returnTo: app.basePath,
98
+ ${RETURN_TO[Boolean(app.routes?.length)]},
73
99
  development: 'loopback-http',
74
100
  };
75
101
  ${published.map((name) => publishedProfile(name, site[name], app)).join('')}
@@ -95,9 +121,14 @@ export const runtimeOptions: BrowserRuntimeOptions =
95
121
  `;
96
122
  }
97
123
 
98
- /** Vite base path, development server and PWA settings for this app's path. */
124
+ /** Vite base path, development server, page title and PWA settings. */
99
125
  export function viteSource(app) {
100
126
  const p = app.path;
127
+ // The start page and every route, with or without a trailing slash, as the
128
+ // app's router accepts them; route names need no escaping.
129
+ const routes = app.routes?.length
130
+ ? `((${app.routes.join('|')})\\/?)?`
131
+ : '';
101
132
  const escaped = p.replaceAll('/', '\\/');
102
133
  const pwa = app.pwa
103
134
  ? `{
@@ -109,12 +140,12 @@ export function viteSource(app) {
109
140
  id: ${json(p)},
110
141
  name: ${json(app.title)},
111
142
  short_name: ${json(app.title)},
112
- lang: ${json(app.language)},
113
- start_url: ${json(p)},
143
+ ${app.description ? ` description: ${json(app.description)},\n` : ''}${app.language === AUTO ? '' : ` lang: ${json(app.language)},
144
+ `} start_url: ${json(p)},
114
145
  scope: ${json(p)},
115
146
  display: 'standalone',
116
- background_color: '#f6faf9',
117
- theme_color: '#176354',
147
+ background_color: '#ffffff',
148
+ theme_color: '#ffffff',
118
149
  icons: [
119
150
  { src: ${json(`${p}icon-192.png`)}, sizes: '192x192', type: 'image/png' },
120
151
  { src: ${json(`${p}icon-512.png`)}, sizes: '512x512', type: 'image/png' },
@@ -124,10 +155,10 @@ export function viteSource(app) {
124
155
  // Precache the built shell only; nothing is cached at runtime.
125
156
  globPatterns: ['**/*.{js,css,html,png,svg,webmanifest}'],
126
157
  runtimeCaching: [],
127
- // Serve the cached shell only for this app's start route. The callback,
128
- // Pod and login pages always go to the network.
158
+ // Serve the cached shell only for this app's start page and routes. The
159
+ // callback, Pod and login pages always go to the network.
129
160
  navigateFallback: ${json(`${p}index.html`)},
130
- navigateFallbackAllowlist: [/^${escaped}$/],
161
+ navigateFallbackAllowlist: [/^${escaped}${routes}$/],
131
162
  skipWaiting: false,
132
163
  clientsClaim: false,
133
164
  cleanupOutdatedCaches: true,
@@ -135,10 +166,20 @@ export function viteSource(app) {
135
166
  }`
136
167
  : 'null';
137
168
  return `${HEADER}
169
+ import type { Plugin } from 'vite';
138
170
  import type { VitePWAOptions } from 'vite-plugin-pwa';
139
171
 
140
172
  export const base = ${json(p)};
141
173
 
174
+ // The page title from apps.json, added to the head of every page Vite serves
175
+ // or builds; index.html has no <title> of its own.
176
+ export const pageTitle: Plugin = {
177
+ name: 'sempods-page-title',
178
+ transformIndexHtml: () => [
179
+ { tag: 'title', children: ${json(escapeHtml(app.title))}, injectTo: 'head' },
180
+ ],
181
+ };
182
+
142
183
  export const server = {
143
184
  host: '127.0.0.1',
144
185
  port: ${app.devPort},
@@ -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,89 @@
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.
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { escapeHtml } from './html.mjs';
8
+ import { ifPresent } from './json.mjs';
9
+ import { SKELETON } from './paths.mjs';
10
+
11
+ export const PAGE = 'index.html';
12
+ export const VITE_CONFIG = 'vite.config.ts';
13
+
14
+ // The skeleton's vite.config.ts from 0.7.0 until the plugin, which the
15
+ // migration replaces with the current one.
16
+ const VITE_CONFIG_0_7 = `import react from '@vitejs/plugin-react';
17
+ import { defineConfig } from 'vite';
18
+ import { VitePWA } from 'vite-plugin-pwa';
19
+ import { base, pwa, server } from './vite.sempods.generated.ts';
20
+
21
+ // Base path, development port and PWA settings come from apps.json through the
22
+ // generated file; add your own plugins and options here.
23
+ export default defineConfig({
24
+ base,
25
+ server,
26
+ preview: server,
27
+ plugins: [react(), ...(pwa ? [VitePWA(pwa)] : [])],
28
+ });
29
+ `;
30
+
31
+ // A <title> element in the head; an SVG <title> in the body is not one.
32
+ const OWN_TITLE = /<title[\s>]/i;
33
+ const head = (html) => html.split(/<body[\s>]/i)[0];
34
+ const usesPlugin = (config) => /\bpageTitle\b/.test(config);
35
+
36
+ /** Why the page would not get its title from apps.json; empty when it does. */
37
+ export function pageProblems(appDir, id) {
38
+ const problems = [];
39
+ const html = ifPresent(() => readFileSync(join(appDir, PAGE), 'utf8'));
40
+ if (html !== undefined && OWN_TITLE.test(head(html)))
41
+ problems.push(
42
+ `apps/${id}/${PAGE} has its own <title>; remove it, the page title comes from apps.json`,
43
+ );
44
+ const config = ifPresent(() =>
45
+ readFileSync(join(appDir, VITE_CONFIG), 'utf8'),
46
+ );
47
+ if (config !== undefined && !usesPlugin(config))
48
+ problems.push(
49
+ `apps/${id}/${VITE_CONFIG} does not add pageTitle from vite.sempods.generated.ts to its plugins, so the page has no title`,
50
+ );
51
+ return problems;
52
+ }
53
+
54
+ /**
55
+ * Moves one app to the generated page title where that is unambiguous: a
56
+ * vite.config.ts unchanged from the skeleton gets the plugin, and a page
57
+ * whose only title is the app's title from apps.json loses it. Returns the
58
+ * changed files as apps/<id>/<file>; anything else is left to check.
59
+ */
60
+ export function adoptPageTitle(appDir, app) {
61
+ const changed = [];
62
+ const configFile = join(appDir, VITE_CONFIG);
63
+ const config = ifPresent(() => readFileSync(configFile, 'utf8'));
64
+ if (config === VITE_CONFIG_0_7) {
65
+ writeFileSync(
66
+ configFile,
67
+ readFileSync(join(SKELETON, VITE_CONFIG), 'utf8'),
68
+ );
69
+ changed.push(VITE_CONFIG);
70
+ }
71
+ const pageFile = join(appDir, PAGE);
72
+ const html = ifPresent(() => readFileSync(pageFile, 'utf8'));
73
+ const line = new RegExp(
74
+ `^[ \\t]*<title>${escapeRegExp(escapeHtml(app.title))}</title>[ \\t]*\\r?\\n`,
75
+ 'm',
76
+ );
77
+ if (
78
+ html !== undefined &&
79
+ (changed.length > 0 || usesPlugin(config ?? '')) &&
80
+ head(html).match(/<title[\s>]/gi)?.length === 1 &&
81
+ line.test(head(html))
82
+ ) {
83
+ writeFileSync(pageFile, html.replace(line, ''));
84
+ changed.push(PAGE);
85
+ }
86
+ return changed.map((file) => `apps/${app.id}/${file}`);
87
+ }
88
+
89
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');