@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
package/lib/menu.test.mjs CHANGED
@@ -83,7 +83,8 @@ test('the app store is Find apps', () => {
83
83
  });
84
84
 
85
85
  test('a screen of its own is itself, and the start screen is none', () => {
86
- assert.equal(currentPart('advanced', known), 'advanced');
86
+ assert.equal(currentPart('developer', known), 'developer');
87
+ assert.equal(currentPart('developer/parts/editor', known), 'developer');
87
88
  assert.equal(currentPart('licenses', known), 'licenses');
88
89
  assert.equal(currentPart('', known), '');
89
90
  });
@@ -159,7 +160,7 @@ const menuGroups = [
159
160
  { id: 'company', title: 'Business', ids: ['logo', 'company'] },
160
161
  { id: 'appearance', title: 'Appearance', ids: ['theme-store', 'header', 'footer', 'colors'] },
161
162
  { id: 'privacy', title: 'Privacy', ids: ['cookie-banner', 'cookies'] },
162
- { id: 'settings', title: 'Settings', ids: ['advanced'] },
163
+ { id: 'settings', title: 'Settings', ids: ['developer'] },
163
164
  ];
164
165
  const hrefs = {
165
166
  pages: '#list/pages',
@@ -182,7 +183,7 @@ const hrefs = {
182
183
  logo: '#company/logo',
183
184
  'cookie-banner': '#privacy/cookie-banner',
184
185
  cookies: '#list/cookies',
185
- advanced: '#advanced',
186
+ developer: '#developer',
186
187
  };
187
188
  const menu = menuTree(
188
189
  menuGroups,
@@ -228,22 +229,22 @@ test('Redirects has All and Not found as children', () => {
228
229
  );
229
230
  });
230
231
 
231
- test('Find apps is the first item under Apps, then the apps installed, and Advanced the only one under Settings', () => {
232
+ test('Find apps is the first item under Apps, then the apps installed, and Developer the only one under Settings', () => {
232
233
  assert.deepEqual(
233
234
  menu[3].items.map((i) => i.id),
234
235
  ['appstore', 'calendly'],
235
236
  );
236
237
  assert.deepEqual(
237
238
  menu[7].items.map((i) => i.id),
238
- ['advanced'],
239
+ ['developer'],
239
240
  );
240
241
  });
241
242
 
242
- test('Analytics, SEO, the SEO rating and Licenses are not in the menu', () => {
243
- for (const gone of ['analytics', 'seo', 'rating', 'licenses']) {
243
+ test('Analytics, SEO, the SEO rating, Licenses and System status are not in the menu', () => {
244
+ for (const gone of ['analytics', 'seo', 'rating', 'licenses', 'status']) {
244
245
  assert.equal(allIds.includes(gone), false);
245
246
  }
246
- for (const gone of ['#analytics', '#seo', '#seo/rating', '#licenses']) {
247
+ for (const gone of ['#analytics', '#seo', '#seo/rating', '#licenses', '#status']) {
247
248
  assert.equal(allHrefs.includes(gone), false);
248
249
  }
249
250
  });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The size in pixels of a WebP image, read from its header (a lossy VP8, a lossless VP8L or an extended VP8X chunk).
3
+ * @param {Uint8Array} bytes
4
+ * @returns {ImageSize | null} null when `bytes` is not a WebP of one of these kinds
5
+ */
6
+ export function webpSize(bytes: Uint8Array): ImageSize | null;
7
+ /**
8
+ * The problems in the pending notes: a note missing in a locale, a kind that differs between locales, and each problem of
9
+ * entryProblems. Names the file and the locale of each problem.
10
+ * @param {Pending} pending
11
+ * @returns {string[]} empty when all good
12
+ */
13
+ export function problemsOf(pending: Pending): string[];
14
+ /**
15
+ * The pending notes of each locale as one release of `version`: the notes as they are in their files, sorted by id, and the
16
+ * ids of the cards that have a screenshot. A locale without notes is left out.
17
+ * @param {Pending} pending
18
+ * @param {string} version
19
+ * @returns {Record<string, { file: ReleasedNotes, images: string[] }>}
20
+ */
21
+ export function released(pending: Pending, version: string): Record<string, {
22
+ file: ReleasedNotes;
23
+ images: string[];
24
+ }>;
25
+ /**
26
+ * The repository path of a released screenshot.
27
+ * @param {string} version
28
+ * @param {string} locale
29
+ * @param {string} id
30
+ * @returns {string}
31
+ */
32
+ export function imagePath(version: string, locale: string, id: string): string;
33
+ /**
34
+ * The notes for a locale, else the en-US notes, else undefined.
35
+ * @param {Record<string, ReleaseNote[]> | undefined} notes
36
+ * @param {string} locale
37
+ * @returns {ReleaseNote[] | undefined}
38
+ */
39
+ export function notesFor(notes: Record<string, ReleaseNote[]> | undefined, locale: string): ReleaseNote[] | undefined;
40
+ /**
41
+ * The kept versions an update brought, newest first. After a finished update to the running version: the versions after
42
+ * `from` up to the running one. Else: the running version alone, when it is kept.
43
+ * @param {{ from?: string, to: string, state: string } | null} update
44
+ * @param {string} running
45
+ * @param {string[]} kept versions that have notes in KV
46
+ * @returns {string[]}
47
+ */
48
+ export function installedVersions(update: {
49
+ from?: string;
50
+ to: string;
51
+ state: string;
52
+ } | null, running: string, kept: string[]): string[];
53
+ /**
54
+ * The release notes and screenshots of `version` from an unzipped npm tar. Notes: package/release-notes/i18n/<locale>/<version>.json
55
+ * per locale. Screenshots: one per card, at imagePath(). A missing or unreadable notes file leaves its locale out; a missing
56
+ * screenshot is left out. Returns undefined when no locale has notes.
57
+ * @param {Uint8Array} tar
58
+ * @param {string} version
59
+ * @returns {{ notes: Record<string, ReleaseNote[]>, images: { locale: string, id: string, bytes: Uint8Array }[] } | undefined}
60
+ */
61
+ export function notesFromTar(tar: Uint8Array, version: string): {
62
+ notes: Record<string, ReleaseNote[]>;
63
+ images: {
64
+ locale: string;
65
+ id: string;
66
+ bytes: Uint8Array;
67
+ }[];
68
+ } | undefined;
69
+ /**
70
+ * The address the editor loads a card's screenshot from (served by the Worker).
71
+ * @param {string} version
72
+ * @param {string} locale
73
+ * @param {string} id
74
+ * @returns {string}
75
+ */
76
+ export function imageUrl(version: string, locale: string, id: string): string;
77
+ /**
78
+ * The parts of a screenshot address, or null when the path is not one or a part is not valid: version x.y.z, locale en-US or
79
+ * da-DK, id of a-z, 0-9 and "-". Keeps any other text out of the KV key.
80
+ * @param {string} pathname
81
+ * @returns {{ version: string, locale: string, id: string } | null}
82
+ */
83
+ export function parseImagePath(pathname: string): {
84
+ version: string;
85
+ locale: string;
86
+ id: string;
87
+ } | null;
88
+ /**
89
+ * The notes of a version with the screenshot address set on each card. Does not change `notes`; lines stay as they are.
90
+ * @param {string} version
91
+ * @param {Record<string, ReleaseNote[]> | undefined} notes locale -> notes
92
+ * @returns {Record<string, ReleaseNote[]> | undefined}
93
+ */
94
+ export function withImageUrls(version: string, notes: Record<string, ReleaseNote[]> | undefined): Record<string, ReleaseNote[]> | undefined;
95
+ /** @typedef {{ id: string, kind: 'card', title: string, text: string, image?: string } | { id: string, kind: 'line', text: string }} ReleaseNote */
96
+ /** @typedef {{ version: string, notes: ReleaseNote[] }} ReleasedNotes */
97
+ /** @typedef {{ width: number, height: number }} ImageSize */
98
+ /** @typedef {{ note: object, imageBytes: number | null, imageSize: ImageSize | null }} PendingEntry imageBytes: size of <id>.webp in bytes, null when there is none; imageSize: its pixels, null when it is not a WebP */
99
+ /** @typedef {Record<string, Record<string, PendingEntry>>} Pending locale -> id -> entry */
100
+ /** The largest screenshot a card may have, in bytes. */
101
+ export const MAX_IMAGE_BYTES: number;
102
+ /** The width of a card's screenshot in pixels (375 CSS pixels at scale 2) and its greatest height (400 CSS pixels at scale 2). */
103
+ export const IMAGE_WIDTH: 750;
104
+ export const MAX_IMAGE_HEIGHT: 800;
105
+ export function imageKey(version: string, locale: string, id: string): string;
106
+ export type ReleaseNote = {
107
+ id: string;
108
+ kind: "card";
109
+ title: string;
110
+ text: string;
111
+ image?: string;
112
+ } | {
113
+ id: string;
114
+ kind: "line";
115
+ text: string;
116
+ };
117
+ export type ReleasedNotes = {
118
+ version: string;
119
+ notes: ReleaseNote[];
120
+ };
121
+ export type ImageSize = {
122
+ width: number;
123
+ height: number;
124
+ };
125
+ /**
126
+ * imageBytes: size of <id>.webp in bytes, null when there is none; imageSize: its pixels, null when it is not a WebP
127
+ */
128
+ export type PendingEntry = {
129
+ note: object;
130
+ imageBytes: number | null;
131
+ imageSize: ImageSize | null;
132
+ };
133
+ /**
134
+ * locale -> id -> entry
135
+ */
136
+ export type Pending = Record<string, Record<string, PendingEntry>>;
@@ -0,0 +1,249 @@
1
+ // Pure helpers for release notes: checks pending notes (cards with a screenshot of the right type and size, lines), groups them per version and locale,
2
+ // picks a locale's notes, lists the versions an update brought and reads a version's notes and screenshots from its tar.
3
+ // Also builds the Worker's screenshot address and KV key, parses the address and adds the address to a version's cards.
4
+ // Exposes MAX_IMAGE_BYTES, IMAGE_WIDTH, MAX_IMAGE_HEIGHT, webpSize, problemsOf, released, imagePath, notesFor, installedVersions, notesFromTar, imageUrl, imageKey,
5
+ // parseImagePath and withImageUrls. Depends on ./cms-version.mjs and ./changelog.mjs; no node: imports.
6
+ import { compareVersions } from './cms-version.mjs';
7
+ import { bytesFromTar, fileFromTar } from './changelog.mjs';
8
+
9
+ /** @typedef {{ id: string, kind: 'card', title: string, text: string, image?: string } | { id: string, kind: 'line', text: string }} ReleaseNote */
10
+ /** @typedef {{ version: string, notes: ReleaseNote[] }} ReleasedNotes */
11
+ /** @typedef {{ width: number, height: number }} ImageSize */
12
+ /** @typedef {{ note: object, imageBytes: number | null, imageSize: ImageSize | null }} PendingEntry imageBytes: size of <id>.webp in bytes, null when there is none; imageSize: its pixels, null when it is not a WebP */
13
+ /** @typedef {Record<string, Record<string, PendingEntry>>} Pending locale -> id -> entry */
14
+
15
+ /** The largest screenshot a card may have, in bytes. */
16
+ export const MAX_IMAGE_BYTES = 200 * 1024;
17
+
18
+ /** The width of a card's screenshot in pixels (375 CSS pixels at scale 2) and its greatest height (400 CSS pixels at scale 2). */
19
+ export const IMAGE_WIDTH = 750;
20
+ export const MAX_IMAGE_HEIGHT = 800;
21
+
22
+ /** The locales every pending note must exist in. */
23
+ const LOCALES = ['en-US', 'da-DK'];
24
+
25
+ /** The kinds of note. */
26
+ const KINDS = ['card', 'line'];
27
+
28
+ const pathOf = (locale, id) => `release-notes/i18n/${locale}/pending/${id}.json`;
29
+ const isFilled = (value) => typeof value === 'string' && value.trim() !== '';
30
+
31
+ /**
32
+ * The size in pixels of a WebP image, read from its header (a lossy VP8, a lossless VP8L or an extended VP8X chunk).
33
+ * @param {Uint8Array} bytes
34
+ * @returns {ImageSize | null} null when `bytes` is not a WebP of one of these kinds
35
+ */
36
+ export function webpSize(bytes) {
37
+ const text = (from, to) => String.fromCharCode(...bytes.subarray(from, to));
38
+ if (bytes.length < 30 || text(0, 4) !== 'RIFF' || text(8, 12) !== 'WEBP') return null;
39
+ const u16 = (at) => bytes[at] | (bytes[at + 1] << 8);
40
+ const u24 = (at) => u16(at) | (bytes[at + 2] << 16);
41
+ switch (text(12, 16)) {
42
+ case 'VP8 ':
43
+ if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) return null;
44
+ return { width: u16(26) & 0x3fff, height: u16(28) & 0x3fff };
45
+ case 'VP8L':
46
+ if (bytes[20] !== 0x2f) return null;
47
+ return {
48
+ width: 1 + (bytes[21] | ((bytes[22] & 0x3f) << 8)),
49
+ height: 1 + ((bytes[22] >> 6) | (bytes[23] << 2) | ((bytes[24] & 0x0f) << 10)),
50
+ };
51
+ case 'VP8X':
52
+ return { width: 1 + u24(24), height: 1 + u24(27) };
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The problems of one pending note: its id, its kind, its text and, for a card, its title and screenshot.
60
+ * @param {string} locale
61
+ * @param {string} id
62
+ * @param {PendingEntry} entry
63
+ * @returns {string[]}
64
+ */
65
+ function entryProblems(locale, id, { note, imageBytes, imageSize }) {
66
+ const path = pathOf(locale, id);
67
+ const fields = note ?? {};
68
+ const problems = [];
69
+ if (fields.id !== id) problems.push(`${path}: id "${fields.id}" does not match the file name`);
70
+ if (!KINDS.includes(fields.kind)) return [...problems, `${path}: kind must be "card" or "line"`];
71
+ if (!isFilled(fields.text)) problems.push(`${path}: text is empty`);
72
+ if (fields.kind === 'card') {
73
+ if (!isFilled(fields.title)) problems.push(`${path}: title is empty`);
74
+ if (imageBytes === null) problems.push(`${path}: a card needs the screenshot ${id}.webp`);
75
+ else {
76
+ const image = pathOf(locale, id).replace(/\.json$/, '.webp');
77
+ if (imageBytes > MAX_IMAGE_BYTES) problems.push(`${image}: screenshot is ${Math.ceil(imageBytes / 1024)} KB, the limit is 200 KB`);
78
+ if (!imageSize) problems.push(`${image}: screenshot is not a WebP file`);
79
+ else if (imageSize.width !== IMAGE_WIDTH || imageSize.height > MAX_IMAGE_HEIGHT)
80
+ problems.push(
81
+ `${image}: screenshot is ${imageSize.width}×${imageSize.height} px, it must be ${IMAGE_WIDTH} px wide and at most ${MAX_IMAGE_HEIGHT} px high`,
82
+ );
83
+ }
84
+ } else if (imageBytes !== null) {
85
+ problems.push(`${path}: a line has no screenshot (${id}.webp)`);
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ /**
91
+ * The problems in the pending notes: a note missing in a locale, a kind that differs between locales, and each problem of
92
+ * entryProblems. Names the file and the locale of each problem.
93
+ * @param {Pending} pending
94
+ * @returns {string[]} empty when all good
95
+ */
96
+ export function problemsOf(pending) {
97
+ const problems = [];
98
+ const locales = [...new Set([...LOCALES, ...Object.keys(pending)])];
99
+ const ids = [...new Set(locales.flatMap((locale) => Object.keys(pending[locale] ?? {})))];
100
+ for (const id of ids) {
101
+ const kinds = new Set();
102
+ for (const locale of locales) {
103
+ const entry = pending[locale]?.[id];
104
+ if (!entry) {
105
+ problems.push(`${pathOf(locale, id)} is missing`);
106
+ continue;
107
+ }
108
+ problems.push(...entryProblems(locale, id, entry));
109
+ kinds.add(entry.note?.kind);
110
+ }
111
+ if (kinds.size > 1) problems.push(`${id}: kind differs between locales`);
112
+ }
113
+ return problems;
114
+ }
115
+
116
+ /**
117
+ * The pending notes of each locale as one release of `version`: the notes as they are in their files, sorted by id, and the
118
+ * ids of the cards that have a screenshot. A locale without notes is left out.
119
+ * @param {Pending} pending
120
+ * @param {string} version
121
+ * @returns {Record<string, { file: ReleasedNotes, images: string[] }>}
122
+ */
123
+ export function released(pending, version) {
124
+ const result = {};
125
+ for (const [locale, entries] of Object.entries(pending)) {
126
+ const ids = Object.keys(entries).sort();
127
+ if (ids.length === 0) continue;
128
+ result[locale] = {
129
+ file: { version, notes: ids.map((id) => entries[id].note) },
130
+ images: ids.filter((id) => entries[id].note.kind === 'card'),
131
+ };
132
+ }
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * The repository path of a released screenshot.
138
+ * @param {string} version
139
+ * @param {string} locale
140
+ * @param {string} id
141
+ * @returns {string}
142
+ */
143
+ export function imagePath(version, locale, id) {
144
+ return `release-notes/i18n/${locale}/${version}/${id}.webp`;
145
+ }
146
+
147
+ /**
148
+ * The notes for a locale, else the en-US notes, else undefined.
149
+ * @param {Record<string, ReleaseNote[]> | undefined} notes
150
+ * @param {string} locale
151
+ * @returns {ReleaseNote[] | undefined}
152
+ */
153
+ export function notesFor(notes, locale) {
154
+ return notes?.[locale] ?? notes?.['en-US'];
155
+ }
156
+
157
+ /**
158
+ * The kept versions an update brought, newest first. After a finished update to the running version: the versions after
159
+ * `from` up to the running one. Else: the running version alone, when it is kept.
160
+ * @param {{ from?: string, to: string, state: string } | null} update
161
+ * @param {string} running
162
+ * @param {string[]} kept versions that have notes in KV
163
+ * @returns {string[]}
164
+ */
165
+ export function installedVersions(update, running, kept) {
166
+ const finished = update?.state === 'done' && update.to === running && typeof update.from === 'string';
167
+ const versions = finished
168
+ ? kept.filter((version) => compareVersions(version, update.from) === 1 && compareVersions(version, running) <= 0)
169
+ : kept.filter((version) => version === running);
170
+ return versions.sort((a, b) => compareVersions(b, a));
171
+ }
172
+
173
+ /**
174
+ * The release notes and screenshots of `version` from an unzipped npm tar. Notes: package/release-notes/i18n/<locale>/<version>.json
175
+ * per locale. Screenshots: one per card, at imagePath(). A missing or unreadable notes file leaves its locale out; a missing
176
+ * screenshot is left out. Returns undefined when no locale has notes.
177
+ * @param {Uint8Array} tar
178
+ * @param {string} version
179
+ * @returns {{ notes: Record<string, ReleaseNote[]>, images: { locale: string, id: string, bytes: Uint8Array }[] } | undefined}
180
+ */
181
+ export function notesFromTar(tar, version) {
182
+ const notes = {};
183
+ const images = [];
184
+ for (const locale of LOCALES) {
185
+ const text = fileFromTar(tar, `package/release-notes/i18n/${locale}/${version}.json`);
186
+ if (text === null) continue;
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(text);
190
+ } catch {
191
+ continue; // Bad JSON: this locale has no notes.
192
+ }
193
+ if (!Array.isArray(parsed?.notes)) continue;
194
+ notes[locale] = parsed.notes;
195
+ for (const note of parsed.notes) {
196
+ if (note?.kind !== 'card') continue;
197
+ const bytes = bytesFromTar(tar, `package/${imagePath(version, locale, note.id)}`);
198
+ if (bytes !== null) images.push({ locale, id: note.id, bytes });
199
+ }
200
+ }
201
+ return Object.keys(notes).length > 0 ? { notes, images } : undefined;
202
+ }
203
+
204
+ /**
205
+ * The address the editor loads a card's screenshot from (served by the Worker).
206
+ * @param {string} version
207
+ * @param {string} locale
208
+ * @param {string} id
209
+ * @returns {string}
210
+ */
211
+ export function imageUrl(version, locale, id) {
212
+ return `/api/cms/note-image/${version}/${locale}/${id}`;
213
+ }
214
+
215
+ /**
216
+ * The KV key of a card's screenshot.
217
+ * @param {string} version
218
+ * @param {string} locale
219
+ * @param {string} id
220
+ * @returns {string}
221
+ */
222
+ export const imageKey = (version, locale, id) => `cms:note-image:${version}:${locale}:${id}`;
223
+
224
+ /**
225
+ * The parts of a screenshot address, or null when the path is not one or a part is not valid: version x.y.z, locale en-US or
226
+ * da-DK, id of a-z, 0-9 and "-". Keeps any other text out of the KV key.
227
+ * @param {string} pathname
228
+ * @returns {{ version: string, locale: string, id: string } | null}
229
+ */
230
+ export function parseImagePath(pathname) {
231
+ const match = /^\/api\/cms\/note-image\/(\d+\.\d+\.\d+)\/(en-US|da-DK)\/([a-z0-9-]+)$/.exec(pathname);
232
+ return match ? { version: match[1], locale: match[2], id: match[3] } : null;
233
+ }
234
+
235
+ /**
236
+ * The notes of a version with the screenshot address set on each card. Does not change `notes`; lines stay as they are.
237
+ * @param {string} version
238
+ * @param {Record<string, ReleaseNote[]> | undefined} notes locale -> notes
239
+ * @returns {Record<string, ReleaseNote[]> | undefined}
240
+ */
241
+ export function withImageUrls(version, notes) {
242
+ if (!notes) return notes;
243
+ return Object.fromEntries(
244
+ Object.entries(notes).map(([locale, list]) => [
245
+ locale,
246
+ list.map((note) => (note?.kind === 'card' ? { ...note, image: imageUrl(version, locale, note.id) } : note)),
247
+ ]),
248
+ );
249
+ }