@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/bin/morpheme.mjs +1 -1
- package/defaults/editor.ts +3 -1
- package/lib/changelog.d.mts +24 -0
- package/lib/changelog.mjs +65 -0
- package/lib/changelog.test.d.mts +1 -0
- package/lib/changelog.test.mjs +67 -0
- package/lib/cms-version.d.mts +56 -0
- package/lib/cms-version.mjs +85 -0
- package/lib/cms-version.test.d.mts +1 -0
- package/lib/cms-version.test.mjs +68 -0
- package/lib/lockfile.d.mts +19 -0
- package/lib/lockfile.mjs +135 -0
- package/lib/lockfile.test.d.mts +1 -0
- package/lib/lockfile.test.mjs +88 -0
- package/lib/range.d.mts +4 -0
- package/lib/range.mjs +104 -0
- package/lib/range.test.d.mts +1 -0
- package/lib/range.test.mjs +46 -0
- package/package.json +2 -1
- package/scripts/built-site.mjs +14 -0
- package/scripts/check-cms-update.mjs +405 -0
- package/site/theme.d.mts +1 -1
- package/site/theme.mjs +2 -2
- package/src/App.tsx +3 -0
- package/src/CmsPage.tsx +139 -0
- package/src/Library.tsx +2 -0
- package/src/appFiles.ts +2 -2
- package/src/apps.ts +2 -2
- package/src/blocks.ts +1 -1
- package/src/cmsView.d.mts +15 -0
- package/src/cmsView.mjs +78 -0
- package/src/cmsView.test.d.mts +1 -0
- package/src/cmsView.test.mjs +146 -0
- package/src/frontmatter.mjs +4 -1
- package/src/frontmatter.test.mjs +6 -0
- package/src/i18n/da-DK/cms.ts +30 -0
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/index.ts +2 -0
- package/src/i18n/en-US/cms.ts +34 -0
- package/src/i18n/en-US/common.ts +2 -0
- package/src/i18n/en-US/index.ts +2 -0
- package/src/registry.tsx +3 -3
- package/src/schema.ts +1 -1
- package/starter/.prettierignore +1 -0
- package/starter/README.md +1 -0
- package/starter/package.json +1 -1
- package/starter/wrangler.jsonc +1 -1
- package/worker/cms-update.js +245 -0
- package/worker/github.js +8 -6
- package/worker/index.js +6 -1
|
@@ -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
|
+
});
|
package/lib/range.d.mts
ADDED
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphemecms/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-next-20261104164754",
|
|
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,7 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "node scripts/demo-build.mjs",
|
|
43
43
|
"icons": "node scripts/icon-packs.mjs",
|
|
44
|
+
"release:version": "changeset version && node scripts/starter.mjs",
|
|
44
45
|
"prepack": "node scripts/types.mjs write",
|
|
45
46
|
"postpack": "node scripts/types.mjs clean"
|
|
46
47
|
},
|
package/scripts/built-site.mjs
CHANGED
|
@@ -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) })
|