@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
package/instructions/setup.md
CHANGED
|
@@ -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.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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"
|
|
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
|
|
44
|
-
by default; `--no-pwa` is available if the owner chooses it
|
|
45
|
-
app. The dev server is long-running: keep it in a separate
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
103
|
-
pass
|
|
104
|
-
|
|
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.
|
|
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.
|
|
59
|
-
"@sempods/client-sdk": "^0.
|
|
58
|
+
"@sempods/app-sdk": "^0.6.0",
|
|
59
|
+
"@sempods/client-sdk": "^0.6.0"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {}
|
|
62
62
|
}
|
package/scripts/build-site.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}
|
|
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.
|
package/scripts/icon.mjs
ADDED
|
@@ -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
|
+
}
|
package/scripts/lib/apps.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
}
|
package/scripts/lib/generate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '#
|
|
117
|
-
theme_color: '#
|
|
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
|
|
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},
|