@morphemecms/cms 0.3.0 → 0.4.0-next-20261104164754

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 (52) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/bin/morpheme.mjs +1 -1
  4. package/defaults/editor.ts +3 -1
  5. package/lib/changelog.d.mts +24 -0
  6. package/lib/changelog.mjs +65 -0
  7. package/lib/changelog.test.d.mts +1 -0
  8. package/lib/changelog.test.mjs +67 -0
  9. package/lib/cms-version.d.mts +56 -0
  10. package/lib/cms-version.mjs +85 -0
  11. package/lib/cms-version.test.d.mts +1 -0
  12. package/lib/cms-version.test.mjs +68 -0
  13. package/lib/lockfile.d.mts +19 -0
  14. package/lib/lockfile.mjs +135 -0
  15. package/lib/lockfile.test.d.mts +1 -0
  16. package/lib/lockfile.test.mjs +88 -0
  17. package/lib/range.d.mts +4 -0
  18. package/lib/range.mjs +104 -0
  19. package/lib/range.test.d.mts +1 -0
  20. package/lib/range.test.mjs +46 -0
  21. package/package.json +2 -1
  22. package/scripts/built-site.mjs +14 -0
  23. package/scripts/check-cms-update.mjs +405 -0
  24. package/site/theme.d.mts +1 -1
  25. package/site/theme.mjs +2 -2
  26. package/src/App.tsx +3 -0
  27. package/src/CmsPage.tsx +139 -0
  28. package/src/Library.tsx +2 -0
  29. package/src/appFiles.ts +2 -2
  30. package/src/apps.ts +2 -2
  31. package/src/blocks.ts +1 -1
  32. package/src/cmsView.d.mts +15 -0
  33. package/src/cmsView.mjs +78 -0
  34. package/src/cmsView.test.d.mts +1 -0
  35. package/src/cmsView.test.mjs +146 -0
  36. package/src/frontmatter.mjs +4 -1
  37. package/src/frontmatter.test.mjs +6 -0
  38. package/src/i18n/da-DK/cms.ts +30 -0
  39. package/src/i18n/da-DK/common.ts +1 -0
  40. package/src/i18n/da-DK/index.ts +2 -0
  41. package/src/i18n/en-US/cms.ts +34 -0
  42. package/src/i18n/en-US/common.ts +2 -0
  43. package/src/i18n/en-US/index.ts +2 -0
  44. package/src/registry.tsx +3 -3
  45. package/src/schema.ts +1 -1
  46. package/starter/.prettierignore +1 -0
  47. package/starter/README.md +1 -0
  48. package/starter/package.json +1 -1
  49. package/starter/wrangler.jsonc +1 -1
  50. package/worker/cms-update.js +245 -0
  51. package/worker/github.js +8 -6
  52. package/worker/index.js +6 -1
@@ -0,0 +1,405 @@
1
+ // Checks the Worker's own update of the CMS (packages/editor/worker/cms-update.js): the hourly check of npm, the update's commit, the
2
+ // minute watch of its check run with the undo, the 20 minutes' fallback, and /api/cms. GitHub, npm, KV and the notifications are
3
+ // played here (a fake KV, an in-memory package.json and lock, a fake fetch); nothing goes to GitHub, npm or Cloudflare.
4
+ // Run: npm run check:worker
5
+ import { gzipSync } from 'node:zlib';
6
+ import { config } from '../site/siteConfig.mjs';
7
+ import { workerModules } from './worker-modules.mjs';
8
+
9
+ const [{ RUNNING, checkForUpdate, cmsStatus, handleCms, startUpdate, watchUpdate }, { GitHubError }] = await workerModules(
10
+ 'cms-update.js',
11
+ 'files.js',
12
+ );
13
+
14
+ const problems = [];
15
+ const expect = (ok, what) => ok || problems.push(what);
16
+
17
+ const T0 = Date.parse('2026-11-04T10:00:00Z');
18
+ const MINUTE = 60 * 1000;
19
+ const PACKAGE = '@morphemecms/cms';
20
+ const danish = String(config.locale).startsWith('da');
21
+ const commitUpdate = (v) => (danish ? `Opdater Morpheme til ${v}` : `Update Morpheme to ${v}`);
22
+ const commitUndo = (v) => (danish ? `Fortryd opdateringen af Morpheme til ${v}` : `Undo the update of Morpheme to ${v}`);
23
+
24
+ /** Workers KV, as much of it as the Worker uses; `reads` and `writes` count the calls. */
25
+ function kvStore() {
26
+ const map = new Map();
27
+ const kv = {
28
+ reads: 0,
29
+ writes: 0,
30
+ map,
31
+ get: async (key, type) => {
32
+ kv.reads++;
33
+ const value = map.get(key);
34
+ return value === undefined ? null : type === 'json' ? JSON.parse(value) : value;
35
+ },
36
+ put: async (key, value) => {
37
+ kv.writes++;
38
+ map.set(key, String(value));
39
+ },
40
+ delete: async (key) => {
41
+ kv.writes++;
42
+ map.delete(key);
43
+ },
44
+ list: async ({ prefix = '' } = {}) => ({ keys: [...map.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })) }),
45
+ };
46
+ return kv;
47
+ }
48
+ const stored = (kv, key) => (kv.map.has(key) ? JSON.parse(kv.map.get(key)) : null);
49
+
50
+ /** A gzipped tar with the files (`{ path: text }`), as npm packs a package. */
51
+ function tarball(files) {
52
+ const blocks = [];
53
+ for (const [path, text] of Object.entries(files)) {
54
+ const data = new TextEncoder().encode(text);
55
+ const header = new Uint8Array(512);
56
+ header.set(new TextEncoder().encode(path), 0);
57
+ header.set(new TextEncoder().encode(data.length.toString(8).padStart(11, '0')), 124);
58
+ blocks.push(header, data, new Uint8Array(Math.ceil(data.length / 512) * 512 - data.length));
59
+ }
60
+ blocks.push(new Uint8Array(1024));
61
+ return gzipSync(Buffer.concat(blocks));
62
+ }
63
+
64
+ const CHANGELOG = '# Changelog\n\n## 0.1.1\n\n- Fixed a thing.\n\n## 0.1.0\n\n- The first version.\n';
65
+ const manifestOf = (version, extra = {}) => ({
66
+ name: PACKAGE,
67
+ version,
68
+ license: 'MIT',
69
+ dist: { tarball: `https://registry.npmjs.org/@morphemecms/cms/-/cms-${version}.tgz`, integrity: `sha512-${version}` },
70
+ ...extra,
71
+ });
72
+
73
+ /**
74
+ * The site and the world around it: package.json and the lock in memory, npm, the check runs, and the recorded commits and
75
+ * notifications. `checks`: a commit's check run by its id ('success' | 'failure'; none: no run yet), `forbidden`: GitHub answers
76
+ * the check runs with 403, `commitFails`: from which commit on `commitFiles` throws.
77
+ */
78
+ function world({ range = '0.1.0', latest = '0.1.1', manifestExtra = {}, lock = true } = {}) {
79
+ const files = {
80
+ 'package.json': JSON.stringify({ name: 'site', dependencies: { [PACKAGE]: range } }, null, 2) + '\n',
81
+ 'package-lock.json':
82
+ JSON.stringify(
83
+ {
84
+ name: 'site',
85
+ lockfileVersion: 3,
86
+ packages: {
87
+ '': { name: 'site', dependencies: { [PACKAGE]: range } },
88
+ [`node_modules/${PACKAGE}`]: { version: '0.1.0', resolved: manifestOf('0.1.0').dist.tarball, integrity: 'sha512-0.1.0' },
89
+ },
90
+ },
91
+ null,
92
+ 2,
93
+ ) + '\n',
94
+ };
95
+ if (!lock) delete files['package-lock.json'];
96
+ const w = {
97
+ kv: kvStore(),
98
+ files,
99
+ commits: [],
100
+ notices: [],
101
+ urls: [],
102
+ checks: {},
103
+ forbidden: false,
104
+ commitFails: null,
105
+ may: true,
106
+ latest,
107
+ readCount: 0,
108
+ };
109
+ w.env = { GITHUB_PUBLISH_TOKEN: 'token', PUSH: w.kv };
110
+ w.deps = {
111
+ readFiles: async (env, paths) => {
112
+ w.readCount++;
113
+ return { head: `head${w.commits.length}`, texts: paths.map((p) => files[p]) };
114
+ },
115
+ commitFiles: async (env, head, headline, changes) => {
116
+ if (w.failure && w.commits.length >= (w.commitFails ?? 0)) throw w.failure;
117
+ for (const { path, text } of changes) files[path] = text;
118
+ w.commits.push({ headline, changes, head });
119
+ return `c${w.commits.length}`;
120
+ },
121
+ fetch: async (url) => {
122
+ url = String(url);
123
+ w.urls.push(url);
124
+ const check = /api\.github\.com\/repos\/[^/]+\/[^/]+\/commits\/(\w+)\/check-runs$/.exec(url);
125
+ if (check) {
126
+ if (w.forbidden) return new Response('{}', { status: 403 });
127
+ const conclusion = w.checks[check[1]];
128
+ const runs = [{ app: { slug: 'some-other-app' }, conclusion: 'failure', started_at: '2026-11-04T10:00:00Z' }];
129
+ if (conclusion) runs.push({ app: { slug: 'cloudflare-workers-and-pages' }, conclusion, started_at: '2026-11-04T10:01:00Z' });
130
+ return Response.json({ check_runs: runs });
131
+ }
132
+ if (url === 'https://registry.npmjs.org/-/package/@morphemecms%2fcms/dist-tags') return Response.json({ latest: w.latest });
133
+ const manifest = /^https:\/\/registry\.npmjs\.org\/@morphemecms%2fcms\/(\d+\.\d+\.\d+)$/.exec(url);
134
+ if (manifest) return Response.json(manifestOf(manifest[1], manifest[1] === w.latest ? manifestExtra : {}));
135
+ if (url.endsWith('.tgz')) return new Response(tarball({ 'package/CHANGELOG.md': CHANGELOG }));
136
+ return new Response('{}', { status: 404 });
137
+ },
138
+ notify: async (env, notice, texts) => void w.notices.push({ notice, texts }),
139
+ mayEdit: async () => w.may,
140
+ };
141
+ return w;
142
+ }
143
+
144
+ const request = (path, init) => new Request(`https://site.example${path}`, init);
145
+ const post = (version) =>
146
+ request('/api/cms/update', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ version }) });
147
+
148
+ // 1. An exact version, a newer one on npm: one commit, the files written, the update kept, the notes kept.
149
+ {
150
+ const w = world();
151
+ await checkForUpdate(w.env, w.deps, T0);
152
+ expect(w.commits.length === 1, `opdateringen lavede ${w.commits.length} commits, ikke 1`);
153
+ expect(w.commits[0]?.headline === commitUpdate('0.1.1'), `commitbeskeden var "${w.commits[0]?.headline}"`);
154
+ expect(JSON.parse(w.files['package.json']).dependencies[PACKAGE] === '0.1.1', 'package.json sagde ikke 0.1.1');
155
+ const entry = JSON.parse(w.files['package-lock.json']).packages[`node_modules/${PACKAGE}`];
156
+ expect(
157
+ entry.version === '0.1.1' && entry.resolved === manifestOf('0.1.1').dist.tarball && entry.integrity === 'sha512-0.1.1',
158
+ 'låsen fik ikke 0.1.1s resolved og integrity',
159
+ );
160
+ expect(w.files['package.json'].endsWith('}\n') && w.files['package-lock.json'].endsWith('}\n'), 'filerne endte ikke på en linjeskift');
161
+ const update = stored(w.kv, 'cms:update');
162
+ expect(
163
+ update?.state === 'waiting' && update.from === '0.1.0' && update.to === '0.1.1' && update.commit === 'c1',
164
+ `cms:update var ${JSON.stringify(update)}`,
165
+ );
166
+ expect(stored(w.kv, 'cms:notes:0.1.1')?.text === '- Fixed a thing.', 'versionens noter blev ikke gemt');
167
+ expect(stored(w.kv, 'cms:notes:0.1.0') === null, 'noter for en version, sitet allerede har, blev gemt');
168
+ expect(stored(w.kv, 'cms:checked')?.latest === '0.1.1', 'cms:checked fik ikke latest');
169
+
170
+ // 2. The hourly step while it waits: nothing.
171
+ const before = w.urls.length;
172
+ await checkForUpdate(w.env, w.deps, T0 + 60 * MINUTE);
173
+ expect(w.commits.length === 1 && w.urls.length === before, 'timetjekket gjorde noget, mens en opdatering ventede');
174
+
175
+ // 3. The update's check run fails: the undo, the skip, then the undo's build is fine.
176
+ w.checks.c1 = 'failure';
177
+ await watchUpdate(w.env, w.deps, T0 + 2 * MINUTE);
178
+ expect(w.commits.length === 2 && w.commits[1].headline === commitUndo('0.1.1'), 'en fejlet opdatering blev ikke fortrudt med en commit');
179
+ expect(JSON.parse(w.files['package.json']).dependencies[PACKAGE] === '0.1.0', 'fortrydelsen skrev ikke 0.1.0 tilbage');
180
+ expect(
181
+ JSON.parse(w.files['package-lock.json']).packages[`node_modules/${PACKAGE}`].integrity === 'sha512-0.1.0',
182
+ 'fortrydelsen skrev ikke 0.1.0 tilbage i låsen',
183
+ );
184
+ expect(stored(w.kv, 'cms:skip:0.1.1')?.commit === 'c1', 'den fortrudte version blev ikke sprunget over');
185
+ const undoing = stored(w.kv, 'cms:update');
186
+ expect(undoing?.state === 'undoing' && undoing.undoCommit === 'c2', `cms:update var ${JSON.stringify(undoing)}`);
187
+ expect(w.notices.length === 0, 'der blev sendt en besked, før fortrydelsen var bygget');
188
+ await watchUpdate(w.env, w.deps, T0 + 3 * MINUTE);
189
+ expect(stored(w.kv, 'cms:update')?.state === 'undoing', 'fortrydelsen blev regnet for færdig uden et resultat');
190
+ w.checks.c2 = 'success';
191
+ await watchUpdate(w.env, w.deps, T0 + 4 * MINUTE);
192
+ expect(stored(w.kv, 'cms:update')?.state === 'undone', 'fortrydelsen blev ikke til undone');
193
+ expect(w.notices.length === 1 && w.notices[0].notice.error === true, 'undone gav ikke én besked med error');
194
+ const notice = w.notices[0]?.notice;
195
+ expect(notice?.type === 'updates' && notice.id === 'cms' && notice.text.includes('0.1.1'), 'beskeden havde ikke typen updates');
196
+ expect(
197
+ Object.keys(w.notices[0]?.texts ?? {})
198
+ .sort()
199
+ .join() === 'da-DK,en-US' && w.notices[0].texts['da-DK'][notice.text]?.includes('fortrudt'),
200
+ 'beskeden havde ikke den danske tekst',
201
+ );
202
+ // The skipped version is not taken by itself again.
203
+ await checkForUpdate(w.env, w.deps, T0 + 70 * MINUTE);
204
+ expect(w.commits.length === 2 && stored(w.kv, 'cms:checked')?.problem?.kind === 'skipped', 'en fortrudt version blev taget igen');
205
+ }
206
+
207
+ // 4. The site runs the new version: done, one notification.
208
+ {
209
+ const w = world();
210
+ w.files['package.json'] = JSON.stringify({ dependencies: { [PACKAGE]: RUNNING } });
211
+ await w.kv.put(
212
+ 'cms:update',
213
+ JSON.stringify({ commit: 'c1', from: '0.0.0', to: RUNNING, at: new Date(T0).toISOString(), state: 'waiting' }),
214
+ );
215
+ await watchUpdate(w.env, w.deps, T0 + 5 * MINUTE);
216
+ expect(stored(w.kv, 'cms:update')?.state === 'done', 'en opdatering, der kører, blev ikke done');
217
+ expect(w.notices.length === 1 && !w.notices[0].notice.error, 'done gav ikke én besked uden error');
218
+ await watchUpdate(w.env, w.deps, T0 + 6 * MINUTE);
219
+ expect(w.notices.length === 1, 'done blev meldt to gange');
220
+ }
221
+
222
+ // 5. A version the site does not pin exactly is left alone.
223
+ for (const range of ['*', '^0.1.0', 'next']) {
224
+ const w = world({ range });
225
+ await checkForUpdate(w.env, w.deps, T0);
226
+ expect(w.commits.length === 0, `"${range}" blev opdateret`);
227
+ expect(stored(w.kv, 'cms:checked')?.problem?.kind === 'not-managed', `"${range}" gav ikke not-managed`);
228
+ }
229
+
230
+ // 6. A new first number, and a skipped version, are not taken by themselves.
231
+ {
232
+ const w = world({ latest: '1.0.0' });
233
+ await checkForUpdate(w.env, w.deps, T0);
234
+ expect(
235
+ w.commits.length === 0 && stored(w.kv, 'cms:checked')?.problem?.kind === 'major',
236
+ 'en ny hovedversion blev taget, eller meldt forkert',
237
+ );
238
+ expect(stored(w.kv, 'cms:checked')?.latest === '1.0.0', 'latest blev ikke gemt ved en hovedversion');
239
+ const s = world();
240
+ await s.kv.put('cms:skip:0.1.1', JSON.stringify({ version: '0.1.1', commit: 'x', at: new Date(T0).toISOString() }));
241
+ await checkForUpdate(s.env, s.deps, T0);
242
+ expect(
243
+ s.commits.length === 0 && stored(s.kv, 'cms:checked')?.problem?.kind === 'skipped',
244
+ 'en fortrudt version blev taget, eller meldt forkert',
245
+ );
246
+ const n = world({ latest: '0.1.0' });
247
+ await checkForUpdate(n.env, n.deps, T0);
248
+ expect(n.commits.length === 0 && !stored(n.kv, 'cms:checked')?.problem, 'der var et problem, selv om sitet er opdateret');
249
+ }
250
+
251
+ // 7. A token that cannot write: nothing is committed or kept.
252
+ for (const error of [new GitHubError('Forbidden', 403), new GitHubError('Resource not accessible by personal access token', 200)]) {
253
+ const w = world();
254
+ w.failure = error;
255
+ await checkForUpdate(w.env, w.deps, T0);
256
+ expect(stored(w.kv, 'cms:update') === null, `cms:update blev gemt, selv om gemningen fejlede (${error.status})`);
257
+ expect(
258
+ stored(w.kv, 'cms:checked')?.problem?.kind === 'cannot-write',
259
+ `en nøgle uden skriverettighed gav ikke cannot-write (${error.status})`,
260
+ );
261
+ }
262
+
263
+ // 8. Without the token (or KV) nothing is asked and nothing is kept.
264
+ for (const [what, patch] of [
265
+ ['uden nøgle', { GITHUB_PUBLISH_TOKEN: undefined }],
266
+ ['uden KV', { PUSH: undefined }],
267
+ ]) {
268
+ const w = world();
269
+ const env = { ...w.env, ...patch };
270
+ await checkForUpdate(env, w.deps, T0);
271
+ await watchUpdate(env, w.deps, T0);
272
+ expect(w.urls.length === 0 && w.readCount === 0 && w.kv.writes === 0 && w.commits.length === 0, `tjekket gjorde noget ${what}`);
273
+ }
274
+
275
+ // 9. Check runs GitHub does not give: the update is undone after 20 minutes.
276
+ {
277
+ const w = world();
278
+ await checkForUpdate(w.env, w.deps, T0);
279
+ w.forbidden = true;
280
+ await watchUpdate(w.env, w.deps, T0 + MINUTE);
281
+ expect(stored(w.kv, 'cms:checked')?.noChecks === true, 'noChecks blev ikke sat, da check runs gav 403');
282
+ await watchUpdate(w.env, w.deps, T0 + 19 * MINUTE);
283
+ expect(stored(w.kv, 'cms:update')?.state === 'waiting' && w.commits.length === 1, 'opdateringen blev fortrudt efter 19 minutter');
284
+ await watchUpdate(w.env, w.deps, T0 + 21 * MINUTE);
285
+ expect(stored(w.kv, 'cms:update')?.state === 'undoing' && w.commits.length === 2, 'opdateringen blev ikke fortrudt efter 21 minutter');
286
+ await watchUpdate(w.env, w.deps, T0 + 30 * MINUTE);
287
+ expect(stored(w.kv, 'cms:update')?.state === 'undoing', 'fortrydelsen blev regnet for færdig efter 9 minutter');
288
+ await watchUpdate(w.env, w.deps, T0 + 42 * MINUTE);
289
+ expect(stored(w.kv, 'cms:update')?.state === 'undone' && w.notices.length === 1, 'fortrydelsen blev ikke undone efter 20 minutter');
290
+ expect(stored(w.kv, 'cms:checked')?.noChecks === true, 'noChecks blev væk');
291
+ }
292
+
293
+ // 10. The undo cannot be committed: stuck, one notification, then nothing.
294
+ {
295
+ const w = world();
296
+ await checkForUpdate(w.env, w.deps, T0);
297
+ w.checks.c1 = 'failure';
298
+ w.failure = new GitHubError('Conflict', 409);
299
+ w.commitFails = 1;
300
+ await watchUpdate(w.env, w.deps, T0 + 2 * MINUTE);
301
+ expect(stored(w.kv, 'cms:update')?.state === 'stuck', 'en fortrydelse, der ikke kunne gemmes, gav ikke stuck');
302
+ expect(w.notices.length === 1 && w.notices[0].notice.error === true, 'stuck gav ikke én besked med error');
303
+ const urls = w.urls.length;
304
+ await watchUpdate(w.env, w.deps, T0 + 3 * MINUTE);
305
+ expect(w.notices.length === 1 && w.urls.length === urls && w.commits.length === 1, 'minuttet efter stuck gjorde noget');
306
+ // Its undo's build failing is stuck as well.
307
+ const u = world();
308
+ await checkForUpdate(u.env, u.deps, T0);
309
+ u.checks.c1 = 'failure';
310
+ await watchUpdate(u.env, u.deps, T0 + 2 * MINUTE);
311
+ u.checks.c2 = 'failure';
312
+ await watchUpdate(u.env, u.deps, T0 + 3 * MINUTE);
313
+ expect(
314
+ stored(u.kv, 'cms:update')?.state === 'stuck' && u.notices.length === 1 && u.notices[0].notice.error,
315
+ 'en fortrydelse, der ikke byggede, gav ikke stuck',
316
+ );
317
+ }
318
+
319
+ // 11. Nothing waiting: one KV read, no request.
320
+ {
321
+ const w = world();
322
+ await watchUpdate(w.env, w.deps, T0);
323
+ expect(
324
+ w.kv.reads === 1 && w.urls.length === 0 && w.kv.writes === 0,
325
+ `minuttet uden en opdatering læste ${w.kv.reads} gange og gjorde ${w.urls.length} kald`,
326
+ );
327
+ }
328
+
329
+ // 12. /api/cms.
330
+ {
331
+ const w = world({ latest: '1.0.0' });
332
+ w.may = false;
333
+ expect((await handleCms(request('/api/cms'), w.env, undefined, w.deps))?.status === 403, 'en, der ikke kan gemme, fik status');
334
+ expect((await handleCms(post('1.0.0'), w.env, undefined, w.deps))?.status === 403, 'en, der ikke kan gemme, kunne opdatere');
335
+ expect((await handleCms(request('/api/other'), w.env, undefined, w.deps)) === null, 'en anden adresse blev ikke givet videre');
336
+ w.may = true;
337
+ await checkForUpdate(w.env, w.deps, T0);
338
+ const status = await (await handleCms(request('/api/cms'), w.env, undefined, w.deps)).json();
339
+ expect(
340
+ status.running === RUNNING &&
341
+ status.token === true &&
342
+ status.kv === true &&
343
+ status.checked?.latest === '1.0.0' &&
344
+ status.update === null &&
345
+ Array.isArray(status.skipped) &&
346
+ Array.isArray(status.notes),
347
+ `statussen havde ikke formen: ${JSON.stringify(status)}`,
348
+ );
349
+ expect((await handleCms(post('0.9.9'), w.env, undefined, w.deps)).status === 400, 'en version, der ikke er latest, blev taget imod');
350
+
351
+ // A waiting update: 409.
352
+ const k = world();
353
+ await checkForUpdate(k.env, k.deps, T0);
354
+ expect((await handleCms(post('0.1.1'), k.env, undefined, k.deps)).status === 409, 'en opdatering, mens en anden venter, gav ikke 409');
355
+
356
+ // A skipped version, tried again: its skip is gone and a commit is made.
357
+ const s = world();
358
+ await s.kv.put('cms:skip:0.1.1', JSON.stringify({ version: '0.1.1', commit: 'x', at: new Date(T0).toISOString() }));
359
+ await checkForUpdate(s.env, s.deps, T0);
360
+ const again = await handleCms(post('0.1.1'), s.env, undefined, s.deps);
361
+ expect(again.status === 200 && (await again.json()).update?.state === 'waiting', 'Prøv igen gav ikke en ventende opdatering');
362
+ expect(stored(s.kv, 'cms:skip:0.1.1') === null && s.commits.length === 1, 'Prøv igen fjernede ikke spring-over-nøglen og committede');
363
+
364
+ // A package that wants npm: 422.
365
+ const n = world({ manifestExtra: { dependencies: { left: '1.0.0' } } });
366
+ await checkForUpdate(n.env, n.deps, T0);
367
+ expect(n.commits.length === 0 && stored(n.kv, 'cms:checked')?.problem?.kind === 'needs-npm', 'needs-npm blev ikke meldt af timetjekket');
368
+ const npm = await handleCms(post('0.1.1'), n.env, undefined, n.deps);
369
+ expect(npm.status === 422 && (await npm.json()).error === 'needs-npm', 'en pakke, der kræver npm, gav ikke 422');
370
+
371
+ // A token that cannot write: 502; no token: 503.
372
+ const c = world();
373
+ await checkForUpdate({ ...c.env, GITHUB_PUBLISH_TOKEN: undefined }, c.deps, T0);
374
+ c.kv.map.set('cms:checked', JSON.stringify({ at: new Date(T0).toISOString(), latest: '0.1.1' }));
375
+ c.failure = new GitHubError('Forbidden', 403);
376
+ expect((await handleCms(post('0.1.1'), c.env, undefined, c.deps)).status === 502, 'en nøgle, der ikke kan gemme, gav ikke 502');
377
+ expect(
378
+ (await handleCms(post('0.1.1'), { ...c.env, GITHUB_PUBLISH_TOKEN: undefined }, undefined, c.deps)).status === 503,
379
+ 'uden nøgle gav ikke 503',
380
+ );
381
+ const direct = await startUpdate(world().env, '0.1.1', world().deps, T0).catch((e) => e);
382
+ expect(direct?.to === '0.1.1' && direct.state === 'waiting', 'startUpdate gav ikke opdateringen');
383
+ }
384
+
385
+ // The page's notes: from the running version up to the newest, newest first.
386
+ {
387
+ const [major, minor, patch] = RUNNING.split('-')[0].split('.').map(Number);
388
+ const next1 = `${major}.${minor}.${patch + 1}`;
389
+ const next2 = `${major}.${minor}.${patch + 2}`;
390
+ const w = world();
391
+ for (const v of ['0.0.0', RUNNING, next1, next2, '9.9.9'])
392
+ await w.kv.put(`cms:notes:${v}`, JSON.stringify({ version: v, text: `Notes ${v}` }));
393
+ await w.kv.put('cms:checked', JSON.stringify({ at: new Date(T0).toISOString(), latest: next2 }));
394
+ const { notes } = await cmsStatus(w.env);
395
+ expect(
396
+ notes.map((n) => n.version).join() === `${next2},${next1}`,
397
+ `noterne var ${notes.map((n) => n.version).join()}, ikke ${next2},${next1}`,
398
+ );
399
+ }
400
+
401
+ if (problems.length) {
402
+ console.error(problems.map((p) => `- ${p}`).join('\n'));
403
+ process.exit(1);
404
+ }
405
+ console.log('Workerens opdatering af CMS’et er i orden.');
package/site/theme.d.mts CHANGED
@@ -65,7 +65,7 @@ export function themePlugin(): {
65
65
  load(id: any): string | null;
66
66
  };
67
67
  /** What a block is missing to be drawn from its view (packages/editor/src/standard/view.tsx): a standard block's
68
- * contract its view.ts and load.ts, an app's block (src/apps/<app>/blocks/<type>/) those and its View.tsx; and the
68
+ * contract its view.ts and load.ts, an app's block (src/apps/<app>/blocks/<type>/) its viewOf.ts, load.ts and View.tsx; and the
69
69
  * files of how a block looked before (Site.astro, draw.tsx), which nothing uses now. */
70
70
  export function blockProblems(): string[];
71
71
  /** The theme every theme builds on, Material (this package's theme/, id 'saturn'): it has all of THEME_FILES and the look of every
package/site/theme.mjs CHANGED
@@ -465,7 +465,7 @@ export function themePlugin() {
465
465
  }
466
466
 
467
467
  /** What a block is missing to be drawn from its view (packages/editor/src/standard/view.tsx): a standard block's
468
- * contract its view.ts and load.ts, an app's block (src/apps/<app>/blocks/<type>/) those and its View.tsx; and the
468
+ * contract its view.ts and load.ts, an app's block (src/apps/<app>/blocks/<type>/) its viewOf.ts, load.ts and View.tsx; and the
469
469
  * files of how a block looked before (Site.astro, draw.tsx), which nothing uses now. */
470
470
  export function blockProblems() {
471
471
  const problems = [];
@@ -475,7 +475,7 @@ export function blockProblems() {
475
475
  for (const app of folders(new URL('src/apps/', root)))
476
476
  for (const type of folders(new URL(`src/apps/${app}/blocks/`, root))) {
477
477
  const dir = `src/apps/${app}/blocks/${type}`;
478
- for (const f of ['index.tsx', 'view.ts', 'load.ts', 'View.tsx']) if (!has(`${dir}/${f}`)) problems.push(`${dir}/${f} mangler.`);
478
+ for (const f of ['index.tsx', 'viewOf.ts', 'load.ts', 'View.tsx']) if (!has(`${dir}/${f}`)) problems.push(`${dir}/${f} mangler.`);
479
479
  for (const f of OLD_LOOK)
480
480
  if (has(`${dir}/${f}`)) problems.push(`${dir}/${f}: blokken tegnes af View.tsx, både på sitet og i editoren.`);
481
481
  }
package/src/App.tsx CHANGED
@@ -31,6 +31,7 @@
31
31
  // theme's design tokens, "Værdier" (Tokens.tsx)
32
32
  // blokvalg the blocks that can be put in, "Blokindstillinger" (BlockSettings.tsx)
33
33
  // avanceret "Avanceret", under "Indstillinger": the copy of the site's files in R2 (Advanced.tsx)
34
+ // morpheme "Morpheme", under "Indstillinger": the CMS's version, a newer one and its notes, an update's progress (CmsPage.tsx)
34
35
  // licenser "Licenser", under "Indstillinger": the licences of what the site and the editor are made with, and
35
36
  // licenser/<id> one with its text (Licenses.tsx)
36
37
  // apps the apps that come with the site, apps/<app> one with its button to install it and, while it
@@ -92,6 +93,7 @@ import Tokens from './Tokens';
92
93
  import BrandForm from './BrandForm';
93
94
  import BlockSettings from './BlockSettings';
94
95
  import Advanced, { ADVANCED_ROUTE } from './Advanced';
96
+ import CmsPage, { CMS_ROUTE } from './CmsPage';
95
97
  import Licenses, { LICENSES_ROUTE, LicensePage } from './Licenses';
96
98
  import { StoreThemePage } from './ThemeStore';
97
99
  import {
@@ -841,6 +843,7 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
841
843
  if (route === 'udseende/logo') return <BrandForm env={env} />;
842
844
  if (route === 'blokvalg') return <BlockSettings env={env} files={files} />;
843
845
  if (route === ADVANCED_ROUTE) return <Advanced env={env} />;
846
+ if (route === CMS_ROUTE) return <CmsPage />;
844
847
  if (route === LICENSES_ROUTE) return <Licenses />;
845
848
  if (route.startsWith(`${LICENSES_ROUTE}/`)) return <LicensePage key={route} id={route.slice(LICENSES_ROUTE.length + 1)} />;
846
849
  if (route === 'apps') return <Apps env={env} files={files} />;
@@ -0,0 +1,139 @@
1
+ // "Morpheme" (#morpheme), under "Indstillinger": the CMS version the site runs, a newer one with its notes, why it is
2
+ // not taken by itself, and an update's progress. Exposes CMS_ROUTE and the page. Depends on cmsView.mjs and the Worker's /api/cms.
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { CheckCircle, Clock, Info, Sparkle, WarningCircle } from '@phosphor-icons/react';
5
+ import Screen from './Screen';
6
+ import { Button, EmptyNote, Group, ListItem, ProblemNote } from './ui';
7
+ import { backFrom } from './site';
8
+ import { cmsView } from './cmsView.mjs';
9
+ import { snackbar } from '../lib/snackbar';
10
+ import { t } from './i18n';
11
+ import { when } from './when';
12
+
13
+ export const CMS_ROUTE = 'morpheme';
14
+
15
+ type Problem = 'unreachable' | 'cannot-write' | 'no-token' | 'not-managed' | 'slow-undo';
16
+ type UpdateState = 'waiting' | 'undoing' | 'done' | 'undone' | 'stuck';
17
+ type View = {
18
+ running: string;
19
+ checkedAt: string | null;
20
+ latest: string | null;
21
+ notes: { version: string; text: string }[];
22
+ action: 'update' | 'try-again' | null;
23
+ npmCommand: string | null;
24
+ update: { state: UpdateState; to: string; at: string } | null;
25
+ problems: Problem[];
26
+ };
27
+ type Status = object;
28
+
29
+ /** The Worker's error code in words; any other answer says what status it was. */
30
+ function errorText(code: unknown, status: number, version = '') {
31
+ if (code === 'not-set-up') return t.cms.noToken;
32
+ if (code === 'waiting') return t.cms.waiting(version);
33
+ return t.app.githubAnswered(status);
34
+ }
35
+
36
+ /** The Worker's answer (GET /api/cms) as the page shows it, or why it could not be read. */
37
+ async function readStatus(): Promise<Status> {
38
+ const res = await fetch('/api/cms', { cache: 'no-store' });
39
+ const json = await res.json().catch(() => ({}));
40
+ if (!res.ok) throw new Error(errorText(json.error, res.status));
41
+ return json;
42
+ }
43
+
44
+ /** Why the site does not take the newer version by itself, or nothing. */
45
+ const reasonOf = (view: View) =>
46
+ view.action === 'update' ? t.cms.major : view.action === 'try-again' && view.latest ? t.cms.skipped(view.latest) : null;
47
+
48
+ function UpdateRow({ update }: { update: NonNullable<View['update']> }) {
49
+ const text = t.cms[update.state](update.to);
50
+ const bad = update.state === 'undone' || update.state === 'stuck';
51
+ return (
52
+ <ListItem
53
+ icon={bad ? WarningCircle : update.state === 'done' ? CheckCircle : Clock}
54
+ tone={bad ? 'accent' : undefined}
55
+ headline={text}
56
+ supporting={when(new Date(update.at))}
57
+ />
58
+ );
59
+ }
60
+
61
+ export default function CmsPage() {
62
+ const [view, setView] = useState<View | null>(null);
63
+ const [problem, setProblem] = useState('');
64
+ const [busy, setBusy] = useState(false);
65
+
66
+ const load = useCallback(async () => {
67
+ try {
68
+ setView(cmsView(await readStatus()) as View);
69
+ setProblem('');
70
+ } catch (e) {
71
+ setProblem((e as Error).message);
72
+ }
73
+ }, []);
74
+ useEffect(() => {
75
+ void load();
76
+ }, [load]);
77
+
78
+ async function update(version: string) {
79
+ setBusy(true);
80
+ try {
81
+ const res = await fetch('/api/cms/update', {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({ version }),
85
+ });
86
+ if (!res.ok) {
87
+ const json = await res.json().catch(() => ({}));
88
+ throw new Error(errorText(json.error, res.status, version));
89
+ }
90
+ await load();
91
+ } catch (e) {
92
+ snackbar({ text: (e as Error).message, error: true });
93
+ } finally {
94
+ setBusy(false);
95
+ }
96
+ }
97
+
98
+ const reason = view && reasonOf(view);
99
+ return (
100
+ <Screen title={t.common.parts.morpheme} back={backFrom(CMS_ROUTE)}>
101
+ {problem && <ProblemNote alert>{problem}</ProblemNote>}
102
+ {!view && !problem && <EmptyNote busy>{t.app.loading}</EmptyNote>}
103
+ {view && (
104
+ <>
105
+ <Group>
106
+ <ListItem icon={Info} headline={t.cms.running(view.running)} supporting={view.latest ? undefined : t.cms.upToDate} />
107
+ <ListItem icon={Clock} headline={view.checkedAt ? t.cms.checked(when(new Date(view.checkedAt))) : t.cms.notChecked} />
108
+ {view.update && <UpdateRow update={view.update} />}
109
+ {view.latest && <ListItem icon={Sparkle} tone="accent" headline={t.cms.available(view.latest)} supporting={reason} />}
110
+ {view.npmCommand && <ListItem headline={t.cms.needsNpm} supporting={<code>{view.npmCommand}</code>} />}
111
+ {view.problems.map((kind) => (
112
+ <ListItem key={kind} icon={WarningCircle} tone="accent" headline={t.cms[kindText[kind]]} />
113
+ ))}
114
+ </Group>
115
+ {view.action && view.latest && (
116
+ <Button kind="filled" disabled={busy} onClick={() => void update(view.latest!)}>
117
+ {view.action === 'update' ? t.cms.update : t.cms.tryAgain}
118
+ </Button>
119
+ )}
120
+ {view.notes.length > 0 && (
121
+ <Group title={t.cms.whatsNew}>
122
+ {view.notes.map((n) => (
123
+ <ListItem key={n.version} headline={t.cms.running(n.version)} supporting={n.text} />
124
+ ))}
125
+ </Group>
126
+ )}
127
+ </>
128
+ )}
129
+ </Screen>
130
+ );
131
+ }
132
+
133
+ const kindText: Record<Problem, 'unreachable' | 'cannotWrite' | 'noToken' | 'notManaged' | 'slowUndo'> = {
134
+ unreachable: 'unreachable',
135
+ 'cannot-write': 'cannotWrite',
136
+ 'no-token': 'noToken',
137
+ 'not-managed': 'notManaged',
138
+ 'slow-undo': 'slowUndo',
139
+ };
package/src/Library.tsx CHANGED
@@ -111,6 +111,7 @@ import { MOTION_SCHEMES, schemeInUse, type MotionScheme } from '../lib/motion';
111
111
  import { applyMotion } from './theme';
112
112
  import { colorsOf } from './Colors';
113
113
  import { OwnerLicenses } from './Licenses';
114
+ import { CMS_ROUTE } from './CmsPage';
114
115
  import { apps } from './site';
115
116
  import { offBlocks } from './registry';
116
117
  import { sideChoicesOf, sideSettingOf } from './SideColumn';
@@ -133,6 +134,7 @@ export function partOf(id: string, files: RepoFile[]): { label: string; href: st
133
134
  if (id === 'blokvalg') return { label: parts.blockSettings, href: '#blokvalg', count: null };
134
135
  if (id === 'apps') return { label: parts.apps, href: '#apps', count: null };
135
136
  if (id === 'appstore') return { label: t.apps.find, href: `#${STORE_ROUTE}`, count: null };
137
+ if (id === 'morpheme') return { label: parts.morpheme, href: `#${CMS_ROUTE}`, count: null };
136
138
  if (id === 'avanceret') return { label: parts.advanced, href: '#avanceret', count: null };
137
139
  if (id === 'licenser') return { label: parts.licenses, href: '#licenser', count: null };
138
140
  const screen = partScreens.find((s) => s.id === id);
package/src/appFiles.ts CHANGED
@@ -24,6 +24,6 @@ export const appPictures = import.meta.glob<string>('/src/apps/*/screenshots/*.{
24
24
  });
25
25
  export const appBlocks = import.meta.glob<{ default: BlockDefinition }>('/src/apps/*/blocks/*/index.tsx', { eager: true });
26
26
  export const appShared = import.meta.glob<{ default: SharedTexts }>('/src/apps/*/blocks/*/shared.ts', { eager: true });
27
- /** The apps' blocks drawn from their view: their drawing (View.tsx) and what makes their view in the editor (view.ts). */
27
+ /** The apps' blocks drawn from their view: their drawing (View.tsx) and what makes their view in the editor (viewOf.ts). */
28
28
  export const appViews = import.meta.glob<{ default: View<any> }>('/src/apps/*/blocks/*/View.tsx', { eager: true });
29
- export const appViewOfs = import.meta.glob<{ viewOf: ViewOf<any> }>('/src/apps/*/blocks/*/view.ts', { eager: true });
29
+ export const appViewOfs = import.meta.glob<{ viewOf: ViewOf<any> }>('/src/apps/*/blocks/*/viewOf.ts', { eager: true });
package/src/apps.ts CHANGED
@@ -7,14 +7,14 @@
7
7
  // screenshots/ the pictures it is shown with in the store, picked to give an idea of it (`screenshots` in
8
8
  // index.ts, packages/editor/scripts/app-screenshots.mjs), on its page;
9
9
  // texts.ts its texts in the editor's other languages, as a block's;
10
- // blocks/<type>/ the blocks it adds, each a folder like a standard block's (index.tsx, view.ts, load.ts,
10
+ // blocks/<type>/ the blocks it adds, each a folder like a standard block's (index.tsx, viewOf.ts, load.ts,
11
11
  // View.tsx, texts.ts, evt. shared.ts and script.ts);
12
12
  // lists in index.ts the lists it brings (`lists`, a FolderCollection each, with its icon): under "Lister" while
13
13
  // it is installed, and on its page; the entries are files in the site's data (src/data/), so
14
14
  // they stay, as the rest of the content, when it is uninstalled;
15
15
  // <list>/ a kind of entry it adds to one of the site's lists (`kinds` in index.ts), e.g. popups/: a
16
16
  // pop-up with a signup form, chosen under "+" with the list's own kinds (FolderCollection
17
- // .kinds); its part (View.tsx, given its view by view.ts in the editor and load.ts on the site,
17
+ // .kinds); its part (View.tsx, given its view by viewOf.ts in the editor and load.ts on the site,
18
18
  // and script.ts for what it does in a browser) is drawn in the pop-up under its text, on the site
19
19
  // (Popup.astro finds them) and in the editor (`part` in index.ts, kindDraw);
20
20
  // slots/head.astro, slots/body-end.astro what it puts on every page of the site, in <head> or at the
package/src/blocks.ts CHANGED
@@ -44,7 +44,7 @@ export type SiteBlock = {
44
44
  /** Before its name where it is picked (BlockDialog.tsx). */
45
45
  icon: Icon;
46
46
  /** How it looks on the site, with what is in it now (the card in the text, its sheet): its View.tsx given the view
47
- * its view.ts makes (registry.tsx). Without it, the card has the lines of describe. */
47
+ * its view.ts (an app's: viewOf.ts) makes (registry.tsx). Without it, the card has the lines of describe. */
48
48
  draw?: (props: Record<string, any>, data: BlockData) => ReactNode;
49
49
  /** Gone from the site by itself (a notice whose time is over): its card is greyed, like a hidden one. */
50
50
  over?: (props: BlockProps) => boolean;