@morphemecms/cms 0.14.0-next-20261107080444 → 0.14.0

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 (74) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/lib/changelog.d.mts +7 -0
  3. package/lib/changelog.mjs +16 -5
  4. package/lib/footerClearance.d.mts +1 -0
  5. package/lib/footerClearance.mjs +9 -0
  6. package/lib/footerClearance.test.mjs +24 -0
  7. package/lib/footerLinks.d.mts +12 -0
  8. package/lib/footerLinks.mjs +26 -0
  9. package/lib/footerLinks.test.mjs +32 -0
  10. package/lib/menu.test.mjs +3 -3
  11. package/lib/releaseNotes.d.mts +136 -0
  12. package/lib/releaseNotes.mjs +249 -0
  13. package/lib/releaseNotes.test.mjs +267 -0
  14. package/lib/serviceFeed.d.mts +2 -0
  15. package/lib/serviceFeed.mjs +13 -0
  16. package/lib/serviceFeed.test.mjs +53 -0
  17. package/lib/serviceStatus.d.mts +72 -0
  18. package/lib/serviceStatus.mjs +143 -0
  19. package/lib/serviceStatus.test.mjs +136 -0
  20. package/lib/strayDividers.d.mts +6 -0
  21. package/lib/strayDividers.mjs +15 -0
  22. package/lib/strayDividers.test.mjs +25 -0
  23. package/package.json +5 -3
  24. package/release-notes/i18n/da-DK/0.14.0/apps-turn-off.webp +0 -0
  25. package/release-notes/i18n/da-DK/0.14.0/editor-footer.webp +0 -0
  26. package/release-notes/i18n/da-DK/0.14.0/google-preview-closer.webp +0 -0
  27. package/release-notes/i18n/da-DK/0.14.0/quiet-lamps-wander.webp +0 -0
  28. package/release-notes/i18n/da-DK/0.14.0.json +49 -0
  29. package/release-notes/i18n/en-US/0.14.0/apps-turn-off.webp +0 -0
  30. package/release-notes/i18n/en-US/0.14.0/editor-footer.webp +0 -0
  31. package/release-notes/i18n/en-US/0.14.0/google-preview-closer.webp +0 -0
  32. package/release-notes/i18n/en-US/0.14.0/quiet-lamps-wander.webp +0 -0
  33. package/release-notes/i18n/en-US/0.14.0.json +49 -0
  34. package/scripts/check-release-notes.mjs +12 -0
  35. package/scripts/release-notes-pending.mjs +46 -0
  36. package/scripts/release-notes.mjs +39 -0
  37. package/src/App.tsx +3 -0
  38. package/src/AppEnabledSwitch.tsx +21 -0
  39. package/src/AppList.tsx +3 -2
  40. package/src/AppSettingsTab.tsx +5 -10
  41. package/src/CmsPage.tsx +9 -11
  42. package/src/CommunicationParts.tsx +17 -1
  43. package/src/EditorFooter.tsx +19 -9
  44. package/src/FooterCopyright.tsx +7 -0
  45. package/src/FooterDivider.tsx +5 -0
  46. package/src/FooterLink.tsx +18 -0
  47. package/src/FooterLinks.tsx +34 -0
  48. package/src/ReleaseNoteCard.tsx +17 -0
  49. package/src/ReleaseNotes.tsx +52 -0
  50. package/src/Screen.tsx +21 -12
  51. package/src/ServiceStatus.tsx +59 -0
  52. package/src/cmsView.d.mts +1 -0
  53. package/src/cmsView.mjs +4 -2
  54. package/src/cmsView.test.mjs +6 -0
  55. package/src/fabPresence.tsx +24 -0
  56. package/src/i18n/da-DK/cms.ts +2 -0
  57. package/src/i18n/da-DK/footer.ts +26 -0
  58. package/src/i18n/da-DK/index.ts +2 -0
  59. package/src/i18n/da-DK/parts.ts +5 -0
  60. package/src/i18n/en-US/cms.ts +4 -2
  61. package/src/i18n/en-US/footer.ts +24 -0
  62. package/src/i18n/en-US/index.ts +2 -0
  63. package/src/i18n/en-US/parts.ts +5 -0
  64. package/src/statusReport.ts +24 -0
  65. package/src/ui.tsx +2 -0
  66. package/src/useFooterClearance.ts +30 -0
  67. package/src/useStrayDividers.ts +24 -0
  68. package/starter/package.json +1 -1
  69. package/theme/styles/global.css +10 -0
  70. package/theme/styles/ui.css +13 -0
  71. package/theme/ui/Badge.tsx +9 -3
  72. package/worker/cms-update.js +49 -12
  73. package/worker/index.js +4 -1
  74. package/worker/service-status.js +18 -0
package/CHANGELOG.md CHANGED
@@ -1,18 +1,22 @@
1
1
  # @morphemecms/cms
2
2
 
3
- ## 0.14.0-next-20261107080444
3
+ ## 0.14.0
4
4
 
5
5
  ### Minor Changes
6
6
 
7
7
  - a70c310: Apps can be turned off without uninstalling them; the app page shows the app's version.
8
+ - 26e7adc: The editor footer has links to support, system status, security reports, the privacy policy and the licenses, and a copyright line. System status shows whether GitHub and Cloudflare work for your site.
9
+ - 4c30155: The Morpheme page shows a friendly note for each change: why, how and what.
8
10
 
9
11
  ### Patch Changes
10
12
 
13
+ - da37368: The Enabled switch on an app's page sits right below the app's description.
11
14
  - dd625db: Editor menu: Advanced is now Developer, with Parts and Caching and builds in it. Parts moved from Appearance; old links still work.
12
15
  - 5cd4149: Editor menu: "Lists" is now "Dynamic lists", one row that opens the page with all lists (no fold-out).
13
16
  - 1fe1daa: The text toolbar is no taller than its tools on a phone, and it stays as far from the top of the screen as from the sides while you scroll.
14
17
  - b47a580: Google preview looks more like Google: a flat result on a phone, no card on a computer, a ringed site icon, https:// in the address, the ⋮ menu, a larger phone title, and the primary keyword in bold in the description.
15
18
  - 006c78b: The connection to Morpheme moves from the store screens to the Morpheme page under Settings, with the Morpheme logo.
19
+ - a4da24e: System status checks only the Cloudflare services your site uses, so a site without R2 or D1 is not marked down by them.
16
20
 
17
21
  ## 0.13.0
18
22
 
@@ -4,6 +4,13 @@
4
4
  * @returns {Promise<Uint8Array>}
5
5
  */
6
6
  export function gunzip(bytes: Uint8Array): Promise<Uint8Array>;
7
+ /**
8
+ * The bytes 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 {Uint8Array | null}
12
+ */
13
+ export function bytesFromTar(tar: Uint8Array, path: string): Uint8Array | null;
7
14
  /**
8
15
  * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
9
16
  * @param {Uint8Array} tar
package/lib/changelog.mjs CHANGED
@@ -1,5 +1,5 @@
1
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
2
+ // sections between two versions. Exposes gunzip, bytesFromTar, fileFromTar and sectionsBetween. Depends on ./cms-version.mjs; no node: imports
3
3
  // (runs in a Cloudflare Worker and in Node 18+).
4
4
  import { compareVersions } from './cms-version.mjs';
5
5
 
@@ -21,12 +21,12 @@ function field(header, start, end) {
21
21
  }
22
22
 
23
23
  /**
24
- * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
24
+ * The bytes of the entry named exactly `path` in a tar, or null when there is none.
25
25
  * @param {Uint8Array} tar
26
26
  * @param {string} path
27
- * @returns {string | null}
27
+ * @returns {Uint8Array | null}
28
28
  */
29
- export function fileFromTar(tar, path) {
29
+ export function bytesFromTar(tar, path) {
30
30
  let offset = 0;
31
31
  while (offset + 512 <= tar.length) {
32
32
  const header = tar.subarray(offset, offset + 512);
@@ -35,12 +35,23 @@ export function fileFromTar(tar, path) {
35
35
  const prefix = field(header, 345, 500);
36
36
  const size = parseInt(field(header, 124, 136).trim(), 8) || 0;
37
37
  const data = tar.subarray(offset + 512, offset + 512 + size);
38
- if ((prefix ? prefix + '/' + name : name) === path) return new TextDecoder().decode(data);
38
+ if ((prefix ? prefix + '/' + name : name) === path) return data;
39
39
  offset += 512 + Math.ceil(size / 512) * 512;
40
40
  }
41
41
  return null;
42
42
  }
43
43
 
44
+ /**
45
+ * The UTF-8 text of the entry named exactly `path` in a tar, or null when there is none.
46
+ * @param {Uint8Array} tar
47
+ * @param {string} path
48
+ * @returns {string | null}
49
+ */
50
+ export function fileFromTar(tar, path) {
51
+ const bytes = bytesFromTar(tar, path);
52
+ return bytes === null ? null : new TextDecoder().decode(bytes);
53
+ }
54
+
44
55
  /**
45
56
  * The changelog's sections for versions after `from` up to and including `to`, newest first, without empty ones.
46
57
  * @param {string} changelog
@@ -0,0 +1 @@
1
+ export function needsClearance(pageHeight: number, windowHeight: number, hasFab: boolean): boolean;
@@ -0,0 +1,9 @@
1
+ // Whether a screen needs clearance under its footer for the floating action button: only when it has one and its content
2
+ // pushes the footer below the window, so a short screen has its footer at the very bottom. Exposes needsClearance. No imports.
3
+
4
+ /**
5
+ * @param {number} pageHeight The page's height with no clearance added.
6
+ * @param {number} windowHeight The window's height.
7
+ * @param {boolean} hasFab Whether the screen shows a FAB.
8
+ */
9
+ export const needsClearance = (pageHeight, windowHeight, hasFab) => hasFab && pageHeight > windowHeight;
@@ -0,0 +1,24 @@
1
+ // Tests for lib/footerClearance.mjs: clearance under the footer only for a FAB, and only when the page is taller than the window.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { needsClearance } from './footerClearance.mjs';
5
+
6
+ test('a page that fits the window needs no clearance', () => {
7
+ assert.equal(needsClearance(600, 800, true), false);
8
+ });
9
+
10
+ test('a page exactly as tall as the window needs none', () => {
11
+ assert.equal(needsClearance(800, 800, true), false);
12
+ });
13
+
14
+ test('a tall page with a FAB needs clearance', () => {
15
+ assert.equal(needsClearance(801, 800, true), true);
16
+ });
17
+
18
+ test('a tall page without a FAB needs none', () => {
19
+ assert.equal(needsClearance(2000, 800, false), false);
20
+ });
21
+
22
+ test('a short page without a FAB needs none', () => {
23
+ assert.equal(needsClearance(600, 800, false), false);
24
+ });
@@ -0,0 +1,12 @@
1
+ /** @typedef {{ id: 'support' | 'status' | 'terms' | 'security' | 'privacy' | 'licenses', href: string | null }} FooterLink */
2
+ /** @type {FooterLink[]} A link with `href: null` is not shown yet. */
3
+ export const FOOTER_LINKS: FooterLink[];
4
+ export function copyright(year: number): string;
5
+ export function visibleLinks(links: FooterLink[]): (FooterLink & {
6
+ href: string;
7
+ })[];
8
+ export function isExternal(href: string): boolean;
9
+ export type FooterLink = {
10
+ id: "support" | "status" | "terms" | "security" | "privacy" | "licenses";
11
+ href: string | null;
12
+ };
@@ -0,0 +1,26 @@
1
+ // The links in the editor's footer, in the order they are shown, and its copyright line (EditorFooter.tsx).
2
+ // Exposes FOOTER_LINKS, copyright, visibleLinks and isExternal. No imports.
3
+
4
+ /** @typedef {{ id: 'support' | 'status' | 'terms' | 'security' | 'privacy' | 'licenses', href: string | null }} FooterLink */
5
+
6
+ /** @type {FooterLink[]} A link with `href: null` is not shown yet. */
7
+ export const FOOTER_LINKS = [
8
+ { id: 'support', href: 'https://github.com/larsadolfsen/morpheme-cms/issues' },
9
+ { id: 'status', href: '#status' },
10
+ { id: 'terms', href: null },
11
+ { id: 'security', href: 'https://github.com/larsadolfsen/morpheme-cms/security' },
12
+ { id: 'privacy', href: 'https://getmorpheme.com/privacy-policy/' },
13
+ { id: 'licenses', href: '#licenses' },
14
+ ];
15
+
16
+ /** The copyright line for a year (the caller passes the current one: this has no Date). @param {number} year */
17
+ export const copyright = (year) => `© ${year} Lars Adolfsen`;
18
+
19
+ /**
20
+ * @param {FooterLink[]} links
21
+ * @returns {(FooterLink & { href: string })[]}
22
+ */
23
+ export const visibleLinks = (links) => /** @type {(FooterLink & { href: string })[]} */ (links.filter((link) => link.href !== null));
24
+
25
+ /** Whether a link leaves the editor (opens in a new tab). @param {string} href */
26
+ export const isExternal = (href) => href.startsWith('https://');
@@ -0,0 +1,32 @@
1
+ // Tests for lib/footerLinks.mjs: which links the footer shows, and which of them leave the editor.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { copyright, FOOTER_LINKS, isExternal, visibleLinks } from './footerLinks.mjs';
5
+
6
+ test('while terms has no URL, the footer shows 5 links and no terms', () => {
7
+ const shown = visibleLinks(FOOTER_LINKS);
8
+ assert.equal(shown.length, 5);
9
+ assert.equal(
10
+ shown.some((l) => l.id === 'terms'),
11
+ false,
12
+ );
13
+ });
14
+
15
+ test('with a terms URL, the footer shows 6 links and terms is third', () => {
16
+ const links = FOOTER_LINKS.map((l) => (l.id === 'terms' ? { ...l, href: 'https://example.com/terms' } : l));
17
+ const shown = visibleLinks(links);
18
+ assert.equal(shown.length, 6);
19
+ assert.equal(shown[2].id, 'terms');
20
+ });
21
+
22
+ test('each external href starts with https://, and the others stay in the editor', () => {
23
+ for (const { href } of visibleLinks(FOOTER_LINKS)) {
24
+ assert.equal(isExternal(href), href.startsWith('https://'));
25
+ assert.equal(isExternal(href), !href.startsWith('#'));
26
+ }
27
+ });
28
+
29
+ test('the copyright line has the year it is given', () => {
30
+ assert.equal(copyright(2026), '© 2026 Lars Adolfsen');
31
+ assert.equal(copyright(2027), '© 2027 Lars Adolfsen');
32
+ });
package/lib/menu.test.mjs CHANGED
@@ -240,11 +240,11 @@ test('Find apps is the first item under Apps, then the apps installed, and Devel
240
240
  );
241
241
  });
242
242
 
243
- test('Analytics, SEO, the SEO rating and Licenses are not in the menu', () => {
244
- 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']) {
245
245
  assert.equal(allIds.includes(gone), false);
246
246
  }
247
- for (const gone of ['#analytics', '#seo', '#seo/rating', '#licenses']) {
247
+ for (const gone of ['#analytics', '#seo', '#seo/rating', '#licenses', '#status']) {
248
248
  assert.equal(allHrefs.includes(gone), false);
249
249
  }
250
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
+ }