@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.4.0-next-20261104182038
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
+ - dd9ab22: Settings › Morpheme says when the site has no KV storage (PUSH), and no longer fails to open when the site runs a pre-release of Morpheme.
13
+ - cf9f144: Keep the line breaks of a file's front matter (CRLF or LF) when the editor writes it back.
14
+ - 6cb2c17: `check:worker` passes when the site runs a pre-release of Morpheme (as the staging site does).
15
+ - 682da97: New sites from the starter can reach the Morpheme store on the same Cloudflare account, and Prettier skips store theme files.
16
+
3
17
  ## 0.3.0
4
18
 
5
19
  ### 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`), `view.ts` og `load.ts` (det, blokken viser, i editoren og på sitet) og `View.tsx` (tegningen, begge steder). Pakken finder dem selv.
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'],
@@ -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,18 @@ 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[] = [
162
+ 'udseende',
163
+ 'apps',
164
+ 'appstore',
165
+ 'morpheme',
166
+ 'maaling',
167
+ 'promovering',
168
+ 'privatliv',
169
+ 'seo',
170
+ 'avanceret',
171
+ 'licenser',
172
+ ];
160
173
 
161
174
  /** The site's own short lists, on a screen of their own under "Lister" (#lister), with those the standard blocks
162
175
  * bring ("Udtalelser") and the apps (their lists.ts). */
@@ -1,20 +1,20 @@
1
- Pictogrammers Free License
2
- --------------------------
3
-
4
- This icon collection is released as free, open source, and GPL friendly by
5
- the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
6
- for commercial projects, open source projects, or anything really.
7
-
8
- # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
9
- Some of the icons are redistributed under the Apache 2.0 license. All other
10
- icons are either redistributed under their respective licenses or are
11
- distributed under the Apache 2.0 license.
12
-
13
- # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
14
- All web and desktop fonts are distributed under the Apache 2.0 license. Web
15
- and desktop fonts contain some icons that are redistributed under the Apache
16
- 2.0 license. All other icons are either redistributed under their respective
17
- licenses or are distributed under the Apache 2.0 license.
18
-
19
- # Code: MIT (https://opensource.org/licenses/MIT)
1
+ Pictogrammers Free License
2
+ --------------------------
3
+
4
+ This icon collection is released as free, open source, and GPL friendly by
5
+ the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
6
+ for commercial projects, open source projects, or anything really.
7
+
8
+ # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
9
+ Some of the icons are redistributed under the Apache 2.0 license. All other
10
+ icons are either redistributed under their respective licenses or are
11
+ distributed under the Apache 2.0 license.
12
+
13
+ # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
14
+ All web and desktop fonts are distributed under the Apache 2.0 license. Web
15
+ and desktop fonts contain some icons that are redistributed under the Apache
16
+ 2.0 license. All other icons are either redistributed under their respective
17
+ licenses or are distributed under the Apache 2.0 license.
18
+
19
+ # Code: MIT (https://opensource.org/licenses/MIT)
20
20
  The MIT license applies to all non-font and non-icon files.
@@ -1,20 +1,20 @@
1
- Pictogrammers Free License
2
- --------------------------
3
-
4
- This icon collection is released as free, open source, and GPL friendly by
5
- the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
6
- for commercial projects, open source projects, or anything really.
7
-
8
- # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
9
- Some of the icons are redistributed under the Apache 2.0 license. All other
10
- icons are either redistributed under their respective licenses or are
11
- distributed under the Apache 2.0 license.
12
-
13
- # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
14
- All web and desktop fonts are distributed under the Apache 2.0 license. Web
15
- and desktop fonts contain some icons that are redistributed under the Apache
16
- 2.0 license. All other icons are either redistributed under their respective
17
- licenses or are distributed under the Apache 2.0 license.
18
-
19
- # Code: MIT (https://opensource.org/licenses/MIT)
1
+ Pictogrammers Free License
2
+ --------------------------
3
+
4
+ This icon collection is released as free, open source, and GPL friendly by
5
+ the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
6
+ for commercial projects, open source projects, or anything really.
7
+
8
+ # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
9
+ Some of the icons are redistributed under the Apache 2.0 license. All other
10
+ icons are either redistributed under their respective licenses or are
11
+ distributed under the Apache 2.0 license.
12
+
13
+ # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
14
+ All web and desktop fonts are distributed under the Apache 2.0 license. Web
15
+ and desktop fonts contain some icons that are redistributed under the Apache
16
+ 2.0 license. All other icons are either redistributed under their respective
17
+ licenses or are distributed under the Apache 2.0 license.
18
+
19
+ # Code: MIT (https://opensource.org/licenses/MIT)
20
20
  The MIT license applies to all non-font and non-icon files.
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2023 Phosphor Icons
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Phosphor Icons
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
@@ -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
+ }