@morphemecms/cms 0.13.0 → 0.14.0-next-20261107093528

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 (166) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/defaults/editor.ts +3 -14
  3. package/icons/lucide/dots-three-vertical.svg +1 -0
  4. package/icons/material-symbols/dots-three-vertical.svg +1 -0
  5. package/icons/phosphor/dots-three-vertical.svg +1 -0
  6. package/lib/appChanges.d.mts +16 -0
  7. package/lib/appChanges.mjs +20 -0
  8. package/lib/appChanges.test.mjs +30 -0
  9. package/lib/apps.d.mts +2 -2
  10. package/lib/apps.mjs +13 -7
  11. package/lib/appsFile.d.mts +20 -0
  12. package/lib/appsFile.mjs +57 -0
  13. package/lib/appsFile.test.mjs +53 -0
  14. package/lib/build-settings.mjs +1 -1
  15. package/lib/changelog.d.mts +7 -0
  16. package/lib/changelog.mjs +16 -5
  17. package/lib/footerClearance.d.mts +1 -0
  18. package/lib/footerClearance.mjs +9 -0
  19. package/lib/footerClearance.test.mjs +24 -0
  20. package/lib/footerLinks.d.mts +12 -0
  21. package/lib/footerLinks.mjs +26 -0
  22. package/lib/footerLinks.test.mjs +32 -0
  23. package/lib/google-result.test.mjs +40 -9
  24. package/lib/menu.mjs +1 -1
  25. package/lib/menu.test.mjs +9 -8
  26. package/lib/releaseNotes.d.mts +136 -0
  27. package/lib/releaseNotes.mjs +249 -0
  28. package/lib/releaseNotes.test.mjs +267 -0
  29. package/lib/serviceFeed.d.mts +2 -0
  30. package/lib/serviceFeed.mjs +13 -0
  31. package/lib/serviceFeed.test.mjs +53 -0
  32. package/lib/serviceStatus.d.mts +72 -0
  33. package/lib/serviceStatus.mjs +143 -0
  34. package/lib/serviceStatus.test.mjs +136 -0
  35. package/lib/strayDividers.d.mts +6 -0
  36. package/lib/strayDividers.mjs +15 -0
  37. package/lib/strayDividers.test.mjs +25 -0
  38. package/package.json +5 -3
  39. package/release-notes/i18n/da-DK/pending/apps-turn-off.json +6 -0
  40. package/release-notes/i18n/da-DK/pending/apps-turn-off.webp +0 -0
  41. package/release-notes/i18n/da-DK/pending/developer-menu.json +5 -0
  42. package/release-notes/i18n/da-DK/pending/dynamic-lists-page.json +5 -0
  43. package/release-notes/i18n/da-DK/pending/editor-footer.json +6 -0
  44. package/release-notes/i18n/da-DK/pending/editor-footer.webp +0 -0
  45. package/release-notes/i18n/da-DK/pending/editor-toolbar-dock.json +5 -0
  46. package/release-notes/i18n/da-DK/pending/google-preview-closer.json +6 -0
  47. package/release-notes/i18n/da-DK/pending/google-preview-closer.webp +0 -0
  48. package/release-notes/i18n/da-DK/pending/morpheme-connection-page.json +5 -0
  49. package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.json +6 -0
  50. package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.webp +0 -0
  51. package/release-notes/i18n/en-US/pending/apps-turn-off.json +6 -0
  52. package/release-notes/i18n/en-US/pending/apps-turn-off.webp +0 -0
  53. package/release-notes/i18n/en-US/pending/developer-menu.json +5 -0
  54. package/release-notes/i18n/en-US/pending/dynamic-lists-page.json +5 -0
  55. package/release-notes/i18n/en-US/pending/editor-footer.json +6 -0
  56. package/release-notes/i18n/en-US/pending/editor-footer.webp +0 -0
  57. package/release-notes/i18n/en-US/pending/editor-toolbar-dock.json +5 -0
  58. package/release-notes/i18n/en-US/pending/google-preview-closer.json +6 -0
  59. package/release-notes/i18n/en-US/pending/google-preview-closer.webp +0 -0
  60. package/release-notes/i18n/en-US/pending/morpheme-connection-page.json +5 -0
  61. package/release-notes/i18n/en-US/pending/quiet-lamps-wander.json +6 -0
  62. package/release-notes/i18n/en-US/pending/quiet-lamps-wander.webp +0 -0
  63. package/scripts/app-screenshots.mjs +4 -4
  64. package/scripts/build-settings-problems.mjs +1 -1
  65. package/scripts/built-site.mjs +1 -1
  66. package/scripts/check-content.mjs +1 -1
  67. package/scripts/check-release-notes.mjs +12 -0
  68. package/scripts/check-worker.mjs +1 -1
  69. package/scripts/editor-screens.mjs +32 -16
  70. package/scripts/release-notes-pending.mjs +46 -0
  71. package/scripts/release-notes.mjs +39 -0
  72. package/site/apps.ts +10 -4
  73. package/site/consent.ts +3 -3
  74. package/site/defineSite.mjs +1 -1
  75. package/site/icons.d.mts +4 -0
  76. package/site/icons.mjs +2 -0
  77. package/site/pageColors.ts +3 -3
  78. package/site/siteCsp.d.mts +1 -1
  79. package/site/siteCsp.mjs +3 -2
  80. package/src/ActionParts.tsx +1 -1
  81. package/src/Advanced.tsx +4 -4
  82. package/src/App.tsx +22 -17
  83. package/src/AppEnabledSwitch.tsx +21 -0
  84. package/src/AppList.tsx +47 -19
  85. package/src/AppSettingsTab.tsx +9 -5
  86. package/src/BlockParts.tsx +7 -7
  87. package/src/BlockSettings.tsx +3 -3
  88. package/src/CmsPage.tsx +11 -10
  89. package/src/Colors.tsx +2 -2
  90. package/src/CommunicationParts.tsx +18 -2
  91. package/src/ContainmentParts.tsx +1 -1
  92. package/src/DataForm.tsx +1 -0
  93. package/src/EditorFooter.tsx +19 -9
  94. package/src/EntryForm.tsx +1 -0
  95. package/src/FooterCopyright.tsx +7 -0
  96. package/src/FooterDivider.tsx +5 -0
  97. package/src/FooterLink.tsx +18 -0
  98. package/src/FooterLinks.tsx +34 -0
  99. package/src/Library.tsx +21 -10
  100. package/src/MainDrawerParts.tsx +1 -1
  101. package/src/MarkdownEditor.tsx +1 -1
  102. package/src/ModalSheetSample.tsx +1 -1
  103. package/src/MorphemeConnection.tsx +30 -0
  104. package/src/MorphemeLogo.tsx +9 -0
  105. package/src/NavigationParts.tsx +1 -1
  106. package/src/NavigationRestParts.tsx +1 -1
  107. package/src/Parts.tsx +9 -9
  108. package/src/PartsShown.tsx +1 -1
  109. package/src/PickerParts.tsx +1 -1
  110. package/src/ReleaseNoteCard.tsx +17 -0
  111. package/src/ReleaseNotes.tsx +52 -0
  112. package/src/Screen.tsx +21 -12
  113. package/src/SearchPreview.tsx +37 -14
  114. package/src/SelectionParts.tsx +1 -1
  115. package/src/ServiceStatus.tsx +59 -0
  116. package/src/SideColumn.tsx +2 -2
  117. package/src/StoreConnectNotice.tsx +1 -1
  118. package/src/StoreConnectedRow.tsx +20 -14
  119. package/src/StoreStatus.tsx +2 -10
  120. package/src/ThemeStoreScreen.tsx +1 -2
  121. package/src/Tokens.tsx +2 -2
  122. package/src/apps.ts +16 -14
  123. package/src/buildSettings.ts +1 -1
  124. package/src/cmsView.d.mts +1 -0
  125. package/src/cmsView.mjs +4 -2
  126. package/src/cmsView.test.mjs +6 -0
  127. package/src/editor.css +13 -2
  128. package/src/examples.ts +2 -2
  129. package/src/fabPresence.tsx +24 -0
  130. package/src/googleResult.d.mts +12 -1
  131. package/src/googleResult.mjs +19 -2
  132. package/src/i18n/da-DK/apps.ts +5 -1
  133. package/src/i18n/da-DK/cms.ts +3 -0
  134. package/src/i18n/da-DK/common.ts +3 -2
  135. package/src/i18n/da-DK/footer.ts +26 -0
  136. package/src/i18n/da-DK/index.ts +2 -0
  137. package/src/i18n/da-DK/parts.ts +5 -0
  138. package/src/i18n/en-US/advanced.ts +1 -1
  139. package/src/i18n/en-US/apps.ts +6 -1
  140. package/src/i18n/en-US/cms.ts +5 -2
  141. package/src/i18n/en-US/common.ts +6 -4
  142. package/src/i18n/en-US/footer.ts +24 -0
  143. package/src/i18n/en-US/index.ts +2 -0
  144. package/src/i18n/en-US/parts.ts +8 -3
  145. package/src/menuChildren.ts +6 -14
  146. package/src/movedRoutes.mjs +12 -8
  147. package/src/movedRoutes.test.mjs +13 -10
  148. package/src/r2Caching.ts +1 -1
  149. package/src/registry.tsx +2 -2
  150. package/src/schema.ts +1 -1
  151. package/src/site.ts +2 -2
  152. package/src/statusReport.ts +24 -0
  153. package/src/store.ts +4 -8
  154. package/src/themeUi.ts +7 -7
  155. package/src/ui.tsx +4 -2
  156. package/src/useFooterClearance.ts +30 -0
  157. package/src/useStrayDividers.ts +24 -0
  158. package/theme/components/AppSlot.astro +4 -4
  159. package/theme/styles/global.css +10 -0
  160. package/theme/styles/ui.css +13 -0
  161. package/theme/ui/Badge.tsx +9 -3
  162. package/worker/cms-update.js +49 -12
  163. package/worker/index.js +10 -4
  164. package/worker/plan-status.js +1 -1
  165. package/worker/r2-status.js +1 -1
  166. package/worker/service-status.js +18 -0
@@ -0,0 +1,136 @@
1
+ // lib/serviceStatus.mjs tests; run with: node --test lib/serviceStatus.test.mjs
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { PROVIDERS, providersFor, stateOf, worst, providerFrom, reportFrom } from './serviceStatus.mjs';
5
+
6
+ test('stateOf maps statuses', () => {
7
+ assert.equal(stateOf('operational'), 'ok');
8
+ assert.equal(stateOf('degraded_performance'), 'minor');
9
+ assert.equal(stateOf('under_maintenance'), 'minor');
10
+ assert.equal(stateOf('partial_outage'), 'major');
11
+ assert.equal(stateOf('major_outage'), 'major');
12
+ assert.equal(stateOf('foo'), 'unknown');
13
+ assert.equal(stateOf(undefined), 'unknown');
14
+ });
15
+
16
+ test('worst picks most severe', () => {
17
+ assert.equal(worst([]), 'ok');
18
+ assert.equal(worst(['ok', 'unknown']), 'unknown');
19
+ assert.equal(worst(['unknown', 'minor']), 'minor');
20
+ assert.equal(worst(['minor', 'major', 'ok']), 'major');
21
+ });
22
+
23
+ test('providerFrom with null summary', () => {
24
+ const p = providerFrom(PROVIDERS[0], null);
25
+ assert.equal(p.state, 'unknown');
26
+ assert.equal(p.components.length, 3);
27
+ for (const c of p.components) {
28
+ assert.equal(c.status, 'unknown');
29
+ assert.equal(c.state, 'unknown');
30
+ }
31
+ });
32
+
33
+ test('providerFrom with partial feed', () => {
34
+ const feed = { components: [{ id: '8l4ygp009s5s', name: 'x', status: 'major_outage' }] };
35
+ const p = providerFrom(PROVIDERS[0], feed);
36
+ assert.equal(p.state, 'major');
37
+ assert.equal(p.components[0].name, 'Git Operations');
38
+ assert.equal(p.components[0].status, 'major_outage');
39
+ assert.equal(p.components[0].state, 'major');
40
+ assert.equal(p.components[1].status, 'unknown');
41
+ assert.equal(p.components[2].status, 'unknown');
42
+ });
43
+
44
+ test('providerFrom ignores extra components', () => {
45
+ const s = {
46
+ components: [
47
+ { id: '57srcl8zcn7c', name: 'Workers', status: 'operational' },
48
+ { id: 'pg21w9xb991r', name: 'Workers Assets', status: 'operational' },
49
+ { id: 'vcpsk0w6g86k', name: 'Workers Builds', status: 'operational' },
50
+ { id: 'tmh50tx2nprs', name: 'Workers KV', status: 'operational' },
51
+ { id: 'z0yd3sxqjqdm', name: 'D1', status: 'operational' },
52
+ { id: 'hb7g5sq2zz0h', name: 'R2', status: 'operational' },
53
+ { id: 'other', name: 'Other', status: 'major_outage' },
54
+ ],
55
+ };
56
+ const p = providerFrom(PROVIDERS[1], s);
57
+ assert.equal(p.state, 'ok');
58
+ assert.equal(p.components.length, 6);
59
+ });
60
+
61
+ test('providerFrom maps unknown status to unknown', () => {
62
+ const feed = { components: [{ id: '8l4ygp009s5s', status: 'weird' }] };
63
+ const p = providerFrom(PROVIDERS[0], feed);
64
+ assert.equal(p.components[0].status, 'unknown');
65
+ assert.equal(p.components[0].state, 'unknown');
66
+ });
67
+
68
+ test('reportFrom with all nulls', () => {
69
+ const r = reportFrom({ github: null, cloudflare: null });
70
+ assert.equal(r.state, 'unknown');
71
+ assert.deepEqual(
72
+ r.providers.map((p) => p.id),
73
+ ['github', 'cloudflare'],
74
+ );
75
+ });
76
+
77
+ test('reportFrom with minor degradation', () => {
78
+ const github = {
79
+ components: [
80
+ { id: '8l4ygp009s5s', name: 'Git Operations', status: 'operational' },
81
+ { id: 'brv1bkgrwx7q', name: 'API Requests', status: 'operational' },
82
+ { id: '4230lsnqdsld', name: 'Webhooks', status: 'operational' },
83
+ ],
84
+ };
85
+ const cloudflare = {
86
+ components: [
87
+ { id: '57srcl8zcn7c', name: 'Workers', status: 'operational' },
88
+ { id: 'pg21w9xb991r', name: 'Workers Assets', status: 'operational' },
89
+ { id: 'vcpsk0w6g86k', name: 'Workers Builds', status: 'operational' },
90
+ { id: 'tmh50tx2nprs', name: 'Workers KV', status: 'operational' },
91
+ { id: 'z0yd3sxqjqdm', name: 'D1', status: 'operational' },
92
+ { id: 'hb7g5sq2zz0h', name: 'R2', status: 'degraded_performance' },
93
+ ],
94
+ };
95
+ const r = reportFrom({ github, cloudflare });
96
+ assert.equal(r.state, 'minor');
97
+ });
98
+
99
+ const cloudflareIds = (report) => report.providers.find((p) => p.id === 'cloudflare').components.map((c) => c.name);
100
+ const feed = (id, status) => ({ components: [{ id, status }] });
101
+ const ALL = ['NOT_FOUND', 'PUSH', 'FILES', 'CONSENT'];
102
+
103
+ test('without FILES there is no R2, and an R2 outage in the feed does not count', () => {
104
+ const report = reportFrom({ github: null, cloudflare: feed('hb7g5sq2zz0h', 'major_outage') }, providersFor(['NOT_FOUND', 'CONSENT']));
105
+ assert.equal(cloudflareIds(report).includes('R2'), false);
106
+ assert.notEqual(report.providers.find((p) => p.id === 'cloudflare').state, 'major');
107
+ });
108
+
109
+ test('with FILES R2 is there, and its outage counts', () => {
110
+ const report = reportFrom({ github: null, cloudflare: feed('hb7g5sq2zz0h', 'major_outage') }, providersFor(ALL));
111
+ assert.equal(cloudflareIds(report).includes('R2'), true);
112
+ assert.equal(report.providers.find((p) => p.id === 'cloudflare').state, 'major');
113
+ });
114
+
115
+ test('D1 counts only with CONSENT', () => {
116
+ assert.equal(cloudflareIds(reportFrom({}, providersFor(['FILES', 'PUSH']))).includes('D1'), false);
117
+ assert.equal(cloudflareIds(reportFrom({}, providersFor(['CONSENT']))).includes('D1'), true);
118
+ });
119
+
120
+ test('Workers KV counts with NOT_FOUND or PUSH, and with neither it does not', () => {
121
+ assert.equal(cloudflareIds(reportFrom({}, providersFor(['NOT_FOUND']))).includes('Workers KV'), true);
122
+ assert.equal(cloudflareIds(reportFrom({}, providersFor(['PUSH']))).includes('Workers KV'), true);
123
+ assert.equal(cloudflareIds(reportFrom({}, providersFor(['FILES', 'CONSENT']))).includes('Workers KV'), false);
124
+ });
125
+
126
+ test('with no binding the three Workers components stay, and GitHub does not change', () => {
127
+ const report = reportFrom({}, providersFor([]));
128
+ assert.deepEqual(cloudflareIds(report), ['Workers', 'Workers Assets', 'Workers Builds']);
129
+ assert.equal(report.providers.find((p) => p.id === 'github').components.length, PROVIDERS[0].components.length);
130
+ });
131
+
132
+ test('reportFrom without a list reports every component, and a report has no binding names', () => {
133
+ const report = reportFrom({});
134
+ assert.equal(cloudflareIds(report).length, 6);
135
+ assert.equal(JSON.stringify(report).includes('needs'), false);
136
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @param {number[]} rows The vertical position of each item in the row, in order, items and dividers alternating, starting
3
+ * and ending with an item (the same value for items on the same line).
4
+ * @returns {number[]} The indexes of the dividers (the odd ones) that are first or last on their line.
5
+ */
6
+ export function strayDividers(rows: number[]): number[];
@@ -0,0 +1,15 @@
1
+ // Which dividers in a wrapping row of items stand at the start or the end of a line (and so separate nothing).
2
+ // Exposes strayDividers. No imports.
3
+
4
+ /**
5
+ * @param {number[]} rows The vertical position of each item in the row, in order, items and dividers alternating, starting
6
+ * and ending with an item (the same value for items on the same line).
7
+ * @returns {number[]} The indexes of the dividers (the odd ones) that are first or last on their line.
8
+ */
9
+ export function strayDividers(rows) {
10
+ const stray = [];
11
+ for (let i = 1; i < rows.length - 1; i += 2) {
12
+ if (rows[i] !== rows[i - 1] || rows[i] !== rows[i + 1]) stray.push(i);
13
+ }
14
+ return stray;
15
+ }
@@ -0,0 +1,25 @@
1
+ // Tests for lib/strayDividers.mjs: a divider is hidden when it is first or last on its line.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { strayDividers } from './strayDividers.mjs';
5
+
6
+ test('on one line no divider is stray', () => {
7
+ assert.deepEqual(strayDividers([0, 0, 0, 0, 0]), []);
8
+ });
9
+
10
+ test('a divider that ends a line is stray', () => {
11
+ assert.deepEqual(strayDividers([0, 0, 0, 0, 24]), [3]);
12
+ });
13
+
14
+ test('a divider that starts a line is stray', () => {
15
+ assert.deepEqual(strayDividers([0, 0, 0, 24, 24]), [3]);
16
+ });
17
+
18
+ test('a divider that ends one line and one that starts the next are both stray', () => {
19
+ assert.deepEqual(strayDividers([0, 0, 0, 0, 24, 24, 24]), [3]);
20
+ assert.deepEqual(strayDividers([0, 0, 0, 24, 24, 24, 48]), [3, 5]);
21
+ });
22
+
23
+ test('a row of one item has none', () => {
24
+ assert.deepEqual(strayDividers([0]), []);
25
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.13.0",
3
+ "version": "0.14.0-next-20261107093528",
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",
@@ -24,6 +24,7 @@
24
24
  "icons",
25
25
  "lib",
26
26
  "pages",
27
+ "release-notes",
27
28
  "scripts",
28
29
  "!scripts/starter.mjs",
29
30
  "!scripts/check-starter.mjs",
@@ -40,7 +41,7 @@
40
41
  },
41
42
  "scripts": {
42
43
  "build": "node scripts/demo-build.mjs",
43
- "check": "npm run format:check && npm run check:language && npm test && npm run check:types && npm run build && npm run check:site-types && npm run check:editor",
44
+ "check": "npm run format:check && npm run check:language && npm run check:release-notes && npm test && npm run check:types && npm run build && npm run check:site-types && npm run check:editor",
44
45
  "check:site-types": "node scripts/check-site-types.mjs",
45
46
  "check:types": "tsc -p tsconfig.types.json --noEmit",
46
47
  "check:editor": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:editor",
@@ -52,7 +53,8 @@
52
53
  "format": "prettier --write .",
53
54
  "format:check": "prettier --check .",
54
55
  "check:language": "node scripts/check-language.mjs",
55
- "release:version": "changeset version && node scripts/starter.mjs",
56
+ "check:release-notes": "node scripts/check-release-notes.mjs",
57
+ "release:version": "changeset version && node scripts/release-notes.mjs && node scripts/starter.mjs",
56
58
  "prepack": "node scripts/types.mjs write",
57
59
  "postpack": "node scripts/types.mjs clean"
58
60
  },
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "apps-turn-off",
3
+ "kind": "card",
4
+ "title": "Slå en app fra uden at miste den",
5
+ "text": "Nogle gange vil du sætte en app på pause uden at skulle sætte den op igen bagefter. Åbn appen under Apps, og slå Aktiveret fra lige under beskrivelsen. Indstillingerne bliver liggende, så du kan slå den til igen, når du vil."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "developer-menu",
3
+ "kind": "line",
4
+ "text": "Avanceret hedder nu Udvikler under Indstillinger, med Dele samt Caching og builds; gamle links virker stadig."
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "dynamic-lists-page",
3
+ "kind": "line",
4
+ "text": "Lister hedder nu Dynamiske lister, én række i menuen, der åbner en side med alle dine lister."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "editor-footer",
3
+ "kind": "card",
4
+ "title": "Se om GitHub og Cloudflare virker",
5
+ "text": "Når noget ikke vil gemme eller udgive, vil du gerne vide, om problemet er din hjemmeside eller de tjenester, den kører på. Rul ned til bunden af editoren, og tryk på Driftsstatus. Den tjekker kun de tjenester, din hjemmeside bruger."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "editor-toolbar-dock",
3
+ "kind": "line",
4
+ "text": "På en telefon er værktøjslinjen til tekst slankere og holder samme afstand til kanterne."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "google-preview-closer",
3
+ "kind": "card",
4
+ "title": "En Google-forhåndsvisning, der ligner Google",
5
+ "text": "Du vil gerne se, hvordan en side ser ud på Google, før du udgiver den. Åbn en side eller et indlæg, og se Google-forhåndsvisningen. Den ligner nu Google på både telefon og computer, og dit vigtigste søgeord står med fed."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "morpheme-connection-page",
3
+ "kind": "line",
4
+ "text": "Din Forbindelse til Appstore ligger nu på siden Morpheme under Indstillinger."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "quiet-lamps-wander",
3
+ "kind": "card",
4
+ "title": "Se, hvad der er nyt, med billeder",
5
+ "text": "Opdateringer skal føles som en venlig hilsen, ikke som en udviklerlog. Åbn siden Morpheme, så ser du hver nyhed med et billede og et par almindelige ord. Alt, der er ændret i din version, står lige der."
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "apps-turn-off",
3
+ "kind": "card",
4
+ "title": "Turn an app off without losing it",
5
+ "text": "Sometimes you want to pause an app without setting it up again later. Open the app under Apps and turn off Enabled, right below its description. Its settings stay, so you can turn it on again whenever you like."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "developer-menu",
3
+ "kind": "line",
4
+ "text": "Advanced is now Developer under Settings, with Parts and Caching and builds; old links still work."
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "dynamic-lists-page",
3
+ "kind": "line",
4
+ "text": "Lists is now Dynamic lists, one menu row that opens a page with all your lists."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "editor-footer",
3
+ "kind": "card",
4
+ "title": "See if GitHub and Cloudflare are working",
5
+ "text": "When something won't save or publish, you want to know if the problem is your site or the services it runs on. Scroll to the bottom of the editor and tap System status. It checks only the services your site uses."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "editor-toolbar-dock",
3
+ "kind": "line",
4
+ "text": "On a phone, the text toolbar is slimmer and keeps an even gap to the edges."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "google-preview-closer",
3
+ "kind": "card",
4
+ "title": "A Google preview that looks like Google",
5
+ "text": "You want to see how a page looks in Google before you publish it. Open a page or post and look at its Google preview. It now matches Google on a phone and a computer, with your main keyword in bold."
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "morpheme-connection-page",
3
+ "kind": "line",
4
+ "text": "Your Store connection now sits on the Morpheme page under Settings."
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "quiet-lamps-wander",
3
+ "kind": "card",
4
+ "title": "See what's new, with pictures",
5
+ "text": "Updates should feel like a friendly hello, not a developer log. Open the Morpheme page and you see each new thing with a picture and a few plain words. Everything that changed in your version is right there."
6
+ }
@@ -1,7 +1,7 @@
1
1
  // Takes the pictures each app is shown with on its page and in the app store (AppAbout in
2
2
  // packages/editor/src/AppList.tsx): the few its index.ts picks to give an idea of it (`screenshots`, AppShot in
3
3
  // packages/editor/src/store.ts), as a theme's are, on a phone, in the standard theme with blue colours and with made-up
4
- // content only, never the site's: its blocks and kinds as "Udseende" › "Dele" › "Apps" draws them with the examples,
4
+ // content only, never the site's: its blocks and kinds as "Developer" › "Parts" › "Apps" draws them with the examples,
5
5
  // its lists, its settings (with the made-up `values` of the picture) and its own page in the editor, into
6
6
  // src/apps/<app>/screenshots/<n>-<what>.jpg (shotFile). The site is built in a folder of its own with the theme and the
7
7
  // colours (src/data/theme.json and colors.json as they were after), and the editor has every app installed, with GitHub
@@ -30,8 +30,8 @@ const BLUE = { source: '#1a73e8', variant: 'tonalSpot', contrast: 'standard' };
30
30
 
31
31
  const ids = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(APPS).filter((id) => existsSync(join(APPS, id, 'index.ts')));
32
32
 
33
- // The pictures each app picks, read as store-release.mjs does, in their order. `part`: its drawing under "Dele" ›
34
- // "Blokke" or the list's screen (`parts`: #appearance/parts/blocks, #appearance/parts/list/<list>) by the part's name; `editor`: a screen of the editor (`at`: from the top of what is
33
+ // The pictures each app picks, read as store-release.mjs does, in their order. `part`: its drawing under "Parts" ›
34
+ // "Blokke" or the list's screen (`parts`: #developer/parts/blocks, #developer/parts/list/<list>) by the part's name; `editor`: a screen of the editor (`at`: from the top of what is
35
35
  // there). And each app's settings as the pictures show them: those it starts with, and the made-up ones of its picture.
36
36
  const vite = await createServer({
37
37
  configFile: false,
@@ -136,7 +136,7 @@ try {
136
136
  for (const [i, shot] of shots[id].entries()) {
137
137
  const options = { path: join(folder, shot.name), type: 'jpeg', quality: 80 };
138
138
  if (shot.part) {
139
- await go(`appearance/parts/${shot.parts}`);
139
+ await go(`developer/parts/${shot.parts}`);
140
140
  // Tall enough for the whole drawing, under the screen's top bar.
141
141
  await editor.setViewportSize({ width: phone.width, height: 1600 });
142
142
  const drawing = editor.locator(`section[data-part="${shot.part}"] > :last-child`).first();
@@ -1,4 +1,4 @@
1
- // The check of the build settings file (src/data/build.json, Indstillinger › Avanceret): an object with only the
1
+ // The check of the build settings file (src/data/build.json, Settings › Developer › Caching and builds): an object with only the
2
2
  // boolean keys contentLane and paidBuilds. Exposes buildSettingsProblems; used by check-content.mjs. No dependencies.
3
3
 
4
4
  const KEYS = ['contentLane', 'paidBuilds'];
@@ -138,7 +138,7 @@ export const STORE_STATES = {
138
138
  * store (a key of STORE_STATES). */
139
139
  export async function openEditor(page, site, texts = {}, store = 'button') {
140
140
  let state = STORE_STATES[store];
141
- // R2 caching under "Avanceret": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
141
+ // R2 caching under "Caching and builds": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
142
142
  await page.route(`${site}/api/r2?**`, (r) =>
143
143
  r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
144
144
  );
@@ -185,7 +185,7 @@ try {
185
185
  tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean);
186
186
  } catch {}
187
187
  result.problems.push(...casingProblems(tracked));
188
- // The build settings the editor writes under "Avanceret": only boolean contentLane and paidBuilds; a missing file passes.
188
+ // The build settings the editor writes under "Caching and builds": only boolean contentLane and paidBuilds; a missing file passes.
189
189
  result.problems.push(
190
190
  ...buildSettingsProblems(BUILD_SETTINGS_PATH, existsSync(BUILD_SETTINGS_PATH) ? readFileSync(BUILD_SETTINGS_PATH, 'utf8') : undefined),
191
191
  );
@@ -0,0 +1,12 @@
1
+ // Checks the pending release notes: each note has its twin in every locale with the same kind, an id that matches its file name,
2
+ // and the fields and screenshot its kind needs (a card: title, text and a screenshot of at most 200 KB; a line: text only).
3
+ // Run by npm run check:release-notes; usage: node scripts/check-release-notes.mjs [root]. Exits 1 and prints each problem.
4
+ import { problemsOf } from '../lib/releaseNotes.mjs';
5
+ import { readPending, repoRoot } from './release-notes-pending.mjs';
6
+
7
+ const problems = problemsOf(readPending(repoRoot(process.argv[2])));
8
+ if (problems.length > 0) {
9
+ for (const problem of problems) console.error(problem);
10
+ process.exit(1);
11
+ }
12
+ console.log('Release notes: no problems.');
@@ -472,7 +472,7 @@ try {
472
472
  await run('r2-fejler', failing);
473
473
  await settle();
474
474
 
475
- // R2 caching under "Indstillinger" › "Avanceret" (r2Caching.ts): the Worker's settings have the line and its bucket, and
475
+ // R2 caching under Settings › Developer › Caching and builds (r2Caching.ts): the Worker's settings have the line and its bucket, and
476
476
  // with it on or off they are settings wrangler reads, with the bucket bound as FILES or no bucket; on and off again,
477
477
  // they are as they were. And /api/r2 (packages/editor/worker/r2-status.js) says what Cloudflare says of the bucket, only to someone who
478
478
  // can save, so the switch is turned on only when Cloudflare has it.
@@ -4,7 +4,7 @@
4
4
  // packages/editor/src/icon.tsx), there or on one of the built site's pages. An icon drawn from Material Symbols instead
5
5
  // of the icon pack (data-icon-fallback) is only listed, with where it is: it does not fail.
6
6
  // The screens are found as the editor's own links: from the start screen (the drawer's links are in the page also while it is
7
- // closed) and "Dele", #appearance/parts, "Temaer", #appearance/themes, "Søgemaskineoptimering", #seo, "Appstore", #settings/appstore, and "Apps", #apps, which the menu no longer links to), and
7
+ // closed) and "Parts", #developer/parts, "Temaer", #appearance/themes, "Søgemaskineoptimering", #seo, "Appstore", #settings/appstore, and "Apps", #apps, which the menu no longer links to), and
8
8
  // from each screen it opens, two steps down. GitHub is answered from the files in this repository, so it needs no login
9
9
  // and changes nothing. It runs on the built site: npm run build && npm run check:screens (or on another
10
10
  // folder with a built site, its path after the command: packages/editor/scripts/check-themes.mjs).
@@ -68,10 +68,10 @@ for (const [width, height] of [
68
68
  });
69
69
  await openEditor(page, site);
70
70
 
71
- // From the start screen (the drawer's links), and from "Dele", "Temaer", "Søgemaskineoptimering" and "Appstore"
71
+ // From the start screen (the drawer's links), and from "Parts", "Temaer", "Søgemaskineoptimering" and "Appstore"
72
72
  // (the apps' pages and their developers'), whose screens are a step further down than the others. "Apps" (the list of
73
73
  // the apps installed) has no row in the menu, so it is a start of its own.
74
- const starts = ['', 'appearance/parts', 'appearance/themes', 'seo', 'settings/appstore', 'apps'];
74
+ const starts = ['', 'developer/parts', 'appearance/themes', 'seo', 'settings/appstore', 'apps'];
75
75
  const seen = new Set(starts);
76
76
  let next = starts;
77
77
  for (let depth = 0; depth <= 2 && next.length; depth++) {
@@ -104,27 +104,43 @@ for (const [width, height] of [
104
104
  await page.close();
105
105
  }
106
106
 
107
- // "Appstore" in the three states of the site's key to the store (STORE_STATES): what each shows, and a picture of it in
107
+ // "Appstore" and "Morpheme" in the three states of the site's key to the store (STORE_STATES): what each shows, and a picture of it in
108
108
  // SCREENS_DIR (the temporary folder's morpheme-screens by default).
109
109
  const shots = process.env.SCREENS_DIR || join(tmpdir(), 'morpheme-screens');
110
110
  mkdirSync(shots, { recursive: true });
111
111
  const keyStates = {
112
- open: { shows: ['Hjemmesiden er ikke forbundet til Morpheme', 'Forbind til Morpheme'], hides: ['Afbryd forbindelsen til Morpheme'] },
113
- readOnly: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
114
- button: { shows: ['Forbundet til Morpheme', 'site_sample', 'Afbryd forbindelsen til Morpheme'], hides: ['Forbind til Morpheme'] },
112
+ open: {
113
+ appstore: {
114
+ shows: ['Hjemmesiden er ikke forbundet til Morpheme', 'Forbind til Morpheme'],
115
+ hides: ['Afbryd forbindelsen til Morpheme'],
116
+ },
117
+ morpheme: { shows: ['Forbind til Morpheme'], hides: ['Afbryd forbindelsen til Morpheme'] },
118
+ },
119
+ readOnly: {
120
+ appstore: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
121
+ morpheme: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
122
+ },
123
+ button: {
124
+ appstore: { shows: [], hides: ['Forbundet til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
125
+ morpheme: { shows: ['Forbundet til Morpheme', 'site_sample', 'Afbryd forbindelsen til Morpheme'], hides: ['Forbind til Morpheme'] },
126
+ },
115
127
  };
116
- for (const [state, { shows, hides }] of Object.entries(keyStates)) {
128
+ // The screens that show the key: where each is (its address after the "#", CMS_ROUTE in CmsPage.tsx for "Morpheme").
129
+ const keyScreens = { appstore: 'settings/appstore', morpheme: 'morpheme' };
130
+ for (const [state, checks] of Object.entries(keyStates)) {
117
131
  const page = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'da-DK' });
118
132
  page.on('pageerror', (e) => problems.push(`Appstore (${state}): ${e.message}`));
119
133
  await openEditor(page, site, {}, state);
120
- await page.evaluate(() => (location.hash = 'settings/appstore'));
121
- await page.waitForTimeout(500);
122
- const text = await page.evaluate(() => document.body.innerText);
123
- for (const x of shows) if (!text.includes(x)) problems.push(`Appstore (${state}): "${x}" mangler`);
124
- for (const x of hides) if (text.includes(x)) problems.push(`Appstore (${state}): "${x}" skal ikke vises`);
125
- const file = join(shots, `appstore-${state}.png`);
126
- await page.screenshot({ path: file });
127
- console.log(`Appstore (${state}): ${file}`);
134
+ for (const [screen, { shows, hides }] of Object.entries(checks)) {
135
+ await page.evaluate((hash) => (location.hash = hash), keyScreens[screen]);
136
+ await page.waitForTimeout(500);
137
+ const text = await page.evaluate(() => document.body.innerText);
138
+ for (const x of shows) if (!text.includes(x)) problems.push(`${screen} (${state}): "${x}" mangler`);
139
+ for (const x of hides) if (text.includes(x)) problems.push(`${screen} (${state}): "${x}" skal ikke vises`);
140
+ const file = join(shots, `${screen}-${state}.png`);
141
+ await page.screenshot({ path: file });
142
+ console.log(`${screen} (${state}): ${file}`);
143
+ }
128
144
  await page.close();
129
145
  }
130
146
 
@@ -0,0 +1,46 @@
1
+ // Reads the pending release notes of a repository (release-notes/i18n/<locale>/pending/<id>.json, and <id>.webp for a card)
2
+ // into the Pending object of lib/releaseNotes.mjs, and resolves the repository root. Exposes readPending(root) and repoRoot(arg).
3
+ // Depends on node:fs, node:path, node:url and webpSize of lib/releaseNotes.mjs; shared by release-notes.mjs and check-release-notes.mjs.
4
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { webpSize } from '../lib/releaseNotes.mjs';
8
+
9
+ /**
10
+ * Reads every pending note of every locale, with the size of its screenshot in bytes and in pixels.
11
+ * @param {string} root the repository root
12
+ * @returns {Record<string, Record<string, { note: object, imageBytes: number | null, imageSize: { width: number, height: number } | null }>>} locale -> id -> entry; {} when there are no folders
13
+ */
14
+ export function readPending(root) {
15
+ const i18n = join(root, 'release-notes', 'i18n');
16
+ const pending = {};
17
+ if (!existsSync(i18n)) return pending;
18
+ for (const locale of readdirSync(i18n, { withFileTypes: true })) {
19
+ const dir = join(i18n, locale.name, 'pending');
20
+ if (!locale.isDirectory() || !existsSync(dir)) continue;
21
+ pending[locale.name] = {};
22
+ const files = readdirSync(dir)
23
+ .filter((name) => name.endsWith('.json'))
24
+ .sort();
25
+ for (const file of files) {
26
+ const id = file.slice(0, -'.json'.length);
27
+ const image = join(dir, `${id}.webp`);
28
+ const bytes = existsSync(image) ? readFileSync(image) : null;
29
+ pending[locale.name][id] = {
30
+ note: JSON.parse(readFileSync(join(dir, file), 'utf8')),
31
+ imageBytes: bytes ? bytes.length : null,
32
+ imageSize: bytes ? webpSize(bytes) : null,
33
+ };
34
+ }
35
+ }
36
+ return pending;
37
+ }
38
+
39
+ /**
40
+ * The repository root: the argument when given, else the folder above this script.
41
+ * @param {string | undefined} arg
42
+ * @returns {string}
43
+ */
44
+ export function repoRoot(arg) {
45
+ return resolve(arg ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
46
+ }
@@ -0,0 +1,39 @@
1
+ // Release step: moves the pending release notes into release-notes/i18n/<locale>/<version>.json, their screenshots into
2
+ // <version>/<id>.webp, the version from package.json. Run by release:version after changeset version.
3
+ // Usage: node scripts/release-notes.mjs [root]; exits 1 on a problem or an existing file.
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { imagePath, problemsOf, released } from '../lib/releaseNotes.mjs';
7
+ import { readPending, repoRoot } from './release-notes-pending.mjs';
8
+
9
+ const root = repoRoot(process.argv[2]);
10
+ const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
11
+ const pending = readPending(root);
12
+
13
+ const problems = problemsOf(pending);
14
+ if (problems.length > 0) {
15
+ for (const problem of problems) console.error(problem);
16
+ process.exit(1);
17
+ }
18
+
19
+ const result = released(pending, version);
20
+ const targets = Object.keys(result).map((locale) => join(root, 'release-notes', 'i18n', locale, `${version}.json`));
21
+ const existing = targets.filter((target) => existsSync(target));
22
+ if (existing.length > 0) {
23
+ for (const target of existing) console.error(`${target} exists; a released version is never changed`);
24
+ process.exit(1);
25
+ }
26
+
27
+ for (const [locale, { file, images }] of Object.entries(result)) {
28
+ const localeDir = join(root, 'release-notes', 'i18n', locale);
29
+ writeFileSync(join(localeDir, `${version}.json`), `${JSON.stringify(file, null, 2)}\n`);
30
+ for (const id of images) {
31
+ const target = join(root, imagePath(version, locale, id));
32
+ mkdirSync(dirname(target), { recursive: true });
33
+ renameSync(join(localeDir, 'pending', `${id}.webp`), target);
34
+ }
35
+ for (const id of Object.keys(pending[locale])) {
36
+ rmSync(join(localeDir, 'pending', `${id}.json`));
37
+ }
38
+ }
39
+ console.log(`Release notes for ${version}: ${Object.keys(result).join(', ') || 'none'}.`);