@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Tests for lib/releaseNotes.mjs: checks pending cards and lines, groups them per version, picks a locale's notes, lists
|
|
2
|
+
// installed versions and reads notes and screenshots from a tar.
|
|
3
|
+
// Run: node --test lib/releaseNotes.test.mjs
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import {
|
|
7
|
+
MAX_IMAGE_BYTES,
|
|
8
|
+
MAX_IMAGE_HEIGHT,
|
|
9
|
+
webpSize,
|
|
10
|
+
problemsOf,
|
|
11
|
+
released,
|
|
12
|
+
imagePath,
|
|
13
|
+
notesFor,
|
|
14
|
+
installedVersions,
|
|
15
|
+
notesFromTar,
|
|
16
|
+
imageUrl,
|
|
17
|
+
imageKey,
|
|
18
|
+
parseImagePath,
|
|
19
|
+
withImageUrls,
|
|
20
|
+
} from './releaseNotes.mjs';
|
|
21
|
+
|
|
22
|
+
const card = (id, overrides = {}) => ({ id, kind: 'card', title: `Title ${id}`, text: `Text ${id}`, ...overrides });
|
|
23
|
+
const line = (id, overrides = {}) => ({ id, kind: 'line', text: `Text ${id}`, ...overrides });
|
|
24
|
+
/** A pending entry: a card has a 1000-byte screenshot of 750×800 px by default, a line none. */
|
|
25
|
+
const entry = (
|
|
26
|
+
note,
|
|
27
|
+
imageBytes = note.kind === 'card' ? 1000 : null,
|
|
28
|
+
imageSize = imageBytes === null ? null : { width: 750, height: 800 },
|
|
29
|
+
) => ({
|
|
30
|
+
note,
|
|
31
|
+
imageBytes,
|
|
32
|
+
imageSize,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** The first 40 bytes of a WebP: RIFF header, the chunk's name and its start. */
|
|
36
|
+
const webp = (chunk, body) => {
|
|
37
|
+
const bytes = new Uint8Array(40);
|
|
38
|
+
bytes.set(Buffer.from('RIFF'), 0);
|
|
39
|
+
bytes.set(Buffer.from('WEBP'), 8);
|
|
40
|
+
bytes.set(Buffer.from(chunk), 12);
|
|
41
|
+
bytes.set(body.bytes, body.at);
|
|
42
|
+
return bytes;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
test('problemsOf is empty when every card and line has its twin, text and screenshot', () => {
|
|
46
|
+
const pending = {
|
|
47
|
+
'en-US': { a: entry(card('a')), b: entry(line('b')) },
|
|
48
|
+
'da-DK': { a: entry(card('a', { title: 'Titel' })), b: entry(line('b')) },
|
|
49
|
+
};
|
|
50
|
+
assert.deepEqual(problemsOf(pending), []);
|
|
51
|
+
});
|
|
52
|
+
test('problemsOf names a note missing in a locale', () => {
|
|
53
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a')) } });
|
|
54
|
+
assert.equal(problems.length, 1);
|
|
55
|
+
assert.match(problems[0], /\ba\b/);
|
|
56
|
+
assert.match(problems[0], /da-DK/);
|
|
57
|
+
});
|
|
58
|
+
test('problemsOf names a kind that differs between locales', () => {
|
|
59
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a')) }, 'da-DK': { a: entry(line('a')) } });
|
|
60
|
+
assert.equal(problems.length, 1);
|
|
61
|
+
assert.match(problems[0], /kind/);
|
|
62
|
+
});
|
|
63
|
+
test('problemsOf names a note whose id differs from its file name', () => {
|
|
64
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('b')) }, 'da-DK': { a: entry(card('a')) } });
|
|
65
|
+
assert.equal(problems.length, 1);
|
|
66
|
+
assert.match(problems[0], /\ba\b/);
|
|
67
|
+
assert.match(problems[0], /en-US/);
|
|
68
|
+
});
|
|
69
|
+
test('problemsOf names a note without a kind, such as the old shape', () => {
|
|
70
|
+
const old = { id: 'a', title: 'Title', why: 'Why', how: 'How', what: 'What' };
|
|
71
|
+
const problems = problemsOf({ 'en-US': { a: entry(old, null) }, 'da-DK': { a: entry(old, null) } });
|
|
72
|
+
assert.equal(problems.length, 2);
|
|
73
|
+
assert.match(problems[0], /kind/);
|
|
74
|
+
});
|
|
75
|
+
test('problemsOf names a card without a title', () => {
|
|
76
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a', { title: ' ' })) }, 'da-DK': { a: entry(card('a')) } });
|
|
77
|
+
assert.equal(problems.length, 1);
|
|
78
|
+
assert.match(problems[0], /title/);
|
|
79
|
+
});
|
|
80
|
+
test('problemsOf names a card or a line without text', () => {
|
|
81
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a', { text: '' })) }, 'da-DK': { a: entry(card('a')) } });
|
|
82
|
+
assert.match(problems[0], /text/);
|
|
83
|
+
const lineProblems = problemsOf({ 'en-US': { b: entry(line('b', { text: '' })) }, 'da-DK': { b: entry(line('b')) } });
|
|
84
|
+
assert.match(lineProblems[0], /text/);
|
|
85
|
+
});
|
|
86
|
+
test('problemsOf names a card without a screenshot', () => {
|
|
87
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a'), null) }, 'da-DK': { a: entry(card('a')) } });
|
|
88
|
+
assert.equal(problems.length, 1);
|
|
89
|
+
assert.match(problems[0], /screenshot/);
|
|
90
|
+
});
|
|
91
|
+
test('problemsOf names a screenshot above 200 KB and accepts one of exactly 200 KB', () => {
|
|
92
|
+
const big = problemsOf({ 'en-US': { a: entry(card('a'), MAX_IMAGE_BYTES + 1) }, 'da-DK': { a: entry(card('a')) } });
|
|
93
|
+
assert.equal(big.length, 1);
|
|
94
|
+
assert.match(big[0], /limit/);
|
|
95
|
+
assert.deepEqual(problemsOf({ 'en-US': { a: entry(card('a'), MAX_IMAGE_BYTES) }, 'da-DK': { a: entry(card('a')) } }), []);
|
|
96
|
+
});
|
|
97
|
+
test('problemsOf names a screenshot that is not 750 px wide or is higher than 800 px, with the file and the size', () => {
|
|
98
|
+
const twin = { 'da-DK': { a: entry(card('a')) } };
|
|
99
|
+
const wide = problemsOf({ 'en-US': { a: entry(card('a'), 1000, { width: 751, height: 800 }) }, ...twin });
|
|
100
|
+
assert.equal(wide.length, 1);
|
|
101
|
+
assert.match(wide[0], /en-US\/pending\/a\.webp/);
|
|
102
|
+
assert.match(wide[0], /751×800/);
|
|
103
|
+
const high = problemsOf({ 'en-US': { a: entry(card('a'), 1000, { width: 750, height: MAX_IMAGE_HEIGHT + 1 }) }, ...twin });
|
|
104
|
+
assert.match(high[0], /750×801/);
|
|
105
|
+
assert.deepEqual(problemsOf({ 'en-US': { a: entry(card('a'), 1000, { width: 750, height: 120 }) }, ...twin }), []);
|
|
106
|
+
});
|
|
107
|
+
test('problemsOf names a screenshot that is not a WebP', () => {
|
|
108
|
+
const problems = problemsOf({ 'en-US': { a: entry(card('a'), 1000, null) }, 'da-DK': { a: entry(card('a')) } });
|
|
109
|
+
assert.equal(problems.length, 1);
|
|
110
|
+
assert.match(problems[0], /not a WebP/);
|
|
111
|
+
});
|
|
112
|
+
test('webpSize reads a lossy VP8 header', () => {
|
|
113
|
+
// frame tag (3 bytes), start code 9d 01 2a, then width 750 and height 800 (14 bits each, little endian)
|
|
114
|
+
const header = webp('VP8 ', { at: 20, bytes: [0, 0, 0, 0x9d, 0x01, 0x2a, 750 & 255, 750 >> 8, 800 & 255, 800 >> 8] });
|
|
115
|
+
assert.deepEqual(webpSize(header), { width: 750, height: 800 });
|
|
116
|
+
});
|
|
117
|
+
test('webpSize reads a lossless VP8L header', () => {
|
|
118
|
+
// signature 2f, then 14 bits of width-1 and 14 bits of height-1
|
|
119
|
+
const w = 750 - 1;
|
|
120
|
+
const h = 700 - 1;
|
|
121
|
+
const header = webp('VP8L', { at: 20, bytes: [0x2f, w & 255, ((w >> 8) & 0x3f) | ((h & 3) << 6), (h >> 2) & 255, (h >> 10) & 15] });
|
|
122
|
+
assert.deepEqual(webpSize(header), { width: 750, height: 700 });
|
|
123
|
+
});
|
|
124
|
+
test('webpSize reads an extended VP8X header', () => {
|
|
125
|
+
const w = 750 - 1;
|
|
126
|
+
const h = 640 - 1;
|
|
127
|
+
const header = webp('VP8X', { at: 24, bytes: [w & 255, (w >> 8) & 255, w >> 16, h & 255, (h >> 8) & 255, h >> 16] });
|
|
128
|
+
assert.deepEqual(webpSize(header), { width: 750, height: 640 });
|
|
129
|
+
});
|
|
130
|
+
test('webpSize gives null for anything that is not a WebP of these kinds', () => {
|
|
131
|
+
assert.equal(webpSize(new Uint8Array(0)), null);
|
|
132
|
+
assert.equal(webpSize(new Uint8Array(40)), null);
|
|
133
|
+
assert.equal(webpSize(Buffer.from('PNG-not-a-webp'.padEnd(40, 'x'))), null);
|
|
134
|
+
assert.equal(webpSize(webp('VP8 ', { at: 20, bytes: [0, 0, 0, 1, 2, 3, 1, 0, 1, 0] })), null);
|
|
135
|
+
assert.equal(webpSize(webp('ABCD', { at: 24, bytes: [1, 2, 3] })), null);
|
|
136
|
+
});
|
|
137
|
+
test('problemsOf names a line with a screenshot', () => {
|
|
138
|
+
const problems = problemsOf({ 'en-US': { b: entry(line('b'), 500) }, 'da-DK': { b: entry(line('b')) } });
|
|
139
|
+
assert.equal(problems.length, 1);
|
|
140
|
+
assert.match(problems[0], /no screenshot/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('released keeps each note as it is, sorted by id, with the ids of its cards', () => {
|
|
144
|
+
const pending = { 'en-US': { b: entry(card('b')), a: entry(line('a')) } };
|
|
145
|
+
assert.deepEqual(released(pending, '0.14.0'), {
|
|
146
|
+
'en-US': { file: { version: '0.14.0', notes: [line('a'), card('b')] }, images: ['b'] },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
test('released leaves out a locale without notes', () => assert.deepEqual(released({ 'da-DK': {} }, '0.14.0'), {}));
|
|
150
|
+
test('released gives nothing without notes', () => assert.deepEqual(released({}, '0.14.0'), {}));
|
|
151
|
+
test('imagePath gives the released screenshot path of a card', () => {
|
|
152
|
+
assert.equal(imagePath('0.14.0', 'da-DK', 'brave-cats-run'), 'release-notes/i18n/da-DK/0.14.0/brave-cats-run.webp');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('notesFor gives the locale notes when there are some', () => {
|
|
156
|
+
const x = line('x');
|
|
157
|
+
const y = line('y');
|
|
158
|
+
assert.deepEqual(notesFor({ 'en-US': [x], 'da-DK': [y] }, 'da-DK'), [y]);
|
|
159
|
+
});
|
|
160
|
+
test('notesFor falls back to the en-US notes', () => {
|
|
161
|
+
const x = line('x');
|
|
162
|
+
assert.deepEqual(notesFor({ 'en-US': [x] }, 'da-DK'), [x]);
|
|
163
|
+
});
|
|
164
|
+
test('notesFor gives undefined without notes', () => assert.equal(notesFor(undefined, 'da-DK'), undefined));
|
|
165
|
+
|
|
166
|
+
test('installedVersions gives the finished update range, newest first', () => {
|
|
167
|
+
const update = { from: '0.13.0', to: '0.14.1', state: 'done' };
|
|
168
|
+
assert.deepEqual(installedVersions(update, '0.14.1', ['0.14.0', '0.14.1', '0.15.0']), ['0.14.1', '0.14.0']);
|
|
169
|
+
});
|
|
170
|
+
test('installedVersions gives only the running version when the update went to another version', () => {
|
|
171
|
+
assert.deepEqual(installedVersions({ to: '0.14.0', state: 'done' }, '0.14.1', ['0.14.1']), ['0.14.1']);
|
|
172
|
+
});
|
|
173
|
+
test('installedVersions gives only the running version, and only when it is kept', () => {
|
|
174
|
+
assert.deepEqual(installedVersions(null, '0.14.1', ['0.14.0']), []);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/** One tar entry: a 512-byte header, then the data padded to a multiple of 512. */
|
|
178
|
+
function tarEntry(name, content) {
|
|
179
|
+
const data = Buffer.from(content);
|
|
180
|
+
const header = Buffer.alloc(512);
|
|
181
|
+
header.write(name, 0, 'ascii');
|
|
182
|
+
header.write('0000644\0', 100, 'ascii');
|
|
183
|
+
header.write(data.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
|
|
184
|
+
header.write('0', 156, 'ascii');
|
|
185
|
+
return Buffer.concat([header, data, Buffer.alloc(Math.ceil(data.length / 512) * 512 - data.length)]);
|
|
186
|
+
}
|
|
187
|
+
const tarball = (...entries) => new Uint8Array(Buffer.concat([...entries, Buffer.alloc(1024)]));
|
|
188
|
+
const notesFile = (locale, version, notes) =>
|
|
189
|
+
tarEntry(`package/release-notes/i18n/${locale}/${version}.json`, JSON.stringify({ version, notes }));
|
|
190
|
+
const imageFile = (locale, version, id, bytes) => tarEntry(`package/release-notes/i18n/${locale}/${version}/${id}.webp`, bytes);
|
|
191
|
+
|
|
192
|
+
test('notesFromTar reads the notes and screenshot bytes of each locale', () => {
|
|
193
|
+
const da = card('a', { title: 'Titel' });
|
|
194
|
+
const tar = tarball(
|
|
195
|
+
notesFile('en-US', '0.14.0', [card('a')]),
|
|
196
|
+
notesFile('da-DK', '0.14.0', [da]),
|
|
197
|
+
imageFile('en-US', '0.14.0', 'a', [1, 2, 3]),
|
|
198
|
+
imageFile('da-DK', '0.14.0', 'a', [4, 5]),
|
|
199
|
+
);
|
|
200
|
+
const result = notesFromTar(tar, '0.14.0');
|
|
201
|
+
assert.deepEqual(result.notes, { 'en-US': [card('a')], 'da-DK': [da] });
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
result.images.map(({ locale, id, bytes }) => ({ locale, id, bytes: [...bytes] })),
|
|
204
|
+
[
|
|
205
|
+
{ locale: 'en-US', id: 'a', bytes: [1, 2, 3] },
|
|
206
|
+
{ locale: 'da-DK', id: 'a', bytes: [4, 5] },
|
|
207
|
+
],
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
test('notesFromTar leaves out a locale without a file, and a line has no screenshot', () => {
|
|
211
|
+
const tar = tarball(notesFile('en-US', '0.14.0', [line('a')]));
|
|
212
|
+
assert.deepEqual(notesFromTar(tar, '0.14.0'), { notes: { 'en-US': [line('a')] }, images: [] });
|
|
213
|
+
});
|
|
214
|
+
test('notesFromTar leaves out a card whose screenshot is missing from the tar', () => {
|
|
215
|
+
const tar = tarball(notesFile('en-US', '0.14.0', [card('a')]));
|
|
216
|
+
assert.deepEqual(notesFromTar(tar, '0.14.0'), { notes: { 'en-US': [card('a')] }, images: [] });
|
|
217
|
+
});
|
|
218
|
+
test('notesFromTar gives undefined without any notes file', () => {
|
|
219
|
+
assert.equal(notesFromTar(tarball(tarEntry('package/x', 'y')), '0.14.0'), undefined);
|
|
220
|
+
});
|
|
221
|
+
test('notesFromTar skips a locale with bad JSON', () => {
|
|
222
|
+
const tar = tarball(tarEntry('package/release-notes/i18n/en-US/0.14.0.json', '{bad'), notesFile('da-DK', '0.14.0', [line('a')]));
|
|
223
|
+
assert.deepEqual(notesFromTar(tar, '0.14.0'), { notes: { 'da-DK': [line('a')] }, images: [] });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('imageUrl and imageKey name a screenshot by version, locale and id', () => {
|
|
227
|
+
assert.equal(imageUrl('0.14.0', 'da-DK', 'quiet-lamps'), '/api/cms/note-image/0.14.0/da-DK/quiet-lamps');
|
|
228
|
+
assert.equal(imageKey('0.14.0', 'da-DK', 'quiet-lamps'), 'cms:note-image:0.14.0:da-DK:quiet-lamps');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('parseImagePath reads a screenshot address', () => {
|
|
232
|
+
assert.deepEqual(parseImagePath('/api/cms/note-image/0.14.0/en-US/quiet-lamps-2'), {
|
|
233
|
+
version: '0.14.0',
|
|
234
|
+
locale: 'en-US',
|
|
235
|
+
id: 'quiet-lamps-2',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('parseImagePath rejects a bad version, locale, id or shape', () => {
|
|
240
|
+
for (const path of [
|
|
241
|
+
'/api/cms/note-image/0.14/en-US/a',
|
|
242
|
+
'/api/cms/note-image/0.14.0-beta/en-US/a',
|
|
243
|
+
'/api/cms/note-image/0.14.0/fr-FR/a',
|
|
244
|
+
'/api/cms/note-image/0.14.0/en-US/A',
|
|
245
|
+
'/api/cms/note-image/0.14.0/en-US/a:b',
|
|
246
|
+
'/api/cms/note-image/0.14.0/en-US/a/b',
|
|
247
|
+
'/api/cms/note-image/0.14.0/en-US/',
|
|
248
|
+
'/api/cms/note-image/0.14.0/en-US/..%2Fx',
|
|
249
|
+
'/api/cms',
|
|
250
|
+
])
|
|
251
|
+
assert.equal(parseImagePath(path), null, path);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('withImageUrls sets the address on cards only and leaves the input alone', () => {
|
|
255
|
+
const notes = {
|
|
256
|
+
'en-US': [
|
|
257
|
+
{ id: 'a', kind: 'card', title: 'T', text: 'x' },
|
|
258
|
+
{ id: 'b', kind: 'line', text: 'y' },
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const out = withImageUrls('0.14.0', notes);
|
|
262
|
+
assert.equal(out['en-US'][0].image, '/api/cms/note-image/0.14.0/en-US/a');
|
|
263
|
+
assert.deepEqual(out['en-US'][1], { id: 'b', kind: 'line', text: 'y' });
|
|
264
|
+
assert.equal(notes['en-US'][0].image, undefined);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('withImageUrls gives undefined without notes', () => assert.equal(withImageUrls('0.14.0', undefined), undefined));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Fetches one Statuspage feed for the Worker's /api/status (worker/service-status.js).
|
|
2
|
+
// Exposes feedOf(url, fetcher, ms). No imports; kept out of worker/ so node --test can load it.
|
|
3
|
+
|
|
4
|
+
/** The parsed JSON of a feed, or null when the fetch throws, the answer is not ok, the body is not JSON, or it takes longer than ms (aborted). */
|
|
5
|
+
export async function feedOf(url, fetcher = fetch, ms = 3000) {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetcher(url, { headers: { accept: 'application/json' }, cf: { cacheTtl: 60 }, signal: AbortSignal.timeout(ms) });
|
|
8
|
+
if (!res.ok) return null;
|
|
9
|
+
return await res.json();
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tests for feedOf in lib/serviceFeed.mjs: a feed that fails or is slow gives null, a good one gives its JSON.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { feedOf } from './serviceFeed.mjs';
|
|
5
|
+
|
|
6
|
+
test('a fetcher that throws gives null', async () => {
|
|
7
|
+
const fetcher = async () => {
|
|
8
|
+
throw new Error('down');
|
|
9
|
+
};
|
|
10
|
+
assert.equal(await feedOf('https://x.example/f', fetcher), null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('a 500 gives null', async () => {
|
|
14
|
+
assert.equal(await feedOf('https://x.example/f', async () => new Response('{}', { status: 500 })), null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('a body that is not JSON gives null', async () => {
|
|
18
|
+
assert.equal(await feedOf('https://x.example/f', async () => new Response('<html>')), null);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a good JSON body gives the parsed object, asked with the accept header and cache hint', async () => {
|
|
22
|
+
let seen;
|
|
23
|
+
const body = await feedOf('https://x.example/f', async (url, init) => {
|
|
24
|
+
seen = init;
|
|
25
|
+
return Response.json({ components: [] });
|
|
26
|
+
});
|
|
27
|
+
assert.deepEqual(body, { components: [] });
|
|
28
|
+
assert.equal(seen.headers.accept, 'application/json');
|
|
29
|
+
assert.equal(seen.cf.cacheTtl, 60);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A fetcher that never answers until its request is aborted, as a hung server does.
|
|
33
|
+
const hung = (url, init) =>
|
|
34
|
+
new Promise((_, reject) => {
|
|
35
|
+
init.signal.addEventListener('abort', () => reject(init.signal.reason));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a feed slower than the timeout gives null', async () => {
|
|
39
|
+
assert.equal(await feedOf('https://x.example/f', hung, 20), null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the request carries a signal that aborts after the timeout', async () => {
|
|
43
|
+
let seen;
|
|
44
|
+
await feedOf(
|
|
45
|
+
'https://x.example/f',
|
|
46
|
+
(url, init) => {
|
|
47
|
+
seen = init.signal;
|
|
48
|
+
return hung(url, init);
|
|
49
|
+
},
|
|
50
|
+
20,
|
|
51
|
+
);
|
|
52
|
+
assert.equal(seen.aborted, true);
|
|
53
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} status
|
|
3
|
+
* @returns {ServiceState}
|
|
4
|
+
*/
|
|
5
|
+
export function stateOf(status: string): ServiceState;
|
|
6
|
+
/**
|
|
7
|
+
* @param {ServiceState[]} states
|
|
8
|
+
* @returns {ServiceState}
|
|
9
|
+
*/
|
|
10
|
+
export function worst(states: ServiceState[]): ServiceState;
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} provider
|
|
13
|
+
* @param {object|null} summary
|
|
14
|
+
* @returns {ServiceProvider}
|
|
15
|
+
*/
|
|
16
|
+
export function providerFrom(provider: object, summary: object | null): ServiceProvider;
|
|
17
|
+
/**
|
|
18
|
+
* The providers with only the components a site has the bindings for.
|
|
19
|
+
* @param {Iterable<string>} bindings The names of the bindings the site has.
|
|
20
|
+
* @returns {typeof PROVIDERS}
|
|
21
|
+
*/
|
|
22
|
+
export function providersFor(bindings: Iterable<string>): typeof PROVIDERS;
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ github: object|null, cloudflare: object|null }} summaries
|
|
25
|
+
* @param {typeof PROVIDERS} [list] The providers to report on (providersFor), all by default.
|
|
26
|
+
* @returns {StatusReport}
|
|
27
|
+
*/
|
|
28
|
+
export function reportFrom(summaries: {
|
|
29
|
+
github: object | null;
|
|
30
|
+
cloudflare: object | null;
|
|
31
|
+
}, list?: typeof PROVIDERS): StatusReport;
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {'ok' | 'minor' | 'major' | 'unknown'} ServiceState
|
|
34
|
+
* @typedef {'operational' | 'degraded_performance' | 'under_maintenance' | 'partial_outage' | 'major_outage' | 'unknown'} ComponentStatus
|
|
35
|
+
* @typedef {{ id: string, name: string, status: ComponentStatus, state: ServiceState }} ServiceComponent
|
|
36
|
+
* @typedef {{ id: 'github' | 'cloudflare', name: 'GitHub' | 'Cloudflare', page: string, state: ServiceState, components: ServiceComponent[] }} ServiceProvider
|
|
37
|
+
* @typedef {{ state: ServiceState, providers: ServiceProvider[] }} StatusReport
|
|
38
|
+
*/
|
|
39
|
+
export const PROVIDERS: {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
feed: string;
|
|
43
|
+
page: string;
|
|
44
|
+
components: ({
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
needs?: undefined;
|
|
48
|
+
} | {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
needs: string[];
|
|
52
|
+
})[];
|
|
53
|
+
}[];
|
|
54
|
+
export type ServiceState = "ok" | "minor" | "major" | "unknown";
|
|
55
|
+
export type ComponentStatus = "operational" | "degraded_performance" | "under_maintenance" | "partial_outage" | "major_outage" | "unknown";
|
|
56
|
+
export type ServiceComponent = {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
status: ComponentStatus;
|
|
60
|
+
state: ServiceState;
|
|
61
|
+
};
|
|
62
|
+
export type ServiceProvider = {
|
|
63
|
+
id: "github" | "cloudflare";
|
|
64
|
+
name: "GitHub" | "Cloudflare";
|
|
65
|
+
page: string;
|
|
66
|
+
state: ServiceState;
|
|
67
|
+
components: ServiceComponent[];
|
|
68
|
+
};
|
|
69
|
+
export type StatusReport = {
|
|
70
|
+
state: ServiceState;
|
|
71
|
+
providers: ServiceProvider[];
|
|
72
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The status of the services a Morpheme site needs (GitHub, Cloudflare), from their Statuspage feeds.
|
|
2
|
+
// Exposes PROVIDERS, providersFor, stateOf, worst, providerFrom and reportFrom. No imports: the Worker and the editor use it.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {'ok' | 'minor' | 'major' | 'unknown'} ServiceState
|
|
6
|
+
* @typedef {'operational' | 'degraded_performance' | 'under_maintenance' | 'partial_outage' | 'major_outage' | 'unknown'} ComponentStatus
|
|
7
|
+
* @typedef {{ id: string, name: string, status: ComponentStatus, state: ServiceState }} ServiceComponent
|
|
8
|
+
* @typedef {{ id: 'github' | 'cloudflare', name: 'GitHub' | 'Cloudflare', page: string, state: ServiceState, components: ServiceComponent[] }} ServiceProvider
|
|
9
|
+
* @typedef {{ state: ServiceState, providers: ServiceProvider[] }} StatusReport
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// A component with `needs` counts only when the site has at least one of those bindings (a KV namespace, a D1 database or an
|
|
13
|
+
// R2 bucket in its Worker's settings, starter/wrangler.jsonc); one without always counts. See providersFor.
|
|
14
|
+
export const PROVIDERS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'github',
|
|
17
|
+
name: 'GitHub',
|
|
18
|
+
feed: 'https://www.githubstatus.com/api/v2/summary.json',
|
|
19
|
+
page: 'https://www.githubstatus.com/',
|
|
20
|
+
components: [
|
|
21
|
+
{ id: '8l4ygp009s5s', name: 'Git Operations' },
|
|
22
|
+
{ id: 'brv1bkgrwx7q', name: 'API Requests' },
|
|
23
|
+
{ id: '4230lsnqdsld', name: 'Webhooks' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'cloudflare',
|
|
28
|
+
name: 'Cloudflare',
|
|
29
|
+
feed: 'https://www.cloudflarestatus.com/api/v2/summary.json',
|
|
30
|
+
page: 'https://www.cloudflarestatus.com/',
|
|
31
|
+
components: [
|
|
32
|
+
{ id: '57srcl8zcn7c', name: 'Workers' },
|
|
33
|
+
{ id: 'pg21w9xb991r', name: 'Workers Assets' },
|
|
34
|
+
{ id: 'vcpsk0w6g86k', name: 'Workers Builds' },
|
|
35
|
+
{ id: 'tmh50tx2nprs', name: 'Workers KV', needs: ['NOT_FOUND', 'PUSH'] },
|
|
36
|
+
{ id: 'z0yd3sxqjqdm', name: 'D1', needs: ['CONSENT'] },
|
|
37
|
+
{ id: 'hb7g5sq2zz0h', name: 'R2', needs: ['FILES'] },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const KNOWN_STATUSES = new Set(['operational', 'degraded_performance', 'under_maintenance', 'partial_outage', 'major_outage']);
|
|
43
|
+
|
|
44
|
+
const SEVERITY_ORDER = ['ok', 'unknown', 'minor', 'major'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} status
|
|
48
|
+
* @returns {ServiceState}
|
|
49
|
+
*/
|
|
50
|
+
export function stateOf(status) {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 'operational':
|
|
53
|
+
return 'ok';
|
|
54
|
+
case 'degraded_performance':
|
|
55
|
+
case 'under_maintenance':
|
|
56
|
+
return 'minor';
|
|
57
|
+
case 'partial_outage':
|
|
58
|
+
case 'major_outage':
|
|
59
|
+
return 'major';
|
|
60
|
+
default:
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {ServiceState[]} states
|
|
67
|
+
* @returns {ServiceState}
|
|
68
|
+
*/
|
|
69
|
+
export function worst(states) {
|
|
70
|
+
if (states.length === 0) return 'ok';
|
|
71
|
+
let worstState = 'ok';
|
|
72
|
+
for (const s of states) {
|
|
73
|
+
if (SEVERITY_ORDER.indexOf(s) > SEVERITY_ORDER.indexOf(worstState)) {
|
|
74
|
+
worstState = s;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return worstState;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {object} provider
|
|
82
|
+
* @param {object|null} summary
|
|
83
|
+
* @returns {ServiceProvider}
|
|
84
|
+
*/
|
|
85
|
+
export function providerFrom(provider, summary) {
|
|
86
|
+
const componentsMap = new Map();
|
|
87
|
+
if (summary && Array.isArray(summary.components)) {
|
|
88
|
+
for (const c of summary.components) {
|
|
89
|
+
componentsMap.set(c.id, c);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const components = provider.components.map((c) => {
|
|
94
|
+
const feedComponent = componentsMap.get(c.id);
|
|
95
|
+
const rawStatus = feedComponent ? feedComponent.status : null;
|
|
96
|
+
const status = rawStatus && KNOWN_STATUSES.has(rawStatus) ? /** @type {ComponentStatus} */ (rawStatus) : 'unknown';
|
|
97
|
+
return {
|
|
98
|
+
id: c.id,
|
|
99
|
+
name: c.name,
|
|
100
|
+
status,
|
|
101
|
+
state: stateOf(status),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: provider.id,
|
|
107
|
+
name: provider.name,
|
|
108
|
+
page: provider.page,
|
|
109
|
+
state: worst(components.map((c) => c.state)),
|
|
110
|
+
components,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The providers with only the components a site has the bindings for.
|
|
116
|
+
* @param {Iterable<string>} bindings The names of the bindings the site has.
|
|
117
|
+
* @returns {typeof PROVIDERS}
|
|
118
|
+
*/
|
|
119
|
+
export function providersFor(bindings) {
|
|
120
|
+
const have = new Set(bindings);
|
|
121
|
+
return PROVIDERS.map((p) => ({
|
|
122
|
+
...p,
|
|
123
|
+
components: p.components.filter((c) => !c.needs || c.needs.some((name) => have.has(name))),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {{ github: object|null, cloudflare: object|null }} summaries
|
|
129
|
+
* @param {typeof PROVIDERS} [list] The providers to report on (providersFor), all by default.
|
|
130
|
+
* @returns {StatusReport}
|
|
131
|
+
*/
|
|
132
|
+
export function reportFrom(summaries, list = PROVIDERS) {
|
|
133
|
+
const providers = list.map((p) => {
|
|
134
|
+
const key = p.id;
|
|
135
|
+
const summary = summaries[key] || null;
|
|
136
|
+
return providerFrom(p, summary);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
state: worst(providers.map((p) => p.state)),
|
|
141
|
+
providers,
|
|
142
|
+
};
|
|
143
|
+
}
|