@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 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
@@ -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 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[]}
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 installedVersions(update: {
49
- from?: string;
50
- to: string;
51
- state: string;
52
- } | null, running: string, kept: string[]): string[];
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;
@@ -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, lists the versions an update brought and reads a version's notes and screenshots from its tar.
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, installedVersions, notesFromTar, imageUrl, imageKey,
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 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[]}
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 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));
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, lists
2
- // installed versions and reads notes and screenshots from a tar.
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
- installedVersions,
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
- 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']);
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('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']);
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('installedVersions gives only the running version, and only when it is kept', () => {
174
- assert.deepEqual(installedVersions(null, '0.14.1', ['0.14.0']), []);
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.0",
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. Run by release:version after changeset version.
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 the update just installed,
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
- {view.installed.length > 0 && <ReleaseNotes title={t.cms.newInYourVersion} entries={view.installed} />}
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. In the modal drawer a section's list is a step in the history (levelStep.mjs): the phone's back gesture
6
- // goes from it to the first level. Exposes MainMenu. The tree and the open place are lib/menu.mjs
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
- // A section's list in the modal drawer: the back gesture goes to the first level (levelStep.mjs), not to the screen before.
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
- step.current = levelStep(window);
87
- return () => step.current?.stop();
88
- }, []);
89
- useEffect(() => {
90
- step.current?.set(modal && open && shown !== null, () => {
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
@@ -3,7 +3,6 @@ export function cmsView(status: any, now?: number): {
3
3
  checkedAt: any;
4
4
  latest: any;
5
5
  notes: any[];
6
- installed: any;
7
6
  action: string | null;
8
7
  npmCommand: string | null;
9
8
  update: {
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. `installed` passes the notes of the update just installed on as the Worker gives them.
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
- const installed = Array.isArray(status.installed) ? status.installed : [];
81
-
82
- return { running, checkedAt, latest, notes, installed, action, npmCommand, update, problems };
80
+ return { running, checkedAt, latest, notes, action, npmCommand, update, problems };
83
81
  }
@@ -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
- });
@@ -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.0",
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",
@@ -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 and the installed ones, with the screenshot address on each card. Depends on github.js, session.js, push.js and lib/{lockfile,cms-version,changelog,releaseNotes}.mjs;
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, installedVersions, notesFromTar, parseImagePath, withImageUrls } from '../lib/releaseNotes.mjs';
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`) and those of the versions installed (`installed`, see installedVersions). Each entry is
215
- * `{ version, text, notes? }`; `notes` maps a locale to its release notes. Both lists stay empty when the running version is a pre-release.
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
 
@@ -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
- }
@@ -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
- });