@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
@@ -20,29 +20,42 @@ file or anything under `node_modules`.
20
20
  ## Choose the first interaction
21
21
 
22
22
  Use the request and recorded answers. Ask only for missing information needed
23
- for the first useful interaction. Use the requested UI language, or the owner's
24
- language when supported (`de` or `en`); otherwise use `en` and tell the owner.
25
- Choose a short app ID from the idea, such as `konsum`, and tell the owner; the
26
- generator validates allowed IDs and reserved names. Record choices and
27
- progress as they become known.
28
-
29
- A name, GitHub review identity and the version PR choice are not needed to
30
- try an app. Do not ask for them before the first local interaction. A missing
31
- Pod also allows UI work; record Pod testing as pending. Before accessing a Pod,
32
- use the owner's full test Pod URL and a dedicated context, with synthetic data.
23
+ for the first useful interaction. Before generating the app, follow the
24
+ [planning steps](app-workflow.md#start-a-new-app-with-a-short-design-conversation):
25
+ purpose, personal or shared data, existing records to reuse, and where reads,
26
+ new records and edits belong. Propose fitting visual defaults. Apps follow the
27
+ browser's language, German or English (otherwise English), and carry their
28
+ texts in both; record the owner's language for writing them. Fix one language
29
+ (`--language de` or `en`) only when the owner asks for it. If the owner's
30
+ language is neither, the UI still shows English and German only: tell the
31
+ owner. Choose a short app ID from the idea, such as `konsum`, and tell the
32
+ owner; the generator validates allowed IDs and reserved names. Record choices
33
+ and progress as they become known.
34
+
35
+ The owner's name, a GitHub review identity and the version PR choice are not
36
+ needed to try an app. Do not ask for them before the first local interaction.
37
+ If existing data matters, offer the workflow's
38
+ [optional MCP inspection](app-workflow.md#optionally-inspect-existing-data-through-mcp)
39
+ within the owner's agreed read scope. It is not a setup requirement or an
40
+ app sign-in. Without an app connection to a Pod only the sign-in screen can
41
+ be seen; record Pod testing as pending. For app trials, use the owner's full
42
+ test Pod URL and dedicated contexts (one per context the app's shape uses),
43
+ with synthetic data.
33
44
 
34
45
  ## Create the first app and open it
35
46
 
36
47
  From the repository root, after the bootstrap in `INIT.md`:
37
48
 
38
49
  ```sh
39
- pnpm run new-app konsum --title "Einkaufsliste" --language de
50
+ pnpm run new-app konsum --title "Einkaufsliste" \
51
+ --description "Was im Haushalt fehlt, für alle." --theme warm
40
52
  pnpm run dev konsum
41
53
  ```
42
54
 
43
- Replace the example ID, title and language with the recorded choices. PWA is on
44
- by default; `--no-pwa` is available if the owner chooses it when creating an
45
- app. The dev server is long-running: keep it in a separate terminal/session.
55
+ Replace the example ID, title, description and theme with the recorded
56
+ choices. PWA is on by default; `--no-pwa` is available if the owner chooses it
57
+ when creating an app. The dev server is long-running: keep it in a separate
58
+ terminal/session.
46
59
  Open the exact URL it reports; `apps.json` records the assigned `devPort` and
47
60
  `path`. Do not switch hostnames or ports during sign-in.
48
61
 
@@ -54,15 +67,15 @@ manifest or choose another ID silently to make setup pass. A failed command
54
67
  leaves setup in progress; diagnose it before proceeding.
55
68
 
56
69
  Continue with the [app workflow](app-workflow.md) for the first useful
57
- interaction. Once the owner can try the screen, ask once whether they have a
58
- test Pod for trying it with data, unless the setup record answers that. If so,
59
- let the user sign in in the browser and select their test context; otherwise
60
- record Pod testing as pending. Run `pnpm run check` in another terminal and
61
- report its result.
70
+ interaction. Once the app starts, ask once whether they have a test Pod for
71
+ trying it with data, unless the setup record answers that. If so, let the user
72
+ sign in in the browser and select their test contexts; otherwise record Pod
73
+ testing as pending. Run `pnpm run check` in another terminal and report its
74
+ result.
62
75
 
63
76
  ## Choose repository preferences (optional)
64
77
 
65
- After the owner can try the first interaction, offer to set a GitHub review
78
+ After the owner has seen the first interaction, offer to set a GitHub review
66
79
  owner and to enable version PRs for the SDK and the tooling. Use choices
67
80
  already given. If the owner has not chosen these preferences, leave them
68
81
  deferred; do not wait for an answer to finish local setup. If the owner wants
@@ -99,9 +112,11 @@ a token; never ask for one.
99
112
  ## Finish setup
100
113
 
101
114
  Run `pnpm run check` again. Mark setup **done** only when the chosen app starts
102
- at its assigned URL, its first useful interaction is available and local checks
103
- pass. Deferred review identity or version PRs do not block completion;
104
- keep their status visible in the setup record. Record command results and
115
+ at its assigned URL, its first useful interaction is available, local checks
116
+ pass and the owner has seen the screen and given a verdict (or, without a test
117
+ Pod, the sign-in screen, with the rest recorded as pending). Deferred review
118
+ identity or version PRs do not block completion; keep their status visible in
119
+ the setup record. Record command results and
105
120
  remaining gaps separately: no Pod, incomplete login or untested installed-PWA
106
121
  behavior are not successful interoperability evidence. If setup is already
107
122
  done, reopen the recorded app and follow its notes; do not recreate it.
@@ -0,0 +1,43 @@
1
+ # Check a screen visually
2
+
3
+ The owner judges an app by what they see. Before reporting a screen as done,
4
+ look at it the way they will and fix what fails; the [app workflow](app-workflow.md)
5
+ says when.
6
+
7
+ ## Look at the real states
8
+
9
+ Open the app in a browser you can control, signed in to the test context, and
10
+ take screenshots:
11
+
12
+ - at 375px wide and at desktop width;
13
+ - in light and in dark mode;
14
+ - with real states: the empty list, a few items, an open item or dialog, and
15
+ a notice (for example after a refused change or with read-only access).
16
+
17
+ Without a browser tool, or without a test context, say which states you could
18
+ not see; do not describe them as checked. Without a Pod, only the sign-in
19
+ screen is visible.
20
+
21
+ ## Checklist
22
+
23
+ - Spacing and alignment are consistent; nothing touches the screen edge.
24
+ - The hierarchy is clear: one primary action per screen or dialog, other
25
+ actions read as text.
26
+ - Every control has a 44px touch target; checkboxes are wrapped in their
27
+ label.
28
+ - Nothing scrolls sideways at 375px.
29
+ - The empty state says in one friendly line what to do next.
30
+ - The app does not repeat a message an SDK component already shows.
31
+ - The app's icon and name are present in the header and on the sign-in
32
+ screen.
33
+ - Text and controls keep their contrast in both modes; colours come from the
34
+ app's theme.
35
+ - A bar fixed at the bottom is not covered and leaves room for the content
36
+ above it.
37
+
38
+ ## Ask the owner
39
+
40
+ Fix what fails, then show the owner the 375px screenshot of the main state and
41
+ ask for a short verdict before moving on. Record the states checked, the
42
+ widths and modes, and the owner's verdict under Tested in the app's
43
+ `NOTES.md`; design choices made on the way go under Design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sempods/apps",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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},