@morphemecms/cms 0.14.2 → 0.14.3-next-20261107160925

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/lib/license-texts/Unsplash.txt +7 -0
  3. package/lib/licenses.d.mts +8 -0
  4. package/lib/licenses.editions.test.mjs +45 -0
  5. package/lib/licenses.mjs +40 -0
  6. package/lib/releaseNotes.d.mts +79 -9
  7. package/lib/releaseNotes.mjs +136 -21
  8. package/lib/releaseNotes.test.mjs +187 -5
  9. package/package.json +1 -1
  10. package/release-notes/editions/0.14.json +12 -0
  11. package/release-notes/editions/0.14.webp +0 -0
  12. package/release-notes/i18n/da-DK/0.14.0.json +12 -11
  13. package/release-notes/i18n/da-DK/0.14.1.json +3 -2
  14. package/release-notes/i18n/da-DK/0.14.2.json +5 -4
  15. package/release-notes/i18n/da-DK/pending/edition-photo-card.json +6 -0
  16. package/release-notes/i18n/da-DK/pending/edition-photo-card.webp +0 -0
  17. package/release-notes/i18n/da-DK/pending/short-notification-buttons.json +1 -0
  18. package/release-notes/i18n/da-DK/pending/version-heading-date-photo.json +5 -0
  19. package/release-notes/i18n/en-US/0.14.0.json +13 -12
  20. package/release-notes/i18n/en-US/0.14.1.json +3 -2
  21. package/release-notes/i18n/en-US/0.14.2.json +5 -4
  22. package/release-notes/i18n/en-US/pending/edition-photo-card.json +6 -0
  23. package/release-notes/i18n/en-US/pending/edition-photo-card.webp +0 -0
  24. package/release-notes/i18n/en-US/pending/short-notification-buttons.json +1 -0
  25. package/release-notes/i18n/en-US/pending/version-heading-date-photo.json +5 -0
  26. package/scripts/release-notes.mjs +2 -1
  27. package/src/App.tsx +4 -1
  28. package/src/CmsPage.tsx +7 -2
  29. package/src/Licenses.tsx +4 -0
  30. package/src/Notifications.tsx +1 -1
  31. package/src/ReleaseNoteLines.tsx +17 -0
  32. package/src/ReleaseNoteSection.tsx +12 -0
  33. package/src/ReleaseNotes.tsx +49 -27
  34. package/src/Screen.tsx +2 -2
  35. package/src/UpdatesNotice.tsx +104 -69
  36. package/src/VersionLinePage.tsx +42 -0
  37. package/src/bundledNotes.ts +40 -5
  38. package/src/editor.css +4 -1
  39. package/src/i18n/da-DK/cms.ts +4 -0
  40. package/src/i18n/da-DK/library.ts +7 -4
  41. package/src/i18n/en-US/cms.ts +7 -0
  42. package/src/i18n/en-US/library.ts +12 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.14.3-next-20261107160925
4
+
5
+ ### Patch Changes
6
+
7
+ - ed6e436: The release notes on the Morpheme page and the start screen are rewritten in plainer words, in English and Danish.
8
+ - aa478c9: After an update, the start screen shows a small card with the edition's photo and name instead of the large list of news.
9
+ - 8b7e313: The buttons under Notifications are now Clear and Settings.
10
+ - b1e7393: The Morpheme page names each version after its edition, with its date and photo, and a test version shows the changes merged since the last release.
11
+
3
12
  ## 0.14.2
4
13
 
5
14
  ### Patch Changes
@@ -0,0 +1,7 @@
1
+ Unsplash grants you an irrevocable, nonexclusive, worldwide copyright license to
2
+ download, copy, modify, distribute, perform, and use photos from Unsplash for free,
3
+ including for commercial purposes, without permission from or attributing the photographer
4
+ or Unsplash. This license does not include the right to compile photos from Unsplash to
5
+ replicate a similar or competing service.
6
+
7
+ https://unsplash.com/license
@@ -98,6 +98,12 @@ export function declarationProblems({ license, thirdParty, folder, where, what,
98
98
  * checked with what it gives in place of what a package lacks (declarationProblems); those they depend on are not.
99
99
  * @param {string} root @param {string[]} folders @param {Set<string>} [skip] */
100
100
  export function codePackageProblems(root: string, folders: string[], skip?: Set<string>): any[];
101
+ /** The photo of each edition the CMS carries (release-notes/editions/<line>.json and .webp, one per minor line, shown on
102
+ * the start screen after an update) as a work in "Licenser": a picture in the editor, its id `editions/<line>`, named after its place
103
+ * (en-US), by its photographer under the Unsplash License (license-texts/Unsplash.txt). An edition without a photographer
104
+ * gives a work without an author, which workProblems stops the build on.
105
+ * @param {string} cmsRoot the CMS package's folder @returns {object[]} */
106
+ export function editionWorks(cmsRoot: string): object[];
101
107
  /** An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
102
108
  * (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
103
109
  * (an app installed; the theme in use, and the standard theme it builds on).
@@ -147,6 +153,8 @@ export const UNLICENSED: "UNLICENSED";
147
153
  export const OWN_LICENSE_FILE: "LICENSE";
148
154
  export function packageName(specifier: any): any;
149
155
  export function ownerId(kind: any, id: any): string;
156
+ /** The licence of the editions' photos (release-notes/editions/), as "Licenser" shows it. */
157
+ export const UNSPLASH_LICENSE: "Unsplash License";
150
158
  /**
151
159
  * An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
152
160
  * (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
@@ -0,0 +1,45 @@
1
+ // Tests editionWorks in licenses.mjs: each edition's photo as a picture in the editor, under the Unsplash License.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { editionWorks, UNSPLASH_LICENSE, workProblems } from './licenses.mjs';
8
+
9
+ const root = (editions) => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'editions-'));
11
+ mkdirSync(join(dir, 'release-notes', 'editions'), { recursive: true });
12
+ for (const [version, edition] of Object.entries(editions))
13
+ writeFileSync(join(dir, 'release-notes', 'editions', `${version}.json`), JSON.stringify(edition));
14
+ return dir;
15
+ };
16
+
17
+ test("an edition's photo is a picture in the editor, by its photographer, under the Unsplash License", () => {
18
+ const [work] = editionWorks(
19
+ root({ 0.14: { name: 'Braies', place: { 'en-US': 'Lago di Braies, Italy' }, photo: { by: 'A Photographer' } } }),
20
+ );
21
+ assert.equal(work.id, 'editions/0.14');
22
+ assert.equal(work.name, 'Lago di Braies, Italy');
23
+ assert.equal(work.author, 'A Photographer');
24
+ assert.equal(work.license, UNSPLASH_LICENSE);
25
+ assert.equal(work.kind, 'image');
26
+ assert.deepEqual(work.where, ['editor']);
27
+ assert.match(work.text, /^Unsplash grants you an irrevocable/);
28
+ assert.deepEqual(workProblems(work, 'x'), []);
29
+ });
30
+
31
+ test('an edition without a photographer is a problem', () => {
32
+ const [work] = editionWorks(root({ 0.14: { name: 'Braies' } }));
33
+ assert.equal(work.name, 'Braies');
34
+ assert.equal(workProblems(work, 'x').length, 1);
35
+ });
36
+
37
+ test('no editions folder gives no works', () => {
38
+ assert.deepEqual(editionWorks(mkdtempSync(join(tmpdir(), 'none-'))), []);
39
+ });
40
+
41
+ test("the CMS's own editions all have a photographer", () => {
42
+ const works = editionWorks(new URL('..', import.meta.url).pathname);
43
+ assert.ok(works.length > 0);
44
+ for (const work of works) assert.deepEqual(workProblems(work, work.id), []);
45
+ });
package/lib/licenses.mjs CHANGED
@@ -433,6 +433,40 @@ const HELPERS = [
433
433
  ];
434
434
  const OWN_VIRTUAL = [/^\0virtual:(app-folders|all-icons|material-symbols|theme-coverage)$/, /^\0site-theme-icons?(:|$)/, /^\0app-self:/];
435
435
 
436
+ // ---------------------------------------------------------------------------------------------------
437
+ // The editions' photos
438
+
439
+ /** The licence of the editions' photos (release-notes/editions/), as "Licenser" shows it. */
440
+ export const UNSPLASH_LICENSE = 'Unsplash License';
441
+
442
+ /** The photo of each edition the CMS carries (release-notes/editions/<line>.json and .webp, one per minor line, shown on
443
+ * the start screen after an update) as a work in "Licenser": a picture in the editor, its id `editions/<line>`, named after its place
444
+ * (en-US), by its photographer under the Unsplash License (license-texts/Unsplash.txt). An edition without a photographer
445
+ * gives a work without an author, which workProblems stops the build on.
446
+ * @param {string} cmsRoot the CMS package's folder @returns {object[]} */
447
+ export function editionWorks(cmsRoot) {
448
+ const dir = join(cmsRoot, 'release-notes', 'editions');
449
+ if (!existsSync(dir)) return [];
450
+ const text = read(fileURLToPath(new URL('./license-texts/Unsplash.txt', import.meta.url)));
451
+ return readdirSync(dir)
452
+ .filter((f) => f.endsWith('.json'))
453
+ .sort()
454
+ .map((f) => {
455
+ const line = f.slice(0, -'.json'.length);
456
+ const edition = JSON.parse(readFileSync(join(dir, f), 'utf8'));
457
+ return {
458
+ id: `editions/${line}`,
459
+ name: edition.place?.['en-US'] ?? edition.name,
460
+ version: null,
461
+ author: edition.photo?.by ?? null,
462
+ license: UNSPLASH_LICENSE,
463
+ text,
464
+ kind: 'image',
465
+ where: ['editor'],
466
+ };
467
+ });
468
+ }
469
+
436
470
  /** An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
437
471
  * (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
438
472
  * (an app installed; the theme in use, and the standard theme it builds on).
@@ -628,6 +662,12 @@ export function licensesIntegration({ root, iconPacks, owners }) {
628
662
  if (pack.theme) packWorks.set(pack.theme, [...(packWorks.get(pack.theme) ?? []), ...own.map((w) => w.id)]);
629
663
  }
630
664
 
665
+ // The editions' photos, which the editor shows after an update.
666
+ for (const work of editionWorks(fileURLToPath(new URL('..', import.meta.url)))) {
667
+ problems.push(...workProblems(work, `Udgaven ${work.id}`));
668
+ add(work);
669
+ }
670
+
631
671
  // The apps' and the themes' own licences and what they declare (checked by npm run check:content, with what they
632
672
  // give in place of what a package lacks), shipped or not; and what is shipped through them alone.
633
673
  const owned = [];
@@ -16,9 +16,10 @@ export function problemsOf(pending: Pending): string[];
16
16
  * ids of the cards that have a screenshot. A locale without notes is left out.
17
17
  * @param {Pending} pending
18
18
  * @param {string} version
19
+ * @param {string} [date] the day it is released (YYYY-MM-DD)
19
20
  * @returns {Record<string, { file: ReleasedNotes, images: string[] }>}
20
21
  */
21
- export function released(pending: Pending, version: string): Record<string, {
22
+ export function released(pending: Pending, version: string, date?: string): Record<string, {
22
23
  file: ReleasedNotes;
23
24
  images: string[];
24
25
  }>;
@@ -37,10 +38,18 @@ export function imagePath(version: string, locale: string, id: string): string;
37
38
  * @returns {ReleaseNote[] | undefined}
38
39
  */
39
40
  export function notesFor(notes: Record<string, ReleaseNote[]> | undefined, locale: string): ReleaseNote[] | undefined;
41
+ /**
42
+ * The day a test version was made, from its snapshot id (0.14.3-next-20261107145215 → 2026-11-07); undefined for another
43
+ * version.
44
+ * @param {string} version
45
+ * @returns {string | undefined}
46
+ */
47
+ export function testVersionDate(version: string): string | undefined;
40
48
  /**
41
49
  * The notes that are new since `from` (the version seen last), in `locale` (else English): those of the entries newer than
42
50
  * it, the newest version first, its cards before its lines. A test version (0.14.1-next.2) has not had the notes of its
43
- * own version yet. Nothing when `from` is not a version.
51
+ * own version yet. An entry of a test version (its merged, not yet released notes) counts as its version without the
52
+ * pre-release. Nothing when `from` is not a version.
44
53
  * @param {{ version: string, notes?: Record<string, ReleaseNote[]> }[]} entries
45
54
  * @param {string | undefined} from
46
55
  * @param {string} locale
@@ -51,21 +60,67 @@ export function notesSince(entries: {
51
60
  notes?: Record<string, ReleaseNote[]>;
52
61
  }[], from: string | undefined, locale: string): ReleaseNote[];
53
62
  /**
54
- * The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
55
- * (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
56
- * (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
57
- * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before.
63
+ * The changelog's versions (CHANGELOG.md, as Changesets writes it), each with its changes as lines: a bullet's text
64
+ * without its commit ("- 3fbb65f: The start screen …" → "The start screen …", its id the commit) and code marks, with the
65
+ * lines it runs on to. Newest first; a version that is not x.y.z is left out.
66
+ * @param {string} changelog
67
+ * @returns {{ version: string, lines: ReleaseNote[] }[]}
68
+ */
69
+ export function changelogSections(changelog: string): {
70
+ version: string;
71
+ lines: ReleaseNote[];
72
+ }[];
73
+ /**
74
+ * The notes of every released version up to `version`, from the files the package carries
75
+ * (release-notes/i18n/<locale>/<version>.json), and before them from its changelog, newest first. A card gets the address
76
+ * of its screenshot (<locale>/<version>/<id>.webp) when that file exists. Newer versions and versions without notes are left out.
77
+ * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before,
78
+ * then, first, its merged notes (`pending`). Each released version has the day it was released (its file's `date`).
58
79
  * It runs as the editor loads (bundledNotes.ts), so it never throws: nothing when `version` is not a version.
59
80
  * @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
60
81
  * @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
61
82
  * @param {string} version the running version
62
- * @returns {{ version: string, text: string, notes: Record<string, ReleaseNote[]> }[]}
83
+ * @param {Record<string, ReleaseNote>} [pending] path -> a note not released yet, e.g. '../release-notes/i18n/en-US/pending/<id>.json':
84
+ * on a test version, the notes merged since the last release, as the first entry, under the running version, dated by
85
+ * its snapshot id (testVersionDate); a card's screenshot <locale>/pending/<id>.webp
86
+ * @param {string} [changelog] the package's CHANGELOG.md: the versions without a notes file (those before the notes),
87
+ * with their changes as lines, in English (changelogSections)
88
+ * @returns {{ version: string, text: string, date?: string, notes: Record<string, ReleaseNote[]> }[]}
63
89
  */
64
- export function bundledNotes(files: Record<string, ReleasedNotes>, images: Record<string, string>, version: string): {
90
+ export function bundledNotes(files: Record<string, ReleasedNotes>, images: Record<string, string>, version: string, pending?: Record<string, ReleaseNote>, changelog?: string): {
65
91
  version: string;
66
92
  text: string;
93
+ date?: string;
67
94
  notes: Record<string, ReleaseNote[]>;
68
95
  }[];
96
+ /**
97
+ * The entries grouped by minor line, newest line first, each line's entries in their order (bundledNotes'): one page each.
98
+ * @template {{ version: string }} T
99
+ * @param {T[]} entries
100
+ * @returns {{ line: string, entries: T[] }[]}
101
+ */
102
+ export function versionLines<T extends {
103
+ version: string;
104
+ }>(entries: T[]): {
105
+ line: string;
106
+ entries: T[];
107
+ }[];
108
+ /** @typedef {{ line: string, name: string, place: Record<string, string>, by: string | null, image: string }} Edition line: its minor line ("0.14"); name: the place its photo is from, short ("Braies"); place: in full, per locale; by: its photographer; image: the photo's address */
109
+ /**
110
+ * The edition of a version's minor line: one photo per line (release-notes/editions/<line>.json with its photo
111
+ * <line>.webp, e.g. 0.14.json for every 0.14 version, a test version 0.14.3-next-… too).
112
+ * @param {Record<string, { name?: string, place?: Record<string, string>, photo?: { by?: string } }>} files path -> the edition's file
113
+ * @param {Record<string, string>} images path -> the photo's address
114
+ * @param {string} version
115
+ * @returns {Edition | undefined} undefined when its line has none, or it has no name or no photo
116
+ */
117
+ export function editionOf(files: Record<string, {
118
+ name?: string;
119
+ place?: Record<string, string>;
120
+ photo?: {
121
+ by?: string;
122
+ };
123
+ }>, images: Record<string, string>, version: string): Edition | undefined;
69
124
  /**
70
125
  * The release notes and screenshots of `version` from an unzipped npm tar. Notes: package/release-notes/i18n/<locale>/<version>.json
71
126
  * per locale. Screenshots: one per card, at imagePath(). A missing or unreadable notes file leaves its locale out; a missing
@@ -109,7 +164,7 @@ export function parseImagePath(pathname: string): {
109
164
  */
110
165
  export function withImageUrls(version: string, notes: Record<string, ReleaseNote[]> | undefined): Record<string, ReleaseNote[]> | undefined;
111
166
  /** @typedef {{ id: string, kind: 'card', title: string, text: string, image?: string } | { id: string, kind: 'line', text: string }} ReleaseNote */
112
- /** @typedef {{ version: string, notes: ReleaseNote[] }} ReleasedNotes */
167
+ /** @typedef {{ version: string, date?: string, notes: ReleaseNote[] }} ReleasedNotes date: the day it was released (YYYY-MM-DD) */
113
168
  /** @typedef {{ width: number, height: number }} ImageSize */
114
169
  /** @typedef {{ note: object, imageBytes: number | null, imageSize: ImageSize | null }} PendingEntry imageBytes: size of <id>.webp in bytes, null when there is none; imageSize: its pixels, null when it is not a WebP */
115
170
  /** @typedef {Record<string, Record<string, PendingEntry>>} Pending locale -> id -> entry */
@@ -118,8 +173,19 @@ export const MAX_IMAGE_BYTES: number;
118
173
  /** 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). */
119
174
  export const IMAGE_WIDTH: 750;
120
175
  export const MAX_IMAGE_HEIGHT: 800;
176
+ export function minorOf(version: string): string;
121
177
  export function staleImageDirs(dirs: string[], version: string): string[];
122
178
  export function imageKey(version: string, locale: string, id: string): string;
179
+ /**
180
+ * line: its minor line ("0.14"); name: the place its photo is from, short ("Braies"); place: in full, per locale; by: its photographer; image: the photo's address
181
+ */
182
+ export type Edition = {
183
+ line: string;
184
+ name: string;
185
+ place: Record<string, string>;
186
+ by: string | null;
187
+ image: string;
188
+ };
123
189
  export type ReleaseNote = {
124
190
  id: string;
125
191
  kind: "card";
@@ -131,8 +197,12 @@ export type ReleaseNote = {
131
197
  kind: "line";
132
198
  text: string;
133
199
  };
200
+ /**
201
+ * date: the day it was released (YYYY-MM-DD)
202
+ */
134
203
  export type ReleasedNotes = {
135
204
  version: string;
205
+ date?: string;
136
206
  notes: ReleaseNote[];
137
207
  };
138
208
  export type ImageSize = {
@@ -2,12 +2,12 @@
2
2
  // picks a locale's notes and those new since a version, 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
4
  // Exposes MAX_IMAGE_BYTES, IMAGE_WIDTH, MAX_IMAGE_HEIGHT, webpSize, problemsOf, released, imagePath, notesFor, notesSince, bundledNotes, staleImageDirs, notesFromTar, imageUrl, imageKey,
5
- // parseImagePath and withImageUrls. Depends on ./cms-version.mjs and ./changelog.mjs; no node: imports.
5
+ // parseImagePath, withImageUrls, editionOf, testVersionDate, changelogSections, minorOf and versionLines. 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';
8
8
 
9
9
  /** @typedef {{ id: string, kind: 'card', title: string, text: string, image?: string } | { id: string, kind: 'line', text: string }} ReleaseNote */
10
- /** @typedef {{ version: string, notes: ReleaseNote[] }} ReleasedNotes */
10
+ /** @typedef {{ version: string, date?: string, notes: ReleaseNote[] }} ReleasedNotes date: the day it was released (YYYY-MM-DD) */
11
11
  /** @typedef {{ width: number, height: number }} ImageSize */
12
12
  /** @typedef {{ note: object, imageBytes: number | null, imageSize: ImageSize | null }} PendingEntry imageBytes: size of <id>.webp in bytes, null when there is none; imageSize: its pixels, null when it is not a WebP */
13
13
  /** @typedef {Record<string, Record<string, PendingEntry>>} Pending locale -> id -> entry */
@@ -118,15 +118,16 @@ export function problemsOf(pending) {
118
118
  * ids of the cards that have a screenshot. A locale without notes is left out.
119
119
  * @param {Pending} pending
120
120
  * @param {string} version
121
+ * @param {string} [date] the day it is released (YYYY-MM-DD)
121
122
  * @returns {Record<string, { file: ReleasedNotes, images: string[] }>}
122
123
  */
123
- export function released(pending, version) {
124
+ export function released(pending, version, date) {
124
125
  const result = {};
125
126
  for (const [locale, entries] of Object.entries(pending)) {
126
127
  const ids = Object.keys(entries).sort();
127
128
  if (ids.length === 0) continue;
128
129
  result[locale] = {
129
- file: { version, notes: ids.map((id) => entries[id].note) },
130
+ file: { version, ...(date ? { date } : {}), notes: ids.map((id) => entries[id].note) },
130
131
  images: ids.filter((id) => entries[id].note.kind === 'card'),
131
132
  };
132
133
  }
@@ -155,11 +156,25 @@ export function notesFor(notes, locale) {
155
156
  }
156
157
 
157
158
  const PLAIN = /^\d+\.\d+\.\d+$/;
159
+ /** A version without its pre-release (0.14.3-next-20261107145215 → 0.14.3). */
160
+ const baseOf = (version) => String(version ?? '').split('-')[0];
161
+
162
+ /**
163
+ * The day a test version was made, from its snapshot id (0.14.3-next-20261107145215 → 2026-11-07); undefined for another
164
+ * version.
165
+ * @param {string} version
166
+ * @returns {string | undefined}
167
+ */
168
+ export function testVersionDate(version) {
169
+ const m = /-next-(\d{4})(\d{2})(\d{2})\d*$/.exec(String(version ?? ''));
170
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : undefined;
171
+ }
158
172
 
159
173
  /**
160
174
  * The notes that are new since `from` (the version seen last), in `locale` (else English): those of the entries newer than
161
175
  * it, the newest version first, its cards before its lines. A test version (0.14.1-next.2) has not had the notes of its
162
- * own version yet. Nothing when `from` is not a version.
176
+ * own version yet. An entry of a test version (its merged, not yet released notes) counts as its version without the
177
+ * pre-release. Nothing when `from` is not a version.
163
178
  * @param {{ version: string, notes?: Record<string, ReleaseNote[]> }[]} entries
164
179
  * @param {string | undefined} from
165
180
  * @param {string} locale
@@ -169,43 +184,143 @@ export function notesSince(entries, from, locale) {
169
184
  const [base, pre] = String(from ?? '').split('-');
170
185
  if (!PLAIN.test(base)) return [];
171
186
  const notes = entries
172
- .filter((entry) => PLAIN.test(entry.version) && compareVersions(entry.version, base) >= (pre === undefined ? 1 : 0))
173
- .sort((a, b) => compareVersions(b.version, a.version))
187
+ .filter((entry) => PLAIN.test(baseOf(entry.version)) && compareVersions(baseOf(entry.version), base) >= (pre === undefined ? 1 : 0))
188
+ .sort((a, b) => compareVersions(baseOf(b.version), baseOf(a.version)))
174
189
  .flatMap((entry) => notesFor(entry.notes, locale) ?? []);
175
190
  return [...notes.filter((note) => note.kind === 'card'), ...notes.filter((note) => note.kind === 'line')];
176
191
  }
192
+ /**
193
+ * The changelog's versions (CHANGELOG.md, as Changesets writes it), each with its changes as lines: a bullet's text
194
+ * without its commit ("- 3fbb65f: The start screen …" → "The start screen …", its id the commit) and code marks, with the
195
+ * lines it runs on to. Newest first; a version that is not x.y.z is left out.
196
+ * @param {string} changelog
197
+ * @returns {{ version: string, lines: ReleaseNote[] }[]}
198
+ */
199
+ export function changelogSections(changelog) {
200
+ // Markdown's code marks are not shown: the page writes plain text.
201
+ const plain = (text) => text.replace(/`([^`]*)`/g, '$1').trim();
202
+ const sections = [];
203
+ let current = null;
204
+ let last = null;
205
+ for (const raw of String(changelog ?? '').split(/\r?\n/)) {
206
+ if (raw.startsWith('## ')) {
207
+ const version = raw.slice(3).trim();
208
+ current = PLAIN.test(version) ? { version, lines: [] } : null;
209
+ if (current) sections.push(current);
210
+ last = null;
211
+ } else if (current && /^- /.test(raw)) {
212
+ const m = /^- (?:([0-9a-f]{7,40}): )?(.*)$/.exec(raw);
213
+ last = { id: `${current.version}-${m[1] ?? current.lines.length}`, kind: 'line', text: plain(m[2]) };
214
+ current.lines.push(last);
215
+ } else if (last && /^\s+\S/.test(raw)) {
216
+ last.text = `${last.text} ${plain(raw)}`;
217
+ } else if (raw.trim() === '' || raw.startsWith('#')) {
218
+ last = null;
219
+ }
220
+ }
221
+ return sections.sort((a, b) => compareVersions(b.version, a.version));
222
+ }
223
+
177
224
  const sameMinor = (a, b) => a.split('.').slice(0, 2).join('.') === b.split('.').slice(0, 2).join('.');
178
225
 
179
226
  /**
180
- * The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
181
- * (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
182
- * (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
183
- * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before.
227
+ * The notes of every released version up to `version`, from the files the package carries
228
+ * (release-notes/i18n/<locale>/<version>.json), and before them from its changelog, newest first. A card gets the address
229
+ * of its screenshot (<locale>/<version>/<id>.webp) when that file exists. Newer versions and versions without notes are left out.
230
+ * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before,
231
+ * then, first, its merged notes (`pending`). Each released version has the day it was released (its file's `date`).
184
232
  * It runs as the editor loads (bundledNotes.ts), so it never throws: nothing when `version` is not a version.
185
233
  * @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
186
234
  * @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
187
235
  * @param {string} version the running version
188
- * @returns {{ version: string, text: string, notes: Record<string, ReleaseNote[]> }[]}
236
+ * @param {Record<string, ReleaseNote>} [pending] path -> a note not released yet, e.g. '../release-notes/i18n/en-US/pending/<id>.json':
237
+ * on a test version, the notes merged since the last release, as the first entry, under the running version, dated by
238
+ * its snapshot id (testVersionDate); a card's screenshot <locale>/pending/<id>.webp
239
+ * @param {string} [changelog] the package's CHANGELOG.md: the versions without a notes file (those before the notes),
240
+ * with their changes as lines, in English (changelogSections)
241
+ * @returns {{ version: string, text: string, date?: string, notes: Record<string, ReleaseNote[]> }[]}
189
242
  */
190
- export function bundledNotes(files, images, version) {
243
+ export function bundledNotes(files, images, version, pending = {}, changelog = '') {
191
244
  const [base, pre] = String(version ?? '').split('-');
192
245
  if (!PLAIN.test(base)) return [];
193
246
  const byVersion = {};
247
+ const dates = {};
248
+ const withImage = (note, locale, folder) => {
249
+ if (note?.kind !== 'card') return note;
250
+ const image = Object.entries(images).find(([imagePath]) => imagePath.endsWith(`/i18n/${locale}/${folder}/${note.id}.webp`));
251
+ return image ? { ...note, image: image[1] } : note;
252
+ };
194
253
  for (const [path, file] of Object.entries(files)) {
195
254
  const match = /\/i18n\/([^/]+)\/([^/]+)\.json$/.exec(path);
196
255
  if (!match || !Array.isArray(file?.notes)) continue;
197
256
  const [, locale, v] = match;
198
- if (!PLAIN.test(v) || !sameMinor(v, base) || compareVersions(v, base) >= (pre === undefined ? 1 : 0)) continue;
257
+ if (!PLAIN.test(v) || compareVersions(v, base) >= (pre === undefined ? 1 : 0)) continue;
199
258
  byVersion[v] ??= {};
200
- byVersion[v][locale] = file.notes.map((note) => {
201
- if (note?.kind !== 'card') return note;
202
- const image = Object.entries(images).find(([imagePath]) => imagePath.endsWith(`/i18n/${locale}/${v}/${note.id}.webp`));
203
- return image ? { ...note, image: image[1] } : note;
204
- });
259
+ byVersion[v][locale] = file.notes.map((note) => withImage(note, locale, v));
260
+ if (typeof file.date === 'string') dates[v] ??= file.date;
261
+ }
262
+ // The versions from before the notes files: their changelog lines, in English.
263
+ for (const section of changelogSections(changelog)) {
264
+ const v = section.version;
265
+ if (byVersion[v] || compareVersions(v, base) >= (pre === undefined ? 1 : 0) || !section.lines.length) continue;
266
+ byVersion[v] = { 'en-US': section.lines };
205
267
  }
206
- return Object.keys(byVersion)
268
+ const entries = Object.keys(byVersion)
207
269
  .sort((a, b) => compareVersions(b, a))
208
- .map((v) => ({ version: v, text: '', notes: byVersion[v] }));
270
+ .map((v) => ({ version: v, text: '', ...(dates[v] ? { date: dates[v] } : {}), notes: byVersion[v] }));
271
+ if (pre === undefined) return entries;
272
+ // A test version: the notes merged since the last release, by locale, sorted by id as release:version will.
273
+ const merged = {};
274
+ for (const [path, note] of Object.entries(pending).sort(([a], [b]) => a.localeCompare(b))) {
275
+ const locale = /\/i18n\/([^/]+)\/pending\/[^/]+\.json$/.exec(path)?.[1];
276
+ if (!locale || !note?.kind) continue;
277
+ (merged[locale] ??= []).push(withImage(note, locale, 'pending'));
278
+ }
279
+ if (!Object.keys(merged).length) return entries;
280
+ const date = testVersionDate(version);
281
+ return [{ version, text: '', ...(date ? { date } : {}), notes: merged }, ...entries];
282
+ }
283
+
284
+ /**
285
+ * A version's minor line, as its page names it (0.14.3-next-… → "0.14").
286
+ * @param {string} version
287
+ * @returns {string}
288
+ */
289
+ export const minorOf = (version) => baseOf(version).split('.').slice(0, 2).join('.');
290
+
291
+ /**
292
+ * The entries grouped by minor line, newest line first, each line's entries in their order (bundledNotes'): one page each.
293
+ * @template {{ version: string }} T
294
+ * @param {T[]} entries
295
+ * @returns {{ line: string, entries: T[] }[]}
296
+ */
297
+ export function versionLines(entries) {
298
+ const lines = new Map();
299
+ for (const entry of entries) {
300
+ const line = minorOf(entry.version);
301
+ if (!lines.has(line)) lines.set(line, []);
302
+ lines.get(line).push(entry);
303
+ }
304
+ return [...lines].map(([line, list]) => ({ line, entries: list })).sort((a, b) => compareVersions(`${b.line}.0`, `${a.line}.0`));
305
+ }
306
+
307
+ /** @typedef {{ line: string, name: string, place: Record<string, string>, by: string | null, image: string }} Edition line: its minor line ("0.14"); name: the place its photo is from, short ("Braies"); place: in full, per locale; by: its photographer; image: the photo's address */
308
+
309
+ /**
310
+ * The edition of a version's minor line: one photo per line (release-notes/editions/<line>.json with its photo
311
+ * <line>.webp, e.g. 0.14.json for every 0.14 version, a test version 0.14.3-next-… too).
312
+ * @param {Record<string, { name?: string, place?: Record<string, string>, photo?: { by?: string } }>} files path -> the edition's file
313
+ * @param {Record<string, string>} images path -> the photo's address
314
+ * @param {string} version
315
+ * @returns {Edition | undefined} undefined when its line has none, or it has no name or no photo
316
+ */
317
+ export function editionOf(files, images, version) {
318
+ if (!PLAIN.test(baseOf(version))) return undefined;
319
+ const line = minorOf(version);
320
+ const file = Object.entries(files).find(([path]) => path.endsWith(`/editions/${line}.json`))?.[1];
321
+ const image = Object.entries(images).find(([path]) => path.endsWith(`/editions/${line}.webp`))?.[1];
322
+ if (!file || !image || !isFilled(file.name)) return undefined;
323
+ return { line, name: file.name.trim(), place: file.place ?? {}, by: file.photo?.by ?? null, image };
209
324
  }
210
325
 
211
326
  /**