@morphemecms/cms 0.9.0-next-20261105115842 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/defaults/collections.ts +112 -58
- package/defaults/company.ts +1 -1
- package/defaults/editor.ts +2 -2
- package/defaults/texts.ts +14 -5
- package/demo/src/content/pages/app-store.md +0 -1
- package/demo/src/content/pages/blocks.md +0 -1
- package/icons/lucide/arrows-horizontal.svg +1 -0
- package/icons/lucide/columns-plus-left.svg +1 -0
- package/icons/lucide/columns-plus-right.svg +1 -0
- package/icons/lucide/file.svg +1 -0
- package/icons/material-symbols/arrows-horizontal.svg +1 -0
- package/icons/material-symbols/columns-plus-left.svg +1 -0
- package/icons/material-symbols/columns-plus-right.svg +1 -0
- package/icons/material-symbols/file.svg +1 -0
- package/icons/phosphor/arrows-horizontal.svg +1 -0
- package/icons/phosphor/columns-plus-left.svg +1 -0
- package/icons/phosphor/columns-plus-right.svg +1 -0
- package/icons/phosphor/file.svg +1 -0
- package/lib/fold-open.d.mts +2 -0
- package/lib/fold-open.mjs +22 -0
- package/lib/fold-open.test.mjs +24 -0
- package/lib/menu.test.mjs +16 -3
- package/lib/page-template.d.mts +54 -0
- package/lib/page-template.mjs +126 -0
- package/lib/page-template.test.mjs +129 -0
- package/lib/side-block.d.mts +2 -16
- package/lib/side-block.mjs +12 -30
- package/lib/side-block.test.mjs +13 -30
- package/package.json +1 -1
- package/scripts/check-side.mjs +49 -22
- package/scripts/editor-roundtrip.mjs +15 -6
- package/scripts/fixtures/side/side-full.md +8 -0
- package/scripts/fixtures/side/side-left.md +8 -0
- package/scripts/fixtures/side/side-none.md +7 -0
- package/scripts/fixtures/side/side-right.md +8 -0
- package/scripts/fixtures/side/side-single.md +8 -0
- package/scripts/side-width.mjs +29 -0
- package/scripts/side-width.test.mjs +30 -0
- package/site/content.config.ts +3 -4
- package/site/header.d.mts +21 -0
- package/site/header.mjs +35 -0
- package/site/header.test.mjs +59 -0
- package/site/icons.d.mts +16 -0
- package/site/icons.mjs +8 -0
- package/site/site.ts +9 -4
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- package/site/tracking.client.ts +4 -4
- package/src/App.tsx +12 -24
- package/src/AppList.tsx +36 -14
- package/src/AppSettingsTab.tsx +5 -3
- package/src/BlockDialog.tsx +50 -5
- package/src/DataFileFields.tsx +35 -0
- package/src/DataForm.tsx +16 -38
- package/src/Deleted.tsx +7 -3
- package/src/EntryForm.tsx +41 -88
- package/src/FooterSettings.tsx +76 -0
- package/src/FormFrame.tsx +2 -1
- package/src/HeaderSettings.tsx +100 -0
- package/src/Library.tsx +26 -14
- package/src/MarkdownEditor.tsx +18 -36
- package/src/Media.tsx +6 -3
- package/src/Redirects.tsx +12 -9
- package/src/SectionSettings.tsx +21 -0
- package/src/SectionSettingsIcon.tsx +10 -0
- package/src/SettingsSections.tsx +57 -0
- package/src/ThemeStore.tsx +28 -9
- package/src/Versions.tsx +3 -15
- package/src/busyAction.d.mts +3 -0
- package/src/busyAction.mjs +8 -0
- package/src/busyAction.test.mjs +16 -0
- package/src/dataFileValues.ts +30 -0
- package/src/editor.css +42 -3
- package/src/fields.tsx +29 -10
- package/src/footerPreview.d.mts +46 -0
- package/src/footerPreview.mjs +84 -0
- package/src/footerPreview.test.mjs +100 -0
- package/src/formSections.ts +100 -0
- package/src/frontmatter.mjs +1 -1
- package/src/frontmatter.test.mjs +11 -11
- package/src/headerPreview.d.mts +48 -0
- package/src/headerPreview.mjs +168 -0
- package/src/headerPreview.test.mjs +183 -0
- package/src/headerTheme.tsx +58 -0
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/library.ts +2 -0
- package/src/i18n/da-DK/preview.ts +2 -0
- package/src/i18n/da-DK/text.ts +1 -0
- package/src/i18n/en-US/common.ts +1 -0
- package/src/i18n/en-US/library.ts +4 -0
- package/src/i18n/en-US/preview.ts +3 -0
- package/src/i18n/en-US/text.ts +2 -1
- package/src/images.ts +7 -2
- package/src/menuChildren.ts +1 -3
- package/src/movedRoutes.d.mts +3 -0
- package/src/movedRoutes.mjs +35 -0
- package/src/movedRoutes.test.mjs +22 -0
- package/src/pagePreview.tsx +40 -29
- package/src/partPreview.tsx +59 -0
- package/src/schema.ts +7 -1
- package/src/sheetPlacement.d.mts +3 -0
- package/src/sheetPlacement.mjs +9 -0
- package/src/sheetPlacement.test.mjs +23 -0
- package/src/standard/views.ts +3 -3
- package/src/templateField.ts +28 -0
- package/src/themes.ts +3 -0
- package/src/ui.tsx +35 -8
- package/src/useDataFiles.ts +94 -0
- package/starter/package.json +1 -1
- package/theme/components/Footer.astro +14 -7
- package/theme/components/Header.astro +58 -33
- package/theme/components/MobileNav.astro +5 -4
- package/theme/components/RelatedPosts.astro +1 -1
- package/theme/components/headerParts.ts +17 -0
- package/theme/layouts/PageLayout.astro +20 -16
- package/theme/styles/base.css +6 -5
- package/theme/styles/blocks.css +8 -12
- package/theme/styles/components.css +8 -1
- package/theme/styles/global.css +5 -9
- package/theme/styles/ui.css +111 -5
- package/theme/theme.json +1 -0
- package/theme/ui/BusyContent.tsx +16 -0
- package/theme/ui/Button.tsx +25 -5
- package/theme/ui/IconButton.tsx +17 -4
- package/scripts/fixtures/side/side-off.md +0 -8
- package/scripts/fixtures/side/side-plain.md +0 -7
- package/scripts/fixtures/side/side-wide.md +0 -8
|
@@ -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
|
+
}
|
package/src/i18n/da-DK/common.ts
CHANGED
|
@@ -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',
|
package/src/i18n/da-DK/text.ts
CHANGED
|
@@ -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',
|
package/src/i18n/en-US/common.ts
CHANGED
|
@@ -100,6 +100,10 @@ export const library = {
|
|
|
100
100
|
changeMotion: 'Change motion',
|
|
101
101
|
motionSaved: (change: string) => `${change}. The website gets the new motion in a few minutes.`,
|
|
102
102
|
motionCommit: (name: string) => `Editor: change motion to ${name}`,
|
|
103
|
+
/** Appearance › Header (HeaderSettings.tsx): the group with the menu button's side. */
|
|
104
|
+
headerMenuButton: 'Menu button',
|
|
105
|
+
/** Appearance › Footer (FooterSettings.tsx): the groups of the footer's fields. */
|
|
106
|
+
footerSections: { text: 'Text', contact: 'Contact', links: 'Links' },
|
|
103
107
|
/** Under "Sidekolonne" (SideColumn.tsx): the choice of no block and what is under it, what is said when no block can be
|
|
104
108
|
* drawn there, asked before one is chosen, the question's button, what is said after it is saved, and the commit, in
|
|
105
109
|
* the site's language (siteT). */
|
|
@@ -7,6 +7,9 @@ export const preview = {
|
|
|
7
7
|
/** How wide the page is shown: Material 3's window sizes, each at its narrowest. */
|
|
8
8
|
size: 'Window size',
|
|
9
9
|
sizes: { compact: 'Compact', medium: 'Medium', expanded: 'Expanded', large: 'Large', extraLarge: 'Extra-large' },
|
|
10
|
+
/** The preview of a part every page has (partPreview.tsx, the header): on a phone and on a computer. */
|
|
11
|
+
phone: 'Phone',
|
|
12
|
+
computer: 'Computer',
|
|
10
13
|
making: 'Loading the preview…',
|
|
11
14
|
failed: (error: string) => `Couldn’t create the preview: ${error}`,
|
|
12
15
|
notHere: 'That page can’t be previewed',
|
package/src/i18n/en-US/text.ts
CHANGED
|
@@ -26,7 +26,8 @@ export const text = {
|
|
|
26
26
|
color: 'Color',
|
|
27
27
|
colors: { accent: 'Accent color', muted: 'Muted text' },
|
|
28
28
|
noColor: 'No color',
|
|
29
|
-
/** The button for the
|
|
29
|
+
/** The button for the settings of a section (an H2 and what follows it), and its sheet's colour choices (packages/editor/lib/section-marker.mjs). */
|
|
30
|
+
sectionSettings: 'Section settings',
|
|
30
31
|
sectionColor: 'Section color',
|
|
31
32
|
sectionColors: { none: 'None', surface: 'Surface', tint: 'Tint', accent: 'Accent' },
|
|
32
33
|
/** The sheet of a link (LinkSheet.tsx). */
|
package/src/images.ts
CHANGED
|
@@ -132,15 +132,18 @@ export async function prepareFile(file: File, taken: string[]): Promise<Upload>
|
|
|
132
132
|
export const publicPath = (path: string) => path.replace(/^public/, '');
|
|
133
133
|
|
|
134
134
|
// The pictures added in a form (a page, a fixed page), kept in the browser until "Gem", which saves
|
|
135
|
-
// them in the same commit. upload: pick one and make it ready;
|
|
135
|
+
// them in the same commit. upload: pick one and make it ready; uploading: true while a chosen picture
|
|
136
|
+
// is made ready (a button shows busy); added: those not saved yet, newest
|
|
136
137
|
// first (offered in the gallery, MediaPicker.tsx); saving: the pictures a text uses.
|
|
137
138
|
export function useUploads(notify: (status: Status) => void) {
|
|
138
139
|
const [uploads, setUploads] = useState<Upload[]>([]);
|
|
140
|
+
const [uploading, setUploading] = useState(false);
|
|
139
141
|
|
|
140
142
|
async function upload(): Promise<Upload | null> {
|
|
141
143
|
const file = await pickImage();
|
|
142
144
|
if (!file) return null;
|
|
143
145
|
notify({ text: t.app.preparingPicture });
|
|
146
|
+
setUploading(true);
|
|
144
147
|
try {
|
|
145
148
|
const prepared = await prepareImage(file);
|
|
146
149
|
setUploads((u) => [...u, prepared]);
|
|
@@ -149,6 +152,8 @@ export function useUploads(notify: (status: Status) => void) {
|
|
|
149
152
|
} catch (e) {
|
|
150
153
|
notify({ text: (e as Error).message, error: true });
|
|
151
154
|
return null;
|
|
155
|
+
} finally {
|
|
156
|
+
setUploading(false);
|
|
152
157
|
}
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -157,7 +162,7 @@ export function useUploads(notify: (status: Status) => void) {
|
|
|
157
162
|
const usedIn = (text: string) => uploads.filter((u) => !u.saved && text.includes(publicPath(u.path)));
|
|
158
163
|
const markSaved = (pictures: Upload[]) => setUploads((all) => all.map((u) => (pictures.includes(u) ? { ...u, saved: true } : u)));
|
|
159
164
|
|
|
160
|
-
return { upload, added, usedIn, markSaved };
|
|
165
|
+
return { upload, uploading, added, usedIn, markSaved };
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
export type Uploads = ReturnType<typeof useUploads>;
|
package/src/menuChildren.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The second level of the main menu (MainMenu.tsx, lib/menu.mjs menuTree): the lists under Lists,
|
|
1
|
+
// The second level of the main menu (MainMenu.tsx, lib/menu.mjs menuTree): the lists under Lists,
|
|
2
2
|
// the parts under Business, the apps installed under Apps and the two lists under Redirects. Exposes childrenOf.
|
|
3
3
|
// Uses site.ts (collections, apps, companyParts), Library.tsx (hrefOf, listsShown) and Redirects.tsx (TAB_ROUTES).
|
|
4
|
-
import { header } from '@site/editor';
|
|
5
4
|
import { installedApps } from './apps';
|
|
6
5
|
import { t } from './i18n';
|
|
7
6
|
import { hrefOf } from './Library';
|
|
@@ -20,7 +19,6 @@ const listOf = (ids: string[]): MenuChild[] =>
|
|
|
20
19
|
/** The sub-items of a menu item, in their order: none for most. */
|
|
21
20
|
export function childrenOf(id: string, files: RepoFile[]): MenuChild[] {
|
|
22
21
|
if (id === 'lister') return listOf(listsShown(files));
|
|
23
|
-
if (id === 'header') return listOf(header);
|
|
24
22
|
if (id === 'virksomheden') return companyParts.map((p) => ({ id: p.id, label: p.label, href: `#virksomheden/${p.id}` }));
|
|
25
23
|
if (id === 'apps') {
|
|
26
24
|
const installed = installedApps(files);
|
|
@@ -0,0 +1,35 @@
|
|
|
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.
|
|
3
|
+
|
|
4
|
+
/** Where each moved screen is now, by its old address. */
|
|
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',
|
|
19
|
+
// The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
|
|
20
|
+
promovering: 'apps',
|
|
21
|
+
'promovering/calendly': 'apps/calendly',
|
|
22
|
+
'promovering/mailchimp': 'apps/mailchimp',
|
|
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',
|
|
25
|
+
// The footer's form was the file's own screen; it is under "Udseende" now.
|
|
26
|
+
'src/data/footer.json': 'udseende/footer',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The address a screen is at now: where it moved to (`more` first, then MOVED), else the same address.
|
|
31
|
+
* @param {string} route
|
|
32
|
+
* @param {Record<string, string>} [more]
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export const movedRoute = (route, more = {}) => more[route] ?? MOVED[route] ?? route;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tests movedRoutes.mjs: an old address opens the screen where it is now, others stay.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { movedRoute } from './movedRoutes.mjs';
|
|
5
|
+
|
|
6
|
+
test('the old header screen opens Appearance › Header', () => {
|
|
7
|
+
assert.equal(movedRoute('header'), 'udseende/header');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('the old footer form opens Appearance › Footer', () => {
|
|
11
|
+
assert.equal(movedRoute('src/data/footer.json'), 'udseende/footer');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('an address that has not moved stays', () => {
|
|
15
|
+
assert.equal(movedRoute('udseende/header'), 'udseende/header');
|
|
16
|
+
assert.equal(movedRoute('lister'), 'lister');
|
|
17
|
+
});
|
|
18
|
+
|
|
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');
|
|
22
|
+
});
|