@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.
- package/CHANGELOG.md +18 -0
- package/defaults/editor.ts +3 -14
- package/icons/lucide/dots-three-vertical.svg +1 -0
- package/icons/material-symbols/dots-three-vertical.svg +1 -0
- package/icons/phosphor/dots-three-vertical.svg +1 -0
- package/lib/appChanges.d.mts +16 -0
- package/lib/appChanges.mjs +20 -0
- package/lib/appChanges.test.mjs +30 -0
- package/lib/apps.d.mts +2 -2
- package/lib/apps.mjs +13 -7
- package/lib/appsFile.d.mts +20 -0
- package/lib/appsFile.mjs +57 -0
- package/lib/appsFile.test.mjs +53 -0
- package/lib/build-settings.mjs +1 -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/google-result.test.mjs +40 -9
- package/lib/menu.mjs +1 -1
- package/lib/menu.test.mjs +9 -8
- 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/pending/apps-turn-off.json +6 -0
- package/release-notes/i18n/da-DK/pending/apps-turn-off.webp +0 -0
- package/release-notes/i18n/da-DK/pending/developer-menu.json +5 -0
- package/release-notes/i18n/da-DK/pending/dynamic-lists-page.json +5 -0
- package/release-notes/i18n/da-DK/pending/editor-footer.json +6 -0
- package/release-notes/i18n/da-DK/pending/editor-footer.webp +0 -0
- package/release-notes/i18n/da-DK/pending/editor-toolbar-dock.json +5 -0
- package/release-notes/i18n/da-DK/pending/google-preview-closer.json +6 -0
- package/release-notes/i18n/da-DK/pending/google-preview-closer.webp +0 -0
- package/release-notes/i18n/da-DK/pending/morpheme-connection-page.json +5 -0
- package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.json +6 -0
- package/release-notes/i18n/da-DK/pending/quiet-lamps-wander.webp +0 -0
- package/release-notes/i18n/en-US/pending/apps-turn-off.json +6 -0
- package/release-notes/i18n/en-US/pending/apps-turn-off.webp +0 -0
- package/release-notes/i18n/en-US/pending/developer-menu.json +5 -0
- package/release-notes/i18n/en-US/pending/dynamic-lists-page.json +5 -0
- package/release-notes/i18n/en-US/pending/editor-footer.json +6 -0
- package/release-notes/i18n/en-US/pending/editor-footer.webp +0 -0
- package/release-notes/i18n/en-US/pending/editor-toolbar-dock.json +5 -0
- package/release-notes/i18n/en-US/pending/google-preview-closer.json +6 -0
- package/release-notes/i18n/en-US/pending/google-preview-closer.webp +0 -0
- package/release-notes/i18n/en-US/pending/morpheme-connection-page.json +5 -0
- package/release-notes/i18n/en-US/pending/quiet-lamps-wander.json +6 -0
- package/release-notes/i18n/en-US/pending/quiet-lamps-wander.webp +0 -0
- package/scripts/app-screenshots.mjs +4 -4
- package/scripts/build-settings-problems.mjs +1 -1
- package/scripts/built-site.mjs +1 -1
- package/scripts/check-content.mjs +1 -1
- package/scripts/check-release-notes.mjs +12 -0
- package/scripts/check-worker.mjs +1 -1
- package/scripts/editor-screens.mjs +32 -16
- package/scripts/release-notes-pending.mjs +46 -0
- package/scripts/release-notes.mjs +39 -0
- package/site/apps.ts +10 -4
- package/site/consent.ts +3 -3
- package/site/defineSite.mjs +1 -1
- package/site/icons.d.mts +4 -0
- package/site/icons.mjs +2 -0
- package/site/pageColors.ts +3 -3
- package/site/siteCsp.d.mts +1 -1
- package/site/siteCsp.mjs +3 -2
- package/src/ActionParts.tsx +1 -1
- package/src/Advanced.tsx +4 -4
- package/src/App.tsx +22 -17
- package/src/AppEnabledSwitch.tsx +21 -0
- package/src/AppList.tsx +47 -19
- package/src/AppSettingsTab.tsx +9 -5
- package/src/BlockParts.tsx +7 -7
- package/src/BlockSettings.tsx +3 -3
- package/src/CmsPage.tsx +11 -10
- package/src/Colors.tsx +2 -2
- package/src/CommunicationParts.tsx +18 -2
- package/src/ContainmentParts.tsx +1 -1
- package/src/DataForm.tsx +1 -0
- package/src/EditorFooter.tsx +19 -9
- package/src/EntryForm.tsx +1 -0
- 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/Library.tsx +21 -10
- package/src/MainDrawerParts.tsx +1 -1
- package/src/MarkdownEditor.tsx +1 -1
- package/src/ModalSheetSample.tsx +1 -1
- package/src/MorphemeConnection.tsx +30 -0
- package/src/MorphemeLogo.tsx +9 -0
- package/src/NavigationParts.tsx +1 -1
- package/src/NavigationRestParts.tsx +1 -1
- package/src/Parts.tsx +9 -9
- package/src/PartsShown.tsx +1 -1
- package/src/PickerParts.tsx +1 -1
- package/src/ReleaseNoteCard.tsx +17 -0
- package/src/ReleaseNotes.tsx +52 -0
- package/src/Screen.tsx +21 -12
- package/src/SearchPreview.tsx +37 -14
- package/src/SelectionParts.tsx +1 -1
- package/src/ServiceStatus.tsx +59 -0
- package/src/SideColumn.tsx +2 -2
- package/src/StoreConnectNotice.tsx +1 -1
- package/src/StoreConnectedRow.tsx +20 -14
- package/src/StoreStatus.tsx +2 -10
- package/src/ThemeStoreScreen.tsx +1 -2
- package/src/Tokens.tsx +2 -2
- package/src/apps.ts +16 -14
- package/src/buildSettings.ts +1 -1
- package/src/cmsView.d.mts +1 -0
- package/src/cmsView.mjs +4 -2
- package/src/cmsView.test.mjs +6 -0
- package/src/editor.css +13 -2
- package/src/examples.ts +2 -2
- package/src/fabPresence.tsx +24 -0
- package/src/googleResult.d.mts +12 -1
- package/src/googleResult.mjs +19 -2
- package/src/i18n/da-DK/apps.ts +5 -1
- package/src/i18n/da-DK/cms.ts +3 -0
- package/src/i18n/da-DK/common.ts +3 -2
- 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/advanced.ts +1 -1
- package/src/i18n/en-US/apps.ts +6 -1
- package/src/i18n/en-US/cms.ts +5 -2
- package/src/i18n/en-US/common.ts +6 -4
- 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 +8 -3
- package/src/menuChildren.ts +6 -14
- package/src/movedRoutes.mjs +12 -8
- package/src/movedRoutes.test.mjs +13 -10
- package/src/r2Caching.ts +1 -1
- package/src/registry.tsx +2 -2
- package/src/schema.ts +1 -1
- package/src/site.ts +2 -2
- package/src/statusReport.ts +24 -0
- package/src/store.ts +4 -8
- package/src/themeUi.ts +7 -7
- package/src/ui.tsx +4 -2
- package/src/useFooterClearance.ts +30 -0
- package/src/useStrayDividers.ts +24 -0
- package/theme/components/AppSlot.astro +4 -4
- 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 +10 -4
- package/worker/plan-status.js +1 -1
- package/worker/r2-status.js +1 -1
- 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('
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
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
|
-
['
|
|
239
|
+
['developer'],
|
|
239
240
|
);
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
test('Analytics, SEO, the SEO rating and
|
|
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
|
+
}
|