@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,31 @@
1
+ // The preview's copy of the coloured H1 panel of a page (theme/layouts/PageLayout.astro): the heading and the text up to the first
2
+ // H2 in one section (data-editor="h1-section"). Exposes setH1Panel, which makes, changes or takes away that panel in the page's
3
+ // document, whatever the page on the site has. Depends on lib/text-sections.mjs (sectionClass); used by pagePreview.tsx.
4
+ import { sectionClass } from '../lib/text-sections.mjs';
5
+
6
+ /** 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
7
+ * class) after it; no colour (null) leaves the header without a panel. A panel the site's page has is first taken away.
8
+ * @param {Document} doc @param {string | null} color @param {string} introHtml */
9
+ export function setH1Panel(doc, color, introHtml) {
10
+ const header = doc.querySelector('article > header');
11
+ const text = doc.querySelector('[data-editor="text"]');
12
+ if (!header || !text) return;
13
+ const old = header.querySelector('[data-editor="h1-section"]');
14
+ if (old) {
15
+ old.querySelector('[data-editor="intro"]')?.remove();
16
+ old.replaceWith(...Array.from(old.childNodes));
17
+ }
18
+ if (!color) return;
19
+ const panel = doc.createElement('section');
20
+ panel.setAttribute('data-editor', 'h1-section');
21
+ panel.className = sectionClass(color);
22
+ panel.append(...Array.from(header.childNodes));
23
+ if (introHtml.trim()) {
24
+ const intro = doc.createElement(text.tagName);
25
+ intro.setAttribute('data-editor', 'intro');
26
+ intro.className = text.className;
27
+ intro.innerHTML = introHtml;
28
+ panel.append(intro);
29
+ }
30
+ header.append(panel);
31
+ }
@@ -0,0 +1,56 @@
1
+ // Tests src/pageH1Panel.mjs: the preview puts a page's header and intro in a coloured panel, or takes the panel away.
2
+ // Run: node --test src/pageH1Panel.test.mjs
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { JSDOM } from 'jsdom';
6
+ import { setH1Panel } from './pageH1Panel.mjs';
7
+
8
+ const PAGE = (header) =>
9
+ new JSDOM(`<body><article><header>${header}</header><div><div data-editor="text" class="prose">x</div></div></article></body>`).window
10
+ .document;
11
+ const PLAIN = '<h1 data-editor="heading">Title</h1>';
12
+
13
+ test('the header and the intro go in a panel of the colour', () => {
14
+ const doc = PAGE(PLAIN);
15
+ setH1Panel(doc, 'tint', '<p>Intro</p>');
16
+ const panel = doc.querySelector('header > section.text-section.text-section-tint[data-editor="h1-section"]');
17
+ assert.ok(panel);
18
+ assert.equal(panel.querySelector('h1').textContent, 'Title');
19
+ const intro = panel.querySelector('[data-editor="intro"]');
20
+ assert.equal(intro.innerHTML, '<p>Intro</p>');
21
+ assert.equal(intro.className, 'prose');
22
+ });
23
+
24
+ test('without an intro the panel holds the header only', () => {
25
+ const doc = PAGE(PLAIN);
26
+ setH1Panel(doc, 'accent', ' ');
27
+ assert.ok(doc.querySelector('[data-editor="h1-section"] h1'));
28
+ assert.equal(doc.querySelector('[data-editor="intro"]'), null);
29
+ });
30
+
31
+ test('no colour takes a panel away and keeps the header', () => {
32
+ const doc = PAGE(
33
+ `<section data-editor="h1-section" class="text-section text-section-tint">${PLAIN}<div data-editor="intro">a</div></section>`,
34
+ );
35
+ setH1Panel(doc, null, '');
36
+ assert.equal(doc.querySelector('[data-editor="h1-section"]'), null);
37
+ assert.equal(doc.querySelector('[data-editor="intro"]'), null);
38
+ assert.equal(doc.querySelector('header > h1').textContent, 'Title');
39
+ });
40
+
41
+ test('another colour replaces the panel of the site', () => {
42
+ const doc = PAGE(
43
+ `<section data-editor="h1-section" class="text-section text-section-tint">${PLAIN}<div data-editor="intro">a</div></section>`,
44
+ );
45
+ setH1Panel(doc, 'surface', '<p>b</p>');
46
+ assert.equal(doc.querySelectorAll('[data-editor="h1-section"]').length, 1);
47
+ assert.ok(doc.querySelector('.text-section-surface'));
48
+ assert.equal(doc.querySelector('.text-section-tint'), null);
49
+ assert.equal(doc.querySelector('[data-editor="intro"]').innerHTML, '<p>b</p>');
50
+ });
51
+
52
+ test('a page without a header is left as it is', () => {
53
+ const doc = new JSDOM('<body><div data-editor="text">x</div></body>').window.document;
54
+ setH1Panel(doc, 'tint', '<p>a</p>');
55
+ assert.equal(doc.querySelector('section'), null);
56
+ });
@@ -8,7 +8,7 @@
8
8
  // puts a block that ends the text (data-editor="end", with its type in data-editor-block). Only what
9
9
  // was changed is put in, so the rest is exactly as on the site. The text is the site's Markdown
10
10
  // (Astro's pipeline, with the HTML written in it), and its blocks are drawn as in the editor
11
- // (registry.tsx preview), with the site's classes; a coloured section is a panel as on the site (lib/text-sections.mjs).
11
+ // (registry.tsx preview), with the site's classes; a coloured section, and a coloured H1 with the text up to the first H2 (pageH1Panel.mjs), is a panel as on the site (lib/text-sections.mjs).
12
12
  //
13
13
  // Exposes pageDocument, usePagePreview and PageDraft, and for the preview of a part every page has (partPreview.tsx) siteShell,
14
14
  // documentHtml, htmlOf and PREVIEW_WIDTH.
@@ -25,7 +25,8 @@ import { useRef, useState, type ReactNode } from 'react';
25
25
  import { createRoot } from 'react-dom/client';
26
26
  import { flushSync } from 'react-dom';
27
27
  import { parseBlock, splitBlocks } from '../lib/blocks.mjs';
28
- import { sectionClass, sectionsOf } from '../lib/text-sections.mjs';
28
+ import { introOf, sectionClass, sectionsOf } from '../lib/text-sections.mjs';
29
+ import { h1SectionColor, withoutH1 } from '../lib/without-h1.mjs';
29
30
  import { BREAKPOINTS, breakpointOf, type Breakpoint } from '../lib/breakpoints.mjs';
30
31
  import { processor } from '../lib/markdown';
31
32
  import { splitPicture } from '../lib/crop.mjs';
@@ -33,6 +34,7 @@ import { isPostPage, leadFill } from './postLead.mjs';
33
34
  import { preview as drawBlock, siteBlocks } from './registry';
34
35
  import { shownAt } from './images';
35
36
  import { pageH1 } from './sections.mjs';
37
+ import { setH1Panel } from './pageH1Panel.mjs';
36
38
  import type { BlockData } from './blocks';
37
39
  import { PageFrame, BottomSheet, SegmentedButton, SheetTitle } from './ui';
38
40
  import { t } from './i18n';
@@ -144,8 +146,11 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
144
146
  }
145
147
 
146
148
  if (draft.body !== undefined) {
147
- // A page's H1 at the top of its text is drawn in its own place above (draft.heading), as on the site.
148
- let html = await markdownHtml(pageH1(draft.body).rest);
149
+ // A page's H1 at the top of its text is drawn in its own place above (draft.heading), as on the site; the colour of its
150
+ // section (the marker before it) puts the heading and the text up to the first H2 in one panel (setH1Panel).
151
+ const whole = isPostPage(draft) ? '' : await markdownHtml(draft.body);
152
+ const h1Color = h1SectionColor(whole);
153
+ let html = isPostPage(draft) ? await markdownHtml(pageH1(draft.body).rest) : withoutH1(whole);
149
154
  // A block that ends the text, where the layout puts it.
150
155
  const end = doc.querySelector('[data-editor="end"]');
151
156
  const type = end?.getAttribute('data-editor-block');
@@ -171,14 +176,19 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
171
176
  leadBox.innerHTML = lead;
172
177
  }
173
178
  const types = siteBlocks.map((b) => b.type);
174
- text.innerHTML = sectionsOf(rest)
175
- .map((run) => {
176
- const inner = splitBlocks(run.html, types)
177
- .map((p) => ('html' in p ? p.html : blockHtml(p.type, p.props, draft.data)))
178
- .join('');
179
- return run.color ? `<section class="${sectionClass(run.color)}">${inner}</section>` : inner;
180
- })
181
- .join('');
179
+ const runs = (part: string) =>
180
+ sectionsOf(part)
181
+ .map((run) => {
182
+ const inner = splitBlocks(run.html, types)
183
+ .map((p) => ('html' in p ? p.html : blockHtml(p.type, p.props, draft.data)))
184
+ .join('');
185
+ return run.color ? `<section class="${sectionClass(run.color)}">${inner}</section>` : inner;
186
+ })
187
+ .join('');
188
+ // With a column beside the text the panel holds the heading only, as on the site.
189
+ const { intro, rest: below } = h1Color && !text.closest('[data-side]') ? introOf(rest) : { intro: '', rest };
190
+ setH1Panel(doc, h1Color, runs(intro));
191
+ text.innerHTML = runs(below);
182
192
  }
183
193
  return documentHtml(doc);
184
194
  }
package/src/schema.ts CHANGED
@@ -77,7 +77,7 @@ export type Field = {
77
77
  * picked when it is made). */
78
78
  locked?: boolean;
79
79
  /** select: the choices on a page of their own under the form, at its address with the field's name after it
80
- * (#apps/content-colors/indstillinger/variant), in place of the sheet, drawn by this with what each gives (a picture of
80
+ * (#apps/content-colors/settings/variant), in place of the sheet, drawn by this with what each gives (a picture of
81
81
  * it); the field links there. Only a field of a part's own form (CompanyForm.tsx: an app's settings), not one in a
82
82
  * group or a list; elsewhere the choices are in the sheet. */
83
83
  page?: ComponentType<FieldPageProps>;
@@ -162,7 +162,7 @@ export type Template = {
162
162
  * bar opens the booking sheet, as "Book en tid" does), else the entry itself opens. */
163
163
  export type Opens = { label: string; as?: string };
164
164
 
165
- /** A place in the editor (a route, e.g. 'virksomheden/kontakt') and the text of the link to it. */
165
+ /** A place in the editor (a route, e.g. 'company/contact') and the text of the link to it. */
166
166
  export type Edit = { to: string; text: string };
167
167
 
168
168
  // A kind of item in a list of blocks (a fixed page's sections; in the editor cards in its text, SectionsField.tsx).
@@ -1,5 +1,5 @@
1
- // The dotted frame round each H2 section of a page or blog post: sets data-section-part (start, middle, end, only) and
2
- // data-section-color (the H2's section colour) on the top-level nodes of a section, which editor.css draws. Exposes
1
+ // The dotted frame round each H1 or H2 section of a page or blog post: sets data-section-part (start, middle, end, only) and
2
+ // data-section-color (the heading's section colour) on the top-level nodes of a section, which editor.css draws. Exposes
3
3
  // sectionFrame (a Milkdown plugin). Uses lib/section-ranges.mjs and src/sectionState.mjs.
4
4
  import { $prose } from '@milkdown/kit/utils';
5
5
  import type { Node } from '@milkdown/kit/prose/model';
@@ -29,7 +29,7 @@ function sectionDecorations(doc: Node): Decoration[] {
29
29
  );
30
30
  }
31
31
 
32
- /** on: false for a fixed page's text (MarkdownEditor's `sections`), whose H2s are the names of its sections: no frame. */
32
+ /** on: false for a fixed page's text (MarkdownEditor's `sections`), whose headings are the names of its sections: no frame. */
33
33
  export const sectionFrame = (on: boolean) =>
34
34
  $prose(
35
35
  () =>
@@ -1,17 +1,17 @@
1
- /** The top-level H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
1
+ /** The top-level H1 or H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
2
2
  * @returns {{ pos: number, color: string | null } | null} */
3
3
  export function sectionHeading(state: import("@milkdown/kit/prose/state").EditorState): {
4
4
  pos: number;
5
5
  color: string | null;
6
6
  } | null;
7
- /** Gives the H2 the cursor is in this colour (null: none). False when it is not in an H2, or the colour is not one of the theme's.
7
+ /** Gives the H1 or H2 the cursor is in this colour (null: none). False when it is not in one, or the colour is not one of the theme's.
8
8
  * @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view
9
9
  * @param {string | null} color */
10
10
  export function setSectionColor(view: {
11
11
  state: import("@milkdown/kit/prose/state").EditorState;
12
12
  dispatch: (tr: import("@milkdown/kit/prose/state").Transaction) => void;
13
13
  }, color: string | null): boolean;
14
- /** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H2's, null without one.
14
+ /** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H1's or H2's, null without one.
15
15
  * @param {import('@milkdown/kit/prose/model').Node[]} nodes @param {{ from: number, to: number }} range @returns {string | null} */
16
16
  export function sectionColorOf(nodes: import("@milkdown/kit/prose/model").Node[], range: {
17
17
  from: number;
@@ -1,18 +1,18 @@
1
1
  // The section colour in the editor's document (the *Sektionsfarve* button, MarkdownEditor.tsx; the frame, sectionFrame.ts): the
2
- // top-level H2 the cursor is in (sectionHeading), setting its colour (setSectionColor) and the colour of a section (sectionColorOf).
2
+ // top-level H1 or H2 the cursor is in (sectionHeading), setting its colour (setSectionColor) and the colour of a section (sectionColorOf).
3
3
  // Only ProseMirror's state; depends on lib/section-marker.mjs. No Milkdown, no browser.
4
4
  import { SECTION_COLORS } from '../lib/section-marker.mjs';
5
5
 
6
- /** The top-level H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
6
+ /** The top-level H1 or H2 the cursor is in, with its colour, or null. @param {import('@milkdown/kit/prose/state').EditorState} state
7
7
  * @returns {{ pos: number, color: string | null } | null} */
8
8
  export function sectionHeading(state) {
9
9
  const { $from } = state.selection;
10
10
  const node = $from.parent;
11
- if ($from.depth !== 1 || node.type.name !== 'heading' || node.attrs.level !== 2) return null;
11
+ if ($from.depth !== 1 || node.type.name !== 'heading' || node.attrs.level > 2) return null;
12
12
  return { pos: $from.before(1), color: node.attrs.section ?? null };
13
13
  }
14
14
 
15
- /** Gives the H2 the cursor is in this colour (null: none). False when it is not in an H2, or the colour is not one of the theme's.
15
+ /** Gives the H1 or H2 the cursor is in this colour (null: none). False when it is not in one, or the colour is not one of the theme's.
16
16
  * @param {{ state: import('@milkdown/kit/prose/state').EditorState, dispatch: (tr: import('@milkdown/kit/prose/state').Transaction) => void }} view
17
17
  * @param {string | null} color */
18
18
  export function setSectionColor(view, color) {
@@ -23,9 +23,9 @@ export function setSectionColor(view, color) {
23
23
  return true;
24
24
  }
25
25
 
26
- /** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H2's, null without one.
26
+ /** The colour of the section made of nodes[range.from..range.to] (sectionRanges): its H1's or H2's, null without one.
27
27
  * @param {import('@milkdown/kit/prose/model').Node[]} nodes @param {{ from: number, to: number }} range @returns {string | null} */
28
28
  export function sectionColorOf(nodes, range) {
29
- const heading = nodes.slice(range.from, range.to + 1).find((n) => n.type.name === 'heading' && n.attrs.level === 2);
29
+ const heading = nodes.slice(range.from, range.to + 1).find((n) => n.type.name === 'heading' && n.attrs.level <= 2);
30
30
  return heading?.attrs.section ?? null;
31
31
  }
@@ -1,4 +1,4 @@
1
- // Tests for src/sectionState.mjs: the H2 the cursor is in, setting its section colour, and the colour of a section.
1
+ // Tests for src/sectionState.mjs: the H1 or H2 the cursor is in, setting its section colour, and the colour of a section.
2
2
  // Run: node --test packages/editor/src/sectionState.test.mjs
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
@@ -16,11 +16,12 @@ const schema = new Schema({
16
16
  },
17
17
  });
18
18
  const node = (type, text, attrs) => schema.node(type, attrs, text ? [schema.text(text)] : []);
19
- // An H2 "Hours" (0-7, coloured tint), a paragraph (7-10), an H3 (10-14).
19
+ // An H2 "Hours" (0-7, coloured tint), a paragraph (7-10), an H3 (10-15), an H1 "Top" (15-20, coloured tint).
20
20
  const doc = schema.node('doc', null, [
21
21
  node('heading', 'Hours', { level: 2, section: 'tint' }),
22
22
  node('paragraph', 'x'),
23
23
  node('heading', 'Sub', { level: 3 }),
24
+ node('heading', 'Top', { level: 1, section: 'tint' }),
24
25
  ]);
25
26
  const viewAt = (pos) => ({
26
27
  state: EditorState.create({ doc, selection: TextSelection.create(doc, pos) }),
@@ -51,6 +52,17 @@ test('a colour the theme has not is refused, and nothing happens outside an H2',
51
52
  assert.equal(view.state.doc.child(0).attrs.section, 'tint');
52
53
  assert.equal(setSectionColor(viewAt(8), 'tint'), false);
53
54
  });
55
+ test('the H1 the cursor is in', () => assert.deepEqual(sectionHeading(viewAt(17).state), { pos: 15, color: 'tint' }));
56
+ test('sets a colour on an H1', () => {
57
+ const view = viewAt(17);
58
+ assert.equal(setSectionColor(view, 'accent'), true);
59
+ assert.equal(view.state.doc.child(3).attrs.section, 'accent');
60
+ assert.equal(view.state.doc.child(3).attrs.level, 1);
61
+ });
62
+ test('the colour of a section is its H1’s', () => {
63
+ const kids = [node('heading', 'Top', { level: 1, section: 'accent' }), node('paragraph', 'x')];
64
+ assert.equal(sectionColorOf(kids, { from: 0, to: 1 }), 'accent');
65
+ });
54
66
  test('the colour of a section is its H2’s, also after a label', () => {
55
67
  const kids = [node('eyebrow', 'Label'), node('heading', 'Hours', { level: 2, section: 'surface' }), node('paragraph', 'x')];
56
68
  assert.equal(sectionColorOf(kids, { from: 0, to: 2 }), 'surface');
@@ -1,4 +1,6 @@
1
- /** step: 'choose' | 'fields' | 'section' | undefined; medium: the window is at least the `medium` breakpoint (600px). */
2
- export function sheetLook(step: any, medium: any): "side-sheet" | "modal-side-sheet" | "sheet";
3
- /** true exactly when sheetLook(step, medium) === 'side-sheet'. */
4
- export function inPanel(step: any, medium: any): boolean;
1
+ /** step: 'choose' | 'fields' | 'section' | undefined; expanded: the window is at least the `expanded` breakpoint. */
2
+ export function sheetLook(step: any, expanded: any): "side-sheet" | "modal-side-sheet" | "sheet";
3
+ /** true exactly when sheetLook(step, expanded) === 'side-sheet'. */
4
+ export function inPanel(step: any, expanded: any): boolean;
5
+ /** true when a card's pen and trash move into the side panel: the fields sheet is the side panel (inPanel('fields', expanded)) and the card has fields. */
6
+ export function editsInPanel(expanded: any, hasFields: any): any;
@@ -1,15 +1,20 @@
1
- // The look of each step of the block sheet (BlockDialog.tsx): a side sheet beside the page from medium for the fields
2
- // and the section settings, a modal side sheet below medium for the fields, else the sheet from the bottom.
3
- // Exposes sheetLook(step, medium) and inPanel(step, medium); no dependencies.
1
+ // The look of each step of the block sheet (BlockDialog.tsx): a side sheet beside the page from expanded for the fields
2
+ // and the section settings, a modal side sheet below expanded for the fields, else the sheet from the bottom.
3
+ // Exposes sheetLook(step, expanded), inPanel(step, expanded) and editsInPanel(expanded, hasFields); no dependencies.
4
4
 
5
- /** step: 'choose' | 'fields' | 'section' | undefined; medium: the window is at least the `medium` breakpoint (600px). */
6
- export function sheetLook(step, medium) {
7
- if (step === 'fields') return medium ? 'side-sheet' : 'modal-side-sheet';
8
- if (step === 'section') return medium ? 'side-sheet' : 'sheet';
5
+ /** step: 'choose' | 'fields' | 'section' | undefined; expanded: the window is at least the `expanded` breakpoint. */
6
+ export function sheetLook(step, expanded) {
7
+ if (step === 'fields') return expanded ? 'side-sheet' : 'modal-side-sheet';
8
+ if (step === 'section') return expanded ? 'side-sheet' : 'sheet';
9
9
  return 'sheet';
10
10
  }
11
11
 
12
- /** true exactly when sheetLook(step, medium) === 'side-sheet'. */
13
- export function inPanel(step, medium) {
14
- return sheetLook(step, medium) === 'side-sheet';
12
+ /** true exactly when sheetLook(step, expanded) === 'side-sheet'. */
13
+ export function inPanel(step, expanded) {
14
+ return sheetLook(step, expanded) === 'side-sheet';
15
+ }
16
+
17
+ /** true when a card's pen and trash move into the side panel: the fields sheet is the side panel (inPanel('fields', expanded)) and the card has fields. */
18
+ export function editsInPanel(expanded, hasFields) {
19
+ return hasFields && inPanel('fields', expanded);
15
20
  }
@@ -1,44 +1,51 @@
1
1
  // Tests for sheetPlacement.mjs: the look (side-sheet, modal-side-sheet, or sheet) and the panel flag of each step.
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
- import { sheetLook, inPanel } from './sheetPlacement.mjs';
4
+ import { sheetLook, inPanel, editsInPanel } from './sheetPlacement.mjs';
5
5
 
6
- test("'fields' below medium is a modal side sheet and not in the panel", () => {
6
+ test("'fields' below expanded is a modal side sheet and not in the panel", () => {
7
7
  assert.equal(sheetLook('fields', false), 'modal-side-sheet');
8
8
  assert.equal(inPanel('fields', false), false);
9
9
  });
10
10
 
11
- test("'fields' at medium is a side sheet and in the panel", () => {
11
+ test("'fields' at expanded is a side sheet and in the panel", () => {
12
12
  assert.equal(sheetLook('fields', true), 'side-sheet');
13
13
  assert.equal(inPanel('fields', true), true);
14
14
  });
15
15
 
16
- test("'section' below medium is a sheet and not in the panel", () => {
16
+ test("'section' below expanded is a sheet and not in the panel", () => {
17
17
  assert.equal(sheetLook('section', false), 'sheet');
18
18
  assert.equal(inPanel('section', false), false);
19
19
  });
20
20
 
21
- test("'section' at medium is a side sheet and in the panel", () => {
21
+ test("'section' at expanded is a side sheet and in the panel", () => {
22
22
  assert.equal(sheetLook('section', true), 'side-sheet');
23
23
  assert.equal(inPanel('section', true), true);
24
24
  });
25
25
 
26
- test("'choose' below medium is a sheet and not in the panel", () => {
26
+ test("'choose' below expanded is a sheet and not in the panel", () => {
27
27
  assert.equal(sheetLook('choose', false), 'sheet');
28
28
  assert.equal(inPanel('choose', false), false);
29
29
  });
30
30
 
31
- test("'choose' at medium is a sheet and not in the panel", () => {
31
+ test("'choose' at expanded is a sheet and not in the panel", () => {
32
32
  assert.equal(sheetLook('choose', true), 'sheet');
33
33
  assert.equal(inPanel('choose', true), false);
34
34
  });
35
35
 
36
- test('a closed sheet below medium is a sheet and not in the panel', () => {
36
+ test('a closed sheet below expanded is a sheet and not in the panel', () => {
37
37
  assert.equal(sheetLook(undefined, false), 'sheet');
38
38
  assert.equal(inPanel(undefined, false), false);
39
39
  });
40
40
 
41
- test('a closed sheet at medium is a sheet and not in the panel', () => {
41
+ test('a closed sheet at expanded is a sheet and not in the panel', () => {
42
42
  assert.equal(sheetLook(undefined, true), 'sheet');
43
43
  assert.equal(inPanel(undefined, true), false);
44
44
  });
45
+
46
+ test('a card edits in the panel only at expanded and with fields', () => {
47
+ assert.equal(editsInPanel(true, true), true);
48
+ assert.equal(editsInPanel(false, true), false);
49
+ assert.equal(editsInPanel(true, false), false);
50
+ assert.equal(editsInPanel(false, false), false);
51
+ });
@@ -0,0 +1,38 @@
1
+ // Whether the standard side panel (BlockDialog's standard side sheet) is open, and whether the menu button reopened the drawer
2
+ // while it is. Not remembered. Exposes setPanelOpen, setDrawerReopened, usePanelOpen, useDrawerReopened; read by menuOpen.ts.
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ let panelOpen = false;
6
+ let reopened = false;
7
+ const listeners = new Set<() => void>();
8
+ const notify = () => listeners.forEach((l) => l());
9
+ const subscribe = (changed: () => void) => {
10
+ listeners.add(changed);
11
+ return () => listeners.delete(changed);
12
+ };
13
+
14
+ /** The panel opens or closes; closing ends a reopening of the drawer. */
15
+ export function setPanelOpen(next: boolean) {
16
+ panelOpen = next;
17
+ if (!next) reopened = false;
18
+ notify();
19
+ }
20
+
21
+ export function setDrawerReopened(next: boolean) {
22
+ reopened = next;
23
+ notify();
24
+ }
25
+
26
+ export const usePanelOpen = () =>
27
+ useSyncExternalStore(
28
+ subscribe,
29
+ () => panelOpen,
30
+ () => false,
31
+ );
32
+
33
+ export const useDrawerReopened = () =>
34
+ useSyncExternalStore(
35
+ subscribe,
36
+ () => reopened,
37
+ () => false,
38
+ );
package/src/site.ts CHANGED
@@ -9,7 +9,7 @@ import { appIdOf, installedApps, withKinds, type AppDefinition, type AppList, ty
9
9
  import { appBlocks, appFiles, appFolders, appPictures, appShared, appStores } from './appFiles';
10
10
  import type { BlockDefinition, SiteBlock } from './blocks';
11
11
  import type { ListRows } from './fields';
12
- import { siteT, siteText, t, translatingSite } from './i18n';
12
+ import { inSiteLanguage, siteT, siteText, t, translatingSite } from './i18n';
13
13
  import { byText, type Block, type Collection, type DataFile, type Edit, type Field, type OwnedList, type Part } from './schema';
14
14
  import { blockTypeOf, type SharedTexts } from './shared';
15
15
  import { appFolder, needsOf, SCREENSHOTS, shotFile } from './store';
@@ -19,17 +19,40 @@ const written = new WeakMap<object, object>();
19
19
  /** A collection, a file, a part or a block as the site wrote it, in its language: for the commits. */
20
20
  export const asWritten = <T extends object>(shown: T): T => (written.get(shown) as T | undefined) ?? shown;
21
21
 
22
- // Only the keys a thing has, so it is the same as it was but for its texts.
23
- function shown<T extends object>(from: T, texts: Partial<T>): T {
22
+ // Only the keys a thing has, so it is the same as it was but for its texts. `link`: remember `from` as what it was written as.
23
+ function shownAs<T extends object>(from: T, texts: Partial<T>, link: boolean): T {
24
24
  const to = { ...from };
25
25
  for (const key of Object.keys(texts) as (keyof T)[]) if (from[key] !== undefined) to[key] = texts[key]!;
26
- written.set(to, from);
26
+ if (link) written.set(to, from);
27
27
  return to;
28
28
  }
29
29
 
30
30
  /** The site's parts with every text of theirs said by `say`: the label, the hint, the link elsewhere,
31
- * the message of a pattern, the options, a switch's two words … of each field, block, file and part. */
32
- export function translator(say: (text: string) => string) {
31
+ * the message of a pattern, the options, a switch's two words … of each field, block, file and part.
32
+ * `sayWritten`: what asWritten gives back for each thing it returns, with its texts said by it (the standard parts'
33
+ * English in the site's language, inSiteLanguage); without it, asWritten gives back the thing as it was. */
34
+ export function translator(say: (text: string) => string, sayWritten?: (text: string) => string) {
35
+ const shownTexts = build(say, !sayWritten);
36
+ if (!sayWritten) return shownTexts;
37
+ const writtenTexts = build(sayWritten, false);
38
+ const both =
39
+ <A extends object>(one: (x: A) => A, said: (x: A) => A) =>
40
+ (x: A): A => {
41
+ const to = one(x);
42
+ written.set(to, said(x));
43
+ return to;
44
+ };
45
+ return {
46
+ ...shownTexts,
47
+ block: both(shownTexts.block, writtenTexts.block),
48
+ collection: both(shownTexts.collection, writtenTexts.collection),
49
+ part: both(shownTexts.part, writtenTexts.part),
50
+ app: both(shownTexts.app, writtenTexts.app),
51
+ } as typeof shownTexts;
52
+ }
53
+
54
+ function build(say: (text: string) => string, link: boolean) {
55
+ const shown = <T extends object>(from: T, texts: Partial<T>): T => shownAs(from, texts, link);
33
56
  const maybe = (text?: string) => (text === undefined ? text : say(text));
34
57
  const edit = (e?: Edit) => e && { ...e, text: say(e.text) };
35
58
  const field = (f: Field): Field =>
@@ -94,7 +117,8 @@ export function translator(say: (text: string) => string) {
94
117
  /** A colour under "Farver" (colorFields in @site/colors): its CSS variable without "--", its name and what it is for. */
95
118
  type ColorField = (typeof site.colorFields)[number];
96
119
 
97
- const local = translator(siteText);
120
+ // A commit names a part in the site's language: the standard parts' English texts through inSiteLanguage, the site's own as written.
121
+ const local = translator(siteText, inSiteLanguage);
98
122
  const each = <T>(all: T[], one: (x: T) => T) => (translatingSite ? all.map(one) : all);
99
123
 
100
124
  // The screens under "Blokke" of the blocks with shared texts (shared.ts in the block's folder), named and
@@ -117,7 +141,7 @@ function sharedPartsOf(blocks: Folders<BlockDefinition>, shared: Folders<SharedT
117
141
  fields,
118
142
  defaults: values,
119
143
  preview: { type, ...preview },
120
- back: { href: '#blokke', label: t.common.parts.blocks },
144
+ back: { href: '#blocks', label: t.common.parts.blocks },
121
145
  },
122
146
  ];
123
147
  });
@@ -142,7 +166,7 @@ export const writtenBlockParts: Part[] = [...site.blockParts, ...sharedParts, ..
142
166
  /** The parts of "Virksomheden"; one with parts of its own (Part.parts) has them going back to it. */
143
167
  export const companyParts = each(
144
168
  site.companyParts.map((p) =>
145
- p.parts ? { ...p, parts: p.parts.map((s) => ({ ...s, back: { href: `#virksomheden/${p.id}`, label: p.label } })) } : p,
169
+ p.parts ? { ...p, parts: p.parts.map((s) => ({ ...s, back: { href: `#company/${p.id}`, label: p.label } })) } : p,
146
170
  ),
147
171
  local.part,
148
172
  );
@@ -155,9 +179,9 @@ export const partScreens = site.partScreens.map((s) => ({
155
179
  local.part,
156
180
  ),
157
181
  }));
158
- /** The site's own parts under "Udseende" (#udseende, Library.tsx Theme), after the logo and icon, each going back to it. */
182
+ /** The site's own parts under "Udseende" (#appearance, Library.tsx Theme), after the logo and icon, each going back to it. */
159
183
  export const appearance = each(
160
- site.appearance.map((p) => ({ ...p, back: { href: '#udseende', label: t.common.parts.appearance } })),
184
+ site.appearance.map((p) => ({ ...p, back: { href: '#appearance', label: t.common.parts.appearance } })),
161
185
  local.part,
162
186
  );
163
187
  /** Where a screen goes back to: the start screen. The menu's sections ("Indhold" …) are lists in the drawer, not screens,
@@ -190,7 +214,7 @@ export const placeOf = (id: string): string | undefined => groups.find((g) => g.
190
214
  * ("Udseende › Standardsider"): the part with its field in the file of the standard pages. */
191
215
  export function standardPageAt(key: string): string {
192
216
  const chosenIn = (p: Part) => p.file === site.config.standardPages && !!p.fields?.some((f) => f.name === key);
193
- const screen = [{ id: 'udseende', label: t.common.parts.appearance, parts: appearance }, ...partScreens].find((s) =>
217
+ const screen = [{ id: 'appearance', label: t.common.parts.appearance, parts: appearance }, ...partScreens].find((s) =>
194
218
  s.parts.some(chosenIn),
195
219
  );
196
220
  if (!screen) return '';
@@ -314,7 +338,7 @@ export const icons = {
314
338
  ),
315
339
  };
316
340
 
317
- /** The short lists, under "Lister" (#lister): the site's, the standard blocks' and the apps', which go back there too. */
341
+ /** The short lists, under "Lister" (#lists): the site's, the standard blocks' and the apps', which go back there too. */
318
342
  export const lists = [...site.lists, ...ownedLists.map((l) => l.id)];
319
343
 
320
344
  /** The lists under "Lister" now: the site's, the standard blocks' and those of the apps installed, without the cookie
@@ -14,7 +14,7 @@ export default defineBlock({
14
14
  label: t.blocks.accordion.label,
15
15
  icon: Rows,
16
16
  hint: t.blocks.accordion.hint,
17
- edit: { to: 'blokke/accordion', text: t.blocks.accordion.edit },
17
+ edit: { to: 'blocks/accordion', text: t.blocks.accordion.edit },
18
18
  fields: [
19
19
  ...headFields,
20
20
  {
@@ -5,7 +5,7 @@ import { siteT, t } from '../../i18n';
5
5
  import { defineShared } from '../../shared';
6
6
 
7
7
  /** The rows it is shown with where no page has it: its screen under "Blokke" and "Tilgængelige
8
- * blokke" (index.tsx). In the site's language, like the drawing. */
8
+ * blocks" (index.tsx). In the site's language, like the drawing. */
9
9
  export const example = { items: siteT.blocks.accordion.example.map((label) => ({ label, href: '' })) };
10
10
 
11
11
  export default defineShared({
@@ -11,7 +11,7 @@ export default defineBlock({
11
11
  label: t.blocks.buttons.label,
12
12
  icon: CursorClick,
13
13
  hint: t.blocks.buttons.hint,
14
- edit: { to: 'blokke/buttons', text: t.blocks.buttons.edit },
14
+ edit: { to: 'blocks/buttons', text: t.blocks.buttons.edit },
15
15
  fields: [{ ...buttonList, hint: t.blocks.buttons.itemsHint }],
16
16
  preview: true,
17
17
  detail: (p) => [
@@ -13,7 +13,7 @@ export default defineBlock({
13
13
  label: t.blocks.cards.label,
14
14
  icon: Cards,
15
15
  hint: t.blocks.cards.hint,
16
- edit: { to: 'blokke/cards', text: t.blocks.cards.edit },
16
+ edit: { to: 'blocks/cards', text: t.blocks.cards.edit },
17
17
  fields: [
18
18
  ...headFields,
19
19
  // An icon is the empty choice: so are the cards from before there was a choice.
@@ -4,7 +4,7 @@ import { siteT, t } from '../../i18n';
4
4
  import { defineShared } from '../../shared';
5
5
 
6
6
  /** The cards it is shown with where no page has it: its screen under "Blokke" and "Tilgængelige
7
- * blokke" (index.tsx). In the site's language, like the drawing. With a page, so the link's text,
7
+ * blocks" (index.tsx). In the site's language, like the drawing. With a page, so the link's text,
8
8
  * which is edited there, is on them. */
9
9
  export const example = { items: siteT.blocks.cards.example.map((c) => ({ ...c, href: '/' })) };
10
10
 
@@ -11,7 +11,7 @@ export default defineBlock({
11
11
  label: t.blocks.cookies.label,
12
12
  icon: Cookie,
13
13
  hint: t.blocks.cookies.hint,
14
- edit: { to: `liste/${config.lists.cookies}`, text: t.blocks.cookies.edit },
14
+ edit: { to: `list/${config.lists.cookies}`, text: t.blocks.cookies.edit },
15
15
  fields: [],
16
16
  example: {},
17
17
  });
@@ -49,7 +49,7 @@ export default defineBlock({
49
49
  label: t.blocks.posts.label,
50
50
  icon: Newspaper,
51
51
  hint: t.blocks.posts.hint,
52
- edit: { to: 'blokke/posts', text: t.blocks.posts.edit },
52
+ edit: { to: 'blocks/posts', text: t.blocks.posts.edit },
53
53
  fields: [
54
54
  ...headFields,
55
55
  // A list (the empty choice: so are the blocks from before there was a choice), or the newest large over the list
@@ -36,7 +36,7 @@ export default defineBlock({
36
36
  label: t.blocks.testimonials.label,
37
37
  icon: Quotes,
38
38
  hint: t.blocks.testimonials.hint,
39
- edit: { to: `liste/${config.lists.testimonials}`, text: t.blocks.testimonials.edit },
39
+ edit: { to: `list/${config.lists.testimonials}`, text: t.blocks.testimonials.edit },
40
40
  fields: [
41
41
  ...headFields,
42
42
  {