@morphemecms/cms 0.14.0 → 0.14.1
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 +7 -0
- package/lib/releaseNotes.d.mts +13 -11
- package/lib/releaseNotes.mjs +38 -14
- package/lib/releaseNotes.test.mjs +76 -10
- package/package.json +1 -1
- package/release-notes/i18n/da-DK/0.14.1.json +15 -0
- package/release-notes/i18n/en-US/0.14.1.json +15 -0
- package/scripts/release-notes.mjs +14 -3
- package/src/CmsPage.tsx +5 -5
- package/src/MainMenu.tsx +9 -14
- package/src/bundledNotes.ts +11 -0
- package/src/cmsView.d.mts +0 -1
- package/src/cmsView.mjs +2 -4
- package/src/cmsView.test.mjs +0 -6
- package/starter/package.json +1 -1
- package/worker/cms-update.js +4 -7
- package/src/levelStep.d.mts +0 -10
- package/src/levelStep.mjs +0 -66
- package/src/levelStep.test.mjs +0 -119
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @morphemecms/cms
|
|
2
2
|
|
|
3
|
+
## 0.14.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- eea5442: On a phone, the back gesture in the open menu goes to the screen before and closes the menu, instead of going up one level in the menu.
|
|
8
|
+
- ee45cf2: Show the release notes of the running version from the installed package, not from KV.
|
|
9
|
+
|
|
3
10
|
## 0.14.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
package/lib/releaseNotes.d.mts
CHANGED
|
@@ -38,18 +38,19 @@ export function imagePath(version: string, locale: string, id: string): string;
|
|
|
38
38
|
*/
|
|
39
39
|
export function notesFor(notes: Record<string, ReleaseNote[]> | undefined, locale: string): ReleaseNote[] | undefined;
|
|
40
40
|
/**
|
|
41
|
-
* The
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* @param {string}
|
|
45
|
-
* @param {string
|
|
46
|
-
* @
|
|
41
|
+
* The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
|
|
42
|
+
* (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
|
|
43
|
+
* (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
|
|
44
|
+
* @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
|
|
45
|
+
* @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
|
|
46
|
+
* @param {string} version the running version
|
|
47
|
+
* @returns {{ version: string, text: string, notes: Record<string, ReleaseNote[]> }[]}
|
|
47
48
|
*/
|
|
48
|
-
export function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
49
|
+
export function bundledNotes(files: Record<string, ReleasedNotes>, images: Record<string, string>, version: string): {
|
|
50
|
+
version: string;
|
|
51
|
+
text: string;
|
|
52
|
+
notes: Record<string, ReleaseNote[]>;
|
|
53
|
+
}[];
|
|
53
54
|
/**
|
|
54
55
|
* The release notes and screenshots of `version` from an unzipped npm tar. Notes: package/release-notes/i18n/<locale>/<version>.json
|
|
55
56
|
* per locale. Screenshots: one per card, at imagePath(). A missing or unreadable notes file leaves its locale out; a missing
|
|
@@ -102,6 +103,7 @@ export const MAX_IMAGE_BYTES: number;
|
|
|
102
103
|
/** 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
104
|
export const IMAGE_WIDTH: 750;
|
|
104
105
|
export const MAX_IMAGE_HEIGHT: 800;
|
|
106
|
+
export function staleImageDirs(dirs: string[], version: string): string[];
|
|
105
107
|
export function imageKey(version: string, locale: string, id: string): string;
|
|
106
108
|
export type ReleaseNote = {
|
|
107
109
|
id: string;
|
package/lib/releaseNotes.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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,
|
|
2
|
+
// picks a locale's notes, builds the notes of the running minor line from the package files and reads a version's notes and screenshots from its tar.
|
|
3
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,
|
|
4
|
+
// Exposes MAX_IMAGE_BYTES, IMAGE_WIDTH, MAX_IMAGE_HEIGHT, webpSize, problemsOf, released, imagePath, notesFor, bundledNotes, staleImageDirs, notesFromTar, imageUrl, imageKey,
|
|
5
5
|
// parseImagePath and withImageUrls. Depends on ./cms-version.mjs and ./changelog.mjs; no node: imports.
|
|
6
6
|
import { compareVersions } from './cms-version.mjs';
|
|
7
7
|
import { bytesFromTar, fileFromTar } from './changelog.mjs';
|
|
@@ -154,22 +154,46 @@ export function notesFor(notes, locale) {
|
|
|
154
154
|
return notes?.[locale] ?? notes?.['en-US'];
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
const PLAIN = /^\d+\.\d+\.\d+$/;
|
|
158
|
+
const sameMinor = (a, b) => a.split('.').slice(0, 2).join('.') === b.split('.').slice(0, 2).join('.');
|
|
159
|
+
|
|
157
160
|
/**
|
|
158
|
-
* The
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* @param {string}
|
|
162
|
-
* @param {string
|
|
163
|
-
* @
|
|
161
|
+
* The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
|
|
162
|
+
* (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
|
|
163
|
+
* (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
|
|
164
|
+
* @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
|
|
165
|
+
* @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
|
|
166
|
+
* @param {string} version the running version
|
|
167
|
+
* @returns {{ version: string, text: string, notes: Record<string, ReleaseNote[]> }[]}
|
|
164
168
|
*/
|
|
165
|
-
export function
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
export function bundledNotes(files, images, version) {
|
|
170
|
+
const byVersion = {};
|
|
171
|
+
for (const [path, file] of Object.entries(files)) {
|
|
172
|
+
const match = /\/i18n\/([^/]+)\/([^/]+)\.json$/.exec(path);
|
|
173
|
+
if (!match || !Array.isArray(file?.notes)) continue;
|
|
174
|
+
const [, locale, v] = match;
|
|
175
|
+
if (!PLAIN.test(v) || !sameMinor(v, version) || compareVersions(v, version) === 1) continue;
|
|
176
|
+
byVersion[v] ??= {};
|
|
177
|
+
byVersion[v][locale] = file.notes.map((note) => {
|
|
178
|
+
if (note?.kind !== 'card') return note;
|
|
179
|
+
const image = Object.entries(images).find(([imagePath]) => imagePath.endsWith(`/i18n/${locale}/${v}/${note.id}.webp`));
|
|
180
|
+
return image ? { ...note, image: image[1] } : note;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return Object.keys(byVersion)
|
|
184
|
+
.sort((a, b) => compareVersions(b, a))
|
|
185
|
+
.map((v) => ({ version: v, text: '', notes: byVersion[v] }));
|
|
171
186
|
}
|
|
172
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The screenshot folders of older minor lines: each x.y.z name whose major and minor differ from `version`'s. Never
|
|
190
|
+
* `pending` or any other name.
|
|
191
|
+
* @param {string[]} dirs names of the folders under release-notes/i18n/<locale>/
|
|
192
|
+
* @param {string} version the running version
|
|
193
|
+
* @returns {string[]}
|
|
194
|
+
*/
|
|
195
|
+
export const staleImageDirs = (dirs, version) => dirs.filter((name) => PLAIN.test(name) && !sameMinor(name, version));
|
|
196
|
+
|
|
173
197
|
/**
|
|
174
198
|
* The release notes and screenshots of `version` from an unzipped npm tar. Notes: package/release-notes/i18n/<locale>/<version>.json
|
|
175
199
|
* per locale. Screenshots: one per card, at imagePath(). A missing or unreadable notes file leaves its locale out; a missing
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Tests for lib/releaseNotes.mjs: checks pending cards and lines, groups them per version, picks a locale's notes,
|
|
2
|
-
//
|
|
1
|
+
// Tests for lib/releaseNotes.mjs: checks pending cards and lines, groups them per version, picks a locale's notes, builds
|
|
2
|
+
// the running version's notes from the package files and reads notes and screenshots from a tar.
|
|
3
3
|
// Run: node --test lib/releaseNotes.test.mjs
|
|
4
4
|
import { test } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
released,
|
|
12
12
|
imagePath,
|
|
13
13
|
notesFor,
|
|
14
|
-
|
|
14
|
+
bundledNotes,
|
|
15
|
+
staleImageDirs,
|
|
15
16
|
notesFromTar,
|
|
16
17
|
imageUrl,
|
|
17
18
|
imageKey,
|
|
@@ -163,16 +164,81 @@ test('notesFor falls back to the en-US notes', () => {
|
|
|
163
164
|
});
|
|
164
165
|
test('notesFor gives undefined without notes', () => assert.equal(notesFor(undefined, 'da-DK'), undefined));
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
const file = (version, notes) => ({ version, notes });
|
|
168
|
+
const dir = '../release-notes/i18n';
|
|
169
|
+
|
|
170
|
+
const en = (v, notes) => ({ [`${dir}/en-US/${v}.json`]: file(v, notes) });
|
|
171
|
+
|
|
172
|
+
test('bundledNotes gives the notes of both locales', () => {
|
|
173
|
+
const files = {
|
|
174
|
+
[`${dir}/en-US/0.14.0.json`]: file('0.14.0', [line('a')]),
|
|
175
|
+
[`${dir}/da-DK/0.14.0.json`]: file('0.14.0', [line('a', { text: 'Tekst' })]),
|
|
176
|
+
};
|
|
177
|
+
assert.deepEqual(bundledNotes(files, {}, '0.14.0'), [
|
|
178
|
+
{ version: '0.14.0', text: '', notes: { 'en-US': [line('a')], 'da-DK': [line('a', { text: 'Tekst' })] } },
|
|
179
|
+
]);
|
|
169
180
|
});
|
|
170
|
-
test('
|
|
171
|
-
assert.deepEqual(
|
|
181
|
+
test('bundledNotes gives the one locale that has notes', () => {
|
|
182
|
+
assert.deepEqual(bundledNotes(en('0.14.0', [line('a')]), {}, '0.14.0')[0].notes, { 'en-US': [line('a')] });
|
|
183
|
+
});
|
|
184
|
+
test('bundledNotes gives the whole minor line, newest first', () => {
|
|
185
|
+
const files = { ...en('0.14.0', [line('a')]), ...en('0.14.1', [line('b')]), ...en('0.14.10', [line('c')]) };
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
bundledNotes(files, {}, '0.14.1').map((e) => e.version),
|
|
188
|
+
['0.14.1', '0.14.0'],
|
|
189
|
+
);
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
bundledNotes(files, {}, '0.14.10').map((e) => e.version),
|
|
192
|
+
['0.14.10', '0.14.1', '0.14.0'],
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
test('bundledNotes ignores another minor', () => {
|
|
196
|
+
const files = { ...en('0.13.0', [line('a')]), ...en('0.13.5', [line('b')]), ...en('0.15.0', [line('c')]), ...en('0.14.0', [line('d')]) };
|
|
197
|
+
assert.deepEqual(
|
|
198
|
+
bundledNotes(files, {}, '0.14.0').map((e) => e.version),
|
|
199
|
+
['0.14.0'],
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
test('bundledNotes ignores a newer version', () => {
|
|
203
|
+
const files = { ...en('0.14.0', [line('a')]), ...en('0.14.2', [line('b')]) };
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
bundledNotes(files, {}, '0.14.1').map((e) => e.version),
|
|
206
|
+
['0.14.0'],
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
test('bundledNotes gives an empty list without a file for the line', () => {
|
|
210
|
+
assert.deepEqual(bundledNotes(en('0.13.0', [line('a')]), {}, '0.14.0'), []);
|
|
211
|
+
assert.deepEqual(bundledNotes({}, {}, '0.14.0'), []);
|
|
212
|
+
});
|
|
213
|
+
test('bundledNotes sets the screenshot address of each version on its cards', () => {
|
|
214
|
+
const files = { ...en('0.14.0', [card('a')]), ...en('0.14.1', [card('b')]) };
|
|
215
|
+
const images = {
|
|
216
|
+
[`${dir}/en-US/0.14.0/a.webp`]: '/assets/a-hash.webp',
|
|
217
|
+
[`${dir}/en-US/0.14.1/b.webp`]: '/assets/b-hash.webp',
|
|
218
|
+
[`${dir}/da-DK/0.14.0/a.webp`]: '/assets/other.webp',
|
|
219
|
+
};
|
|
220
|
+
const result = bundledNotes(files, images, '0.14.1');
|
|
221
|
+
assert.deepEqual(result[0].notes['en-US'], [card('b', { image: '/assets/b-hash.webp' })]);
|
|
222
|
+
assert.deepEqual(result[1].notes['en-US'], [card('a', { image: '/assets/a-hash.webp' })]);
|
|
223
|
+
});
|
|
224
|
+
test('bundledNotes leaves a card without a screenshot file as it is', () => {
|
|
225
|
+
assert.deepEqual(bundledNotes(en('0.14.0', [card('a')]), {}, '0.14.0')[0].notes['en-US'], [card('a')]);
|
|
226
|
+
});
|
|
227
|
+
test('bundledNotes leaves a line as it is', () => {
|
|
228
|
+
const images = { [`${dir}/en-US/0.14.0/a.webp`]: '/assets/a.webp' };
|
|
229
|
+
assert.deepEqual(bundledNotes(en('0.14.0', [line('a')]), images, '0.14.0')[0].notes['en-US'], [line('a')]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('staleImageDirs keeps the folders of the same minor', () => {
|
|
233
|
+
assert.deepEqual(staleImageDirs(['0.14.0', '0.14.1'], '0.14.1'), []);
|
|
172
234
|
});
|
|
173
|
-
test('
|
|
174
|
-
assert.deepEqual(
|
|
235
|
+
test('staleImageDirs gives the older minors and another major', () => {
|
|
236
|
+
assert.deepEqual(staleImageDirs(['0.13.0', '0.14.0', '0.14.1', '1.14.0'], '0.15.0'), ['0.13.0', '0.14.0', '0.14.1', '1.14.0']);
|
|
237
|
+
assert.deepEqual(staleImageDirs(['0.13.2', '0.14.0', '1.14.0'], '0.14.1'), ['0.13.2', '1.14.0']);
|
|
175
238
|
});
|
|
239
|
+
test('staleImageDirs keeps pending', () => assert.deepEqual(staleImageDirs(['pending', '0.13.0'], '0.14.0'), ['0.13.0']));
|
|
240
|
+
test('staleImageDirs keeps a name that is not a version', () => assert.deepEqual(staleImageDirs(['notes', '0.13'], '0.14.0'), []));
|
|
241
|
+
test('staleImageDirs gives nothing for an empty list', () => assert.deepEqual(staleImageDirs([], '0.14.0'), []));
|
|
176
242
|
|
|
177
243
|
/** One tar entry: a 512-byte header, then the data padded to a multiple of 512. */
|
|
178
244
|
function tarEntry(name, content) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphemecms/cms",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lars Adolfsen",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.14.1",
|
|
3
|
+
"notes": [
|
|
4
|
+
{
|
|
5
|
+
"id": "drawer-back-changes-screen",
|
|
6
|
+
"kind": "line",
|
|
7
|
+
"text": "På en telefon fører et swipe tilbage med menuen åben dig nu til skærmen før og lukker menuen."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "notes-from-package",
|
|
11
|
+
"kind": "line",
|
|
12
|
+
"text": "Noterne til din version vises nu lige efter en opdatering."
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.14.1",
|
|
3
|
+
"notes": [
|
|
4
|
+
{
|
|
5
|
+
"id": "drawer-back-changes-screen",
|
|
6
|
+
"kind": "line",
|
|
7
|
+
"text": "On a phone, swiping back with the menu open now takes you to the screen before and closes the menu."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "notes-from-package",
|
|
11
|
+
"kind": "line",
|
|
12
|
+
"text": "The notes for your version now show right after an update."
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Release step: moves the pending release notes into release-notes/i18n/<locale>/<version>.json, their screenshots into
|
|
2
|
-
// <version>/<id>.webp, the version from package.json
|
|
2
|
+
// <version>/<id>.webp, the version from package.json,
|
|
3
|
+
// and deletes the screenshot folders of older minor lines (the package carries the running minor line's). Run by release:version after changeset version.
|
|
3
4
|
// Usage: node scripts/release-notes.mjs [root]; exits 1 on a problem or an existing file.
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
|
-
import { imagePath, problemsOf, released } from '../lib/releaseNotes.mjs';
|
|
7
|
+
import { imagePath, problemsOf, released, staleImageDirs } from '../lib/releaseNotes.mjs';
|
|
7
8
|
import { readPending, repoRoot } from './release-notes-pending.mjs';
|
|
8
9
|
|
|
9
10
|
const root = repoRoot(process.argv[2]);
|
|
@@ -36,4 +37,14 @@ for (const [locale, { file, images }] of Object.entries(result)) {
|
|
|
36
37
|
rmSync(join(localeDir, 'pending', `${id}.json`));
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
// The package carries the screenshots of the running minor line only; the notes files stay.
|
|
41
|
+
const i18n = join(root, 'release-notes', 'i18n');
|
|
42
|
+
for (const locale of readdirSync(i18n, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
43
|
+
const dirs = readdirSync(join(i18n, locale.name), { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
44
|
+
for (const name of staleImageDirs(
|
|
45
|
+
dirs.map((e) => e.name),
|
|
46
|
+
version,
|
|
47
|
+
))
|
|
48
|
+
rmSync(join(i18n, locale.name, name), { recursive: true });
|
|
49
|
+
}
|
|
39
50
|
console.log(`Release notes for ${version}: ${Object.keys(result).join(', ') || 'none'}.`);
|
package/src/CmsPage.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// "Morpheme" (#morpheme), under "Indstillinger": the CMS version the site runs, the notes of
|
|
2
|
-
// a newer one with its notes, why it is not taken by itself, and an update's progress, and the site's connection to the
|
|
3
|
-
// Morpheme store (MorphemeConnection) as its last group. Exposes CMS_ROUTE and the page. Depends on cmsView.mjs,
|
|
1
|
+
// "Morpheme" (#morpheme), under "Indstillinger": the CMS version the site runs, the notes of its minor line (from the
|
|
2
|
+
// package itself, bundledNotes.ts), a newer one with its notes, why it is not taken by itself, and an update's progress, and the site's connection to the
|
|
3
|
+
// Morpheme store (MorphemeConnection) as its last group. Exposes CMS_ROUTE and the page. Depends on cmsView.mjs, bundledNotes.ts,
|
|
4
4
|
// ReleaseNotes (the notes as cards), MorphemeConnection and the Worker's /api/cms.
|
|
5
5
|
import { useCallback, useEffect, useState } from 'react';
|
|
6
6
|
import { CheckCircle, Clock, Info, Sparkle, WarningCircle } from '@phosphor-icons/react';
|
|
@@ -10,6 +10,7 @@ import { ReleaseNotes, type VersionNotes } from './ReleaseNotes';
|
|
|
10
10
|
import { Button, EmptyNote, Group, ListItem, ProblemNote } from './ui';
|
|
11
11
|
import { backFrom } from './site';
|
|
12
12
|
import { cmsView } from './cmsView.mjs';
|
|
13
|
+
import { runningNotes } from './bundledNotes';
|
|
13
14
|
import { snackbar } from '../lib/snackbar';
|
|
14
15
|
import { t } from './i18n';
|
|
15
16
|
import { when } from './when';
|
|
@@ -23,7 +24,6 @@ type View = {
|
|
|
23
24
|
checkedAt: string | null;
|
|
24
25
|
latest: string | null;
|
|
25
26
|
notes: VersionNotes[];
|
|
26
|
-
installed: VersionNotes[];
|
|
27
27
|
action: 'update' | 'try-again' | null;
|
|
28
28
|
npmCommand: string | null;
|
|
29
29
|
update: { state: UpdateState; to: string; at: string } | null;
|
|
@@ -107,7 +107,7 @@ export default function CmsPage() {
|
|
|
107
107
|
{!view && !problem && <EmptyNote busy>{t.app.loading}</EmptyNote>}
|
|
108
108
|
{view && (
|
|
109
109
|
<>
|
|
110
|
-
{
|
|
110
|
+
{runningNotes.length > 0 && <ReleaseNotes title={t.cms.newInYourVersion} entries={runningNotes} />}
|
|
111
111
|
<Group>
|
|
112
112
|
<ListItem icon={Info} headline={t.cms.running(view.running)} supporting={view.latest ? undefined : t.cms.upToDate} />
|
|
113
113
|
<ListItem icon={Clock} headline={view.checkedAt ? t.cms.checked(when(new Date(view.checkedAt))) : t.cms.notChecked} />
|
package/src/MainMenu.tsx
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// sections; a section opens its list, under a back row, with the open screen marked and its fold-out folded out. Apps lists
|
|
3
3
|
// Appstore and a row per app installed. From medium and wider the first level is also the collapsed navigation rail
|
|
4
4
|
// (MainRailItem), whose sections open the drawer at their list. The drawer's header is the signed-in user (AccountRow), a link
|
|
5
|
-
// to the account.
|
|
6
|
-
//
|
|
5
|
+
// to the account. The levels are not steps in the history: in the modal drawer the phone's back gesture goes to the screen
|
|
6
|
+
// before, as everywhere else, and closes the drawer. Exposes MainMenu. The tree and the open place are lib/menu.mjs
|
|
7
7
|
// (menuTree, currentPlace); it only wires them to the drawer.
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { config, header } from '@site/editor';
|
|
@@ -16,7 +16,6 @@ import { AccountRow } from './AccountRow';
|
|
|
16
16
|
import { childrenOf } from './menuChildren';
|
|
17
17
|
import { t } from './i18n';
|
|
18
18
|
import { useMenuOpen } from './menuOpen';
|
|
19
|
-
import { levelStep } from './levelStep.mjs';
|
|
20
19
|
import {
|
|
21
20
|
MainDrawer,
|
|
22
21
|
MainDrawerBack,
|
|
@@ -79,19 +78,15 @@ export function MainMenu({ route, files, user }: { route: string; files: RepoFil
|
|
|
79
78
|
setShown(null);
|
|
80
79
|
return true;
|
|
81
80
|
}, []);
|
|
82
|
-
//
|
|
81
|
+
// The modal drawer closes when the screen changes another way than by a link in it (the phone's back gesture), so the
|
|
82
|
+
// screen before is not left under it.
|
|
83
83
|
const modal = useMenuLayout().expanded !== 'standard';
|
|
84
|
-
const step = useRef<ReturnType<typeof levelStep> | null>(null);
|
|
85
84
|
useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
setFocus('top');
|
|
92
|
-
setShown(null);
|
|
93
|
-
});
|
|
94
|
-
}, [modal, open, shown]);
|
|
85
|
+
if (!modal || !open) return;
|
|
86
|
+
const close = () => setOpen(false);
|
|
87
|
+
window.addEventListener('hashchange', close);
|
|
88
|
+
return () => window.removeEventListener('hashchange', close);
|
|
89
|
+
}, [modal, open, setOpen]);
|
|
95
90
|
// Every level stays in the page (the screen check finds the screens from the links); the ones not shown are `hidden`.
|
|
96
91
|
const sections = tree.filter((entry) => entry.href === undefined);
|
|
97
92
|
return (
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The release notes of the minor line this package is in (x.y.0 up to its version): read at build time from the package's own release-notes/ files.
|
|
2
|
+
// Exposes runningNotes (the entries the Morpheme page shows, newest first; empty when the line has none).
|
|
3
|
+
// Depends on lib/releaseNotes.mjs (bundledNotes) and package.json's version. The globs are relative, so they work here and in a site's node_modules.
|
|
4
|
+
import { bundledNotes } from '../lib/releaseNotes.mjs';
|
|
5
|
+
import { version } from '../package.json';
|
|
6
|
+
import type { ReleasedNotes } from '../lib/releaseNotes.mjs';
|
|
7
|
+
|
|
8
|
+
const files = import.meta.glob<ReleasedNotes>('../release-notes/i18n/*/*.json', { eager: true, import: 'default' });
|
|
9
|
+
const images = import.meta.glob<string>('../release-notes/i18n/*/*/*.webp', { eager: true, query: '?url', import: 'default' });
|
|
10
|
+
|
|
11
|
+
export const runningNotes = bundledNotes(files, images, version);
|
package/src/cmsView.d.mts
CHANGED
package/src/cmsView.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Transforms CMS status into a view for the update page.
|
|
2
|
-
// Exposes cmsView and DONE_SHOWN.
|
|
2
|
+
// Exposes cmsView and DONE_SHOWN.
|
|
3
3
|
// Depends on cms-version for version comparison.
|
|
4
4
|
|
|
5
5
|
import { compareVersions } from '../lib/cms-version.mjs';
|
|
@@ -77,7 +77,5 @@ export function cmsView(status, now = Date.now()) {
|
|
|
77
77
|
problems.push('slow-undo');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return { running, checkedAt, latest, notes, installed, action, npmCommand, update, problems };
|
|
80
|
+
return { running, checkedAt, latest, notes, action, npmCommand, update, problems };
|
|
83
81
|
}
|
package/src/cmsView.test.mjs
CHANGED
|
@@ -150,9 +150,3 @@ test('pre-release running', () => {
|
|
|
150
150
|
const view = cmsView(status, now);
|
|
151
151
|
assert.equal(view.latest, null);
|
|
152
152
|
});
|
|
153
|
-
|
|
154
|
-
test('passes the installed notes on, or an empty list', () => {
|
|
155
|
-
const installed = [{ version: '0.1.1', text: 'Fixes', notes: { 'en-US': [] } }];
|
|
156
|
-
assert.deepEqual(cmsView(makeStatus({ installed }), now).installed, installed);
|
|
157
|
-
assert.deepEqual(cmsView(makeStatus({}), now).installed, []);
|
|
158
|
-
});
|
package/starter/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@material-symbols/svg-400": "0.48.2",
|
|
33
33
|
"@material/material-color-utilities": "^0.3.0",
|
|
34
34
|
"@milkdown/kit": "^7.22.3",
|
|
35
|
-
"@morphemecms/cms": "0.14.
|
|
35
|
+
"@morphemecms/cms": "0.14.1",
|
|
36
36
|
"@phosphor-icons/core": "^2.1.1",
|
|
37
37
|
"@phosphor-icons/react": "^2.1.10",
|
|
38
38
|
"@tailwindcss/typography": "^0.5.20",
|
package/worker/cms-update.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Exposes RUNNING, checkForUpdate, startUpdate, watchUpdate, cmsStatus and handleCms (/api/cms, and GET /api/cms/note-image/<version>/<locale>/<id>,
|
|
4
4
|
// behind the same login check). Needs GITHUB_PUBLISH_TOKEN and the KV binding PUSH. Keeps each new version's changelog text and release
|
|
5
5
|
// notes (en-US, da-DK) as cms:notes:<version> and each card screenshot as cms:note-image:<version>:<locale>:<id>; cmsStatus returns
|
|
6
|
-
// the notes to come
|
|
6
|
+
// the notes to come, with the screenshot address on each card. Depends on github.js, session.js, push.js and lib/{lockfile,cms-version,changelog,releaseNotes}.mjs;
|
|
7
7
|
// I/O is injected (`deps`).
|
|
8
8
|
import { config } from '@site/config';
|
|
9
9
|
import { cms as cmsEn } from '../src/i18n/en-US/cms';
|
|
@@ -11,7 +11,7 @@ import { cms as cmsDa } from '../src/i18n/da-DK/cms';
|
|
|
11
11
|
import { addToLock, NeedsNpm } from '../lib/lockfile.mjs';
|
|
12
12
|
import { compareVersions, managedVersion, nextVersion, PACKAGE, watchStep } from '../lib/cms-version.mjs';
|
|
13
13
|
import { fileFromTar, gunzip, sectionsBetween } from '../lib/changelog.mjs';
|
|
14
|
-
import { imageKey,
|
|
14
|
+
import { imageKey, notesFromTar, parseImagePath, withImageUrls } from '../lib/releaseNotes.mjs';
|
|
15
15
|
import pkg from '../package.json';
|
|
16
16
|
import { GitHubError } from './files.js';
|
|
17
17
|
import { commitFiles, readFiles } from './github.js';
|
|
@@ -211,8 +211,8 @@ export async function watchUpdate(env, deps = defaultDeps, now = Date.now()) {
|
|
|
211
211
|
|
|
212
212
|
/**
|
|
213
213
|
* What the page shows: the running version, the token, what npm said, the update, the skipped versions, the notes of the
|
|
214
|
-
* versions still to come (`notes`)
|
|
215
|
-
*
|
|
214
|
+
* versions still to come (`notes`). Each entry is `{ version, text, notes? }`; `notes` maps a locale to its release notes. The list stays
|
|
215
|
+
* empty when the running version is a pre-release. The running version's own notes are not here: the editor reads them from the package (src/bundledNotes.ts).
|
|
216
216
|
*/
|
|
217
217
|
export async function cmsStatus(env, running = RUNNING) {
|
|
218
218
|
const kv = env.PUSH;
|
|
@@ -224,7 +224,6 @@ export async function cmsStatus(env, running = RUNNING) {
|
|
|
224
224
|
update: null,
|
|
225
225
|
skipped: [],
|
|
226
226
|
notes: [],
|
|
227
|
-
installed: [],
|
|
228
227
|
};
|
|
229
228
|
const plain = /^\d+\.\d+\.\d+$/;
|
|
230
229
|
if (!kv) return status;
|
|
@@ -241,8 +240,6 @@ export async function cmsStatus(env, running = RUNNING) {
|
|
|
241
240
|
);
|
|
242
241
|
wanted.sort((a, b) => compareVersions(b, a));
|
|
243
242
|
status.notes = entries(await Promise.all(wanted.map((v) => kv.get(`cms:notes:${v}`, 'json'))));
|
|
244
|
-
const installed = installedVersions(status.update, running, kept);
|
|
245
|
-
status.installed = entries(await Promise.all(installed.map((v) => kv.get(`cms:notes:${v}`, 'json'))));
|
|
246
243
|
return status;
|
|
247
244
|
}
|
|
248
245
|
|
package/src/levelStep.d.mts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Watches the window's history for the level's step. `set(active, toFirst)`: whether the level is shown (a step is added
|
|
3
|
-
* while it is), and what the back gesture does on it (shows the first level). `stop()` takes the step out and stops.
|
|
4
|
-
* @param {Pick<Window, 'history' | 'location' | 'addEventListener' | 'removeEventListener' | 'document'>} win
|
|
5
|
-
*/
|
|
6
|
-
export function levelStep(win: Pick<Window, "history" | "location" | "addEventListener" | "removeEventListener" | "document">): {
|
|
7
|
-
/** @param {boolean} active @param {() => void} back */
|
|
8
|
-
set(active: boolean, back: () => void): void;
|
|
9
|
-
stop(): void;
|
|
10
|
-
};
|
package/src/levelStep.mjs
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// The second level of the editor's modal main menu (MainMenu.tsx) as a step in the browser's history: while a section's
|
|
2
|
-
// list is shown in the modal drawer, the phone's back gesture (and the browser's back button) goes to the first level
|
|
3
|
-
// instead of to the screen before. The step has the same address and the same place as the screen's (history.state,
|
|
4
|
-
// nav.ts), so going back from it fires no hashchange and nav.ts never sees it. Leaving the level another way (the back
|
|
5
|
-
// row, Escape, the drawer closed) takes the step out again; a link in the drawer is followed once the step is out, so the
|
|
6
|
-
// history never keeps an empty step. Exposes levelStep. A window (the browser's; a fake one in the tests).
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Watches the window's history for the level's step. `set(active, toFirst)`: whether the level is shown (a step is added
|
|
10
|
-
* while it is), and what the back gesture does on it (shows the first level). `stop()` takes the step out and stops.
|
|
11
|
-
* @param {Pick<Window, 'history' | 'location' | 'addEventListener' | 'removeEventListener' | 'document'>} win
|
|
12
|
-
*/
|
|
13
|
-
export function levelStep(win) {
|
|
14
|
-
let pushed = false;
|
|
15
|
-
/** @type {string | null} A link's address, followed once the step is out. */
|
|
16
|
-
let pending = null;
|
|
17
|
-
let toFirst = () => {};
|
|
18
|
-
|
|
19
|
-
const leave = () => {
|
|
20
|
-
if (!pushed) return;
|
|
21
|
-
pushed = false;
|
|
22
|
-
win.history.back();
|
|
23
|
-
};
|
|
24
|
-
const onPop = () => {
|
|
25
|
-
// Back from the step: the first level, on the same screen.
|
|
26
|
-
if (pushed) {
|
|
27
|
-
pushed = false;
|
|
28
|
-
toFirst();
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
// The step taken out for a link: now the link.
|
|
32
|
-
if (pending !== null) {
|
|
33
|
-
const href = pending;
|
|
34
|
-
pending = null;
|
|
35
|
-
win.location.hash = href;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
/** @param {MouseEvent} e */
|
|
39
|
-
const onClick = (e) => {
|
|
40
|
-
if (!pushed || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
41
|
-
const target = /** @type {Element | null} */ (e.target);
|
|
42
|
-
const a = target?.closest?.('a[href^="#"]');
|
|
43
|
-
if (!a || a.getAttribute('target')) return;
|
|
44
|
-
e.preventDefault();
|
|
45
|
-
pending = a.getAttribute('href');
|
|
46
|
-
leave();
|
|
47
|
-
};
|
|
48
|
-
win.addEventListener('popstate', onPop);
|
|
49
|
-
win.document.addEventListener('click', onClick, true);
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
/** @param {boolean} active @param {() => void} back */
|
|
53
|
-
set(active, back) {
|
|
54
|
-
toFirst = back;
|
|
55
|
-
if (active && !pushed) {
|
|
56
|
-
win.history.pushState({ ...(win.history.state ?? {}), level: true }, '');
|
|
57
|
-
pushed = true;
|
|
58
|
-
} else if (!active) leave();
|
|
59
|
-
},
|
|
60
|
-
stop() {
|
|
61
|
-
leave();
|
|
62
|
-
win.removeEventListener('popstate', onPop);
|
|
63
|
-
win.document.removeEventListener('click', onClick, true);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
package/src/levelStep.test.mjs
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
// Tests levelStep.mjs: the modal main menu's second level as a step in the history, with a fake window whose history
|
|
2
|
-
// fires popstate as the browser's does (a step back on the same address: no hashchange).
|
|
3
|
-
// Run: node --test src/levelStep.test.mjs
|
|
4
|
-
import { test } from 'node:test';
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import { levelStep } from './levelStep.mjs';
|
|
7
|
-
|
|
8
|
-
function fakeWindow(hash = '#list/pages') {
|
|
9
|
-
const listeners = { popstate: new Set(), click: new Set() };
|
|
10
|
-
const entries = [{ state: { at: 3, prev: '' }, hash }];
|
|
11
|
-
let index = 0;
|
|
12
|
-
const win = {
|
|
13
|
-
location: {
|
|
14
|
-
get hash() {
|
|
15
|
-
return entries[index].hash;
|
|
16
|
-
},
|
|
17
|
-
set hash(next) {
|
|
18
|
-
entries.splice(index + 1);
|
|
19
|
-
entries.push({ state: null, hash: next });
|
|
20
|
-
index += 1;
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
history: {
|
|
24
|
-
get state() {
|
|
25
|
-
return entries[index].state;
|
|
26
|
-
},
|
|
27
|
-
pushState(state) {
|
|
28
|
-
entries.splice(index + 1);
|
|
29
|
-
entries.push({ state, hash: entries[index].hash });
|
|
30
|
-
index += 1;
|
|
31
|
-
},
|
|
32
|
-
back() {
|
|
33
|
-
index -= 1;
|
|
34
|
-
for (const l of listeners.popstate) l();
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
addEventListener: (type, l) => listeners[type].add(l),
|
|
38
|
-
removeEventListener: (type, l) => listeners[type].delete(l),
|
|
39
|
-
document: {
|
|
40
|
-
addEventListener: (type, l) => listeners[type].add(l),
|
|
41
|
-
removeEventListener: (type, l) => listeners[type].delete(l),
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
const click = (href, extra = {}) => {
|
|
45
|
-
const a = { getAttribute: (name) => (name === 'href' ? href : (extra[name] ?? null)) };
|
|
46
|
-
const e = {
|
|
47
|
-
button: 0,
|
|
48
|
-
defaultPrevented: false,
|
|
49
|
-
target: { closest: () => a },
|
|
50
|
-
preventDefault() {
|
|
51
|
-
this.defaultPrevented = true;
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
for (const l of listeners.click) l(e);
|
|
55
|
-
return e;
|
|
56
|
-
};
|
|
57
|
-
return { win, entries: () => entries.slice(0, index + 1), click, listeners };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
test('the level shown adds a step with the same address and place', () => {
|
|
61
|
-
const { win, entries } = fakeWindow();
|
|
62
|
-
levelStep(win).set(true, () => {});
|
|
63
|
-
assert.equal(entries().length, 2);
|
|
64
|
-
assert.equal(entries()[1].hash, '#list/pages');
|
|
65
|
-
assert.deepEqual(entries()[1].state, { at: 3, prev: '', level: true });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('the back gesture on the level shows the first level and stays on the screen', () => {
|
|
69
|
-
const { win, entries } = fakeWindow();
|
|
70
|
-
let first = 0;
|
|
71
|
-
levelStep(win).set(true, () => (first += 1));
|
|
72
|
-
win.history.back();
|
|
73
|
-
assert.equal(first, 1);
|
|
74
|
-
assert.equal(entries().length, 1);
|
|
75
|
-
assert.equal(win.location.hash, '#list/pages');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('leaving the level another way takes the step out, without the first level called again', () => {
|
|
79
|
-
const { win, entries } = fakeWindow();
|
|
80
|
-
let first = 0;
|
|
81
|
-
const step = levelStep(win);
|
|
82
|
-
step.set(true, () => (first += 1));
|
|
83
|
-
step.set(false, () => (first += 1));
|
|
84
|
-
assert.equal(entries().length, 1);
|
|
85
|
-
assert.equal(first, 0);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('a link on the level is followed after the step is out: no empty step', () => {
|
|
89
|
-
const { win, entries, click } = fakeWindow();
|
|
90
|
-
const step = levelStep(win);
|
|
91
|
-
step.set(true, () => {});
|
|
92
|
-
const e = click('#list/posts');
|
|
93
|
-
assert.equal(e.defaultPrevented, true);
|
|
94
|
-
assert.deepEqual(
|
|
95
|
-
entries().map((s) => s.hash),
|
|
96
|
-
['#list/pages', '#list/posts'],
|
|
97
|
-
);
|
|
98
|
-
// The drawer closes on the link: nothing more to take out.
|
|
99
|
-
step.set(false, () => {});
|
|
100
|
-
assert.equal(entries().length, 2);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('links are left alone at the first level and in a new tab', () => {
|
|
104
|
-
const { win, click } = fakeWindow();
|
|
105
|
-
const step = levelStep(win);
|
|
106
|
-
assert.equal(click('#list/posts').defaultPrevented, false);
|
|
107
|
-
step.set(true, () => {});
|
|
108
|
-
assert.equal(click('#list/posts', { target: '_blank' }).defaultPrevented, false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('stop takes the step out and stops listening', () => {
|
|
112
|
-
const { win, entries, listeners } = fakeWindow();
|
|
113
|
-
const step = levelStep(win);
|
|
114
|
-
step.set(true, () => {});
|
|
115
|
-
step.stop();
|
|
116
|
-
assert.equal(entries().length, 1);
|
|
117
|
-
assert.equal(listeners.popstate.size, 0);
|
|
118
|
-
assert.equal(listeners.click.size, 0);
|
|
119
|
-
});
|