@sempods/apps 0.8.0 → 0.9.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 +166 -0
- package/README.md +13 -6
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +145 -72
- package/instructions/publish.md +29 -11
- package/instructions/setup.md +2 -2
- package/instructions/visual-check.md +20 -2
- package/package.json +5 -3
- package/scripts/build-site.mjs +37 -9
- package/scripts/check-theme.mjs +398 -0
- package/scripts/check.mjs +16 -5
- package/scripts/icon.mjs +1 -1
- package/scripts/lib/apps.mjs +115 -27
- package/scripts/lib/generate.mjs +58 -28
- package/scripts/lib/json.mjs +6 -0
- package/scripts/lib/kit.mjs +81 -0
- package/scripts/lib/merge.mjs +12 -9
- package/scripts/lib/sdk.mjs +10 -2
- package/scripts/lib/themes.mjs +45 -19
- package/scripts/migrate.mjs +6 -1
- package/scripts/new-app.mjs +2 -7
- package/scripts/sdk-update.mjs +32 -10
- package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
- package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
- package/shared/snapshot.json +3 -3
- package/skeleton/app/package.json +3 -0
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
- package/skeleton/app/src/examples/ListScreen.tsx +154 -56
- package/skeleton/app/src/index.css +10 -110
- package/skeleton/app/src/main.tsx +1 -3
- package/skeleton/app/src/places.test.ts +128 -0
- package/skeleton/app/src/places.ts +146 -0
- package/skeleton/app/src/texts.ts +8 -0
- package/skeleton/app/src/themes/calm.css +22 -8
- package/skeleton/app/src/themes/fresh.css +22 -8
- package/skeleton/app/src/themes/playful.css +22 -8
- package/skeleton/app/src/themes/warm.css +22 -8
- package/skeleton/app/vite.config.ts +12 -2
- package/skills/update/SKILL.md +6 -1
- package/update-policy.json +1 -1
- package/skeleton/app/src/routes.test.ts +0 -19
- package/skeleton/app/src/routes.ts +0 -139
- package/skeleton/app/src/ui/Composer.tsx +0 -18
- package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
- package/skeleton/app/src/ui/Dialog.tsx +0 -120
- package/skeleton/app/src/ui/Menu.tsx +0 -118
- package/skeleton/app/src/ui/Tabs.tsx +0 -39
- package/skeleton/app/src/ui/dismiss.ts +0 -30
- package/skeleton/app/src/ui/icons.tsx +0 -53
- package/skeleton/app/src/ui/ui.css +0 -249
package/scripts/lib/apps.mjs
CHANGED
|
@@ -49,7 +49,8 @@ export function invalidId(id) {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Routes answer as <id>/<route>.html next to the app's own build output
|
|
52
|
+
// Routes answer as <id>/<route>.html next to the app's own build output; their
|
|
53
|
+
// first segment must not be one of the app's own paths.
|
|
53
54
|
const ROUTE_RESERVED = new Set([
|
|
54
55
|
'assets',
|
|
55
56
|
'callback',
|
|
@@ -57,6 +58,118 @@ const ROUTE_RESERVED = new Set([
|
|
|
57
58
|
'index',
|
|
58
59
|
'licenses',
|
|
59
60
|
]);
|
|
61
|
+
// A route has at most this many segments, a resource pattern as well.
|
|
62
|
+
const MAX_SEGMENTS = 4;
|
|
63
|
+
// A placeholder of a resource pattern: one segment, named like a variable.
|
|
64
|
+
const PLACEHOLDER = /^:[a-z][a-zA-Z0-9]{0,39}$/;
|
|
65
|
+
|
|
66
|
+
/** Why `segment` cannot be a literal path segment, otherwise undefined. */
|
|
67
|
+
function invalidSegment(segment) {
|
|
68
|
+
if (!ID_PATTERN.test(segment) || segment.endsWith('-'))
|
|
69
|
+
return 'each segment is lowercase: a letter, then up to 39 letters, digits or hyphens';
|
|
70
|
+
// build-site writes <segment>.html and <segment>/, which Windows refuses for
|
|
71
|
+
// device names.
|
|
72
|
+
if (WINDOWS_DEVICES.has(segment))
|
|
73
|
+
return `"${segment}" is a reserved device name on Windows`;
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Why the segments of a route or pattern are not usable, otherwise undefined. */
|
|
78
|
+
function invalidSegments(value, { placeholders }) {
|
|
79
|
+
const segments = value.split('/');
|
|
80
|
+
if (segments.length > MAX_SEGMENTS)
|
|
81
|
+
return `at most ${MAX_SEGMENTS} segments`;
|
|
82
|
+
if (ROUTE_RESERVED.has(segments[0]))
|
|
83
|
+
return `"${segments[0]}" is reserved by the site layout`;
|
|
84
|
+
const names = new Set();
|
|
85
|
+
for (const [i, segment] of segments.entries()) {
|
|
86
|
+
if (placeholders && PLACEHOLDER.test(segment)) {
|
|
87
|
+
// A pattern starting with a placeholder would also catch the callback,
|
|
88
|
+
// assets and every other path of the app.
|
|
89
|
+
if (i === 0) return 'the first segment is a word, not a placeholder';
|
|
90
|
+
if (names.has(segment)) return `${segment} is used twice`;
|
|
91
|
+
names.add(segment);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const reason = invalidSegment(segment);
|
|
95
|
+
if (reason)
|
|
96
|
+
return placeholders
|
|
97
|
+
? `${reason}, or a placeholder such as :id`
|
|
98
|
+
: reason;
|
|
99
|
+
}
|
|
100
|
+
if (placeholders && names.size === 0)
|
|
101
|
+
return 'a pattern has a placeholder such as :id; list an address without one under routes';
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns a reason when `route` cannot name a route, otherwise undefined. A
|
|
107
|
+
* route is a literal address below the app's path: lowercase segments
|
|
108
|
+
* separated by "/", for example "bought" or "tasks/add".
|
|
109
|
+
*/
|
|
110
|
+
export function invalidRoute(route) {
|
|
111
|
+
if (typeof route !== 'string' || route === '')
|
|
112
|
+
return 'a route is text, such as "bought" or "tasks/add"';
|
|
113
|
+
return invalidSegments(route, { placeholders: false });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns a reason when `pattern` cannot name a resource address, otherwise
|
|
118
|
+
* undefined. A resource pattern has literal segments and placeholders for one
|
|
119
|
+
* segment each, for example "tasks/:id" or "tasks/:id/postpone"; it starts
|
|
120
|
+
* with a literal segment, so it never catches the callback or another path
|
|
121
|
+
* of the app.
|
|
122
|
+
*/
|
|
123
|
+
function invalidResource(pattern) {
|
|
124
|
+
if (typeof pattern !== 'string' || pattern === '')
|
|
125
|
+
return 'a resource pattern is text, such as "tasks/:id"';
|
|
126
|
+
return invalidSegments(pattern, { placeholders: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A resource pattern with each placeholder segment replaced. */
|
|
130
|
+
export const fillPlaceholders = (pattern, replacement) =>
|
|
131
|
+
pattern
|
|
132
|
+
.split('/')
|
|
133
|
+
.map((segment) => (segment.startsWith(':') ? replacement : segment))
|
|
134
|
+
.join('/');
|
|
135
|
+
|
|
136
|
+
/** A pattern's shape: the same addresses whatever its placeholders are called. */
|
|
137
|
+
const shape = (pattern) => fillPlaceholders(pattern, ':');
|
|
138
|
+
|
|
139
|
+
/** Lists the problems of one app's `routes` and `resources`. */
|
|
140
|
+
function addressProblems(app) {
|
|
141
|
+
const problems = [];
|
|
142
|
+
const list = (key, invalid) => {
|
|
143
|
+
const values = app[key];
|
|
144
|
+
if (values === undefined) return [];
|
|
145
|
+
if (!Array.isArray(values)) {
|
|
146
|
+
problems.push(`${app.id}: ${key} must be a list`);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const name = key === 'routes' ? 'route' : 'resource';
|
|
150
|
+
const valid = [];
|
|
151
|
+
values.forEach((value, i) => {
|
|
152
|
+
const reason = invalid(value);
|
|
153
|
+
if (reason)
|
|
154
|
+
problems.push(`${app.id}: ${name} ${JSON.stringify(value)}: ${reason}`);
|
|
155
|
+
else if (values.indexOf(value) !== i)
|
|
156
|
+
problems.push(`${app.id}: ${name} ${value} is listed twice`);
|
|
157
|
+
else valid.push(value);
|
|
158
|
+
});
|
|
159
|
+
return valid;
|
|
160
|
+
};
|
|
161
|
+
list('routes', invalidRoute);
|
|
162
|
+
const shapes = new Map();
|
|
163
|
+
for (const pattern of list('resources', invalidResource)) {
|
|
164
|
+
const other = shapes.get(shape(pattern));
|
|
165
|
+
if (other !== undefined && other !== pattern)
|
|
166
|
+
problems.push(
|
|
167
|
+
`${app.id}: resources ${other} and ${pattern} name the same addresses`,
|
|
168
|
+
);
|
|
169
|
+
shapes.set(shape(pattern), pattern);
|
|
170
|
+
}
|
|
171
|
+
return problems;
|
|
172
|
+
}
|
|
60
173
|
|
|
61
174
|
/**
|
|
62
175
|
* Returns a reason when `description` cannot describe an app, otherwise
|
|
@@ -72,22 +185,6 @@ export function invalidDescription(description) {
|
|
|
72
185
|
return undefined;
|
|
73
186
|
}
|
|
74
187
|
|
|
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
|
-
|
|
91
188
|
/**
|
|
92
189
|
* Returns a reason when `value` is not an origin as apps.json records it,
|
|
93
190
|
* otherwise undefined. configure-site also lets the SDK check the identity.
|
|
@@ -195,16 +292,7 @@ export function validateApps(manifest) {
|
|
|
195
292
|
ports.add(app.devPort);
|
|
196
293
|
if (typeof app.pwa !== 'boolean')
|
|
197
294
|
problems.push(`${app.id}: pwa must be true or false`);
|
|
198
|
-
|
|
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
|
-
});
|
|
295
|
+
problems.push(...addressProblems(app));
|
|
208
296
|
}
|
|
209
297
|
return problems;
|
|
210
298
|
}
|
package/scripts/lib/generate.mjs
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
AUTO,
|
|
8
|
+
fillPlaceholders,
|
|
9
|
+
SITE_PROFILES,
|
|
10
|
+
siteIdentity,
|
|
11
|
+
} from './apps.mjs';
|
|
7
12
|
import { escapeHtml } from './html.mjs';
|
|
8
13
|
|
|
9
14
|
const HEADER = `// Generated from apps.json by @sempods/apps. Do not edit: change apps.json
|
|
@@ -34,28 +39,57 @@ const ${name}: BrowserRuntimeOptions = {
|
|
|
34
39
|
clientId: ${json(clientId)},
|
|
35
40
|
redirectUri: ${json(redirectUri)},
|
|
36
41
|
},
|
|
37
|
-
|
|
42
|
+
returnTo: returnPath,
|
|
38
43
|
};
|
|
39
44
|
`;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
const
|
|
45
|
-
false: 'returnTo: app.basePath',
|
|
46
|
-
true: 'returnTo: returnPath',
|
|
47
|
-
};
|
|
47
|
+
// One placeholder of a resource pattern matches one segment of these
|
|
48
|
+
// characters, for example a base64url-encoded IRI.
|
|
49
|
+
const PLACEHOLDER_SOURCE = '[A-Za-z0-9_-]+';
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The paths an app answers as a regular expression source, without the
|
|
53
|
+
* slashes of a literal: the start page, each route and each resource
|
|
54
|
+
* pattern, with or without a trailing slash, and an optional query when
|
|
55
|
+
* `query` is set. Route and pattern segments need no escaping but the slash.
|
|
56
|
+
*/
|
|
57
|
+
function addressSource(app, { query = false } = {}) {
|
|
58
|
+
const alternatives = [
|
|
59
|
+
...(app.routes ?? []),
|
|
60
|
+
...(app.resources ?? []).map((pattern) =>
|
|
61
|
+
fillPlaceholders(pattern, PLACEHOLDER_SOURCE),
|
|
62
|
+
),
|
|
63
|
+
].map((address) => address.replaceAll('/', '\\/'));
|
|
64
|
+
const rest = alternatives.length
|
|
65
|
+
? `(?:(?:${alternatives.join('|')})\\/?)?`
|
|
66
|
+
: '';
|
|
67
|
+
return `^${app.path.replaceAll('/', '\\/')}${rest}${query ? '(?:\\?[^#]*)?' : ''}$`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const RETURN_PATH = (app) => `
|
|
71
|
+
// The addresses the app declares in apps.json: the start page, its routes and
|
|
72
|
+
// its resource patterns.
|
|
73
|
+
const ADDRESS = /${addressSource(app)}/;
|
|
74
|
+
|
|
75
|
+
// The query parameters of the sign-in callback; the SDK refuses a return
|
|
76
|
+
// address that carries one.
|
|
77
|
+
const RESERVED = ['code', 'state', 'error', 'iss', 'response'];
|
|
78
|
+
|
|
79
|
+
// The address sign-in returns to: the current one with its query when the app
|
|
80
|
+
// declares it, otherwise the start page. Never the callback or another app.
|
|
81
|
+
// The runtime calls it when each sign-in starts; the callback's parameters
|
|
82
|
+
// are dropped, the rest of the query stays.
|
|
51
83
|
function returnPath(): string {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.replace(/\\/$/, '');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
const { pathname, search } = location;
|
|
85
|
+
if (!ADDRESS.test(pathname)) return app.basePath;
|
|
86
|
+
const path =
|
|
87
|
+
pathname === app.basePath ? pathname : pathname.replace(/\\/$/, '');
|
|
88
|
+
const query = new URLSearchParams(search);
|
|
89
|
+
if (!RESERVED.some((key) => query.has(key))) return path + search;
|
|
90
|
+
for (const key of RESERVED) query.delete(key);
|
|
91
|
+
const rest = query.toString();
|
|
92
|
+
return rest ? path + '?' + rest : path;
|
|
59
93
|
}
|
|
60
94
|
`;
|
|
61
95
|
|
|
@@ -82,8 +116,9 @@ export const app = {
|
|
|
82
116
|
basePath: ${json(app.path)},
|
|
83
117
|
pwa: ${app.pwa},
|
|
84
118
|
routes: ${json(app.routes ?? [])},
|
|
119
|
+
resources: ${json(app.resources ?? [])},
|
|
85
120
|
} as const;
|
|
86
|
-
${app
|
|
121
|
+
${RETURN_PATH(app)}
|
|
87
122
|
// Local development: a dynamic identity, local HTTP only.
|
|
88
123
|
const local: BrowserRuntimeOptions = {
|
|
89
124
|
identity: {
|
|
@@ -95,7 +130,7 @@ const local: BrowserRuntimeOptions = {
|
|
|
95
130
|
return \`\${location.origin}${app.path}callback\`;
|
|
96
131
|
},
|
|
97
132
|
},
|
|
98
|
-
|
|
133
|
+
returnTo: returnPath,
|
|
99
134
|
development: 'loopback-http',
|
|
100
135
|
};
|
|
101
136
|
${published.map((name) => publishedProfile(name, site[name], app)).join('')}
|
|
@@ -124,12 +159,6 @@ export const runtimeOptions: BrowserRuntimeOptions =
|
|
|
124
159
|
/** Vite base path, development server, page title and PWA settings. */
|
|
125
160
|
export function viteSource(app) {
|
|
126
161
|
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
|
-
: '';
|
|
132
|
-
const escaped = p.replaceAll('/', '\\/');
|
|
133
162
|
const pwa = app.pwa
|
|
134
163
|
? `{
|
|
135
164
|
// The app registers its worker itself (src/main.tsx) and never takes over
|
|
@@ -155,10 +184,11 @@ ${app.description ? ` description: ${json(app.description)},\n` : ''}${app.
|
|
|
155
184
|
// Precache the built shell only; nothing is cached at runtime.
|
|
156
185
|
globPatterns: ['**/*.{js,css,html,png,svg,webmanifest}'],
|
|
157
186
|
runtimeCaching: [],
|
|
158
|
-
// Serve the cached shell only for this app's start page
|
|
159
|
-
// callback, Pod and login pages
|
|
187
|
+
// Serve the cached shell only for this app's start page, routes and
|
|
188
|
+
// resource patterns, with any query. The callback, Pod and login pages
|
|
189
|
+
// always go to the network.
|
|
160
190
|
navigateFallback: ${json(`${p}index.html`)},
|
|
161
|
-
navigateFallbackAllowlist: [
|
|
191
|
+
navigateFallbackAllowlist: [/${addressSource(app, { query: true })}/],
|
|
162
192
|
skipWaiting: false,
|
|
163
193
|
clientsClaim: false,
|
|
164
194
|
cleanupOutdatedCaches: true,
|
package/scripts/lib/json.mjs
CHANGED
|
@@ -5,6 +5,12 @@ import { replaceFile } from './files.mjs';
|
|
|
5
5
|
/** Two-space indentation and a final newline. */
|
|
6
6
|
export const formatJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
7
7
|
|
|
8
|
+
/** An object's entries sorted by key, as package managers keep dependency lists. */
|
|
9
|
+
export const sortedKeys = (object) =>
|
|
10
|
+
Object.fromEntries(
|
|
11
|
+
Object.entries(object).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
12
|
+
);
|
|
13
|
+
|
|
8
14
|
export function writeJson(path, value) {
|
|
9
15
|
writeFileSync(path, formatJson(value));
|
|
10
16
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The UI kit and router the skeleton copied into apps in 0.8.0, until the
|
|
2
|
+
// package @sempods/app-ui replaced them. They are the owner's files: a
|
|
3
|
+
// migration never changes or removes them. It only tells which of them are
|
|
4
|
+
// still as shipped, so moving the app to the package needs no merge there,
|
|
5
|
+
// and which the owner changed, which stay the app's own until they replace
|
|
6
|
+
// them deliberately.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { readText } from './files.mjs';
|
|
10
|
+
|
|
11
|
+
// SHA-256 of each file as 0.8.0-dev.1 and 0.8.0 shipped it, with LF line
|
|
12
|
+
// endings.
|
|
13
|
+
const SHIPPED = {
|
|
14
|
+
'src/routes.ts': [
|
|
15
|
+
'50693779a9ec1a73ab6210445cae1ab9563be105e94d79417772fe78a64d43c9',
|
|
16
|
+
],
|
|
17
|
+
'src/routes.test.ts': [
|
|
18
|
+
'cb92fec8d23daea5587394960f535445d7da156c0ab4346286ddce1375cc42c9',
|
|
19
|
+
],
|
|
20
|
+
'src/ui/Composer.tsx': [
|
|
21
|
+
'dcf6d28da59802da17421d04ef120e29863520790ae8f51ffa58e6221a7da89e',
|
|
22
|
+
],
|
|
23
|
+
'src/ui/ContextSwitch.tsx': [
|
|
24
|
+
'07c584db1dc69f0d4c42ea77691a67687f8deab6d93833fec8b3d9f09878fc6a',
|
|
25
|
+
'953d1f775d0575360251bb8d41e07736912de384d22c2d9fc7ff733959d7db51',
|
|
26
|
+
],
|
|
27
|
+
'src/ui/Dialog.tsx': [
|
|
28
|
+
'b9c01053804e13b6745299d8c7f79e9ec91bf2e75cd9541ac1a9f0c088e66061',
|
|
29
|
+
'b30a55e4b808f7e1dc2998c2f0df5e1a9a6aa16d69be7bcf5a55f579fb7ec873',
|
|
30
|
+
],
|
|
31
|
+
'src/ui/Menu.tsx': [
|
|
32
|
+
'685b4f6ebe9c2ed447dd54e3b80c005a6cf7e7466df61a14f666d53988728fe6',
|
|
33
|
+
],
|
|
34
|
+
'src/ui/Tabs.tsx': [
|
|
35
|
+
'c2da6564da4a229802acf96b00d6c235d0fa0b0f53e407e63466cd347db10647',
|
|
36
|
+
],
|
|
37
|
+
'src/ui/dismiss.ts': [
|
|
38
|
+
'd5fa5a7589f9b7661c531471834e7facca784d466af5e81f2e5dfa70f388dda9',
|
|
39
|
+
],
|
|
40
|
+
'src/ui/icons.tsx': [
|
|
41
|
+
'bf2d7f15d0c6455e15d561515e7a594becdd9f5b364f5e3aaa477e4736bbb6ec',
|
|
42
|
+
],
|
|
43
|
+
'src/ui/ui.css': [
|
|
44
|
+
'43603c18c03dde5e731814989c76c5575e330da5f06d1db32c1cc5566c22cd3c',
|
|
45
|
+
'10fd3f736ed82a96a9727d53ec27722b8d80682e42d9a5f8f66119a35d35bd34',
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const sha256 = (text) =>
|
|
50
|
+
createHash('sha256').update(text.replaceAll('\r\n', '\n')).digest('hex');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The copied kit files an app still has: `unchanged` as shipped, `changed`
|
|
54
|
+
* by the owner. Undefined when the app has none of them.
|
|
55
|
+
*/
|
|
56
|
+
export function copiedKit(appDir) {
|
|
57
|
+
const unchanged = [];
|
|
58
|
+
const changed = [];
|
|
59
|
+
for (const [file, hashes] of Object.entries(SHIPPED)) {
|
|
60
|
+
const text = readText(join(appDir, file));
|
|
61
|
+
if (text === null) continue;
|
|
62
|
+
(hashes.includes(sha256(text)) ? unchanged : changed).push(file);
|
|
63
|
+
}
|
|
64
|
+
return unchanged.length + changed.length > 0
|
|
65
|
+
? { unchanged, changed }
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The migration's note for one app that still has the copied kit. */
|
|
70
|
+
export function kitNote(id, kit) {
|
|
71
|
+
const list = (files) => files.map((file) => `apps/${id}/${file}`).join(', ');
|
|
72
|
+
return [
|
|
73
|
+
`apps/${id} still has the UI kit and router copied in 0.8.0; it keeps working, nothing was changed. Move it to @sempods/app-ui as the 0.9.0 upgrade notes in the tooling's CHANGELOG.md describe.`,
|
|
74
|
+
kit.unchanged.length > 0 &&
|
|
75
|
+
`As shipped, to replace with the package: ${list(kit.unchanged)}.`,
|
|
76
|
+
kit.changed.length > 0 &&
|
|
77
|
+
`Changed in this app, the app's own until the owner replaces them: ${list(kit.changed)}.`,
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
package/scripts/lib/merge.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import { sortedKeys } from './json.mjs';
|
|
7
8
|
|
|
8
9
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
9
10
|
|
|
@@ -125,13 +126,21 @@ export function mergeManifest(
|
|
|
125
126
|
);
|
|
126
127
|
}
|
|
127
128
|
// The SDK sits where the release puts it; elsewhere only if the release
|
|
128
|
-
// declares it nowhere in this manifest.
|
|
129
|
+
// declares it nowhere in this manifest. A package of it that this
|
|
130
|
+
// manifest lacks waits for sdk-update: it may not exist at this version.
|
|
129
131
|
if (field.endsWith('ependencies'))
|
|
130
132
|
for (const key of sdk) {
|
|
131
133
|
const declared = DEPENDENCY_FIELDS.some(
|
|
132
134
|
(f) => theirs[f]?.[key] !== undefined,
|
|
133
135
|
);
|
|
134
|
-
|
|
136
|
+
const ownDeclared = DEPENDENCY_FIELDS.some(
|
|
137
|
+
(f) => ours[f]?.[key] !== undefined,
|
|
138
|
+
);
|
|
139
|
+
if (theirs[field]?.[key] !== undefined && !ownDeclared)
|
|
140
|
+
notes.push(
|
|
141
|
+
`${where} ${field}.${key}: not added; pnpm run sdk-update <version> adds it with the SDK`,
|
|
142
|
+
);
|
|
143
|
+
else if (
|
|
135
144
|
declared
|
|
136
145
|
? theirs[field]?.[key] !== undefined
|
|
137
146
|
: target[key] !== undefined
|
|
@@ -145,13 +154,7 @@ export function mergeManifest(
|
|
|
145
154
|
}
|
|
146
155
|
}
|
|
147
156
|
// Package managers keep dependency lists sorted; scripts and the rest keep their order.
|
|
148
|
-
const ordered = field.endsWith('ependencies')
|
|
149
|
-
? Object.fromEntries(
|
|
150
|
-
Object.entries(target).sort(([a], [b]) =>
|
|
151
|
-
a < b ? -1 : a > b ? 1 : 0,
|
|
152
|
-
),
|
|
153
|
-
)
|
|
154
|
-
: target;
|
|
157
|
+
const ordered = field.endsWith('ependencies') ? sortedKeys(target) : target;
|
|
155
158
|
if (Object.keys(ordered).length) result[field] = ordered;
|
|
156
159
|
else delete result[field];
|
|
157
160
|
}
|
package/scripts/lib/sdk.mjs
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
// The SDK packages
|
|
2
|
-
|
|
1
|
+
// The SDK packages, released together at one version, and how their exact
|
|
2
|
+
// versions compare. The root declares all of them; every app declares the
|
|
3
|
+
// first two and the UI kit when it uses it.
|
|
4
|
+
export const SDK = ['@sempods/app-sdk', '@sempods/client-sdk', '@sempods/app-ui'];
|
|
5
|
+
export const UI_KIT = '@sempods/app-ui';
|
|
6
|
+
export const APP_SDK = SDK.filter((name) => name !== UI_KIT);
|
|
7
|
+
|
|
8
|
+
/** The SDK packages an app declares: the UI kit only when it uses it. */
|
|
9
|
+
export const appSdk = (dependencies) =>
|
|
10
|
+
dependencies?.[UI_KIT] === undefined ? APP_SDK : SDK;
|
|
3
11
|
|
|
4
12
|
/** The app-author reference the installed SDK ships, relative to the root. */
|
|
5
13
|
export const REFERENCE = 'node_modules/@sempods/app-sdk/docs/ai-app-builder.md';
|
package/scripts/lib/themes.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The skeleton's themes: one CSS file per theme in src/themes/, which sets the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// palette roles of the UI kit's theme contract (--primary, --background, …)
|
|
3
|
+
// in light and dark mode. An app imports one of them in its src/index.css;
|
|
4
|
+
// its icon takes that theme's primary colour.
|
|
4
5
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { ifPresent } from './json.mjs';
|
|
@@ -8,7 +9,12 @@ import { SKELETON } from './paths.mjs';
|
|
|
8
9
|
|
|
9
10
|
export const DEFAULT_THEME = 'calm';
|
|
10
11
|
const THEMES_DIR = join('src', 'themes');
|
|
11
|
-
|
|
12
|
+
// The import line in src/index.css; apps from before the UI kit import their
|
|
13
|
+
// theme in src/main.tsx.
|
|
14
|
+
const IMPORTS = [
|
|
15
|
+
['index.css', /^@import '\.\/themes\/([a-z-]+)\.css';$/m],
|
|
16
|
+
['main.tsx', /^import '\.\/themes\/([a-z-]+)\.css';$/m],
|
|
17
|
+
];
|
|
12
18
|
|
|
13
19
|
/** The theme names the skeleton ships, the default first. */
|
|
14
20
|
export function themeNames(skeleton = SKELETON) {
|
|
@@ -19,37 +25,57 @@ export function themeNames(skeleton = SKELETON) {
|
|
|
19
25
|
return [DEFAULT_THEME, ...names.filter((name) => name !== DEFAULT_THEME)];
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A custom property's light and dark colour, from
|
|
30
|
+
* `--name: light-dark(#light, #dark)`; undefined when the theme lacks it.
|
|
31
|
+
*/
|
|
32
|
+
export function tokenColours(css, property) {
|
|
24
33
|
const match = css.match(
|
|
25
34
|
new RegExp(
|
|
26
|
-
|
|
35
|
+
`(?:^|[\\s;{])--${property}:\\s*light-dark\\((#[0-9a-f]{6}),\\s*(#[0-9a-f]{6})\\)`,
|
|
27
36
|
'i',
|
|
28
37
|
),
|
|
29
38
|
);
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
return match
|
|
40
|
+
? { light: match[1].toLowerCase(), dark: match[2].toLowerCase() }
|
|
41
|
+
: undefined;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
/**
|
|
35
|
-
* The icon's colours for a theme file: the
|
|
36
|
-
* on
|
|
45
|
+
* The icon's colours for a theme file: the primary role as the square and the
|
|
46
|
+
* text on it as the letter, in light and dark mode. A theme from before the UI
|
|
47
|
+
* kit gives its --sempods-accent tokens instead.
|
|
37
48
|
*/
|
|
38
49
|
export function iconColours(css) {
|
|
39
|
-
const
|
|
40
|
-
|
|
50
|
+
const pair = (background, letter) => {
|
|
51
|
+
const square = tokenColours(css, background);
|
|
52
|
+
const text = tokenColours(css, letter);
|
|
53
|
+
return square && text ? { square, text } : undefined;
|
|
54
|
+
};
|
|
55
|
+
const found =
|
|
56
|
+
pair('primary', 'primary-foreground') ??
|
|
57
|
+
pair('sempods-accent', 'sempods-on-accent');
|
|
58
|
+
if (!found)
|
|
59
|
+
throw new Error(
|
|
60
|
+
'the theme sets no --primary and --primary-foreground in light-dark(#light, #dark)',
|
|
61
|
+
);
|
|
62
|
+
const { square, text } = found;
|
|
41
63
|
return {
|
|
42
|
-
light: { background:
|
|
43
|
-
dark: { background:
|
|
64
|
+
light: { background: square.light, letter: text.light },
|
|
65
|
+
dark: { background: square.dark, letter: text.dark },
|
|
44
66
|
};
|
|
45
67
|
}
|
|
46
68
|
|
|
47
|
-
/** The theme an app imports
|
|
69
|
+
/** The theme an app imports, if it imports one of these. */
|
|
48
70
|
export function appTheme(appDir) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
71
|
+
for (const [file, pattern] of IMPORTS) {
|
|
72
|
+
const source = ifPresent(() =>
|
|
73
|
+
readFileSync(join(appDir, 'src', file), 'utf8'),
|
|
74
|
+
);
|
|
75
|
+
const name = source?.match(pattern)?.[1];
|
|
76
|
+
if (name) return name;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
/** The CSS of a theme file in an app or in the skeleton. */
|
package/scripts/migrate.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
replaceFile,
|
|
33
33
|
} from './lib/files.mjs';
|
|
34
34
|
import { GENERATED_FILES } from './lib/generate.mjs';
|
|
35
|
+
import { copiedKit, kitNote } from './lib/kit.mjs';
|
|
35
36
|
import {
|
|
36
37
|
adoptPageHead,
|
|
37
38
|
adoptPageTitle,
|
|
@@ -511,7 +512,8 @@ export function migrate(
|
|
|
511
512
|
|
|
512
513
|
/**
|
|
513
514
|
* The apps' part of a migration: files a release moves where that is
|
|
514
|
-
* unambiguous, then the generated configuration.
|
|
515
|
+
* unambiguous, then the generated configuration. App code that needs a
|
|
516
|
+
* decision, such as a copied UI kit, stays as it is with a note.
|
|
515
517
|
*/
|
|
516
518
|
function updateApps(root, report) {
|
|
517
519
|
for (const app of appsOf(root)) {
|
|
@@ -519,6 +521,8 @@ function updateApps(root, report) {
|
|
|
519
521
|
if (!existsSync(dir)) continue;
|
|
520
522
|
const changed = [...adoptPageTitle(dir, app), ...adoptPageHead(dir, app)];
|
|
521
523
|
report.updated.push(...new Set(changed));
|
|
524
|
+
const kit = copiedKit(dir);
|
|
525
|
+
if (kit) report.notes.push(kitNote(app.id, kit));
|
|
522
526
|
}
|
|
523
527
|
report.regenerated = regenerate(root);
|
|
524
528
|
}
|
|
@@ -531,6 +535,7 @@ export function formatMigration(report) {
|
|
|
531
535
|
`The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`,
|
|
532
536
|
...list('Updated', report.updated),
|
|
533
537
|
...list('Regenerated', report.regenerated),
|
|
538
|
+
...list('Notes', report.notes),
|
|
534
539
|
].join('\n');
|
|
535
540
|
const lines = [
|
|
536
541
|
`Migration from ${PACKAGE} ${report.from} to ${report.to}${report.unfinished ? ' is open' : ' is complete'}.`,
|
package/scripts/new-app.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
themeCss,
|
|
34
34
|
themeNames,
|
|
35
35
|
} from './lib/themes.mjs';
|
|
36
|
-
import { readJson, writeJson } from './lib/json.mjs';
|
|
36
|
+
import { readJson, sortedKeys, writeJson } from './lib/json.mjs';
|
|
37
37
|
import { repositoryRoot, SKELETON } from './lib/paths.mjs';
|
|
38
38
|
import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
|
|
39
39
|
import { EXACT_VERSION, SDK } from './lib/sdk.mjs';
|
|
@@ -130,12 +130,7 @@ export function createApp(
|
|
|
130
130
|
writeAppIcons(appDir, app.title, iconColours(themeCss(appDir, theme)));
|
|
131
131
|
const pkgFile = join(appDir, 'package.json');
|
|
132
132
|
const pkg = readJson(pkgFile);
|
|
133
|
-
|
|
134
|
-
pkg.dependencies = Object.fromEntries(
|
|
135
|
-
Object.entries({ ...pkg.dependencies, ...sdk }).sort(([a], [b]) =>
|
|
136
|
-
a < b ? -1 : a > b ? 1 : 0,
|
|
137
|
-
),
|
|
138
|
-
);
|
|
133
|
+
pkg.dependencies = sortedKeys({ ...pkg.dependencies, ...sdk });
|
|
139
134
|
writeJson(pkgFile, pkg);
|
|
140
135
|
writeGenerated(appDir, app, manifest.site);
|
|
141
136
|
writeApps(root, { ...manifest, apps: [...manifest.apps, app] });
|