@morphemecms/cms 0.4.0 → 0.5.0-next-20261104215254

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 (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/morpheme.mjs +2 -2
  3. package/lib/licenses.mjs +1 -1
  4. package/package.json +6 -1
  5. package/scripts/built-site.mjs +24 -7
  6. package/scripts/check-store-connection.mjs +226 -0
  7. package/scripts/check-store.mjs +53 -3
  8. package/scripts/editor-screens.mjs +26 -0
  9. package/scripts/types-include.test.mjs +93 -0
  10. package/site/theme.d.mts +6 -0
  11. package/site/theme.mjs +30 -5
  12. package/site/theme.test.mjs +54 -0
  13. package/src/AppList.tsx +16 -2
  14. package/src/CmsVersion.tsx +14 -0
  15. package/src/Library.tsx +3 -2
  16. package/src/StartStatus.tsx +24 -0
  17. package/src/StoreConnectNotice.tsx +34 -0
  18. package/src/StoreConnectedRow.tsx +36 -0
  19. package/src/SwapText.tsx +22 -0
  20. package/src/allIcons.ts +3 -6
  21. package/src/cmsVersionView.d.mts +7 -0
  22. package/src/cmsVersionView.mjs +11 -0
  23. package/src/cmsVersionView.test.mjs +32 -0
  24. package/src/i18n/da-DK/apps.ts +7 -0
  25. package/src/i18n/da-DK/library.ts +2 -0
  26. package/src/i18n/en-US/apps.ts +8 -0
  27. package/src/i18n/en-US/library.ts +4 -0
  28. package/src/store.ts +5 -0
  29. package/src/storeConnection.ts +24 -0
  30. package/src/storeConnectionCall.test.mjs +57 -0
  31. package/src/storeConnectionCall.ts +37 -0
  32. package/src/virtual.d.ts +5 -0
  33. package/starter/package.json +5 -0
  34. package/theme/editor/looks.d.mts +2 -0
  35. package/worker/index.js +4 -1
  36. package/worker/store-connection.js +106 -0
  37. package/worker/store.js +14 -6
  38. package/lib/apps.test.d.mts +0 -1
  39. package/lib/caseCollisions.test.d.mts +0 -1
  40. package/lib/changelog.test.d.mts +0 -1
  41. package/lib/cms-version.test.d.mts +0 -1
  42. package/lib/lockfile.test.d.mts +0 -1
  43. package/lib/range.test.d.mts +0 -1
  44. package/lib/side-block.test.d.mts +0 -1
  45. package/lib/storeEntries.test.d.mts +0 -1
  46. package/site/storeTheme.test.d.mts +0 -1
  47. package/src/cmsView.test.d.mts +0 -1
  48. package/src/frontmatter.test.d.mts +0 -1
  49. package/src/standard/leads.test.d.mts +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.5.0-next-20261104215254
4
+
5
+ ### Minor Changes
6
+
7
+ - 322a404: Connect a site to the Morpheme store from the editor's Appstore. A key set by hand in MORPHEME_STORE_KEY still works.
8
+
9
+ ### Patch Changes
10
+
11
+ - eb1f36f: A site's build reads Phosphor's icons one at a time, so it passes on Cloudflare's build image, which allows 1024 open files.
12
+ - 6844676: The editor's start screen shows the installed CMS version before the update time, e.g. "Morpheme 0.5.0 · Updated 19.26". A pre-release shows short ("0.5.0-next"); a click shows the full version.
13
+ - 2fa2bb3: New sites install a fixed postcss-selector-parser (6.1.5) under @tailwindcss/typography, which closes CVE-2026-104844.
14
+ - f343523: The package has the types of the theme's `editor/looks.mjs`, so a site's type check (`tsc`) passes again.
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  ### Minor Changes
package/bin/morpheme.mjs CHANGED
@@ -12,7 +12,7 @@ const COMMANDS = {
12
12
  'after-build': ['scheduled-list', 'link-counts', 'page-colors'],
13
13
  'check:content': ['check-content'],
14
14
  'check:editor': ['editor-roundtrip'],
15
- 'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update'],
15
+ 'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update', 'check-store-connection'],
16
16
  'check:screens': ['editor-screens'],
17
17
  'check:themes': ['check-themes'],
18
18
  'check:demo': ['check-demo'],
@@ -24,7 +24,7 @@ const COMMANDS = {
24
24
  };
25
25
 
26
26
  // Node warns that node:sqlite is experimental, which the consent log's check uses as D1.
27
- const QUIET = new Set(['check-consent']);
27
+ const QUIET = new Set(['check-consent', 'check-store-connection']);
28
28
 
29
29
  const [command, ...args] = process.argv.slice(2);
30
30
  if (!COMMANDS[command]) {
package/lib/licenses.mjs CHANGED
@@ -428,7 +428,7 @@ const HELPERS = [
428
428
  [/^\0vite\//, 'vite'],
429
429
  [/^\0rolldown\//, 'rolldown'],
430
430
  ];
431
- const OWN_VIRTUAL = [/^\0virtual:app-folders$/, /^\0site-theme-icons?(:|$)/];
431
+ const OWN_VIRTUAL = [/^\0virtual:(app-folders|all-icons)$/, /^\0site-theme-icons?(:|$)/];
432
432
 
433
433
  /** An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
434
434
  * (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-next-20261104215254",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -138,5 +138,10 @@
138
138
  "vite": "^8.3.4",
139
139
  "wrangler": "^4.152.0",
140
140
  "yaml": "^2.9.1"
141
+ },
142
+ "overrides": {
143
+ "@tailwindcss/typography": {
144
+ "postcss-selector-parser": "^6.1.5"
145
+ }
141
146
  }
142
147
  }
@@ -124,19 +124,36 @@ const CMS_STATUS = {
124
124
  notes: [{ version: '99.0.0', text: 'A made-up note for the checks.\n\nA second paragraph.' }],
125
125
  };
126
126
 
127
+ // The site's key to the store (/api/store/connection, packages/editor/worker/store-connection.js), made up, in the three
128
+ // states "Appstore" shows: a key from the button, no key and the editor may connect, no key and it may only read. A POST
129
+ // or DELETE moves a fake store between the first two, as the Worker would.
130
+ export const STORE_STATES = {
131
+ button: { connected: true, by: 'button', site: 'site_sample', since: '2026-11-04T08:00:00.000Z', mayChange: true },
132
+ open: { connected: false, mayChange: true },
133
+ readOnly: { connected: false, mayChange: false },
134
+ };
135
+
127
136
  /** Opens the editor (/editor/) of the site at `site` in `page`, signed in, with GitHub answered from the files on disk
128
- * (`texts`: some with other texts), and waits until it has loaded the site. */
129
- export async function openEditor(page, site, texts = {}) {
137
+ * (`texts`: some with other texts), and waits until it has loaded the site. `store`: the state of the site's key to the
138
+ * store (a key of STORE_STATES). */
139
+ export async function openEditor(page, site, texts = {}, store = 'button') {
140
+ let state = STORE_STATES[store];
130
141
  // R2 caching under "Avanceret": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
131
142
  await page.route(`${site}/api/r2?**`, (r) =>
132
143
  r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
133
144
  );
134
145
  await page.route(`${site}/api/cms`, (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(CMS_STATUS) }));
135
- await page.route(`${site}/api/store/**`, (r) =>
136
- new URL(r.request().url()).pathname === '/api/store/catalog'
137
- ? r.fulfill({ contentType: 'application/json', body: JSON.stringify(STORE_CATALOG) })
138
- : r.fulfill({ status: 404 }),
139
- );
146
+ await page.route(`${site}/api/store/**`, (r) => {
147
+ const path = new URL(r.request().url()).pathname;
148
+ const answer = (body) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
149
+ if (path === '/api/store/catalog') return answer(state.connected ? STORE_CATALOG : { connected: false });
150
+ if (path === '/api/store/connection') {
151
+ if (r.request().method() === 'POST') state = STORE_STATES.button;
152
+ if (r.request().method() === 'DELETE') state = STORE_STATES.open;
153
+ return answer(state);
154
+ }
155
+ return r.fulfill({ status: 404 });
156
+ });
140
157
  await page.route(`${site}/api/github/**`, (r) => {
141
158
  const route = new URL(r.request().url()).pathname.slice('/api/github/'.length);
142
159
  const found = workerAnswer(route, JSON.parse(r.request().postData() || '{}'), texts);
@@ -0,0 +1,226 @@
1
+ // Checks how the site's Worker gets and keeps a store key from "Connect to Morpheme" (packages/editor/worker/store-connection.js):
2
+ // /api/store/connection for GET, POST and DELETE, who may do what, the table with the key (D1 played by Node's SQLite), and
3
+ // storeKey(). GitHub and the store are played by a fake fetch that records each request; nothing goes to either.
4
+ // Run: npm run check:worker
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { workerModules } from './worker-modules.mjs';
7
+
8
+ const [{ handleConnection, storeKey, forgetKey }] = await workerModules('store-connection.js', 'session.js', 'store.js');
9
+
10
+ const problems = [];
11
+ const expect = (ok, what) => ok || problems.push(what);
12
+
13
+ /** D1, as much of it as the Worker uses, on an SQLite database in memory. */
14
+ function d1() {
15
+ const db = new DatabaseSync(':memory:');
16
+ const statement = (sql, args = []) => ({
17
+ bind: (...values) => statement(sql, values),
18
+ run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
19
+ all: async () => ({ results: db.prepare(sql).all(...args) }),
20
+ first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
21
+ });
22
+ return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
23
+ }
24
+ const rowsOf = (store) => store.db.prepare('SELECT site, key FROM store_connection').all();
25
+
26
+ const SITE = `site_${'a1'.repeat(12)}`;
27
+ const KEY = 'isk_x';
28
+ const HAND = 'hand-key-for-test';
29
+
30
+ /** A fake fetch: GitHub says what `repoAccess` says; the store answers with `store(url, init)`. `storeCalls` records its requests. */
31
+ function world({ repoAccess = { pull: true, push: true }, store = () => Response.json({ site: SITE, key: KEY }, { status: 201 }) } = {}) {
32
+ const storeCalls = [];
33
+ const ask = async (url, init = {}) => {
34
+ if (String(url).startsWith('https://api.github.com/')) return Response.json({ permissions: repoAccess });
35
+ storeCalls.push({ url: String(url), method: init.method ?? 'GET', headers: init.headers ?? {}, body: init.body });
36
+ return store(String(url), init);
37
+ };
38
+ return { ask, storeCalls };
39
+ }
40
+
41
+ let n = 0;
42
+ /** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
43
+ const call = async (w, method, env, { signedIn = true } = {}) => {
44
+ const request = new Request('https://site.example/api/store/connection', {
45
+ method,
46
+ headers: { ...(signedIn ? { cookie: `editor_session=token-${++n}` } : {}), 'sec-fetch-site': 'same-origin' },
47
+ });
48
+ const res = await handleConnection(request, env, {}, w.ask);
49
+ const text = await res.text();
50
+ return { res, text, body: text.startsWith('{') ? JSON.parse(text) : null };
51
+ };
52
+ /** An env with a fresh database, with a row for each [site, key] given. */
53
+ const envWith = async (rows = [], extra = {}) => {
54
+ const CONSENT = d1();
55
+ if (rows.length) {
56
+ await call(world(), 'GET', { CONSENT }); // makes the table on first use
57
+ for (const [site, key] of rows) {
58
+ CONSENT.db.prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)').run(site, key, '2026-11-04T10:00:00.000Z');
59
+ }
60
+ }
61
+ return { CONSENT, ...extra };
62
+ };
63
+
64
+ // Signed out: 401 for each method, and the store is not asked.
65
+ for (const method of ['GET', 'POST', 'DELETE']) {
66
+ const w = world();
67
+ const { res } = await call(w, method, await envWith(), { signedIn: false });
68
+ expect(res.status === 401, `uden login var ${method} ${res.status}, ikke 401`);
69
+ expect(w.storeCalls.length === 0, `uden login blev butikken spurgt (${method})`);
70
+ }
71
+
72
+ // Not even allowed to read: 403.
73
+ {
74
+ const w = world({ repoAccess: { pull: false, push: false } });
75
+ const { res } = await call(w, 'GET', await envWith());
76
+ expect(res.status === 403, `uden læseret var svaret ${res.status}, ikke 403`);
77
+ }
78
+
79
+ // Read-only: GET works, POST and DELETE are 403, and the store is not asked.
80
+ {
81
+ const w = world({ repoAccess: { pull: true, push: false } });
82
+ const env = await envWith();
83
+ const get = await call(w, 'GET', env);
84
+ expect(get.res.status === 200 && get.body.connected === false && get.body.mayChange === false, `GET med kun læseret gav ${get.text}`);
85
+ for (const method of ['POST', 'DELETE']) {
86
+ const { res } = await call(w, method, env);
87
+ expect(res.status === 403, `${method} med kun læseret gav ${res.status}, ikke 403`);
88
+ }
89
+ expect(w.storeCalls.length === 0, 'med kun læseret blev butikken spurgt');
90
+ expect(rowsOf(env.CONSENT).length === 0, 'med kun læseret blev der gemt en række');
91
+ }
92
+
93
+ // Connect: the store gets the origin and owner/name; the row has the key; no answer has the key; a second connect is 409.
94
+ {
95
+ const w = world();
96
+ const env = await envWith();
97
+ const { res, text, body } = await call(w, 'POST', env);
98
+ expect(
99
+ res.status === 200 &&
100
+ body.connected === true &&
101
+ body.by === 'button' &&
102
+ body.site === SITE &&
103
+ body.mayChange === true &&
104
+ typeof body.since === 'string',
105
+ `POST gav ${res.status} ${text}`,
106
+ );
107
+ expect(!text.includes(KEY), 'svaret på POST indeholdt nøglen');
108
+ expect(w.storeCalls.length === 1 && w.storeCalls[0].method === 'POST', `butikken blev spurgt ${w.storeCalls.length} gange`);
109
+ expect(w.storeCalls[0]?.url.endsWith('/store/connect'), `adressen var ${w.storeCalls[0]?.url}`);
110
+ const sent = JSON.parse(w.storeCalls[0]?.body ?? '{}');
111
+ expect(sent.address === 'https://site.example' && /^[^/]+\/[^/]+$/.test(sent.repo), `butikken fik ${w.storeCalls[0]?.body}`);
112
+ const rows = rowsOf(env.CONSENT);
113
+ expect(rows.length === 1 && rows[0].site === SITE && rows[0].key === KEY, 'rækken har ikke nøglen');
114
+ const get = await call(w, 'GET', env);
115
+ expect(get.body.by === 'button' && get.body.site === SITE && !get.text.includes(KEY), `GET efter POST gav ${get.text}`);
116
+ const second = await call(w, 'POST', env);
117
+ expect(second.res.status === 409, `en anden POST gav ${second.res.status}, ikke 409`);
118
+ expect(w.storeCalls.length === 1, 'en anden POST spurgte butikken');
119
+ // MORPHEME_STORE_URL is used when set.
120
+ const w2 = world();
121
+ await call(w2, 'POST', await envWith([], { MORPHEME_STORE_URL: 'https://store.example' }));
122
+ expect(w2.storeCalls[0]?.url === 'https://store.example/store/connect', `MORPHEME_STORE_URL blev ikke brugt: ${w2.storeCalls[0]?.url}`);
123
+ }
124
+
125
+ // The store's 429: 429 and no row. Its 500, an answer without a key, or a thrown fetch: 502 and no row.
126
+ {
127
+ const env = await envWith();
128
+ const { res, body } = await call(world({ store: () => new Response('{}', { status: 429 }) }), 'POST', env);
129
+ expect(res.status === 429 && body.error === 'busy', `butikkens 429 gav ${res.status}`);
130
+ expect(rowsOf(env.CONSENT).length === 0, 'butikkens 429 gav en række');
131
+ }
132
+ for (const [what, store] of [
133
+ ['500', () => new Response(`secret ${KEY}`, { status: 500 })],
134
+ ['et svar uden nøgle', () => Response.json({ site: SITE }, { status: 201 })],
135
+ [
136
+ 'en netværksfejl',
137
+ () => {
138
+ throw new Error(`no network, ${KEY}`);
139
+ },
140
+ ],
141
+ ]) {
142
+ const env = await envWith();
143
+ const { res, text, body } = await call(world({ store }), 'POST', env);
144
+ expect(res.status === 502 && body.error === 'store failed', `butikkens ${what} gav ${res.status} ${text}`);
145
+ expect(!text.includes(KEY), `svaret på butikkens ${what} indeholdt nøglen`);
146
+ expect(rowsOf(env.CONSENT).length === 0, `butikkens ${what} gav en række`);
147
+ }
148
+
149
+ // No database (a branch preview): GET says not connected, POST is 503.
150
+ {
151
+ const w = world();
152
+ const get = await call(w, 'GET', {});
153
+ expect(get.res.status === 200 && get.body.connected === false, `GET uden CONSENT gav ${get.text}`);
154
+ const post = await call(w, 'POST', {});
155
+ expect(post.res.status === 503 && post.body.error === 'no database', `POST uden CONSENT gav ${post.res.status}`);
156
+ expect(w.storeCalls.length === 0, 'POST uden CONSENT spurgte butikken');
157
+ }
158
+
159
+ // A key set by hand: GET says so, POST and DELETE are 409, and the store is not asked.
160
+ {
161
+ const w = world();
162
+ const env = await envWith([], { MORPHEME_STORE_KEY: HAND });
163
+ const get = await call(w, 'GET', env);
164
+ expect(get.body.connected === true && get.body.by === 'hand' && !get.text.includes(HAND), `GET med nøgle i hånden gav ${get.text}`);
165
+ expect((await call(w, 'DELETE', env)).res.status === 409, 'DELETE med nøgle i hånden gav ikke 409');
166
+ expect((await call(w, 'POST', env)).res.status === 409, 'POST med nøgle i hånden gav ikke 409');
167
+ expect(w.storeCalls.length === 0, 'med nøgle i hånden blev butikken spurgt');
168
+ }
169
+
170
+ // Disconnect: on the store's 200 or 401 the row goes (the bearer is the row's key); on 500 or a thrown fetch it stays.
171
+ for (const status of [200, 401]) {
172
+ const w = world({ store: () => new Response('{}', { status }) });
173
+ const env = await envWith([[SITE, KEY]]);
174
+ const { res, body, text } = await call(w, 'DELETE', env);
175
+ expect(
176
+ res.status === 200 && body.connected === false && body.mayChange === true,
177
+ `DELETE med butikkens ${status} gav ${res.status} ${text}`,
178
+ );
179
+ expect(rowsOf(env.CONSENT).length === 0, `DELETE med butikkens ${status} lod rækken stå`);
180
+ expect(w.storeCalls[0]?.method === 'DELETE' && w.storeCalls[0].url.endsWith('/store/connect'), 'DELETE spurgte ikke butikken rigtigt');
181
+ expect(w.storeCalls[0]?.headers.authorization === `Bearer ${KEY}`, 'DELETE brugte ikke rækkens nøgle som bearer');
182
+ }
183
+ for (const store of [
184
+ () => new Response('{}', { status: 500 }),
185
+ () => {
186
+ throw new Error('no network');
187
+ },
188
+ ]) {
189
+ const env = await envWith([[SITE, KEY]]);
190
+ const { res, body } = await call(world({ store }), 'DELETE', env);
191
+ expect(res.status === 502 && body.error === 'store failed', `DELETE med en fejl i butikken gav ${res.status}`);
192
+ expect(rowsOf(env.CONSENT).length === 1, 'DELETE med en fejl i butikken slettede rækken');
193
+ }
194
+ // No key at all: DELETE is a quiet 200 and the store is not asked.
195
+ {
196
+ const w = world();
197
+ const { res, body } = await call(w, 'DELETE', await envWith());
198
+ expect(res.status === 200 && body.connected === false, `DELETE uden nøgle gav ${res.status}`);
199
+ expect(w.storeCalls.length === 0, 'DELETE uden nøgle spurgte butikken');
200
+ }
201
+
202
+ // Other methods: 405.
203
+ expect((await call(world(), 'PUT', await envWith())).res.status === 405, 'PUT gav ikke 405');
204
+
205
+ // storeKey: the hand key first, then the row, else null; forgetKey deletes the rows.
206
+ {
207
+ const env = await envWith([[SITE, KEY]], { MORPHEME_STORE_KEY: HAND });
208
+ const hand = await storeKey(env);
209
+ expect(hand?.key === HAND && hand.by === 'hand', `storeKey med begge gav ${JSON.stringify(hand)}`);
210
+ const row = await storeKey({ CONSENT: env.CONSENT });
211
+ expect(
212
+ row?.key === KEY && row.by === 'button' && row.site === SITE && typeof row.since === 'string',
213
+ `storeKey med en række gav ${JSON.stringify(row)}`,
214
+ );
215
+ expect((await storeKey({ MORPHEME_STORE_KEY: HAND }))?.by === 'hand', 'storeKey uden CONSENT gav ikke nøglen i hånden');
216
+ expect((await storeKey({})) === null, 'storeKey uden noget gav ikke null');
217
+ await forgetKey(env);
218
+ expect(rowsOf(env.CONSENT).length === 0, 'forgetKey slettede ikke rækken');
219
+ expect((await storeKey({ CONSENT: env.CONSENT })) === null, 'storeKey efter forgetKey gav ikke null');
220
+ }
221
+
222
+ if (problems.length) {
223
+ console.error(problems.map((p) => `- ${p}`).join('\n'));
224
+ process.exit(1);
225
+ }
226
+ console.log('Workerens nøgle fra Forbind til Morpheme er i orden.');
@@ -1,11 +1,13 @@
1
1
  // Checks the site's Worker's questions to the store (packages/editor/worker/store.js): /api/store/catalog and /api/store/files/<oid>,
2
- // who may ask, what happens without a key, and the store's refusals and failures. GitHub and the store are played by a fake
3
- // fetch that records each request; nothing goes to either.
2
+ // who may ask, what happens without a key, the key from the button (in D1, played by Node's SQLite) that is forgotten when
3
+ // the store refuses it, and the store's refusals and failures. GitHub and the store are played by a fake fetch that records
4
+ // each request; nothing goes to either.
4
5
  // Run: npm run check:worker
5
6
  import { APP_API, THEME_API } from '../lib/contracts.mjs';
7
+ import { DatabaseSync } from 'node:sqlite';
6
8
  import { workerModules } from './worker-modules.mjs';
7
9
 
8
- const [{ handleStore, STORE_URL }] = await workerModules('store.js', 'session.js');
10
+ const [{ handleStore, STORE_URL }, , { storeKey }] = await workerModules('store.js', 'session.js', 'store-connection.js');
9
11
 
10
12
  const problems = [];
11
13
  const expect = (ok, what) => ok || problems.push(what);
@@ -25,6 +27,28 @@ function world({ repoAccess = { pull: true, push: false }, store = () => new Res
25
27
  return { ask, storeCalls };
26
28
  }
27
29
 
30
+ /** D1, as much of it as the Worker uses, on an SQLite database in memory. */
31
+ function d1() {
32
+ const db = new DatabaseSync(':memory:');
33
+ const statement = (sql, args = []) => ({
34
+ bind: (...values) => statement(sql, values),
35
+ run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
36
+ all: async () => ({ results: db.prepare(sql).all(...args) }),
37
+ first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
38
+ });
39
+ return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
40
+ }
41
+ /** An env with a key from the button in its database (the table is made by storeKey, as on first use). */
42
+ async function envWithRow(key) {
43
+ const CONSENT = d1();
44
+ await storeKey({ CONSENT });
45
+ CONSENT.db
46
+ .prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)')
47
+ .run(`site_${'a1'.repeat(12)}`, key, '2026-11-04T10:00:00.000Z');
48
+ return { CONSENT };
49
+ }
50
+ const rowCount = (env) => env.CONSENT.db.prepare('SELECT COUNT(*) AS n FROM store_connection').get().n;
51
+
28
52
  let n = 0;
29
53
  /** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
30
54
  const request = (path, { signedIn = true, method = 'GET' } = {}) =>
@@ -145,6 +169,32 @@ for (const [status, error] of [
145
169
  expect(!text.includes(KEY), 'svaret på en netværksfejl indeholdt nøglen');
146
170
  }
147
171
 
172
+ // A key from the button: the store gets it as the bearer; when the store refuses it, the row goes, the catalog says
173
+ // "not connected" and a file is not found. A key set by hand that is refused stays a 502.
174
+ {
175
+ const w = world();
176
+ await call(w, 'catalog', await envWithRow('isk_row'));
177
+ expect(
178
+ w.storeCalls[0]?.headers.authorization === 'Bearer isk_row',
179
+ `kataloget blev spurgt med ${w.storeCalls[0]?.headers.authorization}`,
180
+ );
181
+ }
182
+ for (const status of [401, 403]) {
183
+ for (const path of ['catalog', `files/${OID}`]) {
184
+ const env = await envWithRow('isk_row');
185
+ const { res, text } = await call(world({ store: () => new Response('no', { status }) }), path, env);
186
+ const want = path === 'catalog' ? res.status === 200 && JSON.parse(text).connected === false : res.status === 404;
187
+ expect(want, `butikkens ${status} på ${path} med en nøgle fra knappen gav ${res.status} ${text}`);
188
+ expect(rowCount(env) === 0, `butikkens ${status} på ${path} lod nøglen fra knappen stå`);
189
+ }
190
+ }
191
+ {
192
+ const env = { ...(await envWithRow('isk_row')), MORPHEME_STORE_KEY: KEY };
193
+ const { res, text } = await call(world({ store: () => new Response('no', { status: 401 }) }), 'catalog', env);
194
+ expect(res.status === 502 && JSON.parse(text).error === 'store refused', `en nøgle i hånden, der blev afvist, gav ${res.status} ${text}`);
195
+ expect(rowCount(env) === 1, 'en nøgle i hånden, der blev afvist, slettede rækken');
196
+ }
197
+
148
198
  // Other methods and addresses.
149
199
  {
150
200
  const w = world();
@@ -10,6 +10,8 @@
10
10
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
11
  import { join, relative, resolve } from 'node:path';
12
12
  import { chromium } from 'playwright';
13
+ import { mkdirSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
13
15
  import { openEditor, serveBuilt } from './built-site.mjs';
14
16
 
15
17
  const ROOT = `${process.cwd()}/`;
@@ -92,6 +94,30 @@ for (const [width, height] of [
92
94
  await page.close();
93
95
  }
94
96
 
97
+ // "Appstore" in the three states of the site's key to the store (STORE_STATES): what each shows, and a picture of it in
98
+ // SCREENS_DIR (the temporary folder's morpheme-screens by default).
99
+ const shots = process.env.SCREENS_DIR || join(tmpdir(), 'morpheme-screens');
100
+ mkdirSync(shots, { recursive: true });
101
+ const keyStates = {
102
+ open: { shows: ['Hjemmesiden er ikke forbundet til Morpheme', 'Forbind til Morpheme'], hides: ['Afbryd forbindelsen til Morpheme'] },
103
+ readOnly: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
104
+ button: { shows: ['Forbundet til Morpheme', 'site_sample', 'Afbryd forbindelsen til Morpheme'], hides: ['Forbind til Morpheme'] },
105
+ };
106
+ for (const [state, { shows, hides }] of Object.entries(keyStates)) {
107
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'da-DK' });
108
+ page.on('pageerror', (e) => problems.push(`Appstore (${state}): ${e.message}`));
109
+ await openEditor(page, site, {}, state);
110
+ await page.evaluate(() => (location.hash = 'indstillinger/appstore'));
111
+ await page.waitForTimeout(500);
112
+ const text = await page.evaluate(() => document.body.innerText);
113
+ for (const x of shows) if (!text.includes(x)) problems.push(`Appstore (${state}): "${x}" mangler`);
114
+ for (const x of hides) if (text.includes(x)) problems.push(`Appstore (${state}): "${x}" skal ikke vises`);
115
+ const file = join(shots, `appstore-${state}.png`);
116
+ await page.screenshot({ path: file });
117
+ console.log(`Appstore (${state}): ${file}`);
118
+ await page.close();
119
+ }
120
+
95
121
  await browser.close();
96
122
  server.close();
97
123
  if (problems.length) {
@@ -0,0 +1,93 @@
1
+ // Tests that every .mjs module imported by TypeScript or Astro files in the package is included in tsconfig.types.json.
2
+ // This is required because TypeScript reads no types from JavaScript in node_modules, so a site's tsc fails without a .d.mts.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import test from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const PACKAGE = fileURLToPath(new URL('..', import.meta.url));
11
+
12
+ const tsconfigRaw = fs.readFileSync(path.join(PACKAGE, 'tsconfig.types.json'), 'utf-8');
13
+ const tsconfigJson = JSON.parse(
14
+ tsconfigRaw
15
+ .split('\n')
16
+ .filter((line) => !line.trimStart().startsWith('//'))
17
+ .join('\n'),
18
+ );
19
+ const include = tsconfigJson.include;
20
+
21
+ function matches(filePath, pattern) {
22
+ if (pattern.includes('**')) {
23
+ const dir = pattern.replace('/**/*.mjs', '');
24
+ return filePath.startsWith(`${dir}/`) && filePath.endsWith('.mjs');
25
+ }
26
+ return filePath === pattern;
27
+ }
28
+
29
+ function sources(dir) {
30
+ const results = [];
31
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
34
+ continue;
35
+ }
36
+ const fullPath = path.join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ results.push(...sources(fullPath));
39
+ } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx') || entry.name.endsWith('.astro')) {
40
+ if (!entry.name.endsWith('.d.ts')) {
41
+ results.push(path.relative(PACKAGE, fullPath).replace(/\\/g, '/'));
42
+ }
43
+ }
44
+ }
45
+ return results;
46
+ }
47
+
48
+ function mjsImports(text) {
49
+ const regex = /(?:from\s+|import\s*\()\s*(['"])([^'"]+\.mjs)\1/g;
50
+ const specs = [];
51
+ let match;
52
+ while ((match = regex.exec(text)) !== null) {
53
+ specs.push(match[2]);
54
+ }
55
+ return specs;
56
+ }
57
+
58
+ function resolve(file, spec) {
59
+ if (spec.startsWith('@theme/')) {
60
+ return `theme/${spec.slice('@theme/'.length)}`;
61
+ }
62
+ if (spec.startsWith('.')) {
63
+ const dir = path.dirname(file);
64
+ return path.relative(PACKAGE, path.join(dir, spec)).replace(/\\/g, '/');
65
+ }
66
+ return null;
67
+ }
68
+
69
+ test('each .mjs a .ts, .tsx or .astro file imports is in tsconfig.types.json include', () => {
70
+ const missing = [];
71
+ const files = sources(PACKAGE);
72
+ for (const file of files) {
73
+ const content = fs.readFileSync(path.join(PACKAGE, file), 'utf-8');
74
+ const imports = mjsImports(content);
75
+ for (const spec of imports) {
76
+ const resolvedPath = resolve(file, spec);
77
+ if (resolvedPath !== null) {
78
+ const isMatched = include.some((pattern) => matches(resolvedPath, pattern));
79
+ if (!isMatched) {
80
+ missing.push({ file, spec, path: resolvedPath });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ assert.deepEqual(missing, []);
86
+ });
87
+
88
+ test('matches', () => {
89
+ assert.equal(matches('theme/editor/looks.mjs', 'theme/**/*.mjs'), true);
90
+ assert.equal(matches('theme/editor/looks.mjs', 'lib/**/*.mjs'), false);
91
+ assert.equal(matches('scripts/releases.mjs', 'scripts/releases.mjs'), true);
92
+ assert.equal(matches('scripts/other.mjs', 'scripts/releases.mjs'), false);
93
+ });
package/site/theme.d.mts CHANGED
@@ -30,6 +30,10 @@ export function motionProblems(): string[];
30
30
  /** What is wrong with the system's icon packs (packProblems; a theme's own are its themeProblems) and the choice of one:
31
31
  * src/data/icons.json naming one that is not there. */
32
32
  export function iconPackProblems(): string[];
33
+ /** The module code of all Phosphor's icons in `folder` (its `regular/` folder), by the keys `import.meta.glob` gave, the
34
+ * theme's (`icons`, as themeIcons returns them) in place of Phosphor's own. The files are read one at a time: a glob
35
+ * opens them all at once, and the build image's limit of open files (1024) stops the build. */
36
+ export function allIconsModule(folder: any, icons: any): string;
33
37
  /** What a theme is missing (the standard theme: of THEME_FILES and the look of every standard block, another: of
34
38
  * OWN_FILES), what is wrong in its theme.json (name, version, author, icon), a block of its own (a folder in its
35
39
  * blocks/ that is not the look of a standard block, or with a block's contract in it), a part nothing uses (in a
@@ -76,6 +80,8 @@ export const THEME_FILES: string[];
76
80
  export function installedThemes(): string[];
77
81
  /** What every theme has itself. */
78
82
  export const OWN_FILES: string[];
83
+ /** The virtual module with all Phosphor's icons (regular) as text (src/allIcons.ts). */
84
+ export const ALL_ICONS: "virtual:all-icons";
79
85
  export function aliases(): {
80
86
  '@data': string;
81
87
  '@saturn': string;
package/site/theme.mjs CHANGED
@@ -4,9 +4,9 @@
4
4
  // theme or the look of a block, is the standard theme's, styled by the theme in use's CSS. The site and the editor
5
5
  // reach the theme as @theme (themePlugin: the theme in use's file, else the standard theme's), and the site's own
6
6
  // parts (src/site/) as @site (aliases): for packages/editor/astro.mjs and packages/editor/scripts/check-content.mjs. Stops with what is
7
- // wrong, when the theme named is not installed.
7
+ // wrong, when the theme named is not installed. Also the virtual module with all Phosphor's icons (allIconsModule).
8
8
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
- import { relative } from 'node:path';
9
+ import { join, relative } from 'node:path';
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
  import { svgParts } from '@morphemecms/cms/lib/svg.mjs';
12
12
  import { declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
@@ -268,13 +268,35 @@ function reactIcons(icons) {
268
268
  return lines.join('\n');
269
269
  }
270
270
 
271
- /** An icon's SVG file as Phosphor's are (in the text's colour, `fill="currentColor"`), for an import of Phosphor's. */
272
- const fileIcon = (svg) => {
271
+ /** The text of an icon's SVG file as Phosphor's are (in the text's colour, `fill="currentColor"`), not JSON-encoded. */
272
+ const themedSvg = (svg) => {
273
273
  const { viewBox, inner } = svgParts(svg);
274
274
  const box = viewBox ? ` viewBox="${viewBox}"` : '';
275
- return `export default ${JSON.stringify(`<svg xmlns="http://www.w3.org/2000/svg"${box} fill="currentColor">${inner}</svg>`)};`;
275
+ return `<svg xmlns="http://www.w3.org/2000/svg"${box} fill="currentColor">${inner}</svg>`;
276
276
  };
277
277
 
278
+ /** An icon's SVG file as Phosphor's are, for an import of Phosphor's. */
279
+ const fileIcon = (svg) => `export default ${JSON.stringify(themedSvg(svg))};`;
280
+
281
+ /** The virtual module with all Phosphor's icons (regular) as text (src/allIcons.ts). */
282
+ export const ALL_ICONS = 'virtual:all-icons';
283
+
284
+ /** The module code of all Phosphor's icons in `folder` (its `regular/` folder), by the keys `import.meta.glob` gave, the
285
+ * theme's (`icons`, as themeIcons returns them) in place of Phosphor's own. The files are read one at a time: a glob
286
+ * opens them all at once, and the build image's limit of open files (1024) stops the build. */
287
+ export function allIconsModule(folder, icons) {
288
+ const all = {};
289
+ for (const file of readdirSync(folder)
290
+ .filter((f) => f.endsWith('.svg'))
291
+ .sort()) {
292
+ const name = file.slice(0, -'.svg'.length);
293
+ all[`/node_modules/@phosphor-icons/core/assets/regular/${file}`] = svgParts(icons[name])
294
+ ? themedSvg(icons[name])
295
+ : readFileSync(join(folder, file), 'utf8');
296
+ }
297
+ return `export const allIcons = ${JSON.stringify(all)};`;
298
+ }
299
+
278
300
  /** What a theme is missing (the standard theme: of THEME_FILES and the look of every standard block, another: of
279
301
  * OWN_FILES), what is wrong in its theme.json (name, version, author, icon), a block of its own (a folder in its
280
302
  * blocks/ that is not the look of a standard block, or with a block's contract in it), a part nothing uses (in a
@@ -444,6 +466,7 @@ export function themePlugin() {
444
466
  resolveId(source, importer) {
445
467
  // The theme's icons import Phosphor's own.
446
468
  if (source === PHOSPHOR_REACT) return own && importer !== REACT_ICONS ? REACT_ICONS : null;
469
+ if (source === ALL_ICONS) return '\0' + ALL_ICONS;
447
470
  const icon = source.match(PHOSPHOR_FILE)?.[1];
448
471
  if (icon) return svgParts(icons[icon]) ? FILE_ICON + icon : null;
449
472
  if (source.startsWith('@site/')) return siteFile(source.slice('@site/'.length));
@@ -458,6 +481,8 @@ export function themePlugin() {
458
481
  },
459
482
  load(id) {
460
483
  if (id === REACT_ICONS) return reactIcons(icons);
484
+ if (id === '\0' + ALL_ICONS)
485
+ return allIconsModule(fileURLToPath(new URL('node_modules/@phosphor-icons/core/assets/regular/', root)), icons);
461
486
  if (id.startsWith(FILE_ICON)) return fileIcon(icons[id.slice(FILE_ICON.length)]);
462
487
  return null;
463
488
  },