@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +13 -6
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +145 -72
  5. package/instructions/publish.md +29 -11
  6. package/instructions/setup.md +2 -2
  7. package/instructions/visual-check.md +20 -2
  8. package/package.json +5 -3
  9. package/scripts/build-site.mjs +37 -9
  10. package/scripts/check-theme.mjs +398 -0
  11. package/scripts/check.mjs +16 -5
  12. package/scripts/icon.mjs +1 -1
  13. package/scripts/lib/apps.mjs +115 -27
  14. package/scripts/lib/generate.mjs +58 -28
  15. package/scripts/lib/json.mjs +6 -0
  16. package/scripts/lib/kit.mjs +81 -0
  17. package/scripts/lib/merge.mjs +12 -9
  18. package/scripts/lib/sdk.mjs +10 -2
  19. package/scripts/lib/themes.mjs +45 -19
  20. package/scripts/migrate.mjs +6 -1
  21. package/scripts/new-app.mjs +2 -7
  22. package/scripts/sdk-update.mjs +32 -10
  23. package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
  24. package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
  25. package/shared/snapshot.json +3 -3
  26. package/skeleton/app/package.json +3 -0
  27. package/skeleton/app/src/App.tsx +53 -21
  28. package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
  29. package/skeleton/app/src/examples/ListScreen.tsx +154 -56
  30. package/skeleton/app/src/index.css +10 -110
  31. package/skeleton/app/src/main.tsx +1 -3
  32. package/skeleton/app/src/places.test.ts +128 -0
  33. package/skeleton/app/src/places.ts +146 -0
  34. package/skeleton/app/src/texts.ts +8 -0
  35. package/skeleton/app/src/themes/calm.css +22 -8
  36. package/skeleton/app/src/themes/fresh.css +22 -8
  37. package/skeleton/app/src/themes/playful.css +22 -8
  38. package/skeleton/app/src/themes/warm.css +22 -8
  39. package/skeleton/app/vite.config.ts +12 -2
  40. package/skills/update/SKILL.md +6 -1
  41. package/update-policy.json +1 -1
  42. package/skeleton/app/src/routes.test.ts +0 -19
  43. package/skeleton/app/src/routes.ts +0 -139
  44. package/skeleton/app/src/ui/Composer.tsx +0 -18
  45. package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
  46. package/skeleton/app/src/ui/Dialog.tsx +0 -120
  47. package/skeleton/app/src/ui/Menu.tsx +0 -118
  48. package/skeleton/app/src/ui/Tabs.tsx +0 -39
  49. package/skeleton/app/src/ui/dismiss.ts +0 -30
  50. package/skeleton/app/src/ui/icons.tsx +0 -53
  51. package/skeleton/app/src/ui/ui.css +0 -249
@@ -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
- 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
- });
295
+ problems.push(...addressProblems(app));
208
296
  }
209
297
  return problems;
210
298
  }
@@ -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 { AUTO, SITE_PROFILES, siteIdentity } from './apps.mjs';
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
- ${RETURN_TO[Boolean(app.routes?.length)]},
42
+ returnTo: returnPath,
38
43
  };
39
44
  `;
40
45
  }
41
46
 
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
- };
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
- const RETURN_PATH = `
50
- // The page sign-in returns to: the current route, otherwise the start page.
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 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;
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.routes?.length ? RETURN_PATH : ''}
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
- ${RETURN_TO[Boolean(app.routes?.length)]},
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 and routes. The
159
- // callback, Pod and login pages always go to the network.
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: [/^${escaped}${routes}$/],
191
+ navigateFallbackAllowlist: [/${addressSource(app, { query: true })}/],
162
192
  skipWaiting: false,
163
193
  clientsClaim: false,
164
194
  cleanupOutdatedCaches: true,
@@ -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
+ }
@@ -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
- if (
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
  }
@@ -1,5 +1,13 @@
1
- // The SDK packages every app uses, and how their exact versions compare.
2
- export const SDK = ['@sempods/app-sdk', '@sempods/client-sdk'];
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';
@@ -1,6 +1,7 @@
1
1
  // The skeleton's themes: one CSS file per theme in src/themes/, which sets the
2
- // --sempods-* tokens in light and dark mode. An app imports one of them in its
3
- // src/main.tsx; its icon takes that theme's accent.
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
- const IMPORT = /^import '\.\/themes\/([a-z-]+)\.css';$/m;
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
- /** A token's light and dark colour, from `--name: light-dark(#light, #dark)`. */
23
- export function tokenColours(css, token) {
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
- `--sempods-${token}:\\s*light-dark\\((#[0-9a-f]{6}),\\s*(#[0-9a-f]{6})\\)`,
35
+ `(?:^|[\\s;{])--${property}:\\s*light-dark\\((#[0-9a-f]{6}),\\s*(#[0-9a-f]{6})\\)`,
27
36
  'i',
28
37
  ),
29
38
  );
30
- if (!match) throw new Error(`the theme sets no --sempods-${token}`);
31
- return { light: match[1].toLowerCase(), dark: match[2].toLowerCase() };
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 accent as the square and the text
36
- * on the accent as the letter, in light and dark mode.
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 accent = tokenColours(css, 'accent');
40
- const on = tokenColours(css, 'on-accent');
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: accent.light, letter: on.light },
43
- dark: { background: accent.dark, letter: on.dark },
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 in src/main.tsx, if it imports one of these. */
69
+ /** The theme an app imports, if it imports one of these. */
48
70
  export function appTheme(appDir) {
49
- const main = ifPresent(() =>
50
- readFileSync(join(appDir, 'src', 'main.tsx'), 'utf8'),
51
- );
52
- return main?.match(IMPORT)?.[1];
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. */
@@ -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'}.`,
@@ -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
- // Package managers keep dependency lists sorted.
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] });