@morphemecms/cms 0.11.0 → 0.13.0-next-20261106203613

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 (186) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/astro.mjs +2 -1
  3. package/defaults/blockTexts.ts +9 -9
  4. package/defaults/collections.ts +257 -257
  5. package/defaults/colors.ts +74 -74
  6. package/defaults/company.ts +9 -9
  7. package/defaults/editor.ts +65 -65
  8. package/defaults/i18n/da-DK/blockTexts.ts +12 -0
  9. package/defaults/i18n/da-DK/collections.ts +220 -0
  10. package/defaults/i18n/da-DK/colors.ts +102 -0
  11. package/defaults/i18n/da-DK/company.ts +7 -0
  12. package/defaults/i18n/da-DK/editor.ts +13 -0
  13. package/defaults/i18n/da-DK/index.ts +31 -0
  14. package/defaults/i18n/da-DK/internalLinks.ts +12 -0
  15. package/defaults/i18n/da-DK/opens.ts +7 -0
  16. package/defaults/i18n/da-DK/popup.ts +9 -0
  17. package/defaults/i18n/da-DK/privacy.ts +38 -0
  18. package/defaults/i18n/da-DK/rules.ts +56 -0
  19. package/defaults/i18n/da-DK/sitedata.ts +8 -0
  20. package/defaults/i18n/da-DK/standard.ts +8 -0
  21. package/defaults/i18n/da-DK/standardPages.ts +17 -0
  22. package/defaults/internalLinks.ts +8 -8
  23. package/defaults/opens.ts +2 -2
  24. package/defaults/popup.tsx +7 -7
  25. package/defaults/privacy.ts +37 -37
  26. package/defaults/rules.ts +68 -66
  27. package/defaults/schema.ts +1 -1
  28. package/defaults/sitedata.ts +4 -4
  29. package/defaults/standard.ts +9 -6
  30. package/defaults/standardPages.ts +14 -14
  31. package/defaults/texts.ts +7 -510
  32. package/lib/appId.d.mts +1 -0
  33. package/lib/appId.mjs +6 -0
  34. package/lib/appRoute.d.mts +2 -2
  35. package/lib/appRoute.mjs +3 -3
  36. package/lib/appRoute.test.mjs +8 -8
  37. package/lib/apps.mjs +13 -3
  38. package/lib/apps.test.mjs +10 -1
  39. package/lib/contracts.d.mts +3 -2
  40. package/lib/contracts.mjs +3 -2
  41. package/lib/folderChanges.d.mts +29 -0
  42. package/lib/folderChanges.mjs +34 -0
  43. package/lib/licenses.mjs +2 -2
  44. package/lib/licenses.owner.test.mjs +9 -0
  45. package/lib/menu.mjs +4 -4
  46. package/lib/menu.test.mjs +101 -101
  47. package/lib/scheduled.mjs +1 -1
  48. package/lib/section-ranges.d.mts +1 -1
  49. package/lib/section-ranges.mjs +8 -14
  50. package/lib/section-ranges.test.mjs +26 -5
  51. package/lib/storeEntries.d.mts +3 -1
  52. package/lib/storeEntries.mjs +22 -8
  53. package/lib/storeEntries.test.mjs +59 -7
  54. package/lib/text-sections.d.mts +11 -1
  55. package/lib/text-sections.mjs +18 -8
  56. package/lib/text-sections.test.mjs +34 -1
  57. package/lib/translate.d.mts +11 -0
  58. package/lib/translate.mjs +13 -1
  59. package/lib/translate.test.mjs +23 -2
  60. package/lib/video-id.d.mts +6 -2
  61. package/lib/video-id.mjs +5 -1
  62. package/lib/video.d.mts +6 -2
  63. package/lib/video.mjs +5 -1
  64. package/lib/without-h1.d.mts +1 -0
  65. package/lib/without-h1.mjs +19 -4
  66. package/lib/without-h1.test.mjs +30 -1
  67. package/package.json +3 -2
  68. package/scripts/app-screenshots.mjs +9 -8
  69. package/scripts/check-content.mjs +12 -2
  70. package/scripts/check-language.mjs +0 -1
  71. package/scripts/check-site-types.mjs +26 -0
  72. package/scripts/editor-screens.mjs +3 -3
  73. package/scripts/site-types.mjs +14 -0
  74. package/scripts/site-types.test.mjs +21 -0
  75. package/site/activeTheme.test.mjs +52 -0
  76. package/site/appSelf.d.mts +2 -0
  77. package/site/appSelf.mjs +23 -0
  78. package/site/appSelf.test.mjs +35 -0
  79. package/site/content.ts +5 -2
  80. package/site/defineSite.mjs +2 -2
  81. package/site/schema.ts +3 -3
  82. package/site/siteModules.mjs +2 -1
  83. package/site/theme.mjs +19 -8
  84. package/src/Advanced.tsx +2 -2
  85. package/src/App.tsx +79 -78
  86. package/src/AppAdds.tsx +1 -1
  87. package/src/AppList.tsx +39 -24
  88. package/src/BlockDialog.tsx +39 -18
  89. package/src/BlockParts.tsx +6 -6
  90. package/src/BlockSettings.tsx +2 -2
  91. package/src/BrandForm.tsx +2 -2
  92. package/src/Colors.tsx +6 -6
  93. package/src/CompanyForm.tsx +1 -1
  94. package/src/Consents.tsx +3 -3
  95. package/src/CreateFab.tsx +8 -0
  96. package/src/DataForm.tsx +10 -20
  97. package/src/Deleted.tsx +3 -3
  98. package/src/EditorFooter.tsx +2 -2
  99. package/src/EntryForm.tsx +3 -3
  100. package/src/FooterSettings.tsx +2 -2
  101. package/src/FormFrame.tsx +4 -3
  102. package/src/HeaderSettings.tsx +3 -3
  103. package/src/Keywords.tsx +5 -5
  104. package/src/Library.tsx +75 -82
  105. package/src/Licenses.tsx +8 -8
  106. package/src/ListFab.tsx +13 -0
  107. package/src/MainDrawerParts.tsx +3 -3
  108. package/src/MainMenu.tsx +1 -1
  109. package/src/MarkdownEditor.tsx +88 -47
  110. package/src/Media.tsx +11 -20
  111. package/src/MediaTags.tsx +2 -2
  112. package/src/Notifications.tsx +2 -2
  113. package/src/Parts.tsx +17 -17
  114. package/src/Redirects.tsx +14 -22
  115. package/src/Screen.tsx +16 -6
  116. package/src/SeoScores.tsx +6 -6
  117. package/src/SideColumn.tsx +2 -2
  118. package/src/StartFab.tsx +6 -8
  119. package/src/ThemeStore.tsx +37 -16
  120. package/src/Tokens.tsx +2 -2
  121. package/src/apps.ts +7 -8
  122. package/src/checkSite.ts +86 -20
  123. package/src/drawerYield.d.mts +3 -0
  124. package/src/drawerYield.mjs +10 -0
  125. package/src/drawerYield.test.mjs +22 -0
  126. package/src/editor.css +59 -38
  127. package/src/examples.ts +1 -1
  128. package/src/fabScreens.test.mjs +28 -0
  129. package/src/fabScreens.ts +16 -0
  130. package/src/i18n/da-DK/library.ts +2 -2
  131. package/src/i18n/da-DK/text.ts +1 -1
  132. package/src/i18n/en-US/apps.ts +1 -1
  133. package/src/i18n/en-US/common.ts +8 -8
  134. package/src/i18n/en-US/library.ts +5 -5
  135. package/src/i18n/en-US/parts.ts +3 -3
  136. package/src/i18n/en-US/text.ts +1 -1
  137. package/src/i18n/index.ts +4 -8
  138. package/src/images.ts +2 -2
  139. package/src/legacyMove.test.mjs +137 -0
  140. package/src/legacyMove.ts +131 -0
  141. package/src/licenseData.ts +3 -3
  142. package/src/markdown.mjs +10 -7
  143. package/src/markdown.test.mjs +16 -2
  144. package/src/menuChildren.ts +5 -5
  145. package/src/menuOpen.ts +20 -4
  146. package/src/movedRoutes.d.mts +8 -1
  147. package/src/movedRoutes.mjs +102 -21
  148. package/src/movedRoutes.test.mjs +128 -6
  149. package/src/nav.ts +1 -1
  150. package/src/notificationData.ts +1 -1
  151. package/src/pageH1Panel.d.mts +4 -0
  152. package/src/pageH1Panel.mjs +31 -0
  153. package/src/pageH1Panel.test.mjs +56 -0
  154. package/src/pagePreview.tsx +22 -12
  155. package/src/schema.ts +2 -2
  156. package/src/sectionFrame.ts +3 -3
  157. package/src/sectionState.d.mts +3 -3
  158. package/src/sectionState.mjs +6 -6
  159. package/src/sectionState.test.mjs +14 -2
  160. package/src/sheetPlacement.d.mts +6 -4
  161. package/src/sheetPlacement.mjs +15 -10
  162. package/src/sheetPlacement.test.mjs +16 -9
  163. package/src/sidePanel.ts +38 -0
  164. package/src/site.ts +37 -13
  165. package/src/standard/accordion/index.tsx +1 -1
  166. package/src/standard/accordion/shared.ts +1 -1
  167. package/src/standard/buttons/index.tsx +1 -1
  168. package/src/standard/cards/index.tsx +1 -1
  169. package/src/standard/cards/shared.ts +1 -1
  170. package/src/standard/cookies/index.tsx +1 -1
  171. package/src/standard/posts/index.tsx +1 -1
  172. package/src/standard/testimonials/index.tsx +1 -1
  173. package/src/standard/testimonials/list.ts +1 -1
  174. package/src/store.ts +36 -31
  175. package/src/tagUses.d.mts +1 -1
  176. package/src/tagUses.mjs +2 -2
  177. package/src/tagUses.test.mjs +1 -1
  178. package/src/topBarSlot.tsx +7 -15
  179. package/src/ui.tsx +33 -22
  180. package/src/virtual.d.ts +6 -0
  181. package/starter/package.json +1 -1
  182. package/theme/components/PageHeading.astro +22 -0
  183. package/theme/layouts/Base.astro +2 -2
  184. package/theme/layouts/PageLayout.astro +37 -20
  185. package/theme/styles/global.css +2 -1
  186. package/theme/styles/ui.css +40 -10
@@ -0,0 +1,131 @@
1
+ // The legacy move: a store app or theme installed under its old id (folder `src/apps/mailchimp/`) moves to its permanent
2
+ // id (`app_` or `theme_` plus 16 hex) when it is updated. Exposes legacyOf (is a legacy copy installed?), installedHere (does the store list show an entry as installed?) and moveChanges
3
+ // (one list of changes: new folder, old files deleted, every id rewritten; docs/store-ids.md Table 1). Pure; the site's
4
+ // paths are passed in (store.ts binds them to the site's config). Depends on lib/folderChanges.mjs.
5
+ import type { Change, RepoFile } from './github';
6
+ import { folderChanges } from '../lib/folderChanges.mjs';
7
+ import type { StoreRecord } from './store';
8
+
9
+ /** Where a site keeps what names an app or a theme (the site's config, and the popups' folder). */
10
+ export type SitePaths = {
11
+ appsFolder: string;
12
+ themesFolder: string;
13
+ installed: string;
14
+ settings: string;
15
+ theme: string;
16
+ icons: string;
17
+ blockSettings: string;
18
+ popups: string;
19
+ cookieList: string;
20
+ };
21
+
22
+ type Next = { files: { path: string; content: string | Uint8Array }[]; record: StoreRecord };
23
+
24
+ /** The old id of a catalog entry (`entry.legacy`) when a folder of that name is installed from the store (`folders`: the
25
+ * apps or themes the site has, with `store` when the folder has a store.json); else null (none, or the site's own). */
26
+ export function legacyOf(entry: { legacy?: string }, folders: { id: string; store?: unknown }[]): string | null {
27
+ return entry.legacy && folders.some((f) => f.id === entry.legacy && f.store) ? entry.legacy : null;
28
+ }
29
+
30
+ /** Whether the store's list counts a catalog entry as installed: its own id is installed, or its old id (`entry.legacy`)
31
+ * is, as a copy from the store (`folders`: the apps or themes the site has). `installed`: the ids installed. */
32
+ export function installedHere(
33
+ entry: { id: string; legacy?: string },
34
+ installed: string[],
35
+ folders: { id: string; store?: unknown }[],
36
+ ): boolean {
37
+ const old = legacyOf(entry, folders);
38
+ return installed.includes(entry.id) || (old !== null && installed.includes(old));
39
+ }
40
+
41
+ const parse = (text: string): any => {
42
+ try {
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ };
48
+
49
+ /** A file with a value changed (`change` gives the new JSON, or undefined when nothing in it names the legacy id). */
50
+ function rewrite(files: RepoFile[], change: (json: any) => any): Change[] {
51
+ return files.flatMap((f) => {
52
+ const json = parse(f.text);
53
+ const out = json && typeof json === 'object' ? change(json) : undefined;
54
+ return out === undefined ? [] : [{ path: f.path, content: `${JSON.stringify(out, null, 2)}\n`, oid: f.oid }];
55
+ });
56
+ }
57
+
58
+ const inFolder = (site: RepoFile[], folder: string) => site.filter((f) => f.path.startsWith(`${folder}/`) && f.path.endsWith('.json'));
59
+ const only = (site: RepoFile[], path: string) => site.filter((f) => f.path === path);
60
+
61
+ /** The changes that make the folder `current` (the legacy folder, or the item's own when it has no legacy) the store's
62
+ * version of `entry` in its permanent folder. With a legacy id they also delete the legacy folder's files and rewrite
63
+ * every value that names it (`site`: the site's files now). In one commit. */
64
+ export function moveChanges(
65
+ entry: { id: string },
66
+ legacy: string | null,
67
+ next: Next,
68
+ current: RepoFile[],
69
+ site: RepoFile[],
70
+ paths: SitePaths,
71
+ ): Change[] {
72
+ const isApp = entry.id.startsWith('app_');
73
+ const folder = (id: string) => `${isApp ? paths.appsFolder : paths.themesFolder}/${id}`;
74
+ if (!legacy) return folderChanges(folder(entry.id), current, next);
75
+ const swap = (v: unknown) => (v === legacy ? entry.id : undefined);
76
+ const changes = [...folderChanges(folder(entry.id), [], next), ...folderChanges(folder(legacy), current, null)];
77
+ if (isApp) {
78
+ changes.push(
79
+ ...rewrite(only(site, paths.installed), (j) =>
80
+ Array.isArray(j.installed) && j.installed.includes(legacy)
81
+ ? { ...j, installed: [...new Set(j.installed.map((x: unknown) => (x === legacy ? entry.id : x)))].sort() }
82
+ : undefined,
83
+ ),
84
+ ...rewrite(inFolder(site, paths.popups), (j) => (swap(j.type) ? { ...j, type: entry.id } : undefined)),
85
+ ...rewrite(inFolder(site, paths.cookieList), (j) => (swap(j.app) ? { ...j, app: entry.id } : undefined)),
86
+ ...rewrite(only(site, paths.blockSettings), (j) => {
87
+ const off = Array.isArray(j.off)
88
+ ? j.off.map((x: unknown) => (typeof x === 'string' && x.endsWith(`/${legacy}`) ? `${x.slice(0, -legacy.length)}${entry.id}` : x))
89
+ : [];
90
+ return off.some((x: unknown, i: number) => x !== j.off[i]) ? { ...j, off } : undefined;
91
+ }),
92
+ );
93
+ // The settings file is renamed with its content kept.
94
+ const old = site.find((f) => f.path === `${paths.settings}/${legacy}.json`);
95
+ if (old)
96
+ changes.push(
97
+ { path: old.path, content: null, oid: old.oid },
98
+ { path: `${paths.settings}/${entry.id}.json`, content: old.text, oid: null },
99
+ );
100
+ } else {
101
+ changes.push(
102
+ ...rewrite(only(site, paths.theme), (j) => (swap(j.theme) ? { ...j, theme: entry.id } : undefined)),
103
+ ...rewrite(only(site, paths.icons), (j) =>
104
+ typeof j.pack === 'string' && j.pack.startsWith(`${legacy}/`)
105
+ ? { ...j, pack: `${entry.id}${j.pack.slice(legacy.length)}` }
106
+ : undefined,
107
+ ),
108
+ );
109
+ }
110
+ return changes;
111
+ }
112
+
113
+ /** The site's files as they are after `changes` (a changed file with its new text, a deleted one gone, a new one added),
114
+ * for what must be worked out from the site after a move (the cookie list). */
115
+ export function appliedTo(site: RepoFile[], changes: Change[]): RepoFile[] {
116
+ const by = new Map(changes.map((c) => [c.path, c]));
117
+ const kept = site.flatMap((f) => {
118
+ const c = by.get(f.path);
119
+ return !c ? [f] : typeof c.content === 'string' ? [{ ...f, text: c.content }] : [];
120
+ });
121
+ const added = changes.filter((c) => typeof c.content === 'string' && !site.some((f) => f.path === c.path));
122
+ return [...kept, ...added.map((c) => ({ path: c.path, oid: '', text: c.content as string }))];
123
+ }
124
+
125
+ /** Two lists of changes as one: for a path in both, the later one wins (a file rewritten by the move, then deleted by the
126
+ * cookie list), its `oid` that of the site's file; so no file is changed twice. */
127
+ export function mergeChanges(first: Change[], later: Change[]): Change[] {
128
+ const oid = new Map(first.map((c) => [c.path, c.oid]));
129
+ const paths = new Set(later.map((c) => c.path));
130
+ return [...first.filter((c) => !paths.has(c.path)), ...later.map((c) => (oid.has(c.path) ? { ...c, oid: oid.get(c.path)! } : c))];
131
+ }
@@ -20,8 +20,8 @@ export type ThirdParty =
20
20
  /** The licence of one's own work that grants nothing ("all rights reserved"), as npm writes it. */
21
21
  export const UNLICENSED = 'UNLICENSED';
22
22
 
23
- /** Who brings a work: the editor, the site's own code, or an app or a theme (`app/<id>`, `tema/<id>`, LicenseOwner.id). */
24
- export type LicenseWhere = 'editor' | 'site' | `app/${string}` | `tema/${string}`;
23
+ /** Who brings a work: the editor, the site's own code, or an app or a theme (`app/<id>`, `theme/<id>`, LicenseOwner.id). */
24
+ export type LicenseWhere = 'editor' | 'site' | `app/${string}` | `theme/${string}`;
25
25
 
26
26
  /** A third-party work as the build found it: its id (a package's name; `ikoner/<id>` for an icon set; `<owner>/<name>`
27
27
  * for another work an app or a theme declares), its name, version, who made it, its licence's name and text (`standard`:
@@ -46,7 +46,7 @@ export type LicensedWork = {
46
46
  /** An app or a theme with its own licence (`text`: LICENSE in its folder, none when it is UNLICENSED) and the works it
47
47
  * brings (their ids: those it declares, and those shipped through them alone), and whether the site is built with it. */
48
48
  export type LicenseOwner = {
49
- id: `app/${string}` | `tema/${string}`;
49
+ id: `app/${string}` | `theme/${string}`;
50
50
  kind: 'app' | 'theme';
51
51
  /** Its folder's name in src/apps/ or src/themes/. */
52
52
  key: string;
package/src/markdown.mjs CHANGED
@@ -130,8 +130,8 @@ export const textColorSchema = $markSchema('textColor', () => ({
130
130
  },
131
131
  }));
132
132
 
133
- // The colour of a section (packages/editor/lib/section-marker.mjs): the marker line before an H2, or before the label above it
134
- // (eyebrow), is read into the H2's attribute `section` and written back before it. It must be read before remarkSiteBlocks
133
+ // The colour of a section (packages/editor/lib/section-marker.mjs): the marker line before an H1 or H2, or before the label above it
134
+ // (eyebrow), is read into the heading's attribute `section` and written back before it. It must be read before remarkSiteBlocks
135
135
  // sees it, so it is the first plugin of that kind in makeEditor. A marker with nothing to colour is dropped.
136
136
  const htmlOf = (node) => {
137
137
  const only = node?.type === 'paragraph' && node.children?.length === 1 ? node.children[0] : node;
@@ -143,12 +143,12 @@ const remarkSectionMarkers = $remark('remarkSectionMarkers', () => () => (tree)
143
143
  const color = markerColor(htmlOf(child));
144
144
  if (color === null) return true;
145
145
  const next = isLabelLine(htmlOf(parts[i + 1])) ? parts[i + 2] : parts[i + 1];
146
- if (next?.type === 'heading' && next.depth === 2) next.section = color;
146
+ if (next?.type === 'heading' && next.depth <= 2) next.section = color;
147
147
  return false;
148
148
  });
149
149
  });
150
150
 
151
- // The commonmark heading with the section colour as an attribute (only an H2 has one). Written back before the heading, or
151
+ // The commonmark heading with the section colour as an attribute (only an H1 or H2 has one). Written back before the heading, or
152
152
  // before its label, which is already the last thing written (a line of HTML).
153
153
  /** @type {import('@milkdown/kit/utils').$NodeSchema<'heading'>} */
154
154
  export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) => {
@@ -159,7 +159,7 @@ export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) =>
159
159
  parseMarkdown: {
160
160
  match: base.parseMarkdown.match,
161
161
  runner: (state, node, type) => {
162
- state.openNode(type, { level: node.depth, section: node.depth === 2 ? (node.section ?? null) : null });
162
+ state.openNode(type, { level: node.depth, section: node.depth <= 2 ? (node.section ?? null) : null });
163
163
  state.next(node.children);
164
164
  state.closeNode();
165
165
  },
@@ -168,8 +168,11 @@ export const headingWithSection = headingSchema.extendSchema((prev) => (ctx) =>
168
168
  match: base.toMarkdown.match,
169
169
  runner: (state, node) => {
170
170
  const { level, section } = node.attrs;
171
- const written = state.top()?.children;
172
- if (level === 2 && SECTION_COLORS.includes(section) && Array.isArray(written)) {
171
+ const top = state.top();
172
+ // The document has no children before its first node is written: a heading at the very top starts them.
173
+ if (top && !top.children) top.children = [];
174
+ const written = top?.children;
175
+ if (level <= 2 && SECTION_COLORS.includes(section) && Array.isArray(written)) {
173
176
  const last = written.at(-1);
174
177
  const at = last?.type === 'html' && isLabelLine(last.value) ? written.length - 1 : written.length;
175
178
  written.splice(at, 0, { type: 'html', value: sectionMarker(section) });
@@ -1,4 +1,4 @@
1
- // Tests for src/markdown.mjs: the section colour marker is read into the H2's `section` attribute and written back as it was
1
+ // Tests for src/markdown.mjs: the section colour marker is read into the H1's or H2's `section` attribute and written back as it was
2
2
  // (before the label, if any). Milkdown needs a DOM; jsdom stands in. Run: node --test packages/editor/src/markdown.test.mjs
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
@@ -46,6 +46,20 @@ test('a coloured section comes back byte for byte', async () => {
46
46
  assert.deepEqual(nodes, ['heading', 'paragraph', 'heading:tint', 'paragraph', 'heading:accent', 'paragraph']);
47
47
  });
48
48
 
49
+ test('a coloured H1 comes back byte for byte', async () => {
50
+ const text = marked('tint', '# About\n\nText\n');
51
+ const { saved, nodes } = await through(text);
52
+ assert.equal(saved, text);
53
+ assert.deepEqual(nodes, ['heading:tint', 'paragraph']);
54
+ });
55
+
56
+ test('the marker comes before a label above the H1', async () => {
57
+ const text = marked('accent', '<p class="block-eyebrow">Welcome</p>\n\n# About\n\nText\n');
58
+ const { saved, nodes } = await through(text);
59
+ assert.equal(saved, text);
60
+ assert.ok(nodes.includes('heading:accent'));
61
+ });
62
+
49
63
  test('a text without markers is written as before', async () => {
50
64
  const text = '# Title\n\n## One\n\nText\n';
51
65
  assert.equal((await through(text)).saved, text);
@@ -58,7 +72,7 @@ test('the marker comes before a label above the H2', async () => {
58
72
  assert.ok(nodes.includes('heading:surface'));
59
73
  });
60
74
 
61
- test('a marker not followed by an H2 is dropped', async () => {
75
+ test('a marker not followed by an H1 or H2 is dropped', async () => {
62
76
  assert.deepEqual(await through(marked('tint', 'Plain text\n\n## B\n')), {
63
77
  saved: 'Plain text\n\n## B\n',
64
78
  nodes: ['paragraph', 'heading'],
@@ -18,16 +18,16 @@ const listOf = (ids: string[]): MenuChild[] =>
18
18
 
19
19
  /** The sub-items of a menu item, in their order: none for most. */
20
20
  export function childrenOf(id: string, files: RepoFile[]): MenuChild[] {
21
- if (id === 'lister') return listOf(listsShown(files));
22
- if (id === 'virksomheden') return companyParts.map((p) => ({ id: p.id, label: p.label, href: `#virksomheden/${p.id}` }));
21
+ if (id === 'lists') return listOf(listsShown(files));
22
+ if (id === 'company') return companyParts.map((p) => ({ id: p.id, label: p.label, href: `#company/${p.id}` }));
23
23
  if (id === 'apps') {
24
24
  const installed = installedApps(files);
25
25
  return apps.filter((a) => installed.includes(a.id)).map((a) => ({ id: a.id, label: a.label, href: `#apps/${a.id}` }));
26
26
  }
27
- if (id === 'videresendelser') {
27
+ if (id === 'redirects') {
28
28
  return [
29
- { id: 'alle', label: t.redirects.all, href: `#${TAB_ROUTES.redirects}` },
30
- { id: 'ikke-fundet', label: t.redirects.missing.title, href: `#${TAB_ROUTES.missing}` },
29
+ { id: 'all', label: t.redirects.all, href: `#${TAB_ROUTES.redirects}` },
30
+ { id: 'not-found', label: t.redirects.missing.title, href: `#${TAB_ROUTES.missing}` },
31
31
  ];
32
32
  }
33
33
  return [];
package/src/menuOpen.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // Whether the main menu's drawer is open, for the drawer (MainMenu.tsx) and the menu buttons (Screen.tsx, the rail): at expanded
2
2
  // and wider the standard drawer's remembered state (sidebar.ts), below it the modal drawer's, which is not remembered.
3
- // Which one is on screen is lib/menu-layout.mjs. Exposes useMenuOpen.
3
+ // Which one is on screen is lib/menu-layout.mjs. From expanded to below large the standard drawer yields to the rail while
4
+ // the standard side panel is open (drawerYield.mjs, sidePanel.ts); the remembered state does not change for that.
5
+ // Exposes useMenuOpen.
4
6
  import { useCallback, useSyncExternalStore } from 'react';
5
7
  import { setSidebar, useSidebar } from './sidebar';
6
- import { useMenuLayout } from './ui';
8
+ import { drawerShown } from './drawerYield.mjs';
9
+ import { setDrawerReopened, useDrawerReopened, usePanelOpen } from './sidePanel';
10
+ import { useAtLeast, useMenuLayout } from './ui';
7
11
 
8
12
  let modalOpen = false;
9
13
  const listeners = new Set<() => void>();
@@ -28,7 +32,19 @@ export function useMenuOpen() {
28
32
  const wide = useMenuLayout().expanded === 'standard';
29
33
  const standard = useSidebar();
30
34
  const modal = useModalOpen();
31
- const open = wide ? standard : modal;
32
- const setOpen = useCallback((next: boolean) => (wide ? setSidebar(next) : setModal(next)), [wide]);
35
+ const panel = usePanelOpen();
36
+ const large = useAtLeast('large');
37
+ const reopened = useDrawerReopened();
38
+ const open = wide ? drawerShown(standard, panel, large, reopened) : modal;
39
+ const setOpen = useCallback(
40
+ (next: boolean) => {
41
+ if (!wide) return setModal(next);
42
+ // While the drawer yields, opening it is for as long as the panel stays open; closing it is remembered.
43
+ if (next && panel && !large) return setDrawerReopened(true);
44
+ setDrawerReopened(false);
45
+ setSidebar(next);
46
+ },
47
+ [wide, panel, large],
48
+ );
33
49
  return { open, setOpen, toggle: () => setOpen(!open) };
34
50
  }
@@ -1,3 +1,10 @@
1
1
  /** Where each moved screen is now, by its old address. */
2
2
  export const MOVED: Record<string, string>;
3
- export function movedRoute(route: string, more?: Record<string, string>): string;
3
+ /**
4
+ * Old route prefix (Danish, one or more segments, as the routes were before the ids got English names) -> its new
5
+ * English prefix. Only a route that starts with the old prefix is renamed, and the rest of it is data (a file path, a
6
+ * slug, an id) that keeps its name. A key segment `*` matches any one segment and keeps it (in the value too).
7
+ * Old links keep working through it. The longest matching key wins.
8
+ */
9
+ export const RENAMED: Record<string, string>;
10
+ export function movedRoute(route: string, more?: Record<string, string>, renamed?: Record<string, string>): string;
@@ -1,35 +1,116 @@
1
- // The editor's screens that have moved, by their old address after the "#": a link or a bookmark to one opens it where
2
- // it is now. Exposes MOVED and movedRoute (pure). Used by App.tsx (routeOf), which adds the app store's own.
1
+ // The editor's screens that have moved or been renamed, by their old address after the "#": a link or a bookmark to one
2
+ // opens it where it is now. Exposes MOVED, RENAMED and movedRoute (pure). Used by App.tsx (routeOf), which adds the app store's own.
3
3
 
4
4
  /** Where each moved screen is now, by its old address. */
5
5
  export const MOVED = /** @type {Record<string, string>} */ ({
6
- farver: 'udseende/farver',
7
- 'virksomheden/logo': 'udseende/logo',
8
- 'tema/blokvalg': 'blokvalg',
9
- dele: 'udseende/dele',
10
- tema: 'udseende',
11
- 'tema/farver': 'udseende/farver',
12
- 'tema/logo': 'udseende/logo',
13
- 'tema/dele': 'udseende/dele',
14
- 'tema/dele/editor': 'udseende/dele/editor',
15
- 'virksomheden/bank': 'virksomheden/betaling/bank',
16
- videresendelser: 'seo/videresendelser',
17
- 'videresendelser/alle': 'seo/videresendelser/alle',
18
- 'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
6
+ farver: 'appearance/colors',
7
+ 'virksomheden/logo': 'appearance/logo',
8
+ 'tema/blokvalg': 'block-settings',
9
+ dele: 'appearance/parts',
10
+ tema: 'appearance',
11
+ 'tema/farver': 'appearance/colors',
12
+ 'tema/logo': 'appearance/logo',
13
+ 'tema/dele': 'appearance/parts',
14
+ 'tema/dele/editor': 'appearance/parts/editor',
15
+ 'virksomheden/bank': 'company/payment/bank',
16
+ videresendelser: 'seo/redirects',
17
+ 'videresendelser/alle': 'seo/redirects/all',
18
+ 'videresendelser/ikke-fundet': 'seo/redirects/not-found',
19
19
  // The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
20
20
  promovering: 'apps',
21
- 'promovering/calendly': 'apps/calendly',
22
- 'promovering/mailchimp': 'apps/mailchimp',
21
+ 'promovering/calendly': 'apps/app_a030dfba69793996',
22
+ 'promovering/mailchimp': 'apps/app_5c4b6ba067ef127f',
23
23
  // The header's menus and settings were on a screen of their own under "Globalt"; they are under "Udseende" now.
24
- header: 'udseende/header',
24
+ header: 'appearance/header',
25
25
  // The footer's form was the file's own screen; it is under "Udseende" now.
26
- 'src/data/footer.json': 'udseende/footer',
26
+ 'src/data/footer.json': 'appearance/footer',
27
27
  });
28
28
 
29
29
  /**
30
- * The address a screen is at now: where it moved to (`more` first, then MOVED), else the same address.
30
+ * Old route prefix (Danish, one or more segments, as the routes were before the ids got English names) -> its new
31
+ * English prefix. Only a route that starts with the old prefix is renamed, and the rest of it is data (a file path, a
32
+ * slug, an id) that keeps its name. A key segment `*` matches any one segment and keeps it (in the value too).
33
+ * Old links keep working through it. The longest matching key wins.
34
+ */
35
+ export const RENAMED = /** @type {Record<string, string>} */ ({
36
+ indstillinger: 'settings',
37
+ 'indstillinger/appstore/temaer': 'settings/appstore/themes',
38
+ 'apps/*/indstillinger': 'apps/*/settings',
39
+ 'apps/*/om': 'apps/*/about',
40
+ 'apps/udvikler': 'apps/developer',
41
+ udseende: 'appearance',
42
+ 'udseende/temaer': 'appearance/themes',
43
+ 'udseende/ikonpakker': 'appearance/icon-packs',
44
+ 'udseende/bevaegelse': 'appearance/motion',
45
+ 'udseende/sidekolonne': 'appearance/side-column',
46
+ 'udseende/farver': 'appearance/colors',
47
+ 'udseende/farver/reservefarver': 'appearance/colors/fallback-colors',
48
+ 'udseende/dele': 'appearance/parts',
49
+ 'udseende/dele/blokke': 'appearance/parts/blocks',
50
+ 'udseende/dele/liste': 'appearance/parts/list',
51
+ 'udseende/dele/vaerdier': 'appearance/parts/values',
52
+ 'udseende/standardsider': 'appearance/default-pages',
53
+ maaling: 'analytics',
54
+ privatliv: 'privacy',
55
+ 'privatliv/cookiebanner': 'privacy/cookie-banner',
56
+ 'privatliv/samtykker': 'privacy/consents',
57
+ 'privatliv/privatlivssider': 'privacy/privacy-pages',
58
+ lister: 'lists',
59
+ liste: 'list',
60
+ 'liste/kategorier': 'list/tags',
61
+ 'liste/udtalelser': 'list/testimonials',
62
+ 'liste/mobilmenu': 'list/mobile-menu',
63
+ ny: 'new',
64
+ 'ny/kategorier': 'new/tags',
65
+ 'ny/udtalelser': 'new/testimonials',
66
+ slettet: 'deleted',
67
+ 'slettet/kategorier': 'deleted/tags',
68
+ 'slettet/udtalelser': 'deleted/testimonials',
69
+ 'slettet/mobilmenu': 'deleted/mobile-menu',
70
+ 'slettet/filer': 'deleted/files',
71
+ virksomheden: 'company',
72
+ 'virksomheden/kontakt': 'company/contact',
73
+ 'virksomheden/sociale-medier': 'company/social-media',
74
+ 'virksomheden/betaling': 'company/payment',
75
+ blokke: 'blocks',
76
+ filer: 'files',
77
+ 'seo/videresendelser': 'seo/redirects',
78
+ 'seo/videresendelser/alle': 'seo/redirects/all',
79
+ 'seo/videresendelser/ikke-fundet': 'seo/redirects/not-found',
80
+ 'seo/interne-links': 'seo/internal-links',
81
+ 'seo/vurdering': 'seo/rating',
82
+ blokvalg: 'block-settings',
83
+ avanceret: 'advanced',
84
+ licenser: 'licenses',
85
+ konto: 'account',
86
+ notifikationer: 'notifications',
87
+ });
88
+
89
+ /**
90
+ * The address a screen is at now: where it moved to (`more` first, then MOVED), else the address with its longest old
91
+ * prefix in `renamed` replaced and the rest as it is, else the same address.
31
92
  * @param {string} route
32
93
  * @param {Record<string, string>} [more]
94
+ * @param {Record<string, string>} [renamed]
33
95
  * @returns {string}
34
96
  */
35
- export const movedRoute = (route, more = {}) => more[route] ?? MOVED[route] ?? route;
97
+ export const movedRoute = (route, more = {}, renamed = RENAMED) => {
98
+ const moved = more[route] ?? MOVED[route];
99
+ if (moved !== undefined) return moved;
100
+ const parts = route.split('/');
101
+ let best = null;
102
+ for (const key of Object.keys(renamed)) {
103
+ const old = key.split('/');
104
+ if (old.length > parts.length || (best && old.length <= best.old.length)) continue;
105
+ if (old.every((segment, n) => segment === '*' || segment === parts[n])) best = { key, old };
106
+ }
107
+ if (!best) return route;
108
+ const kept = parts.slice(0, best.old.length);
109
+ let n = 0;
110
+ const now = renamed[best.key].split('/').map((segment) => {
111
+ if (segment !== '*') return segment;
112
+ while (best.old[n] !== '*') n += 1;
113
+ return kept[n++];
114
+ });
115
+ return [...now, ...parts.slice(best.old.length)].join('/');
116
+ };
@@ -4,19 +4,141 @@ import assert from 'node:assert/strict';
4
4
  import { movedRoute } from './movedRoutes.mjs';
5
5
 
6
6
  test('the old header screen opens Appearance › Header', () => {
7
- assert.equal(movedRoute('header'), 'udseende/header');
7
+ assert.equal(movedRoute('header'), 'appearance/header');
8
8
  });
9
9
 
10
10
  test('the old footer form opens Appearance › Footer', () => {
11
- assert.equal(movedRoute('src/data/footer.json'), 'udseende/footer');
11
+ assert.equal(movedRoute('src/data/footer.json'), 'appearance/footer');
12
12
  });
13
13
 
14
14
  test('an address that has not moved stays', () => {
15
- assert.equal(movedRoute('udseende/header'), 'udseende/header');
16
- assert.equal(movedRoute('lister'), 'lister');
15
+ assert.equal(movedRoute('appearance/header'), 'appearance/header');
16
+ assert.equal(movedRoute('lists'), 'lists');
17
17
  });
18
18
 
19
19
  test('the extra moves come first', () => {
20
- assert.equal(movedRoute('butik', { butik: 'indstillinger/appstore' }), 'indstillinger/appstore');
21
- assert.equal(movedRoute('tema', { butik: 'x' }), 'udseende');
20
+ assert.equal(movedRoute('butik', { butik: 'settings/appstore' }), 'settings/appstore');
21
+ assert.equal(movedRoute('tema', { butik: 'x' }), 'appearance');
22
+ });
23
+
24
+ test('only the old prefix of an address is renamed, the rest stays', () => {
25
+ const renamed = { indstillinger: 'settings', 'udseende/dele': 'appearance/parts', liste: 'list' };
26
+ assert.equal(movedRoute('indstillinger/appstore', {}, renamed), 'settings/appstore');
27
+ assert.equal(movedRoute('udseende/dele/liste/kontakt', {}, renamed), 'appearance/parts/liste/kontakt');
28
+ });
29
+
30
+ test('a key segment * matches any one segment and keeps it', () => {
31
+ const renamed = { 'apps/*/indstillinger': 'apps/*/settings' };
32
+ assert.equal(movedRoute('apps/x/indstillinger/variant', {}, renamed), 'apps/x/settings/variant');
33
+ assert.equal(movedRoute('apps/x/om', {}, renamed), 'apps/x/om');
34
+ });
35
+
36
+ test('data inside an address is not renamed', () => {
37
+ assert.equal(movedRoute('files/filer/doc.pdf'), 'files/filer/doc.pdf');
38
+ assert.equal(movedRoute('blog/farver'), 'blog/farver');
39
+ assert.equal(movedRoute('list/temaer'), 'list/temaer');
40
+ });
41
+
42
+ test('old prefixes are renamed, the data after them stays', () => {
43
+ assert.equal(movedRoute('filer/filer/doc.pdf'), 'files/filer/doc.pdf');
44
+ assert.equal(movedRoute('liste/temaer'), 'list/temaer');
45
+ assert.equal(movedRoute('udseende/dele/liste/kontakt'), 'appearance/parts/list/kontakt');
46
+ assert.equal(movedRoute('indstillinger/appstore'), 'settings/appstore');
47
+ assert.equal(movedRoute('udseende/farver'), 'appearance/colors');
48
+ });
49
+
50
+ test('every old route prefix opens the screen where it is now', () => {
51
+ const cases = {
52
+ indstillinger: 'settings',
53
+ 'indstillinger/appstore/temaer': 'settings/appstore/themes',
54
+ 'apps/google-analytics/indstillinger': 'apps/google-analytics/settings',
55
+ 'apps/udvikler/acme': 'apps/developer/acme',
56
+ 'apps/cms-version/om': 'apps/cms-version/about',
57
+ 'apps/content-colors/indstillinger/variant': 'apps/content-colors/settings/variant',
58
+ udseende: 'appearance',
59
+ 'udseende/temaer': 'appearance/themes',
60
+ 'udseende/temaer/material': 'appearance/themes/material',
61
+ 'udseende/ikonpakker': 'appearance/icon-packs',
62
+ 'udseende/bevaegelse': 'appearance/motion',
63
+ 'udseende/sidekolonne': 'appearance/side-column',
64
+ 'udseende/farver/reservefarver': 'appearance/colors/fallback-colors',
65
+ 'udseende/dele': 'appearance/parts',
66
+ 'udseende/dele/editor': 'appearance/parts/editor',
67
+ 'udseende/dele/blokke': 'appearance/parts/blocks',
68
+ 'udseende/dele/vaerdier': 'appearance/parts/values',
69
+ 'udseende/standardsider': 'appearance/default-pages',
70
+ 'udseende/logo': 'appearance/logo',
71
+ maaling: 'analytics',
72
+ 'maaling/google-analytics': 'analytics/google-analytics',
73
+ privatliv: 'privacy',
74
+ 'privatliv/cookiebanner': 'privacy/cookie-banner',
75
+ 'privatliv/samtykker': 'privacy/consents',
76
+ 'privatliv/privatlivssider': 'privacy/privacy-pages',
77
+ lister: 'lists',
78
+ liste: 'list',
79
+ 'liste/priser/cookiescanner': 'list/priser/cookiescanner',
80
+ 'liste/kategorier': 'list/tags',
81
+ ny: 'new',
82
+ 'ny/popups/mailchimp': 'new/popups/mailchimp',
83
+ 'ny/kategorier': 'new/tags',
84
+ 'ny/udtalelser': 'new/testimonials',
85
+ slettet: 'deleted',
86
+ 'slettet/priser': 'deleted/priser',
87
+ 'slettet/kategorier': 'deleted/tags',
88
+ 'slettet/udtalelser': 'deleted/testimonials',
89
+ 'liste/udtalelser': 'list/testimonials',
90
+ 'liste/mobilmenu': 'list/mobile-menu',
91
+ 'slettet/mobilmenu': 'deleted/mobile-menu',
92
+ 'slettet/filer': 'deleted/files',
93
+ virksomheden: 'company',
94
+ 'virksomheden/kontakt': 'company/contact',
95
+ 'virksomheden/sociale-medier': 'company/social-media',
96
+ 'virksomheden/betaling/mobilepay': 'company/payment/mobilepay',
97
+ blokke: 'blocks',
98
+ 'blokke/accordion': 'blocks/accordion',
99
+ filer: 'files',
100
+ 'seo/videresendelser': 'seo/redirects',
101
+ 'seo/videresendelser/alle': 'seo/redirects/all',
102
+ 'seo/videresendelser/ikke-fundet': 'seo/redirects/not-found',
103
+ 'seo/interne-links': 'seo/internal-links',
104
+ 'seo/vurdering': 'seo/rating',
105
+ blokvalg: 'block-settings',
106
+ avanceret: 'advanced',
107
+ licenser: 'licenses',
108
+ konto: 'account',
109
+ notifikationer: 'notifications',
110
+ 'licenser/mit': 'licenses/mit',
111
+ };
112
+ for (const [old, now] of Object.entries(cases)) assert.equal(movedRoute(old), now, old);
113
+ });
114
+
115
+ test('a new address stays', () => {
116
+ assert.equal(movedRoute('settings/appstore'), 'settings/appstore');
117
+ assert.equal(movedRoute('blog/mit-indlaeg'), 'blog/mit-indlaeg');
118
+ });
119
+
120
+ test('a moved address wins over a renamed segment', () => {
121
+ assert.equal(movedRoute('tema', {}, { tema: 'x' }), 'appearance');
122
+ });
123
+
124
+ test('an old route to the app store opens it at its English route', () => {
125
+ assert.equal(movedRoute('indstillinger/appstore'), 'settings/appstore');
126
+ });
127
+
128
+ test('the old colours route opens the colours screen under Appearance', () => {
129
+ assert.equal(movedRoute('farver'), 'appearance/colors');
130
+ });
131
+
132
+ test('an old parts route keeps a list id and opens at its English route', () => {
133
+ assert.equal(movedRoute('udseende/dele/liste/kontakt'), 'appearance/parts/list/kontakt');
134
+ });
135
+
136
+ test('the old theme routes open the screens under Appearance', () => {
137
+ assert.equal(movedRoute('tema'), 'appearance');
138
+ assert.equal(movedRoute('tema/dele/editor'), 'appearance/parts/editor');
139
+ });
140
+
141
+ test('the old booking and newsletter settings open the apps by their permanent ids', () => {
142
+ assert.equal(movedRoute('promovering/calendly'), 'apps/app_a030dfba69793996');
143
+ assert.equal(movedRoute('promovering/mailchimp'), 'apps/app_5c4b6ba067ef127f');
22
144
  });
package/src/nav.ts CHANGED
@@ -9,7 +9,7 @@ type Step = { at: number; prev: string | null };
9
9
 
10
10
  const step = () => history.state as Step | null;
11
11
 
12
- /** The screen of an address ("#liste/sider" → "liste/sider", "#" → ""); one with a broken %-sign is
12
+ /** The screen of an address ("#list/sider" → "list/sider", "#" → ""); one with a broken %-sign is
13
13
  * the start screen. */
14
14
  export function screenOf(hash: string) {
15
15
  try {
@@ -1,4 +1,4 @@
1
- // The notifications (#notifikationer, Notifications.tsx): what the editor and the apps have to say that is worth
1
+ // The notifications (#notifications, Notifications.tsx): what the editor and the apps have to say that is worth
2
2
  // reading again, newest first, with when it came and the link it had. Each has a type: the editor's own (`site`: the
3
3
  // site is updated or not, `saved`: something is saved, deleted, restored or installed, `failed`: it could not be,
4
4
  // `updates`: an app from the store updated on its own, AppList.tsx autoUpdate), or
@@ -0,0 +1,4 @@
1
+ /** Puts the page's header (the layout's `article > header`) in a panel of this colour, with the intro (HTML, in the text's own
2
+ * class) after it; no colour (null) leaves the header without a panel. A panel the site's page has is first taken away.
3
+ * @param {Document} doc @param {string | null} color @param {string} introHtml */
4
+ export function setH1Panel(doc: Document, color: string | null, introHtml: string): void;