@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @morphemecms/cms
|
|
2
2
|
|
|
3
|
+
## 0.4.0-next-20261104164754
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cf46cb1: An app's block and pop-up name their view module `viewOf.ts` (it was `view.ts`), so it no longer collides with `View.tsx` on Windows.
|
|
8
|
+
- 1fcbd56: The site updates Morpheme by itself: a new minor or patch version on npm is installed within the hour and undone when the site cannot be built. See Settings › Morpheme.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- cf9f144: Keep the line breaks of a file's front matter (CRLF or LF) when the editor writes it back.
|
|
13
|
+
- 682da97: New sites from the starter can reach the Morpheme store on the same Cloudflare account, and Prettier skips store theme files.
|
|
14
|
+
|
|
3
15
|
## 0.3.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Hele vejledningen står i `docs/editor.md` i sitets repo. Her er kun det, et sit
|
|
|
41
41
|
- `@site/config`: sitets navn, domæne, GitHub-repo og mapper.
|
|
42
42
|
- `@site/worker` (til Workeren): `apps`, `contact`, `moved`, `standard`, `oldAddresses` (sitets videresendelser fra gamle adresser, `{ ids, paths }`) og appenes del af Workeren (`appWorkers`, `appTexts`).
|
|
43
43
|
- **`@site`** for `src/site/`, ellers `defaults/`: i Vite (`siteFile` i `themePlugin`, `site/theme.mjs`, brugt af `astro.mjs` og `scripts/check-content.mjs`, der indlæser reglerne med Vite) og i TypeScript (`paths` i `tsconfig.json`: `"@site/*": ["./src/site/*", "./node_modules/@morphemecms/cms/defaults/*"]`); til Workeren `@site/config` og `@site/worker` i `alias` i `wrangler.jsonc`, med `main` sat til pakkens `worker/index.js`.
|
|
44
|
-
- **Sitets egne blokke** i appenes `blocks/<type>/` (`src/apps/<app>/blocks/<type>/`), hver med `index.tsx` (`defineBlock` fra `@morphemecms/cms/src/blocks`), `
|
|
44
|
+
- **Sitets egne blokke** i appenes `blocks/<type>/` (`src/apps/<app>/blocks/<type>/`), hver med `index.tsx` (`defineBlock` fra `@morphemecms/cms/src/blocks`), `viewOf.ts` og `load.ts` (det, blokken viser, i editoren og på sitet) og `View.tsx` (tegningen, begge steder). Pakken finder dem selv.
|
|
45
45
|
- **Et standardtema**, som alle sitets temaer bygger på, med standardblokkenes udseende (`blocks/<type>/View.tsx`, én tegning til sitet og editoren), som pakken selv finder (dette site: `packages/editor/theme/`, `packages/editor/src/standard/siteLooks.ts` og `packages/editor/src/standard/themeBlocks.ts`).
|
|
46
46
|
- **Siderne** er pakkens (`pages/`, sat ind af `siteConfig()` i `astro.mjs`): sitets sider, bloggen, 404, `/ny/` og editoren (siden, der viser `src/App.tsx`, manifestet, `version.json` og billederne, sitets egen kode bruger). Sitets `astro.config.mjs` er kun `export default await siteConfig()`, og `src/content.config.ts` henter samlingerne fra `site/content.config`.
|
|
47
47
|
- **Standardblokkenes stil:** `blocks.css` i standardtemaet (dette site: `packages/editor/theme/styles/blocks.css`), importeret i hvert temas CSS, og variablerne `--block-*` sat til temaets farver og former.
|
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'],
|
|
15
|
+
'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update'],
|
|
16
16
|
'check:screens': ['editor-screens'],
|
|
17
17
|
'check:themes': ['check-themes'],
|
|
18
18
|
'check:demo': ['check-demo'],
|
package/defaults/editor.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
Signpost,
|
|
44
44
|
Smiley,
|
|
45
45
|
SlidersHorizontal,
|
|
46
|
+
Sparkle,
|
|
46
47
|
SquareHalfBottom,
|
|
47
48
|
SquaresFour,
|
|
48
49
|
Sticker,
|
|
@@ -126,6 +127,7 @@ export const icons: Record<string, Icon> = {
|
|
|
126
127
|
blokvalg: ToggleRight,
|
|
127
128
|
apps: PuzzlePiece,
|
|
128
129
|
appstore: Storefront,
|
|
130
|
+
morpheme: Sparkle,
|
|
129
131
|
avanceret: Wrench,
|
|
130
132
|
licenser: Certificate,
|
|
131
133
|
// The screens under "Udseende" (Library.tsx Theme).
|
|
@@ -156,7 +158,7 @@ export const groups: { title: string; ids: string[] }[] = [
|
|
|
156
158
|
|
|
157
159
|
/** The settings of the whole site, not on the start screen but on "Indstillinger" (#indstillinger, Library.tsx
|
|
158
160
|
* Settings), which the gear in the top bar opens (Screen.tsx SettingsLink); A–Å as a group's. */
|
|
159
|
-
export const settings: string[] = ['udseende', 'apps', 'appstore', 'maaling', 'promovering', 'privatliv', 'seo', 'avanceret', 'licenser'];
|
|
161
|
+
export const settings: string[] = ['udseende', 'apps', 'appstore', 'morpheme', 'maaling', 'promovering', 'privatliv', 'seo', 'avanceret', 'licenser'];
|
|
160
162
|
|
|
161
163
|
/** The site's own short lists, on a screen of their own under "Lister" (#lister), with those the standard blocks
|
|
162
164
|
* bring ("Udtalelser") and the apps (their lists.ts). */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unzips gzip bytes.
|
|
3
|
+
* @param {Uint8Array} bytes
|
|
4
|
+
* @returns {Promise<Uint8Array>}
|
|
5
|
+
*/
|
|
6
|
+
export function gunzip(bytes: Uint8Array): Promise<Uint8Array>;
|
|
7
|
+
/**
|
|
8
|
+
* The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
|
|
9
|
+
* @param {Uint8Array} tar
|
|
10
|
+
* @param {string} path
|
|
11
|
+
* @returns {string | null}
|
|
12
|
+
*/
|
|
13
|
+
export function fileFromTar(tar: Uint8Array, path: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* The changelog's sections for versions after `from` up to and including `to`, newest first, without empty ones.
|
|
16
|
+
* @param {string} changelog
|
|
17
|
+
* @param {string} from
|
|
18
|
+
* @param {string} to
|
|
19
|
+
* @returns {{ version: string, text: string }[]}
|
|
20
|
+
*/
|
|
21
|
+
export function sectionsBetween(changelog: string, from: string, to: string): {
|
|
22
|
+
version: string;
|
|
23
|
+
text: string;
|
|
24
|
+
}[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A version's release notes, read from its npm tarball: unzips the tarball, finds CHANGELOG.md in the tar and cuts out the
|
|
2
|
+
// sections between two versions. Exposes gunzip, fileFromTar and sectionsBetween. Depends on ./cms-version.mjs; no node: imports
|
|
3
|
+
// (runs in a Cloudflare Worker and in Node 18+).
|
|
4
|
+
import { compareVersions } from './cms-version.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unzips gzip bytes.
|
|
8
|
+
* @param {Uint8Array} bytes
|
|
9
|
+
* @returns {Promise<Uint8Array>}
|
|
10
|
+
*/
|
|
11
|
+
export async function gunzip(bytes) {
|
|
12
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
13
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The text of a NUL-terminated field of a tar header. */
|
|
17
|
+
function field(header, start, end) {
|
|
18
|
+
const bytes = header.subarray(start, end);
|
|
19
|
+
const nul = bytes.indexOf(0);
|
|
20
|
+
return new TextDecoder().decode(nul === -1 ? bytes : bytes.subarray(0, nul));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
|
|
25
|
+
* @param {Uint8Array} tar
|
|
26
|
+
* @param {string} path
|
|
27
|
+
* @returns {string | null}
|
|
28
|
+
*/
|
|
29
|
+
export function fileFromTar(tar, path) {
|
|
30
|
+
let offset = 0;
|
|
31
|
+
while (offset + 512 <= tar.length) {
|
|
32
|
+
const header = tar.subarray(offset, offset + 512);
|
|
33
|
+
if (header.every((byte) => byte === 0)) return null;
|
|
34
|
+
const name = field(header, 0, 100);
|
|
35
|
+
const prefix = field(header, 345, 500);
|
|
36
|
+
const size = parseInt(field(header, 124, 136).trim(), 8) || 0;
|
|
37
|
+
const data = tar.subarray(offset + 512, offset + 512 + size);
|
|
38
|
+
if ((prefix ? prefix + '/' + name : name) === path) return new TextDecoder().decode(data);
|
|
39
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The changelog's sections for versions after `from` up to and including `to`, newest first, without empty ones.
|
|
46
|
+
* @param {string} changelog
|
|
47
|
+
* @param {string} from
|
|
48
|
+
* @param {string} to
|
|
49
|
+
* @returns {{ version: string, text: string }[]}
|
|
50
|
+
*/
|
|
51
|
+
export function sectionsBetween(changelog, from, to) {
|
|
52
|
+
const sections = [];
|
|
53
|
+
let current = null;
|
|
54
|
+
for (const line of changelog.split(/\r?\n/)) {
|
|
55
|
+
if (line.startsWith('## ')) {
|
|
56
|
+
current = { version: line.slice(3).trim(), lines: [] };
|
|
57
|
+
sections.push(current);
|
|
58
|
+
} else if (current) current.lines.push(line);
|
|
59
|
+
}
|
|
60
|
+
return sections
|
|
61
|
+
.filter(({ version }) => /^\d+\.\d+\.\d+$/.test(version) && compareVersions(version, from) === 1 && compareVersions(version, to) <= 0)
|
|
62
|
+
.map(({ version, lines }) => ({ version, text: lines.join('\n').trim() }))
|
|
63
|
+
.filter(({ text }) => text !== '')
|
|
64
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Tests for lib/changelog.mjs: a version's release notes read from its npm tarball.
|
|
2
|
+
// Run: node --test packages/editor/lib/changelog.test.mjs
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { gzipSync } from 'node:zlib';
|
|
6
|
+
import { gunzip, fileFromTar, sectionsBetween } from './changelog.mjs';
|
|
7
|
+
|
|
8
|
+
/** One tar entry: a 512-byte header, then the data padded to a multiple of 512. */
|
|
9
|
+
function tarEntry(name, text, prefix = '') {
|
|
10
|
+
const data = Buffer.from(text);
|
|
11
|
+
const header = Buffer.alloc(512);
|
|
12
|
+
header.write(name, 0, 'ascii');
|
|
13
|
+
header.write('0000644\0', 100, 'ascii');
|
|
14
|
+
header.write('0000000\0', 108, 'ascii');
|
|
15
|
+
header.write('0000000\0', 116, 'ascii');
|
|
16
|
+
header.write(data.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
|
|
17
|
+
header.write('00000000000\0', 136, 'ascii');
|
|
18
|
+
header.write('0', 156, 'ascii');
|
|
19
|
+
header.write('ustar\0', 257, 'ascii');
|
|
20
|
+
header.write('00', 263, 'ascii');
|
|
21
|
+
header.write(prefix, 345, 'ascii');
|
|
22
|
+
header.fill(' ', 148, 156);
|
|
23
|
+
const sum = header.reduce((total, byte) => total + byte, 0);
|
|
24
|
+
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
|
|
25
|
+
return Buffer.concat([header, data, Buffer.alloc(Math.ceil(data.length / 512) * 512 - data.length)]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tarball = (...entries) => new Uint8Array(Buffer.concat([...entries, Buffer.alloc(1024)]));
|
|
29
|
+
|
|
30
|
+
const changelogText = '# x\n\n## 0.1.1\n';
|
|
31
|
+
const tar = tarball(tarEntry('package/package.json', '{"name":"x"}'), tarEntry('package/CHANGELOG.md', changelogText));
|
|
32
|
+
|
|
33
|
+
test('fileFromTar finds a file by its name', () => assert.equal(fileFromTar(tar, 'package/CHANGELOG.md'), changelogText));
|
|
34
|
+
test('fileFromTar returns null for a missing file', () => assert.equal(fileFromTar(tar, 'package/missing.md'), null));
|
|
35
|
+
test('fileFromTar joins the prefix of a long name', () => {
|
|
36
|
+
const prefix = 'package/' + 'a'.repeat(120);
|
|
37
|
+
const long = tarball(tarEntry('CHANGELOG.md', 'long one', prefix));
|
|
38
|
+
assert.equal(fileFromTar(long, prefix + '/CHANGELOG.md'), 'long one');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('gunzip gives the input back', async () => {
|
|
42
|
+
const bytes = await gunzip(new Uint8Array(gzipSync(Buffer.from('hello tar'))));
|
|
43
|
+
assert.equal(new TextDecoder().decode(bytes), 'hello tar');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** A changelog with one `### Patch Changes` line and one bullet under each version. */
|
|
47
|
+
const changelogOf = (...versions) =>
|
|
48
|
+
['# @morphemecms/cms', '', ...versions.flatMap((v) => [`## ${v}`, '', '### Patch Changes', '', `- Change ${v}`, ''])].join('\n');
|
|
49
|
+
|
|
50
|
+
const full = changelogOf('0.3.0', '0.2.1', '0.2.0', '0.1.1');
|
|
51
|
+
const versionsOf = (sections) => sections.map((section) => section.version);
|
|
52
|
+
|
|
53
|
+
test('sectionsBetween returns the versions after from up to to, newest first', () => {
|
|
54
|
+
const sections = sectionsBetween(full, '0.1.1', '0.3.0');
|
|
55
|
+
assert.deepEqual(versionsOf(sections), ['0.3.0', '0.2.1', '0.2.0']);
|
|
56
|
+
assert.equal(sections[0].text, '### Patch Changes\n\n- Change 0.3.0');
|
|
57
|
+
});
|
|
58
|
+
test('sectionsBetween is empty when from equals to', () => assert.deepEqual(sectionsBetween(full, '0.2.1', '0.2.1'), []));
|
|
59
|
+
test('sectionsBetween includes to but not from', () => assert.deepEqual(versionsOf(sectionsBetween(full, '0.1.0', '0.1.1')), ['0.1.1']));
|
|
60
|
+
test('sectionsBetween drops a section with only blank lines', () => {
|
|
61
|
+
const text = '# x\n\n## 0.2.0\n\n\n';
|
|
62
|
+
assert.deepEqual(sectionsBetween(text, '0.1.0', '0.2.0'), []);
|
|
63
|
+
});
|
|
64
|
+
test('sectionsBetween ignores pre-release headings', () => {
|
|
65
|
+
const text = `## 0.2.0-next-1\n\n- Next\n\n${changelogOf('0.1.1')}`;
|
|
66
|
+
assert.deepEqual(versionsOf(sectionsBetween(text, '0.1.0', '0.2.0')), ['0.1.1']);
|
|
67
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares two plain X.Y.Z versions number by number; throws Error('pre-release') for a version with a pre-release part.
|
|
3
|
+
* @param {string} a
|
|
4
|
+
* @param {string} b
|
|
5
|
+
* @returns {-1 | 0 | 1}
|
|
6
|
+
*/
|
|
7
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1;
|
|
8
|
+
/**
|
|
9
|
+
* The version of the CMS a site's package.json pins exactly, or why the site does not let the CMS manage it.
|
|
10
|
+
* @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} packageJson
|
|
11
|
+
* @param {{ packages?: Record<string, { link?: boolean }> } | null | undefined} lock
|
|
12
|
+
* @returns {{ version: string } | { reason: 'missing' | 'workspace' | 'tag' | 'range' | 'link' }}
|
|
13
|
+
*/
|
|
14
|
+
export function managedVersion(packageJson: {
|
|
15
|
+
dependencies?: Record<string, string>;
|
|
16
|
+
devDependencies?: Record<string, string>;
|
|
17
|
+
}, lock: {
|
|
18
|
+
packages?: Record<string, {
|
|
19
|
+
link?: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
} | null | undefined): {
|
|
22
|
+
version: string;
|
|
23
|
+
} | {
|
|
24
|
+
reason: "missing" | "workspace" | "tag" | "range" | "link";
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The version to move to, and whether it is taken by itself; null when there is nothing newer to take.
|
|
28
|
+
* @param {string} current
|
|
29
|
+
* @param {string} latest
|
|
30
|
+
* @param {string[]} skipped
|
|
31
|
+
* @returns {null | { version: string, auto: boolean, why?: 'major' | 'skipped' }}
|
|
32
|
+
*/
|
|
33
|
+
export function nextVersion(current: string, latest: string, skipped: string[]): null | {
|
|
34
|
+
version: string;
|
|
35
|
+
auto: boolean;
|
|
36
|
+
why?: "major" | "skipped";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* What to do next with an update being watched (state 'waiting') or an undo being watched (state 'undoing').
|
|
40
|
+
* @param {{ state: string, to: string, from: string, at: string, undoAt?: string }} update times are ISO strings
|
|
41
|
+
* @param {string} running the version the site runs now
|
|
42
|
+
* @param {'success' | 'failure' | null} check the result of the commit's check run
|
|
43
|
+
* @param {number} now milliseconds
|
|
44
|
+
* @returns {'wait' | 'done' | 'undo' | 'undone' | 'stuck'}
|
|
45
|
+
*/
|
|
46
|
+
export function watchStep(update: {
|
|
47
|
+
state: string;
|
|
48
|
+
to: string;
|
|
49
|
+
from: string;
|
|
50
|
+
at: string;
|
|
51
|
+
undoAt?: string;
|
|
52
|
+
}, running: string, check: "success" | "failure" | null, now: number): "wait" | "done" | "undo" | "undone" | "stuck";
|
|
53
|
+
/** The npm package of the CMS. */
|
|
54
|
+
export const PACKAGE: "@morphemecms/cms";
|
|
55
|
+
/** How long an update or its undo is watched before it counts as over, in milliseconds (20 minutes). */
|
|
56
|
+
export const WATCH_LIMIT: number;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Which version of the CMS a site takes, and when an update that did not build is undone: reads the site's package.json,
|
|
2
|
+
// compares versions and steps the update's watch. Exposes PACKAGE, WATCH_LIMIT, compareVersions, managedVersion,
|
|
3
|
+
// nextVersion and watchStep. No dependencies, no I/O (runs in a Worker and in Node).
|
|
4
|
+
|
|
5
|
+
/** The npm package of the CMS. */
|
|
6
|
+
export const PACKAGE = '@morphemecms/cms';
|
|
7
|
+
|
|
8
|
+
/** How long an update or its undo is watched before it counts as over, in milliseconds (20 minutes). */
|
|
9
|
+
export const WATCH_LIMIT = 20 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const PLAIN = /^\d+\.\d+\.\d+$/;
|
|
12
|
+
|
|
13
|
+
/** True when a version has anything after X.Y.Z. */
|
|
14
|
+
const isPreRelease = (version) => !PLAIN.test(version);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compares two plain X.Y.Z versions number by number; throws Error('pre-release') for a version with a pre-release part.
|
|
18
|
+
* @param {string} a
|
|
19
|
+
* @param {string} b
|
|
20
|
+
* @returns {-1 | 0 | 1}
|
|
21
|
+
*/
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
if (isPreRelease(a) || isPreRelease(b)) throw new Error('pre-release');
|
|
24
|
+
const x = a.split('.').map(Number);
|
|
25
|
+
const y = b.split('.').map(Number);
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The version of the CMS a site's package.json pins exactly, or why the site does not let the CMS manage it.
|
|
34
|
+
* @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} packageJson
|
|
35
|
+
* @param {{ packages?: Record<string, { link?: boolean }> } | null | undefined} lock
|
|
36
|
+
* @returns {{ version: string } | { reason: 'missing' | 'workspace' | 'tag' | 'range' | 'link' }}
|
|
37
|
+
*/
|
|
38
|
+
export function managedVersion(packageJson, lock) {
|
|
39
|
+
const value = packageJson?.dependencies?.[PACKAGE] ?? packageJson?.devDependencies?.[PACKAGE];
|
|
40
|
+
if (value === undefined) return { reason: 'missing' };
|
|
41
|
+
if (value === '*' || value.startsWith('workspace:') || value.startsWith('file:')) return { reason: 'workspace' };
|
|
42
|
+
if (lock?.packages?.['node_modules/@morphemecms/cms']?.link === true) return { reason: 'link' };
|
|
43
|
+
if (!/\d+\.\d+/.test(value)) return { reason: 'tag' };
|
|
44
|
+
if (!PLAIN.test(value)) return { reason: 'range' };
|
|
45
|
+
return { version: value };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The version to move to, and whether it is taken by itself; null when there is nothing newer to take.
|
|
50
|
+
* @param {string} current
|
|
51
|
+
* @param {string} latest
|
|
52
|
+
* @param {string[]} skipped
|
|
53
|
+
* @returns {null | { version: string, auto: boolean, why?: 'major' | 'skipped' }}
|
|
54
|
+
*/
|
|
55
|
+
export function nextVersion(current, latest, skipped) {
|
|
56
|
+
if (isPreRelease(latest)) return null;
|
|
57
|
+
if (compareVersions(latest, current) !== 1) return null;
|
|
58
|
+
if (latest.split('.')[0] !== current.split('.')[0]) return { version: latest, auto: false, why: 'major' };
|
|
59
|
+
if (skipped.includes(latest)) return { version: latest, auto: false, why: 'skipped' };
|
|
60
|
+
return { version: latest, auto: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What to do next with an update being watched (state 'waiting') or an undo being watched (state 'undoing').
|
|
65
|
+
* @param {{ state: string, to: string, from: string, at: string, undoAt?: string }} update times are ISO strings
|
|
66
|
+
* @param {string} running the version the site runs now
|
|
67
|
+
* @param {'success' | 'failure' | null} check the result of the commit's check run
|
|
68
|
+
* @param {number} now milliseconds
|
|
69
|
+
* @returns {'wait' | 'done' | 'undo' | 'undone' | 'stuck'}
|
|
70
|
+
*/
|
|
71
|
+
export function watchStep(update, running, check, now) {
|
|
72
|
+
if (update.state === 'waiting') {
|
|
73
|
+
if (running === update.to) return 'done';
|
|
74
|
+
if (check === 'failure') return 'undo';
|
|
75
|
+
if (now - Date.parse(update.at) > WATCH_LIMIT) return 'undo';
|
|
76
|
+
return 'wait';
|
|
77
|
+
}
|
|
78
|
+
if (update.state === 'undoing') {
|
|
79
|
+
if (check === 'success') return 'undone';
|
|
80
|
+
if (check === 'failure') return 'stuck';
|
|
81
|
+
if (now - Date.parse(update.undoAt) > WATCH_LIMIT) return 'undone';
|
|
82
|
+
return 'wait';
|
|
83
|
+
}
|
|
84
|
+
return 'wait';
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Tests for lib/cms-version.mjs: which CMS version a site takes, and when an update is undone.
|
|
2
|
+
// Run: node --test packages/editor/lib/cms-version.test.mjs
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { PACKAGE, WATCH_LIMIT, compareVersions, managedVersion, nextVersion, watchStep } from './cms-version.mjs';
|
|
6
|
+
|
|
7
|
+
test('the package is the CMS', () => assert.equal(PACKAGE, '@morphemecms/cms'));
|
|
8
|
+
test('the watch limit is 20 minutes', () => assert.equal(WATCH_LIMIT, 20 * 60 * 1000));
|
|
9
|
+
|
|
10
|
+
test('compareVersions: older is -1', () => assert.equal(compareVersions('0.1.0', '0.1.1'), -1));
|
|
11
|
+
test('compareVersions: compares number by number', () => assert.equal(compareVersions('0.10.0', '0.9.9'), 1));
|
|
12
|
+
test('compareVersions: equal is 0', () => assert.equal(compareVersions('0.1.1', '0.1.1'), 0));
|
|
13
|
+
test('compareVersions: a pre-release throws', () =>
|
|
14
|
+
assert.throws(() => compareVersions('0.2.0-next-1', '0.1.0'), { message: 'pre-release' }));
|
|
15
|
+
|
|
16
|
+
const lock = { packages: {} };
|
|
17
|
+
const dep = (value) => ({ dependencies: { [PACKAGE]: value } });
|
|
18
|
+
|
|
19
|
+
test('managedVersion: an exact version is managed', () => assert.deepEqual(managedVersion(dep('0.1.1'), lock), { version: '0.1.1' }));
|
|
20
|
+
test('managedVersion: * is a workspace', () => assert.deepEqual(managedVersion(dep('*'), lock), { reason: 'workspace' }));
|
|
21
|
+
test('managedVersion: workspace:* is a workspace', () =>
|
|
22
|
+
assert.deepEqual(managedVersion(dep('workspace:*'), lock), { reason: 'workspace' }));
|
|
23
|
+
test('managedVersion: a tag is a tag', () => assert.deepEqual(managedVersion(dep('next'), lock), { reason: 'tag' }));
|
|
24
|
+
test('managedVersion: ^ is a range', () => assert.deepEqual(managedVersion(dep('^0.1.0'), lock), { reason: 'range' }));
|
|
25
|
+
test('managedVersion: ~ is a range', () => assert.deepEqual(managedVersion(dep('~0.1.0'), lock), { reason: 'range' }));
|
|
26
|
+
test('managedVersion: a linked lock entry is a link', () => {
|
|
27
|
+
const linked = { packages: { 'node_modules/@morphemecms/cms': { link: true } } };
|
|
28
|
+
assert.deepEqual(managedVersion(dep('0.1.1'), linked), { reason: 'link' });
|
|
29
|
+
});
|
|
30
|
+
test('managedVersion: no dependency is missing', () => assert.deepEqual(managedVersion({}, lock), { reason: 'missing' }));
|
|
31
|
+
test('managedVersion: devDependencies count', () => {
|
|
32
|
+
assert.deepEqual(managedVersion({ devDependencies: { [PACKAGE]: '0.1.0' } }, lock), { version: '0.1.0' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('nextVersion: a patch is taken by itself', () =>
|
|
36
|
+
assert.deepEqual(nextVersion('0.1.0', '0.1.1', []), { version: '0.1.1', auto: true }));
|
|
37
|
+
test('nextVersion: a minor is taken by itself', () =>
|
|
38
|
+
assert.deepEqual(nextVersion('0.1.1', '0.2.0', []), { version: '0.2.0', auto: true }));
|
|
39
|
+
test('nextVersion: a major is not', () => {
|
|
40
|
+
assert.deepEqual(nextVersion('0.9.0', '1.0.0', []), { version: '1.0.0', auto: false, why: 'major' });
|
|
41
|
+
});
|
|
42
|
+
test('nextVersion: a skipped version is not', () => {
|
|
43
|
+
assert.deepEqual(nextVersion('0.1.0', '0.1.1', ['0.1.1']), { version: '0.1.1', auto: false, why: 'skipped' });
|
|
44
|
+
});
|
|
45
|
+
test('nextVersion: the same version is null', () => assert.equal(nextVersion('0.1.1', '0.1.1', []), null));
|
|
46
|
+
test('nextVersion: an older version is null', () => assert.equal(nextVersion('0.2.0', '0.1.9', []), null));
|
|
47
|
+
test('nextVersion: a pre-release is null', () => assert.equal(nextVersion('0.1.0', '0.2.0-next-20261103', []), null));
|
|
48
|
+
|
|
49
|
+
const at = '2026-11-03T12:00:00.000Z';
|
|
50
|
+
const min = 60 * 1000;
|
|
51
|
+
const t0 = Date.parse(at);
|
|
52
|
+
const waiting = { state: 'waiting', from: '0.1.0', to: '0.1.1', at };
|
|
53
|
+
const undoing = { state: 'undoing', from: '0.1.0', to: '0.1.1', at, undoAt: at };
|
|
54
|
+
|
|
55
|
+
test('watchStep waiting: the running version wins', () => assert.equal(watchStep(waiting, '0.1.1', null, t0 + 5 * min), 'done'));
|
|
56
|
+
test('watchStep waiting: the running version wins over a failure', () => assert.equal(watchStep(waiting, '0.1.1', 'failure', t0), 'done'));
|
|
57
|
+
test('watchStep waiting: a failed check undoes', () => assert.equal(watchStep(waiting, '0.1.0', 'failure', t0 + 5 * min), 'undo'));
|
|
58
|
+
test('watchStep waiting: 19 minutes waits', () => assert.equal(watchStep(waiting, '0.1.0', null, t0 + 19 * min), 'wait'));
|
|
59
|
+
test('watchStep waiting: 21 minutes undoes', () => assert.equal(watchStep(waiting, '0.1.0', null, t0 + 21 * min), 'undo'));
|
|
60
|
+
test('watchStep waiting: a success that is not online yet waits', () =>
|
|
61
|
+
assert.equal(watchStep(waiting, '0.1.0', 'success', t0 + 5 * min), 'wait'));
|
|
62
|
+
|
|
63
|
+
test('watchStep undoing: success is undone', () => assert.equal(watchStep(undoing, '0.1.0', 'success', t0), 'undone'));
|
|
64
|
+
test('watchStep undoing: failure is stuck', () => assert.equal(watchStep(undoing, '0.1.0', 'failure', t0), 'stuck'));
|
|
65
|
+
test('watchStep undoing: 21 minutes is undone', () => assert.equal(watchStep(undoing, '0.1.0', null, t0 + 21 * min), 'undone'));
|
|
66
|
+
test('watchStep undoing: 5 minutes waits', () => assert.equal(watchStep(undoing, '0.1.0', null, t0 + 5 * min), 'wait'));
|
|
67
|
+
test('watchStep: any other state waits', () =>
|
|
68
|
+
assert.equal(watchStep({ ...waiting, state: 'done' }, '0.1.0', 'failure', t0 + 30 * min), 'wait'));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package.json and package-lock.json (parsed) with the version the manifest describes (from the store's registry,
|
|
3
|
+
* with its dist), as new objects. The version is written exactly ("1.4.0", not "^1.4.0"): the editor decides when to
|
|
4
|
+
* update. Throws NeedsNpm when only `npm install` can do it.
|
|
5
|
+
*/
|
|
6
|
+
export function addToLock(packageJson: any, lock: any, manifest: any): {
|
|
7
|
+
packageJson: any;
|
|
8
|
+
lock: any;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* package.json and package-lock.json (parsed) without the package, as new objects: what `npm uninstall` writes for a
|
|
12
|
+
* package from the store, which has nothing of its own in the lock. Throws NeedsNpm when another package still needs it.
|
|
13
|
+
*/
|
|
14
|
+
export function removeFromLock(packageJson: any, lock: any, name: any): {
|
|
15
|
+
packageJson: any;
|
|
16
|
+
lock: any;
|
|
17
|
+
};
|
|
18
|
+
export class NeedsNpm extends Error {
|
|
19
|
+
}
|
package/lib/lockfile.mjs
ADDED
|
@@ -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 {};
|