@morphemecms/cms 0.3.0 → 0.4.0-next-20261104182038

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 (60) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/bin/morpheme.mjs +1 -1
  4. package/defaults/editor.ts +14 -1
  5. package/icons/lucide/LICENSE-material-design-icons +19 -19
  6. package/icons/material-symbols/LICENSE-material-design-icons +19 -19
  7. package/icons/phosphor/LICENSE +20 -20
  8. package/lib/changelog.d.mts +24 -0
  9. package/lib/changelog.mjs +65 -0
  10. package/lib/changelog.test.d.mts +1 -0
  11. package/lib/changelog.test.mjs +67 -0
  12. package/lib/cms-version.d.mts +56 -0
  13. package/lib/cms-version.mjs +85 -0
  14. package/lib/cms-version.test.d.mts +1 -0
  15. package/lib/cms-version.test.mjs +68 -0
  16. package/lib/lockfile.d.mts +19 -0
  17. package/lib/lockfile.mjs +135 -0
  18. package/lib/lockfile.test.d.mts +1 -0
  19. package/lib/lockfile.test.mjs +88 -0
  20. package/lib/range.d.mts +4 -0
  21. package/lib/range.mjs +104 -0
  22. package/lib/range.test.d.mts +1 -0
  23. package/lib/range.test.mjs +46 -0
  24. package/lib/side-block.test.mjs +1 -1
  25. package/package.json +7 -1
  26. package/scripts/built-site.mjs +14 -0
  27. package/scripts/check-cms-update.mjs +416 -0
  28. package/site/icons.mjs +3 -1
  29. package/site/theme.d.mts +1 -1
  30. package/site/theme.mjs +2 -2
  31. package/src/App.tsx +3 -0
  32. package/src/CmsPage.tsx +140 -0
  33. package/src/EntryForm.tsx +6 -2
  34. package/src/Library.tsx +2 -0
  35. package/src/SideColumn.tsx +6 -1
  36. package/src/appFiles.ts +2 -2
  37. package/src/apps.ts +2 -2
  38. package/src/blocks.ts +1 -1
  39. package/src/cmsView.d.mts +15 -0
  40. package/src/cmsView.mjs +81 -0
  41. package/src/cmsView.test.d.mts +1 -0
  42. package/src/cmsView.test.mjs +152 -0
  43. package/src/frontmatter.mjs +4 -1
  44. package/src/frontmatter.test.mjs +6 -0
  45. package/src/i18n/da-DK/cms.ts +31 -0
  46. package/src/i18n/da-DK/common.ts +1 -0
  47. package/src/i18n/da-DK/index.ts +2 -0
  48. package/src/i18n/en-US/cms.ts +35 -0
  49. package/src/i18n/en-US/common.ts +2 -0
  50. package/src/i18n/en-US/index.ts +2 -0
  51. package/src/registry.tsx +3 -3
  52. package/src/schema.ts +1 -1
  53. package/starter/.prettierignore +1 -0
  54. package/starter/README.md +1 -0
  55. package/starter/package.json +1 -1
  56. package/starter/wrangler.jsonc +1 -1
  57. package/theme/layouts/PageLayout.astro +2 -1
  58. package/worker/cms-update.js +246 -0
  59. package/worker/github.js +8 -6
  60. package/worker/index.js +6 -1
@@ -0,0 +1,135 @@
1
+ // What a site's editor writes when it installs or updates a package from the store (the editor, @morphemecms/cms),
2
+ // without npm: the package in package.json and its entry in package-lock.json, as `npm install` would write them, from
3
+ // the version's manifest in the store's registry. It can, because a package in the store has no dependencies of its
4
+ // own and no install scripts (src/registry.js), so its entry is all there is to change; npm ci checks its integrity.
5
+ // When it cannot (the package wants peers the site does not have in those versions), it says so with NeedsNpm, and
6
+ // the update is done with `npm install` instead. Moved here from morpheme-appstore (lib/lockfile.js), which
7
+ // keeps its copy until it uses this one.
8
+ import { satisfies, Unsupported } from './range.mjs';
9
+
10
+ export class NeedsNpm extends Error {}
11
+
12
+ const OWN_DEPENDENCIES = ['dependencies', 'optionalDependencies', 'bundleDependencies', 'bundledDependencies'];
13
+ const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
14
+
15
+ /** The object with key set to value, the keys sorted as npm sorts them. */
16
+ function sortedWith(object, key, value) {
17
+ const all = { ...object, [key]: value };
18
+ return Object.fromEntries(
19
+ Object.keys(all)
20
+ .sort((a, b) => a.localeCompare(b, 'en'))
21
+ .map((k) => [k, all[k]]),
22
+ );
23
+ }
24
+
25
+ /** The lock's packages with one entry set: an existing one stays where it is, a new one goes where npm puts it. */
26
+ function packagesWith(packages, key, entry) {
27
+ if (key in packages) return { ...packages, [key]: entry };
28
+ const out = {};
29
+ let placed = false;
30
+ for (const [k, v] of Object.entries(packages)) {
31
+ if (!placed && k !== '' && k.localeCompare(key, 'en') > 0) {
32
+ out[key] = entry;
33
+ placed = true;
34
+ }
35
+ out[k] = v;
36
+ }
37
+ if (!placed) out[key] = entry;
38
+ return out;
39
+ }
40
+
41
+ /** A manifest's bin as npm keeps it in the lock: { name: path }, without a leading ./. */
42
+ function binOf(manifest) {
43
+ const bin = typeof manifest.bin === 'string' ? { [manifest.name.split('/').pop()]: manifest.bin } : manifest.bin;
44
+ if (!bin || typeof bin !== 'object') return undefined;
45
+ return Object.fromEntries(Object.entries(bin).map(([k, v]) => [k, String(v).replace(/^(\.\/)+/, '')]));
46
+ }
47
+
48
+ /** Why the peers the package asks for are not what the site has, or null. */
49
+ function peerProblem(manifest, packages) {
50
+ const optional = manifest.peerDependenciesMeta ?? {};
51
+ for (const [peer, range] of Object.entries(manifest.peerDependencies ?? {})) {
52
+ const have = packages[`node_modules/${peer}`]?.version;
53
+ if (!have) {
54
+ if (optional[peer]?.optional) continue;
55
+ return `${manifest.name} ${manifest.version} skal have ${peer} ${range}, som sitet ikke har.`;
56
+ }
57
+ try {
58
+ if (!satisfies(have, range)) return `${manifest.name} ${manifest.version} skal have ${peer} ${range}; sitet har ${have}.`;
59
+ } catch (e) {
60
+ if (e instanceof Unsupported)
61
+ return `${manifest.name} ${manifest.version} skal have ${peer} ${range}, som ikke kan tjekkes uden npm.`;
62
+ throw e;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * package.json and package-lock.json (parsed) with the version the manifest describes (from the store's registry,
70
+ * with its dist), as new objects. The version is written exactly ("1.4.0", not "^1.4.0"): the editor decides when to
71
+ * update. Throws NeedsNpm when only `npm install` can do it.
72
+ */
73
+ export function addToLock(packageJson, lock, manifest) {
74
+ if (lock.lockfileVersion !== 3 || !lock.packages?.[''])
75
+ throw new NeedsNpm('package-lock.json er ikke i den udgave, npm 9 og nyere skriver.');
76
+ const { name, version, dist } = manifest;
77
+ if (!dist?.tarball || !/^sha512-/.test(dist.integrity ?? '')) throw new NeedsNpm(`${name} ${version} har ingen checksum.`);
78
+ for (const field of OWN_DEPENDENCIES) {
79
+ const value = manifest[field];
80
+ if (value && (Array.isArray(value) ? value.length : Object.keys(value).length)) throw new NeedsNpm(`${name} ${version} har ${field}.`);
81
+ }
82
+ if (INSTALL_SCRIPTS.some((s) => manifest.scripts?.[s])) throw new NeedsNpm(`${name} ${version} har scripts, der kører ved installation.`);
83
+ const key = `node_modules/${name}`;
84
+ for (const [path, entry] of Object.entries(lock.packages)) {
85
+ // Another package that needs it, or a copy of it further down: npm decides where it goes then.
86
+ if (path !== '' && path !== key && (path.endsWith(`/${key}`) || entry.dependencies?.[name] || entry.peerDependencies?.[name]))
87
+ throw new NeedsNpm(`${name} bruges også af ${path.slice('node_modules/'.length)}.`);
88
+ }
89
+ const problem = peerProblem(manifest, lock.packages);
90
+ if (problem) throw new NeedsNpm(problem);
91
+
92
+ const entry = { version, resolved: dist.tarball, integrity: dist.integrity };
93
+ if (manifest.license) entry.license = manifest.license;
94
+ const bin = binOf(manifest);
95
+ if (bin) entry.bin = bin;
96
+ for (const field of ['engines', 'funding', 'peerDependencies', 'peerDependenciesMeta'])
97
+ if (manifest[field]) entry[field] = manifest[field];
98
+
99
+ const root = lock.packages[''];
100
+ const packages = packagesWith(lock.packages, key, entry);
101
+ packages[''] = { ...root, dependencies: sortedWith(root.dependencies ?? {}, name, version) };
102
+ return {
103
+ packageJson: { ...packageJson, dependencies: sortedWith(packageJson.dependencies ?? {}, name, version) },
104
+ lock: { ...lock, packages },
105
+ };
106
+ }
107
+
108
+ /** The object without key, or the object itself when it has no such key. */
109
+ function without(object, key) {
110
+ if (!object || !(key in object)) return object;
111
+ const { [key]: _, ...rest } = object;
112
+ return rest;
113
+ }
114
+
115
+ /**
116
+ * package.json and package-lock.json (parsed) without the package, as new objects: what `npm uninstall` writes for a
117
+ * package from the store, which has nothing of its own in the lock. Throws NeedsNpm when another package still needs it.
118
+ */
119
+ export function removeFromLock(packageJson, lock, name) {
120
+ if (lock.lockfileVersion !== 3 || !lock.packages?.[''])
121
+ throw new NeedsNpm('package-lock.json er ikke i den udgave, npm 9 og nyere skriver.');
122
+ const key = `node_modules/${name}`;
123
+ for (const [path, entry] of Object.entries(lock.packages)) {
124
+ if (path === '' || path === key) continue;
125
+ if (path.endsWith(`/${key}`) || entry.dependencies?.[name] || entry.peerDependencies?.[name])
126
+ throw new NeedsNpm(`${name} bruges stadig af ${path.slice('node_modules/'.length)}.`);
127
+ }
128
+ const root = lock.packages[''];
129
+ const packages = { ...without(lock.packages, key) };
130
+ packages[''] = { ...root, dependencies: without(root.dependencies, name) };
131
+ if (!packages[''].dependencies) delete packages[''].dependencies;
132
+ const out = { ...packageJson };
133
+ if (packageJson.dependencies) out.dependencies = without(packageJson.dependencies, name);
134
+ return { packageJson: out, lock: { ...lock, packages } };
135
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ // Tests for lib/lockfile.mjs (moved from morpheme-appstore's test/unit/lockfile.test.mjs). Run: node --test packages/editor/lib/lockfile.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import { addToLock, NeedsNpm, removeFromLock } from './lockfile.mjs';
5
+
6
+ const site = () => ({
7
+ packageJson: { name: 'site', dependencies: { astro: '^7.3.7', yaml: '^2.9.1' } },
8
+ lock: {
9
+ name: 'site',
10
+ lockfileVersion: 3,
11
+ requires: true,
12
+ packages: {
13
+ '': { name: 'site', dependencies: { astro: '^7.3.7', yaml: '^2.9.1' } },
14
+ 'node_modules/astro': { version: '7.3.7' },
15
+ 'node_modules/yaml': { version: '2.9.1' },
16
+ },
17
+ },
18
+ });
19
+
20
+ const manifest = (over = {}) => ({
21
+ name: '@morpheme/cms',
22
+ version: '1.0.0',
23
+ license: 'UNLICENSED',
24
+ peerDependencies: { astro: '^7.3.2', yaml: '^2.0.0' },
25
+ bin: { 'site-editor': './bin/cli.mjs' },
26
+ dist: {
27
+ tarball: 'https://morpheme-appstore.larsadolfsen.workers.dev/npm/@morpheme/cms/-/cms-1.0.0.tgz',
28
+ integrity: 'sha512-abc',
29
+ shasum: 'def',
30
+ },
31
+ ...over,
32
+ });
33
+
34
+ test('a new package goes into package.json and the lock where npm puts it', () => {
35
+ const { packageJson, lock } = addToLock(site().packageJson, site().lock, manifest());
36
+ assert.deepEqual(Object.keys(packageJson.dependencies), ['@morpheme/cms', 'astro', 'yaml']);
37
+ assert.equal(packageJson.dependencies['@morpheme/cms'], '1.0.0');
38
+ assert.deepEqual(Object.keys(lock.packages), ['', 'node_modules/@morpheme/cms', 'node_modules/astro', 'node_modules/yaml']);
39
+ assert.equal(lock.packages[''].dependencies['@morpheme/cms'], '1.0.0');
40
+ assert.deepEqual(lock.packages['node_modules/@morpheme/cms'], {
41
+ version: '1.0.0',
42
+ resolved: manifest().dist.tarball,
43
+ integrity: 'sha512-abc',
44
+ license: 'UNLICENSED',
45
+ bin: { 'site-editor': 'bin/cli.mjs' },
46
+ peerDependencies: { astro: '^7.3.2', yaml: '^2.0.0' },
47
+ });
48
+ });
49
+
50
+ test('an update changes only its own entry', () => {
51
+ const first = addToLock(site().packageJson, site().lock, manifest());
52
+ const next = addToLock(first.packageJson, first.lock, manifest({ version: '1.1.0' }));
53
+ assert.equal(next.lock.packages['node_modules/@morpheme/cms'].version, '1.1.0');
54
+ assert.deepEqual(next.lock.packages['node_modules/yaml'], first.lock.packages['node_modules/yaml']);
55
+ });
56
+
57
+ test('peers the site does not have, in those versions, need npm', () => {
58
+ assert.throws(() => addToLock(site().packageJson, site().lock, manifest({ peerDependencies: { yaml: '^3.0.0' } })), NeedsNpm);
59
+ assert.throws(() => addToLock(site().packageJson, site().lock, manifest({ peerDependencies: { react: '^19.0.0' } })), NeedsNpm);
60
+ assert.doesNotThrow(() =>
61
+ addToLock(
62
+ site().packageJson,
63
+ site().lock,
64
+ manifest({ peerDependencies: { react: '^19.0.0' }, peerDependenciesMeta: { react: { optional: true } } }),
65
+ ),
66
+ );
67
+ });
68
+
69
+ test('dependencies of its own and install scripts need npm', () => {
70
+ assert.throws(() => addToLock(site().packageJson, site().lock, manifest({ dependencies: { yaml: '^2.0.0' } })), NeedsNpm);
71
+ assert.throws(() => addToLock(site().packageJson, site().lock, manifest({ scripts: { postinstall: 'node x.js' } })), NeedsNpm);
72
+ });
73
+
74
+ test('removing a package undoes adding it, and leaves the input alone', () => {
75
+ const added = addToLock(site().packageJson, site().lock, manifest());
76
+ const snapshot = structuredClone(added);
77
+ const { packageJson, lock } = removeFromLock(added.packageJson, added.lock, '@morpheme/cms');
78
+ assert.deepEqual(packageJson, site().packageJson);
79
+ assert.deepEqual(lock, site().lock);
80
+ assert.deepEqual(added, snapshot);
81
+ });
82
+
83
+ test('removing a package another one needs, or from an old lock, needs npm', () => {
84
+ const added = addToLock(site().packageJson, site().lock, manifest());
85
+ added.lock.packages['node_modules/@morpheme/theme'] = { version: '1.0.0', peerDependencies: { '@morpheme/cms': '^1.0.0' } };
86
+ assert.throws(() => removeFromLock(added.packageJson, added.lock, '@morpheme/cms'), NeedsNpm);
87
+ assert.throws(() => removeFromLock(site().packageJson, { ...site().lock, lockfileVersion: 2 }, 'yaml'), NeedsNpm);
88
+ });
@@ -0,0 +1,4 @@
1
+ /** Whether version satisfies range; throws Unsupported for what this does not read. */
2
+ export function satisfies(version: any, range: any): any;
3
+ export class Unsupported extends Error {
4
+ }
package/lib/range.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // Whether a version satisfies an npm range, for the ranges packages write in peerDependencies: exact versions,
2
+ // x-ranges (1, 1.2, 1.2.x, *), ^, ~, the comparators (>=, >, <=, <, =), hyphen ranges and ||. Anything else (and a
3
+ // version with a pre-release) throws Unsupported, so the caller can say that `npm install` must do it instead of
4
+ // guessing.
5
+ // Moved here from morpheme-appstore (lib/range.js), which keeps its copy until it uses this one.
6
+
7
+ export class Unsupported extends Error {}
8
+
9
+ const PART = '(0|[1-9]\\d*|[xX*])';
10
+ const PARTIAL = new RegExp(`^v?${PART}(?:\\.${PART}(?:\\.${PART})?)?$`);
11
+ const VERSION = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
12
+
13
+ const wild = (p) => p === undefined || /^[xX*]$/.test(p);
14
+
15
+ /** [major, minor, patch] of a version, or Unsupported. */
16
+ function parse(version) {
17
+ const m = VERSION.exec(version);
18
+ if (!m) throw new Unsupported(`version ${version}`);
19
+ return m.slice(1, 4).map(Number);
20
+ }
21
+
22
+ function compare(a, b) {
23
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
24
+ return 0;
25
+ }
26
+
27
+ /** A partial version as [numbers given] (1.2 → [1, 2]); x, X and * end it. */
28
+ function partial(text) {
29
+ const m = PARTIAL.exec(text);
30
+ if (!m) throw new Unsupported(`range ${text}`);
31
+ const parts = [];
32
+ for (const p of m.slice(1, 4)) {
33
+ if (wild(p)) break;
34
+ parts.push(Number(p));
35
+ }
36
+ return parts;
37
+ }
38
+
39
+ const pad = (parts) => [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
40
+ /** The first version above everything a partial version covers (1.2 → 1.3.0, 1 → 2.0.0). */
41
+ function above(parts) {
42
+ if (parts.length === 0) return null;
43
+ if (parts.length === 1) return [parts[0] + 1, 0, 0];
44
+ if (parts.length === 2) return [parts[0], parts[1] + 1, 0];
45
+ return null;
46
+ }
47
+
48
+ /** One comparator as [low, lowInclusive, high, highInclusive] bounds (null: none). */
49
+ function bounds(token) {
50
+ const op = /^(>=|<=|>|<|=|\^|~)?(.*)$/.exec(token);
51
+ const [, sign = '', rest] = op;
52
+ const parts = partial(rest);
53
+ const full = pad(parts);
54
+ const next = above(parts);
55
+ switch (sign) {
56
+ case '':
57
+ case '=':
58
+ if (parts.length === 3) return [full, true, full, true];
59
+ return [parts.length ? full : null, true, next, false];
60
+ case '>=':
61
+ return [parts.length ? full : null, true, null, false];
62
+ case '>':
63
+ if (!parts.length) throw new Unsupported(`range ${token}`);
64
+ return parts.length === 3 ? [full, false, null, false] : [next, true, null, false];
65
+ case '<':
66
+ if (!parts.length) throw new Unsupported(`range ${token}`);
67
+ return [null, true, full, false];
68
+ case '<=':
69
+ if (!parts.length) return [null, true, null, false];
70
+ return parts.length === 3 ? [null, true, full, true] : [null, true, next, false];
71
+ case '~':
72
+ if (!parts.length) return [null, true, null, false];
73
+ return [full, true, parts.length === 1 ? [parts[0] + 1, 0, 0] : [parts[0], parts[1] + 1, 0], false];
74
+ case '^': {
75
+ if (!parts.length) return [null, true, null, false];
76
+ const [a, b, c] = full;
77
+ if (a > 0 || parts.length === 1) return [full, true, [a + 1, 0, 0], false];
78
+ if (b > 0 || parts.length === 2) return [full, true, [0, b + 1, 0], false];
79
+ return [full, true, [0, 0, c + 1], false];
80
+ }
81
+ }
82
+ throw new Unsupported(`range ${token}`);
83
+ }
84
+
85
+ function within(v, [low, lowIn, high, highIn]) {
86
+ if (low && (lowIn ? compare(v, low) < 0 : compare(v, low) <= 0)) return false;
87
+ if (high && (highIn ? compare(v, high) > 0 : compare(v, high) >= 0)) return false;
88
+ return true;
89
+ }
90
+
91
+ /** Whether version satisfies range; throws Unsupported for what this does not read. */
92
+ export function satisfies(version, range) {
93
+ const v = parse(version);
94
+ return range.split('||').some((set) => {
95
+ const text = set.trim().replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1');
96
+ if (text === '' || text === '*') return true;
97
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text);
98
+ if (hyphen) {
99
+ const [from, to] = [partial(hyphen[1]), partial(hyphen[2])];
100
+ return within(v, [pad(from), true, to.length === 3 ? pad(to) : above(to), to.length === 3]);
101
+ }
102
+ return text.split(/\s+/).every((token) => within(v, bounds(token)));
103
+ });
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ // Tests for lib/range.mjs (moved from morpheme-appstore's test/unit/range.test.mjs). Run: node --test packages/editor/lib/range.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import { satisfies, Unsupported } from './range.mjs';
5
+
6
+ const cases = [
7
+ ['1.2.3', '1.2.3', true],
8
+ ['1.2.4', '1.2.3', false],
9
+ ['1.9.0', '^1.2.3', true],
10
+ ['2.0.0', '^1.2.3', false],
11
+ ['1.2.2', '^1.2.3', false],
12
+ ['0.2.9', '^0.2.3', true],
13
+ ['0.3.0', '^0.2.3', false],
14
+ ['0.0.3', '^0.0.3', true],
15
+ ['0.0.4', '^0.0.3', false],
16
+ ['1.2.9', '~1.2.3', true],
17
+ ['1.3.0', '~1.2.3', false],
18
+ ['1.9.9', '~1', true],
19
+ ['19.3.0', '^18.0.0 || ^19.0.0', true],
20
+ ['17.0.0', '^18.0.0 || ^19.0.0', false],
21
+ ['7.3.7', '>=7.0.0 <8', true],
22
+ ['8.0.0', '>=7.0.0 <8', false],
23
+ ['1.5.0', '1.x', true],
24
+ ['2.0.0', '1.x', false],
25
+ ['1.2.7', '1.2', true],
26
+ ['1.3.0', '1.2', false],
27
+ ['5.0.0', '*', true],
28
+ ['5.0.0', '', true],
29
+ ['2.0.0', '>1', true],
30
+ ['1.9.9', '>1', false],
31
+ ['1.2.9', '<=1.2', true],
32
+ ['1.3.0', '<=1.2', false],
33
+ ['1.5.0', '1.2.3 - 1.6', true],
34
+ ['1.7.0', '1.2.3 - 1.6', false],
35
+ ['2.1.0', '>= 2.0.0', true],
36
+ ];
37
+
38
+ test('satisfies reads the ranges peerDependencies use', () => {
39
+ for (const [version, range, expected] of cases) assert.equal(satisfies(version, range), expected, `${version} in ${range}`);
40
+ });
41
+
42
+ test('what it does not read throws Unsupported', () => {
43
+ assert.throws(() => satisfies('1.0.0-beta.1', '^1.0.0'), Unsupported);
44
+ assert.throws(() => satisfies('1.0.0', 'latest'), Unsupported);
45
+ assert.throws(() => satisfies('1.0.0', 'npm:other@1'), Unsupported);
46
+ });
@@ -54,7 +54,7 @@ test('besideInSource reads export const beside = true', () => {
54
54
  assert.equal(besideInSource('export const beside = false;'), false);
55
55
  assert.equal(besideInSource('// export const beside = true;'), false);
56
56
  assert.equal(besideInSource('export const load = 1;'), false);
57
- });
57
+ });
58
58
 
59
59
  test('sideChoices lists installed blocks with beside, by label', () => {
60
60
  const blocks = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-next-20261104182038",
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",
@@ -41,6 +41,9 @@
41
41
  "scripts": {
42
42
  "build": "node scripts/demo-build.mjs",
43
43
  "icons": "node scripts/icon-packs.mjs",
44
+ "format": "prettier --write .",
45
+ "format:check": "prettier --check .",
46
+ "release:version": "changeset version && node scripts/starter.mjs",
44
47
  "prepack": "node scripts/types.mjs write",
45
48
  "postpack": "node scripts/types.mjs clean"
46
49
  },
@@ -117,6 +120,9 @@
117
120
  "jsdom": "^29.1.1",
118
121
  "motion": "^14.0.0",
119
122
  "playwright": "^1.64.1",
123
+ "prettier": "^3.9.10",
124
+ "prettier-plugin-astro": "^1.1.2",
125
+ "prettier-plugin-tailwindcss": "^0.8.1",
120
126
  "react": "^19.3.0",
121
127
  "react-dom": "^19.3.0",
122
128
  "rehype-raw": "^7.0.0",
@@ -111,6 +111,19 @@ const STORE_CATALOG = {
111
111
  ],
112
112
  };
113
113
 
114
+ /** What the Worker's GET /api/cms answers (worker/cms-update.js cmsStatus), made up so "Morpheme" (#morpheme) opens with
115
+ * a new major version and its notes, an undone update, and two problems (no token, no check runs to read). The other
116
+ * states (up to date, waiting, skipped, needs npm) are covered by src/cmsView.test.mjs. */
117
+ const CMS_STATUS = {
118
+ running: JSON.parse(readFileSync(`${import.meta.dirname}/../package.json`, 'utf8')).version,
119
+ token: false,
120
+ kv: true,
121
+ checked: { at: '2026-11-03T08:00:00.000Z', latest: '99.0.0', problem: { kind: 'major' }, noChecks: true },
122
+ update: { state: 'undone', to: '0.0.1', at: '2026-11-02T10:30:00.000Z' },
123
+ skipped: ['0.0.1'],
124
+ notes: [{ version: '99.0.0', text: 'A made-up note for the checks.\n\nA second paragraph.' }],
125
+ };
126
+
114
127
  /** Opens the editor (/editor/) of the site at `site` in `page`, signed in, with GitHub answered from the files on disk
115
128
  * (`texts`: some with other texts), and waits until it has loaded the site. */
116
129
  export async function openEditor(page, site, texts = {}) {
@@ -118,6 +131,7 @@ export async function openEditor(page, site, texts = {}) {
118
131
  await page.route(`${site}/api/r2?**`, (r) =>
119
132
  r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
120
133
  );
134
+ await page.route(`${site}/api/cms`, (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(CMS_STATUS) }));
121
135
  await page.route(`${site}/api/store/**`, (r) =>
122
136
  new URL(r.request().url()).pathname === '/api/store/catalog'
123
137
  ? r.fulfill({ contentType: 'application/json', body: JSON.stringify(STORE_CATALOG) })