@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.
- package/CHANGELOG.md +203 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +254 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +33 -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 +89 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +37 -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 → f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102} +8 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +11 -1
- 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 +167 -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 +85 -0
- package/skeleton/app/src/ui/Dialog.tsx +103 -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 +240 -0
- package/skeleton/app/vite.config.ts +4 -4
- package/skills/update/SKILL.md +19 -10
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sempods/apps",
|
|
3
|
-
"version": "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.
|
|
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},
|
|
@@ -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,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, '\\$&');
|