@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.
- package/CHANGELOG.md +5 -1
- package/lib/changelog.d.mts +7 -0
- package/lib/changelog.mjs +16 -5
- package/lib/footerClearance.d.mts +1 -0
- package/lib/footerClearance.mjs +9 -0
- package/lib/footerClearance.test.mjs +24 -0
- package/lib/footerLinks.d.mts +12 -0
- package/lib/footerLinks.mjs +26 -0
- package/lib/footerLinks.test.mjs +32 -0
- package/lib/menu.test.mjs +3 -3
- package/lib/releaseNotes.d.mts +136 -0
- package/lib/releaseNotes.mjs +249 -0
- package/lib/releaseNotes.test.mjs +267 -0
- package/lib/serviceFeed.d.mts +2 -0
- package/lib/serviceFeed.mjs +13 -0
- package/lib/serviceFeed.test.mjs +53 -0
- package/lib/serviceStatus.d.mts +72 -0
- package/lib/serviceStatus.mjs +143 -0
- package/lib/serviceStatus.test.mjs +136 -0
- package/lib/strayDividers.d.mts +6 -0
- package/lib/strayDividers.mjs +15 -0
- package/lib/strayDividers.test.mjs +25 -0
- package/package.json +5 -3
- package/release-notes/i18n/da-DK/0.14.0/apps-turn-off.webp +0 -0
- package/release-notes/i18n/da-DK/0.14.0/editor-footer.webp +0 -0
- package/release-notes/i18n/da-DK/0.14.0/google-preview-closer.webp +0 -0
- package/release-notes/i18n/da-DK/0.14.0/quiet-lamps-wander.webp +0 -0
- package/release-notes/i18n/da-DK/0.14.0.json +49 -0
- package/release-notes/i18n/en-US/0.14.0/apps-turn-off.webp +0 -0
- package/release-notes/i18n/en-US/0.14.0/editor-footer.webp +0 -0
- package/release-notes/i18n/en-US/0.14.0/google-preview-closer.webp +0 -0
- package/release-notes/i18n/en-US/0.14.0/quiet-lamps-wander.webp +0 -0
- package/release-notes/i18n/en-US/0.14.0.json +49 -0
- package/scripts/check-release-notes.mjs +12 -0
- package/scripts/release-notes-pending.mjs +46 -0
- package/scripts/release-notes.mjs +39 -0
- package/src/App.tsx +3 -0
- package/src/AppEnabledSwitch.tsx +21 -0
- package/src/AppList.tsx +3 -2
- package/src/AppSettingsTab.tsx +5 -10
- package/src/CmsPage.tsx +9 -11
- package/src/CommunicationParts.tsx +17 -1
- package/src/EditorFooter.tsx +19 -9
- package/src/FooterCopyright.tsx +7 -0
- package/src/FooterDivider.tsx +5 -0
- package/src/FooterLink.tsx +18 -0
- package/src/FooterLinks.tsx +34 -0
- package/src/ReleaseNoteCard.tsx +17 -0
- package/src/ReleaseNotes.tsx +52 -0
- package/src/Screen.tsx +21 -12
- package/src/ServiceStatus.tsx +59 -0
- package/src/cmsView.d.mts +1 -0
- package/src/cmsView.mjs +4 -2
- package/src/cmsView.test.mjs +6 -0
- package/src/fabPresence.tsx +24 -0
- package/src/i18n/da-DK/cms.ts +2 -0
- package/src/i18n/da-DK/footer.ts +26 -0
- package/src/i18n/da-DK/index.ts +2 -0
- package/src/i18n/da-DK/parts.ts +5 -0
- package/src/i18n/en-US/cms.ts +4 -2
- package/src/i18n/en-US/footer.ts +24 -0
- package/src/i18n/en-US/index.ts +2 -0
- package/src/i18n/en-US/parts.ts +5 -0
- package/src/statusReport.ts +24 -0
- package/src/ui.tsx +2 -0
- package/src/useFooterClearance.ts +30 -0
- package/src/useStrayDividers.ts +24 -0
- package/starter/package.json +1 -1
- package/theme/styles/global.css +10 -0
- package/theme/styles/ui.css +13 -0
- package/theme/ui/Badge.tsx +9 -3
- package/worker/cms-update.js +49 -12
- package/worker/index.js +4 -1
- 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
|
|
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
|
|
package/lib/changelog.d.mts
CHANGED
|
@@ -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
|
|
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 {
|
|
27
|
+
* @returns {Uint8Array | null}
|
|
28
28
|
*/
|
|
29
|
-
export function
|
|
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
|
|
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
|
|
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
|
+
}
|