@morphemecms/cms 0.9.0-next-20261105122349 → 0.9.0

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 (126) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/defaults/collections.ts +110 -56
  3. package/defaults/editor.ts +2 -2
  4. package/defaults/texts.ts +14 -5
  5. package/demo/src/content/pages/app-store.md +0 -1
  6. package/demo/src/content/pages/blocks.md +0 -1
  7. package/icons/lucide/arrows-horizontal.svg +1 -0
  8. package/icons/lucide/columns-plus-left.svg +1 -0
  9. package/icons/lucide/columns-plus-right.svg +1 -0
  10. package/icons/lucide/file.svg +1 -0
  11. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  12. package/icons/material-symbols/columns-plus-left.svg +1 -0
  13. package/icons/material-symbols/columns-plus-right.svg +1 -0
  14. package/icons/material-symbols/file.svg +1 -0
  15. package/icons/phosphor/arrows-horizontal.svg +1 -0
  16. package/icons/phosphor/columns-plus-left.svg +1 -0
  17. package/icons/phosphor/columns-plus-right.svg +1 -0
  18. package/icons/phosphor/file.svg +1 -0
  19. package/lib/fold-open.d.mts +2 -0
  20. package/lib/fold-open.mjs +22 -0
  21. package/lib/fold-open.test.mjs +24 -0
  22. package/lib/menu.test.mjs +16 -3
  23. package/lib/page-template.d.mts +54 -0
  24. package/lib/page-template.mjs +126 -0
  25. package/lib/page-template.test.mjs +129 -0
  26. package/lib/side-block.d.mts +2 -16
  27. package/lib/side-block.mjs +12 -30
  28. package/lib/side-block.test.mjs +13 -30
  29. package/package.json +1 -1
  30. package/scripts/check-side.mjs +49 -22
  31. package/scripts/editor-roundtrip.mjs +15 -6
  32. package/scripts/fixtures/side/side-full.md +8 -0
  33. package/scripts/fixtures/side/side-left.md +8 -0
  34. package/scripts/fixtures/side/side-none.md +7 -0
  35. package/scripts/fixtures/side/side-right.md +8 -0
  36. package/scripts/fixtures/side/side-single.md +8 -0
  37. package/scripts/side-width.mjs +29 -0
  38. package/scripts/side-width.test.mjs +30 -0
  39. package/site/content.config.ts +3 -4
  40. package/site/header.d.mts +21 -0
  41. package/site/header.mjs +35 -0
  42. package/site/header.test.mjs +59 -0
  43. package/site/icons.d.mts +16 -0
  44. package/site/icons.mjs +8 -0
  45. package/site/site.ts +8 -3
  46. package/site/templates.ts +7 -0
  47. package/site/theme.mjs +3 -0
  48. package/src/App.tsx +12 -24
  49. package/src/AppList.tsx +36 -14
  50. package/src/AppSettingsTab.tsx +5 -3
  51. package/src/BlockDialog.tsx +50 -5
  52. package/src/DataFileFields.tsx +35 -0
  53. package/src/DataForm.tsx +16 -38
  54. package/src/Deleted.tsx +7 -3
  55. package/src/EntryForm.tsx +41 -88
  56. package/src/FooterSettings.tsx +76 -0
  57. package/src/FormFrame.tsx +2 -1
  58. package/src/HeaderSettings.tsx +97 -0
  59. package/src/Library.tsx +26 -14
  60. package/src/MarkdownEditor.tsx +18 -36
  61. package/src/Media.tsx +6 -3
  62. package/src/Redirects.tsx +12 -9
  63. package/src/SectionSettings.tsx +21 -0
  64. package/src/SectionSettingsIcon.tsx +10 -0
  65. package/src/SettingsSections.tsx +57 -0
  66. package/src/ThemeStore.tsx +28 -9
  67. package/src/Versions.tsx +3 -15
  68. package/src/busyAction.d.mts +3 -0
  69. package/src/busyAction.mjs +8 -0
  70. package/src/busyAction.test.mjs +16 -0
  71. package/src/dataFileValues.ts +30 -0
  72. package/src/editor.css +37 -3
  73. package/src/fields.tsx +29 -10
  74. package/src/footerPreview.d.mts +46 -0
  75. package/src/footerPreview.mjs +84 -0
  76. package/src/footerPreview.test.mjs +100 -0
  77. package/src/formSections.ts +100 -0
  78. package/src/frontmatter.mjs +1 -1
  79. package/src/frontmatter.test.mjs +11 -11
  80. package/src/headerPreview.d.mts +48 -0
  81. package/src/headerPreview.mjs +168 -0
  82. package/src/headerPreview.test.mjs +183 -0
  83. package/src/headerTheme.tsx +58 -0
  84. package/src/i18n/da-DK/common.ts +1 -0
  85. package/src/i18n/da-DK/library.ts +2 -0
  86. package/src/i18n/da-DK/preview.ts +2 -0
  87. package/src/i18n/da-DK/text.ts +1 -0
  88. package/src/i18n/en-US/common.ts +1 -0
  89. package/src/i18n/en-US/library.ts +4 -0
  90. package/src/i18n/en-US/preview.ts +3 -0
  91. package/src/i18n/en-US/text.ts +2 -1
  92. package/src/images.ts +7 -2
  93. package/src/menuChildren.ts +1 -3
  94. package/src/movedRoutes.d.mts +3 -0
  95. package/src/movedRoutes.mjs +35 -0
  96. package/src/movedRoutes.test.mjs +22 -0
  97. package/src/pagePreview.tsx +40 -29
  98. package/src/partPreview.tsx +59 -0
  99. package/src/schema.ts +7 -1
  100. package/src/sheetPlacement.d.mts +3 -0
  101. package/src/sheetPlacement.mjs +9 -0
  102. package/src/sheetPlacement.test.mjs +23 -0
  103. package/src/standard/views.ts +3 -3
  104. package/src/templateField.ts +28 -0
  105. package/src/themes.ts +3 -0
  106. package/src/ui.tsx +35 -8
  107. package/src/useDataFiles.ts +94 -0
  108. package/starter/package.json +1 -1
  109. package/theme/components/Footer.astro +14 -7
  110. package/theme/components/Header.astro +58 -33
  111. package/theme/components/MobileNav.astro +5 -4
  112. package/theme/components/RelatedPosts.astro +1 -1
  113. package/theme/components/headerParts.ts +17 -0
  114. package/theme/layouts/PageLayout.astro +20 -16
  115. package/theme/styles/base.css +6 -5
  116. package/theme/styles/blocks.css +8 -12
  117. package/theme/styles/components.css +8 -1
  118. package/theme/styles/global.css +5 -9
  119. package/theme/styles/ui.css +111 -5
  120. package/theme/theme.json +1 -0
  121. package/theme/ui/BusyContent.tsx +16 -0
  122. package/theme/ui/Button.tsx +25 -5
  123. package/theme/ui/IconButton.tsx +17 -4
  124. package/scripts/fixtures/side/side-off.md +0 -8
  125. package/scripts/fixtures/side/side-plain.md +0 -7
  126. package/scripts/fixtures/side/side-wide.md +0 -8
@@ -0,0 +1,100 @@
1
+ // The sections of a long form, under each other on one page, each folding in and out under its heading (FoldSection in
2
+ // ui.tsx), with their "on this page" list (SectionList, large and wider): which are open, the one in view (marked in the
3
+ // list while the page scrolls), and a tap in the list, which opens one and scrolls to it. With `single` only one is open
4
+ // at a time: opening one folds the others (the settings screens on a phone, SettingsSections.tsx). Exposes
5
+ // useFormSections. Used by EntryForm.tsx and SettingsSections.tsx. Uses lib/section-in-view.mjs and lib/fold-open.mjs.
6
+ import { useEffect, useRef, useState } from 'react';
7
+ import { flushSync } from 'react-dom';
8
+ import type { NavigationItem } from './ui';
9
+ import { sectionInView, spyLine } from '../lib/section-in-view.mjs';
10
+ import { onlyFirst, withOpen } from '../lib/fold-open.mjs';
11
+
12
+ const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
13
+
14
+ /** `sections`: the form's sections, in their order; `start`: those open from the start (the first marked in the list);
15
+ * `single`: only one open at a time. */
16
+ export function useFormSections<S extends string>(sections: NavigationItem[], start: S[], single = false) {
17
+ // The section in view, marked in the sections list: while the page scrolls, the last one whose top is above a third of the
18
+ // screen (at the bottom of the page, the last one in view). A tap in the list marks its own and scrolls there; the
19
+ // mark stays meanwhile (`steering`, until the scroll ends).
20
+ const [section, setSection] = useState<S>(start[0]);
21
+ const boxes = useRef<Partial<Record<S, HTMLElement | null>>>({});
22
+ const order = useRef<S[]>([]);
23
+ order.current = sections.map((s) => s.id as S);
24
+ const steering = useRef(0);
25
+ const spy = useRef(() => {});
26
+ const steer = (id: S) => {
27
+ setSection(id);
28
+ steering.current = performance.now() + 1500;
29
+ addEventListener('scrollend', () => (steering.current = 0), { once: true });
30
+ };
31
+ const [open, setOpen] = useState<ReadonlySet<S>>(() => new Set(single ? start.slice(0, 1) : start));
32
+ const opened = (was: ReadonlySet<S>, id: S) => withOpen(was, id, single);
33
+ const unfold = (id: S) => setOpen((was) => opened(was, id));
34
+ // The window narrowed to `single`: only the first of those open stays open.
35
+ useEffect(() => {
36
+ if (single) setOpen((was) => onlyFirst(was, order.current));
37
+ }, [single]);
38
+ // A section opened by its heading is marked in the sections list; one folded, the one in view now (the page is shorter,
39
+ // without a scroll). With `single`, one whose heading went up out of view as the one above it folded is scrolled to.
40
+ const toggle = (id: S) => {
41
+ const opening = !open.has(id);
42
+ setOpen((was) => {
43
+ if (opening) return opened(was, id);
44
+ const next = new Set(was);
45
+ next.delete(id);
46
+ return next;
47
+ });
48
+ if (opening) setSection(id);
49
+ else requestAnimationFrame(() => spy.current());
50
+ if (opening && single)
51
+ requestAnimationFrame(() => {
52
+ const box = boxes.current[id];
53
+ if (box && box.getBoundingClientRect().top < 0) box.scrollIntoView({ block: 'start', behavior: scrolling() });
54
+ });
55
+ };
56
+ useEffect(() => {
57
+ let frame = 0;
58
+ spy.current = () => {
59
+ frame = 0;
60
+ if (performance.now() < steering.current) return;
61
+ const atBottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 8;
62
+ const tops: Record<string, number | undefined> = {};
63
+ for (const id of order.current) tops[id] = boxes.current[id]?.getBoundingClientRect().top;
64
+ const here = sectionInView(order.current, tops, spyLine(innerHeight, atBottom));
65
+ if (here) setSection(here as S);
66
+ };
67
+ const onScroll = () => {
68
+ if (!frame) frame = requestAnimationFrame(spy.current);
69
+ };
70
+ addEventListener('scroll', onScroll, { passive: true });
71
+ return () => {
72
+ removeEventListener('scroll', onScroll);
73
+ cancelAnimationFrame(frame);
74
+ };
75
+ }, []);
76
+ // Opens a section and scrolls to it (or to `box` in it). It is opened at once (flushSync), so it is there to scroll to.
77
+ const goTo = (id: S, box?: HTMLElement | null) => {
78
+ if (!open.has(id)) flushSync(() => unfold(id));
79
+ steer(id);
80
+ requestAnimationFrame(() => (box ?? boxes.current[id])?.scrollIntoView({ block: 'start', behavior: scrolling() }));
81
+ };
82
+ return {
83
+ /** The section in view, for the sections list (SectionList `value`). */
84
+ section,
85
+ /** Marks a section in the list while the page scrolls to it (a field that keeps the form from being saved). */
86
+ steer,
87
+ /** Opens a section (a field in it keeps the form from being saved: useWrongField's `open`). */
88
+ unfold,
89
+ goTo,
90
+ /** A tap in the sections list (SectionList `onChange`). */
91
+ pick: (id: string) => goTo(id as S),
92
+ /** A section's FoldSection props: where it is, its heading (its name in the list), and whether it is open. */
93
+ sectionProps: (id: S) => ({
94
+ ref: (el: HTMLElement | null) => void (boxes.current[id] = el),
95
+ title: sections.find((s) => s.id === id)!.label,
96
+ open: open.has(id),
97
+ onToggle: () => toggle(id),
98
+ }),
99
+ };
100
+ }
@@ -44,7 +44,7 @@ function toNode(doc, key, value) {
44
44
  return node;
45
45
  }
46
46
 
47
- // A true/false field that is true when it is missing from the file (Field.missing, "Sidekolonne": `side`): booleanRead
47
+ // A true/false field that is true when it is missing from the file (Field.missing): booleanRead
48
48
  // gives the form's value for what is in the file; booleanWrite what is written for the form's value (nothing, '', when
49
49
  // it is the missing one, so the key leaves the file).
50
50
  export const booleanRead = (value, missing) => (value === undefined ? missing === true : value === true);
@@ -1,12 +1,12 @@
1
- // Tests booleanRead and booleanWrite (frontmatter.mjs): the page switch "Sidekolonne" is `side: false` when off and the
2
- // key is missing when on. Run: node --test src/frontmatter.test.mjs
1
+ // Tests booleanRead and booleanWrite (frontmatter.mjs): a switch that is on when its key is missing (Field.missing) is
2
+ // `key: false` when off and the key is missing when on. Run: node --test src/frontmatter.test.mjs
3
3
  import assert from 'node:assert/strict';
4
4
  import { test } from 'node:test';
5
5
  import { booleanRead, booleanWrite, writeFields } from './frontmatter.mjs';
6
6
 
7
- const yaml = 'title: "Om os"\nwide: false';
7
+ const yaml = 'title: "Om os"\ndraft: false';
8
8
 
9
- test('a missing key reads as on, side: false as off', () => {
9
+ test('a missing key reads as on, key: false as off', () => {
10
10
  assert.equal(booleanRead(undefined, true), true);
11
11
  assert.equal(booleanRead(false, true), false);
12
12
  assert.equal(booleanRead(true, true), true);
@@ -14,11 +14,11 @@ test('a missing key reads as on, side: false as off', () => {
14
14
  assert.equal(booleanRead(true, undefined), true);
15
15
  });
16
16
 
17
- test('off writes side: false, on removes the key and leaves the rest as it was', () => {
18
- const off = writeFields(yaml, { side: booleanWrite(false, true) });
19
- assert.equal(off, `${yaml}\nside: false`);
20
- assert.equal(writeFields(off, { side: booleanWrite(true, true) }), yaml);
21
- assert.equal(writeFields(yaml, { side: booleanWrite(true, true) }), yaml);
17
+ test('off writes key: false, on removes the key and leaves the rest as it was', () => {
18
+ const off = writeFields(yaml, { pinned: booleanWrite(false, true) });
19
+ assert.equal(off, `${yaml}\npinned: false`);
20
+ assert.equal(writeFields(off, { pinned: booleanWrite(true, true) }), yaml);
21
+ assert.equal(writeFields(yaml, { pinned: booleanWrite(true, true) }), yaml);
22
22
  });
23
23
 
24
24
  test('a switch without a missing value is written as it is', () => {
@@ -27,7 +27,7 @@ test('a switch without a missing value is written as it is', () => {
27
27
  });
28
28
 
29
29
  test('front matter with CRLF line breaks is written back unchanged, and added fields use CRLF too', () => {
30
- const crlf = 'title: "Om os"\r\nwide: false';
30
+ const crlf = 'title: "Om os"\r\ndraft: false';
31
31
  assert.equal(writeFields(crlf, {}), crlf);
32
- assert.equal(writeFields(crlf, { side: false }), `${crlf}\r\nside: false`);
32
+ assert.equal(writeFields(crlf, { pinned: false }), `${crlf}\r\npinned: false`);
33
33
  });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * What the form (`now`) changes from the files as they were saved (`saved`): only what differs is in it, so the rest of
3
+ * the page stays exactly as on the site.
4
+ * @param {HeaderValues} saved
5
+ * @param {HeaderValues} now
6
+ * @returns {HeaderDraft}
7
+ */
8
+ export function headerDraft(saved: HeaderValues, now: HeaderValues): HeaderDraft;
9
+ /**
10
+ * Puts what the form changes (headerDraft) into the site's page, drawn as the theme draws it (`theme`).
11
+ * @param {Document} doc
12
+ * @param {HeaderDraft} draft
13
+ * @param {HeaderTheme} theme
14
+ */
15
+ export function swapHeader(doc: Document, draft: HeaderDraft, theme: HeaderTheme): void;
16
+ export type NavLink = import("../site/header.mjs").NavLink;
17
+ export type NavItem = import("../site/header.mjs").NavItem;
18
+ export type Button = import("../site/header.mjs").Button;
19
+ /**
20
+ * The header's three files as the form has them: menu.json, mobile-menu.json and header.json.
21
+ */
22
+ export type HeaderValues = {
23
+ menu: Record<string, any>;
24
+ mobileMenu: Record<string, any>;
25
+ header: Record<string, any>;
26
+ };
27
+ /**
28
+ * What the form changes in the header; undefined leaves it as it is on the site. `nav` null: the menu is off (no items,
29
+ * no menu button); `mobileNav` empty: no bar; `button` null: the phone icon.
30
+ */
31
+ export type HeaderDraft = {
32
+ nav?: NavItem[] | null;
33
+ mobileNav?: NavLink[];
34
+ menuSide?: "start" | "end";
35
+ button?: Button | null;
36
+ };
37
+ /**
38
+ * How the theme draws what the page may not have: the menu button's classes on each side, the button's place as HTML
39
+ * (when `button` changed; with data-editor="header-cta"), an empty bar of "Mobilmenu" with one tab (for a page without
40
+ * one), the name of an item's sub-pages button, and the page's address (for the open page's item).
41
+ */
42
+ export type HeaderTheme = {
43
+ menuClass: Record<"start" | "end", string>;
44
+ cta?: string;
45
+ mobileNav: string;
46
+ subpagesOf: (label: string) => string;
47
+ current: string;
48
+ };
@@ -0,0 +1,168 @@
1
+ // The editor's preview of the header (HeaderSettings.tsx): what the header's settings in the form change (headerDraft),
2
+ // and those changes put into the site's page (swapHeader), in the parts Header.astro and MobileNav.astro mark with
3
+ // data-editor: the menu's items (header-nav), the menu button (header-menu) and its side, the bar of "Mobilmenu"
4
+ // (mobile-nav) and the button's place (header-cta). Exposes headerDraft and swapHeader. Uses site/header.mjs; a DOM (the
5
+ // browser's, jsdom in the tests), no React: what the theme's components draw comes in as HTML.
6
+ import { headerOf, sortedNav } from '../site/header.mjs';
7
+
8
+ /** @typedef {import('../site/header.mjs').NavLink} NavLink */
9
+ /** @typedef {import('../site/header.mjs').NavItem} NavItem */
10
+ /** @typedef {import('../site/header.mjs').Button} Button */
11
+
12
+ /**
13
+ * The header's three files as the form has them: menu.json, mobile-menu.json and header.json.
14
+ * @typedef {{ menu: Record<string, any>, mobileMenu: Record<string, any>, header: Record<string, any> }} HeaderValues
15
+ */
16
+
17
+ /**
18
+ * What the form changes in the header; undefined leaves it as it is on the site. `nav` null: the menu is off (no items,
19
+ * no menu button); `mobileNav` empty: no bar; `button` null: the phone icon.
20
+ * @typedef {{ nav?: NavItem[] | null, mobileNav?: NavLink[], menuSide?: 'start' | 'end', button?: Button | null }} HeaderDraft
21
+ */
22
+
23
+ // As site/site.ts reads the files: the menu's items unless it is off, the bar's links while it is on.
24
+ /** @param {Record<string, any>} menu */
25
+ const navOf = (menu) => (menu.enabled !== false ? sortedNav(menu.items ?? []).map(linkOf) : null);
26
+ /** @param {Record<string, any>} mobileMenu */
27
+ const mobileNavOf = (mobileMenu) => (mobileMenu.enabled ? (mobileMenu.items ?? []).map(linkOf) : []);
28
+ // An item with only what the header shows of it.
29
+ /** @param {Record<string, any>} item @returns {NavItem} */
30
+ const linkOf = ({ label = '', href = '', children }) =>
31
+ children?.length ? { label, href, children: children.map(linkOf) } : { label, href };
32
+
33
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
34
+
35
+ /**
36
+ * What the form (`now`) changes from the files as they were saved (`saved`): only what differs is in it, so the rest of
37
+ * the page stays exactly as on the site.
38
+ * @param {HeaderValues} saved
39
+ * @param {HeaderValues} now
40
+ * @returns {HeaderDraft}
41
+ */
42
+ export function headerDraft(saved, now) {
43
+ /** @type {HeaderDraft} */
44
+ const draft = {};
45
+ const [nav, navBefore] = [navOf(now.menu), navOf(saved.menu)];
46
+ if (!same(nav, navBefore)) draft.nav = nav;
47
+ const [mobileNav, mobileBefore] = [mobileNavOf(now.mobileMenu), mobileNavOf(saved.mobileMenu)];
48
+ if (!same(mobileNav, mobileBefore)) draft.mobileNav = mobileNav;
49
+ const [header, before] = [headerOf(now.header), headerOf(saved.header)];
50
+ if (header.menuSide !== before.menuSide) draft.menuSide = header.menuSide;
51
+ if (!same(header.button, before.button)) draft.button = header.button;
52
+ return draft;
53
+ }
54
+
55
+ /**
56
+ * How the theme draws what the page may not have: the menu button's classes on each side, the button's place as HTML
57
+ * (when `button` changed; with data-editor="header-cta"), an empty bar of "Mobilmenu" with one tab (for a page without
58
+ * one), the name of an item's sub-pages button, and the page's address (for the open page's item).
59
+ * @typedef {{ menuClass: Record<'start' | 'end', string>, cta?: string, mobileNav: string, subpagesOf: (label: string) => string, current: string }} HeaderTheme
60
+ */
61
+
62
+ /** @param {ParentNode} doc @param {string} name @returns {HTMLElement | null} */
63
+ const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
64
+
65
+ // As Header.astro marks the open page: its own address, or a page under it (not the front page's).
66
+ /** @param {string} current @param {string} href */
67
+ const isActive = (current, href) => current === href || (href !== '/' && current.startsWith(href));
68
+
69
+ /** @param {Element | null} a @param {NavLink} link @param {boolean} active */
70
+ function setLink(a, link, active) {
71
+ if (!a) return;
72
+ a.textContent = link.label;
73
+ a.setAttribute('href', link.href);
74
+ if (active) a.setAttribute('aria-current', 'page');
75
+ else a.removeAttribute('aria-current');
76
+ }
77
+
78
+ /** @param {Document} doc @param {string} html */
79
+ function fromHtml(doc, html) {
80
+ const box = doc.createElement('template');
81
+ box.innerHTML = html.trim();
82
+ return box.content;
83
+ }
84
+
85
+ // The menu's items, each drawn as one of the page's: an item with sub-items as one that has them (its button and menu),
86
+ // else as one without. A page whose menu has neither keeps it as it is.
87
+ /** @param {HTMLElement} nav @param {NavItem[]} items @param {HeaderTheme} theme */
88
+ function drawNav(nav, items, theme) {
89
+ const list = nav.querySelector('ul');
90
+ const rows = [...(list?.children ?? [])];
91
+ const plain = rows.find((li) => !li.querySelector('[data-nav-toggle]'));
92
+ const parent = rows.find((li) => li.querySelector('[data-nav-toggle]'));
93
+ if (!list || (!plain && !parent)) return;
94
+ const drawn = items.map((item, i) => {
95
+ const kids = item.children ?? [];
96
+ const li = /** @type {HTMLElement} */ (((kids.length && parent) || plain || parent).cloneNode(true));
97
+ setLink(li.querySelector('a'), item, isActive(theme.current, item.href));
98
+ const toggle = li.querySelector('[data-nav-toggle]');
99
+ const menu = toggle && li.querySelector(`[id="${toggle.getAttribute('aria-controls')}"]`);
100
+ if (!kids.length) {
101
+ toggle?.remove();
102
+ menu?.remove();
103
+ return li;
104
+ }
105
+ if (toggle && menu) {
106
+ menu.id = `menu-${i}`;
107
+ toggle.setAttribute('aria-controls', menu.id);
108
+ toggle.setAttribute('aria-label', theme.subpagesOf(item.label));
109
+ const sub = menu.querySelector('ul');
110
+ const one = sub?.firstElementChild;
111
+ if (sub && one)
112
+ sub.replaceChildren(
113
+ ...kids.map((child) => {
114
+ const row = /** @type {HTMLElement} */ (one.cloneNode(true));
115
+ setLink(row.querySelector('a'), child, theme.current === child.href);
116
+ return row;
117
+ }),
118
+ );
119
+ }
120
+ return li;
121
+ });
122
+ list.replaceChildren(...drawn);
123
+ }
124
+
125
+ // The bar of "Mobilmenu": its links as its tabs, the first one's look; a page without the bar gets the theme's empty one at
126
+ // the end of the header, where MobileNav.astro is.
127
+ /** @param {Document} doc @param {NavLink[]} links @param {HeaderTheme} theme */
128
+ function drawMobileNav(doc, links, theme) {
129
+ let bar = part(doc, 'mobile-nav');
130
+ if (!links.length) return bar?.remove();
131
+ if (!bar) {
132
+ part(doc, 'header')?.append(fromHtml(doc, theme.mobileNav));
133
+ bar = part(doc, 'mobile-nav');
134
+ }
135
+ const list = bar?.querySelector('ul');
136
+ const one = list?.firstElementChild;
137
+ if (!list || !one) return;
138
+ list.replaceChildren(
139
+ ...links.map((link) => {
140
+ const row = /** @type {HTMLElement} */ (one.cloneNode(true));
141
+ setLink(row.querySelector('a'), link, isActive(theme.current, link.href));
142
+ return row;
143
+ }),
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Puts what the form changes (headerDraft) into the site's page, drawn as the theme draws it (`theme`).
149
+ * @param {Document} doc
150
+ * @param {HeaderDraft} draft
151
+ * @param {HeaderTheme} theme
152
+ */
153
+ export function swapHeader(doc, draft, theme) {
154
+ const nav = part(doc, 'header-nav');
155
+ const menu = part(doc, 'header-menu');
156
+ if (draft.nav === null) {
157
+ nav?.remove();
158
+ menu?.remove();
159
+ } else if (draft.nav && nav) drawNav(nav, draft.nav, theme);
160
+ if (draft.mobileNav) drawMobileNav(doc, draft.mobileNav, theme);
161
+ if (draft.button !== undefined && theme.cta !== undefined) part(doc, 'header-cta')?.replaceWith(fromHtml(doc, theme.cta));
162
+ // The menu button first in the header's row, or last (after the button's place).
163
+ if (draft.menuSide && menu?.isConnected && menu.parentElement) {
164
+ menu.className = theme.menuClass[draft.menuSide];
165
+ if (draft.menuSide === 'start') menu.parentElement.prepend(menu);
166
+ else menu.parentElement.append(menu);
167
+ }
168
+ }
@@ -0,0 +1,183 @@
1
+ // Tests headerPreview.mjs: what the header's settings in the form change (headerDraft), and those changes put into a page
2
+ // drawn as Header.astro and MobileNav.astro draw it (swapHeader). jsdom stands in for the browser's DOM.
3
+ // Run: node --test src/headerPreview.test.mjs
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { JSDOM } from 'jsdom';
7
+ import { headerDraft, swapHeader } from './headerPreview.mjs';
8
+
9
+ // A page with the header as the site draws it: the menu button at the start, an item without and one with sub-items, the
10
+ // phone icon, and the bar of "Mobilmenu".
11
+ const PAGE = `<!doctype html><html><body>
12
+ <header data-editor="header"><div data-header-row>
13
+ <div data-editor="header-menu" class="start-class"><button>Menu</button></div>
14
+ <a href="/" class="title">Logo</a>
15
+ <nav data-editor="header-nav"><ul>
16
+ <li data-nav-item><a href="/" class="md-nav-item" aria-current="page">Forside</a></li>
17
+ <li data-nav-item><a href="/b/" class="md-nav-item md-nav-item-parent">Behandlinger</a>
18
+ <button data-nav-toggle aria-controls="menu-1" aria-label="Sider under Behandlinger"></button>
19
+ <div id="menu-1"><ul><li><a href="/b/a/">Arm</a></li></ul></div></li>
20
+ </ul></nav>
21
+ <a data-editor="header-cta" href="tel:12345678" aria-label="Ring 12 34 56 78">phone</a>
22
+ </div>
23
+ <nav data-editor="mobile-nav"><ul><li><a href="/" class="tab" aria-current="page">Forside</a></li></ul></nav>
24
+ </header><main></main></body></html>`;
25
+
26
+ const THEME = {
27
+ menuClass: { start: 'start-class', end: 'end-class' },
28
+ mobileNav: '<nav data-editor="mobile-nav" class="bar"><ul class="tabs"><li><a href="/" class="tab"></a></li></ul></nav>',
29
+ subpagesOf: (label) => `Sider under ${label}`,
30
+ current: '/',
31
+ };
32
+
33
+ const page = () => new JSDOM(PAGE).window.document;
34
+ const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
35
+ const labels = (el) => [...el.querySelectorAll(':scope > ul > li > a')].map((a) => a.textContent);
36
+
37
+ const SAVED = {
38
+ menu: {
39
+ enabled: true,
40
+ items: [
41
+ { label: 'Forside', href: '/' },
42
+ { label: 'Behandlinger', href: '/b/', children: [{ label: 'Arm', href: '/b/a/' }] },
43
+ ],
44
+ },
45
+ mobileMenu: { enabled: true, items: [{ label: 'Forside', href: '/' }] },
46
+ header: { menuSide: 'start' },
47
+ };
48
+ const withChange = (change) => ({ ...SAVED, ...change });
49
+
50
+ test('headerDraft: nothing changed is an empty draft', () => {
51
+ assert.deepEqual(headerDraft(SAVED, structuredClone(SAVED)), {});
52
+ });
53
+
54
+ test('headerDraft: the menu turned off is no items and no menu button', () => {
55
+ assert.deepEqual(headerDraft(SAVED, withChange({ menu: { ...SAVED.menu, enabled: false } })), { nav: null });
56
+ });
57
+
58
+ test('headerDraft: the menu items with their sub-items A–Å', () => {
59
+ const items = [
60
+ {
61
+ label: 'Om',
62
+ href: '/om/',
63
+ children: [
64
+ { label: 'Zone', href: '/z/' },
65
+ { label: 'Arm', href: '/a/' },
66
+ ],
67
+ },
68
+ ];
69
+ assert.deepEqual(headerDraft(SAVED, withChange({ menu: { enabled: true, items } })).nav, [
70
+ {
71
+ label: 'Om',
72
+ href: '/om/',
73
+ children: [
74
+ { label: 'Arm', href: '/a/' },
75
+ { label: 'Zone', href: '/z/' },
76
+ ],
77
+ },
78
+ ]);
79
+ });
80
+
81
+ test('headerDraft: "Mobilmenu" turned off is an empty bar, turned on its links', () => {
82
+ assert.deepEqual(headerDraft(SAVED, withChange({ mobileMenu: { ...SAVED.mobileMenu, enabled: false } })), { mobileNav: [] });
83
+ const off = withChange({ mobileMenu: { enabled: false, items: [{ label: 'Kontakt', href: '/kontakt/' }] } });
84
+ assert.deepEqual(headerDraft(off, withChange({ mobileMenu: { ...off.mobileMenu, enabled: true } })), {
85
+ mobileNav: [{ label: 'Kontakt', href: '/kontakt/' }],
86
+ });
87
+ });
88
+
89
+ test('headerDraft: the button and the menu button at the end', () => {
90
+ const header = { button: { enabled: true, label: 'Book', to: 'booking' }, menuSide: 'end' };
91
+ assert.deepEqual(headerDraft(SAVED, withChange({ header })), { menuSide: 'end', button: { label: 'Book', to: 'booking' } });
92
+ assert.deepEqual(headerDraft(withChange({ header }), SAVED), { menuSide: 'start', button: null });
93
+ });
94
+
95
+ test('swapHeader: an empty draft leaves the page as it is', () => {
96
+ const doc = page();
97
+ const before = doc.body.innerHTML;
98
+ swapHeader(doc, {}, THEME);
99
+ assert.equal(doc.body.innerHTML, before);
100
+ });
101
+
102
+ test('swapHeader: the menu items drawn again, with and without sub-items', () => {
103
+ const doc = page();
104
+ const nav = [
105
+ { label: 'Kontakt', href: '/kontakt/' },
106
+ {
107
+ label: 'Om',
108
+ href: '/om/',
109
+ children: [
110
+ { label: 'Hold', href: '/om/hold/' },
111
+ { label: 'Pris', href: '/om/pris/' },
112
+ ],
113
+ },
114
+ { label: 'Forside', href: '/' },
115
+ ];
116
+ swapHeader(doc, { nav }, THEME);
117
+ const el = part(doc, 'header-nav');
118
+ assert.deepEqual(labels(el), ['Kontakt', 'Om', 'Forside']);
119
+ const [kontakt, om, forside] = el.querySelectorAll(':scope > ul > li');
120
+ assert.equal(kontakt.querySelector('[data-nav-toggle]'), null);
121
+ assert.equal(kontakt.querySelector('a').hasAttribute('aria-current'), false);
122
+ assert.equal(forside.querySelector('a').getAttribute('aria-current'), 'page');
123
+ const toggle = om.querySelector('[data-nav-toggle]');
124
+ assert.equal(toggle.getAttribute('aria-controls'), 'menu-1');
125
+ assert.equal(toggle.getAttribute('aria-label'), 'Sider under Om');
126
+ assert.deepEqual(
127
+ [...om.querySelectorAll('#menu-1 a')].map((a) => [a.textContent, a.getAttribute('href')]),
128
+ [
129
+ ['Hold', '/om/hold/'],
130
+ ['Pris', '/om/pris/'],
131
+ ],
132
+ );
133
+ });
134
+
135
+ test('swapHeader: the menu off removes its items and the menu button', () => {
136
+ const doc = page();
137
+ swapHeader(doc, { nav: null }, THEME);
138
+ assert.equal(part(doc, 'header-nav'), null);
139
+ assert.equal(part(doc, 'header-menu'), null);
140
+ });
141
+
142
+ test('swapHeader: the bar of "Mobilmenu" drawn again, removed, and put in when the page has none', () => {
143
+ const doc = page();
144
+ swapHeader(
145
+ doc,
146
+ {
147
+ mobileNav: [
148
+ { label: 'A', href: '/a/' },
149
+ { label: 'B', href: '/b/' },
150
+ ],
151
+ },
152
+ THEME,
153
+ );
154
+ assert.deepEqual(labels(part(doc, 'mobile-nav')), ['A', 'B']);
155
+ swapHeader(doc, { mobileNav: [] }, THEME);
156
+ assert.equal(part(doc, 'mobile-nav'), null);
157
+ swapHeader(doc, { mobileNav: [{ label: 'C', href: '/' }] }, THEME);
158
+ const bar = part(doc, 'mobile-nav');
159
+ assert.equal(bar.className, 'bar');
160
+ assert.equal(bar.parentElement, part(doc, 'header'));
161
+ assert.deepEqual(labels(bar), ['C']);
162
+ assert.equal(bar.querySelector('a').getAttribute('aria-current'), 'page');
163
+ });
164
+
165
+ test('swapHeader: the button in place of the phone icon', () => {
166
+ const doc = page();
167
+ const cta = '<div data-editor="header-cta" class="cta"><a href="/booking/">Book</a></div>';
168
+ swapHeader(doc, { button: { label: 'Book', to: 'booking' } }, { ...THEME, cta });
169
+ assert.equal(part(doc, 'header-cta').className, 'cta');
170
+ assert.equal(part(doc, 'header-cta').textContent, 'Book');
171
+ assert.equal(doc.querySelectorAll('[data-editor="header-cta"]').length, 1);
172
+ });
173
+
174
+ test('swapHeader: the menu button at the end and back at the start', () => {
175
+ const doc = page();
176
+ swapHeader(doc, { menuSide: 'end' }, THEME);
177
+ const menu = part(doc, 'header-menu');
178
+ assert.equal(menu.className, 'end-class');
179
+ assert.equal(menu.parentElement.lastElementChild, menu);
180
+ swapHeader(doc, { menuSide: 'start' }, THEME);
181
+ assert.equal(menu.className, 'start-class');
182
+ assert.equal(menu.parentElement.firstElementChild, menu);
183
+ });
@@ -0,0 +1,58 @@
1
+ // How the theme draws the parts of the header that its settings put in, as HTML for the editor's preview of the header
2
+ // (headerPreview.mjs HeaderTheme): the button or the phone icon in the button's place, an empty bar of "Mobilmenu", the
3
+ // menu button's classes on each side. Exposes headerThemeOf. Uses the theme's Buttons, IconButton, Tabs and headerParts.ts.
4
+ import Buttons from '@theme/blocks/buttons/Buttons';
5
+ import IconButton from '@theme/ui/IconButton';
6
+ import { tabClass } from '@theme/ui/Tabs';
7
+ import { headerCtaClass, headerEdge, headerMenuClass, mobileNavClass, mobileNavListClass } from '@theme/components/headerParts';
8
+ import { buttonsOf } from './standard/buttons/view';
9
+ import type { Button } from './standard/buttons/fields';
10
+ import type { BlockData } from './blocks';
11
+ import { htmlOf } from './pagePreview';
12
+ import { siteT } from './i18n';
13
+ import type { HeaderTheme } from './headerPreview.mjs';
14
+
15
+ // As Header.astro draws the button's place: the button (filled) from the medium window size up, else the phone icon.
16
+ function ctaOf(button: Button | null, data: BlockData) {
17
+ if (button) {
18
+ return (
19
+ <div data-editor="header-cta" className={headerCtaClass}>
20
+ <Buttons buttons={buttonsOf([{ ...button, kind: 'filled' }], data)} />
21
+ </div>
22
+ );
23
+ }
24
+ const { phone } = data.site.contact;
25
+ return (
26
+ <IconButton
27
+ href={`tel:${phone.replace(/\s/g, '')}`}
28
+ icon="phone"
29
+ label={`${data.site.labels.call} ${phone}`}
30
+ className={headerEdge.end}
31
+ data-editor="header-cta"
32
+ />
33
+ );
34
+ }
35
+
36
+ // As MobileNav.astro draws the bar, with one tab for the links to be drawn as.
37
+ const mobileNavHtml = () =>
38
+ htmlOf(
39
+ <nav aria-label={siteT.theme.mobileMenu} data-mobile-nav data-editor="mobile-nav" className={mobileNavClass}>
40
+ <ul className={mobileNavListClass}>
41
+ <li>
42
+ <a href="/" className={tabClass} />
43
+ </li>
44
+ </ul>
45
+ </nav>,
46
+ );
47
+
48
+ /** The theme's parts for the preview; `button`: the header's button as the form has it (headerOf), when it changed. The
49
+ * HTML is drawn here, when the preview draws the page, not while the form is drawn. */
50
+ export function headerThemeOf(button: Button | null | undefined, data: BlockData): HeaderTheme {
51
+ return {
52
+ menuClass: { start: headerMenuClass('start'), end: headerMenuClass('end') },
53
+ cta: button === undefined ? undefined : htmlOf(ctaOf(button, data)),
54
+ mobileNav: mobileNavHtml(),
55
+ subpagesOf: siteT.theme.subpagesOf,
56
+ current: '/',
57
+ };
58
+ }
@@ -90,6 +90,7 @@ export const common: Messages['common'] = {
90
90
  parts: {
91
91
  company: 'Virksomheden',
92
92
  header: 'Header',
93
+ footer: 'Footer',
93
94
  lists: 'Lister',
94
95
  blocks: 'Blokskabeloner',
95
96
  media: 'Filer',
@@ -67,6 +67,8 @@ export const library: Messages['library'] = {
67
67
  changeMotion: 'Skift bevægelse',
68
68
  motionSaved: (change) => `${change}. Hjemmesiden får den nye bevægelse om et par minutter.`,
69
69
  motionCommit: (name) => `Editor: skift bevægelse til ${name}`,
70
+ headerMenuButton: 'Menuknap',
71
+ footerSections: { text: 'Tekst', contact: 'Kontakt', links: 'Links' },
70
72
  sideNone: 'Ingen',
71
73
  sideNoneHint: 'Siderne har ingen kolonne ved siden af teksten',
72
74
  sideEmpty: 'Ingen installeret blok kan tegnes ved siden af en sides tekst.',
@@ -5,6 +5,8 @@ export const preview: Messages['preview'] = {
5
5
  title: 'Forhåndsvisning',
6
6
  size: 'Vinduesstørrelse',
7
7
  sizes: { compact: 'Kompakt', medium: 'Mellem', expanded: 'Udvidet', large: 'Stor', extraLarge: 'Ekstra stor' },
8
+ phone: 'Telefon',
9
+ computer: 'Computer',
8
10
  making: 'Henter forhåndsvisningen…',
9
11
  failed: (error) => `Kunne ikke oprette forhåndsvisningen: ${error}`,
10
12
  notHere: 'Den side kan ikke forhåndsvises',
@@ -22,6 +22,7 @@ export const text: Messages['text'] = {
22
22
  color: 'Farve',
23
23
  colors: { accent: 'Accentfarve', muted: 'Stille tekst' },
24
24
  noColor: 'Ingen farve',
25
+ sectionSettings: 'Sektionsindstillinger',
25
26
  sectionColor: 'Sektionsfarve',
26
27
  sectionColors: { none: 'Ingen', surface: 'Flade', tint: 'Tone', accent: 'Accent' },
27
28
  linkTo: 'Linket går til',
@@ -103,6 +103,7 @@ export const common = {
103
103
  parts: {
104
104
  company: 'Business',
105
105
  header: 'Header',
106
+ footer: 'Footer',
106
107
  lists: 'Lists',
107
108
  blocks: 'Block templates',
108
109
  media: 'Files',