@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,29 @@
|
|
|
1
|
+
// The width and lead rules of a page's text (theme/layouts/PageLayout.astro), for check-side.mjs: textClasses reads the
|
|
2
|
+
// classes of the text wrapper from built HTML; widthProblem and leadProblem say what is wrong with them. No dependencies,
|
|
3
|
+
// so a unit test can run it.
|
|
4
|
+
|
|
5
|
+
/** The classes of the text wrapper (data-editor="text") in a built page. */
|
|
6
|
+
export const textClasses = (html) =>
|
|
7
|
+
html
|
|
8
|
+
.match(/<div data-editor="text" class="([^"]*)"/)?.[1]
|
|
9
|
+
.replaceAll('&', '&')
|
|
10
|
+
.replaceAll('>', '>')
|
|
11
|
+
.split(/\s+/) ?? [];
|
|
12
|
+
|
|
13
|
+
/** The width rule for the text's classes: with a column the text stops at 66ch; without one the text is as wide as its
|
|
14
|
+
* container (max-w-none) and only its parts outside the blocks stop at 66ch. Returns what is wrong, or null. */
|
|
15
|
+
export const LEAD = ['[&>p:first-child]:type-lead', '[&>p:first-child]:text-muted', '[&>p:first-child]:max-w-[52ch]'];
|
|
16
|
+
|
|
17
|
+
/** The lead rule: without a column the text's first paragraph is the lead (type-lead, muted, 52ch); with one it is not. */
|
|
18
|
+
export function leadProblem(text, side) {
|
|
19
|
+
const has = LEAD.filter((c) => text.includes(c)).length;
|
|
20
|
+
if (side) return has === 0 ? null : 'its first paragraph should not be a lead';
|
|
21
|
+
return has === LEAD.length ? null : 'its first paragraph should be a lead (type-lead, text-muted, max-w-[52ch])';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function widthProblem(text, side) {
|
|
25
|
+
if (side) return text.includes('max-w-[66ch]') && !text.includes('max-w-none') ? null : 'its text should be max-w-[66ch], not max-w-none';
|
|
26
|
+
return text.includes('max-w-none') && text.includes('[&>:not(.not-prose)]:max-w-[66ch]')
|
|
27
|
+
? null
|
|
28
|
+
: 'its text should be max-w-none, with 66ch for what is not a block';
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Tests the width rule of check-side.mjs (side-width.mjs: widthProblem) and how it reads the text's classes from built HTML.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { LEAD, leadProblem, textClasses, widthProblem } from './side-width.mjs';
|
|
5
|
+
|
|
6
|
+
const WIDE = ['prose', 'max-w-none', '[&>:not(.not-prose)]:max-w-[66ch]'];
|
|
7
|
+
const NARROW = ['prose', 'max-w-[66ch]'];
|
|
8
|
+
|
|
9
|
+
test('a page without a column: text as wide as its container, 66ch for what is not a block', () => {
|
|
10
|
+
assert.equal(widthProblem(WIDE, null), null);
|
|
11
|
+
assert.ok(widthProblem(NARROW, null));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('a page with a column: text stops at 66ch', () => {
|
|
15
|
+
assert.equal(widthProblem(NARROW, 'left'), null);
|
|
16
|
+
assert.ok(widthProblem(WIDE, 'right'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('textClasses reads the classes of the text wrapper', () => {
|
|
20
|
+
const html =
|
|
21
|
+
'<article data-template="single"><div data-editor="text" class="prose max-w-none [&>:not(.not-prose)]:max-w-[66ch]"></div>';
|
|
22
|
+
assert.deepEqual(textClasses(html), WIDE);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a page without a column: the first paragraph is the lead; with a column it is not', () => {
|
|
26
|
+
assert.equal(leadProblem([...WIDE, ...LEAD], null), null);
|
|
27
|
+
assert.ok(leadProblem(WIDE, null));
|
|
28
|
+
assert.equal(leadProblem(NARROW, 'left'), null);
|
|
29
|
+
assert.ok(leadProblem([...NARROW, ...LEAD], 'left'));
|
|
30
|
+
});
|
package/site/content.config.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Depends on astro:content.
|
|
3
3
|
import { defineCollection, z } from 'astro:content';
|
|
4
4
|
import { glob } from 'astro/loaders';
|
|
5
|
+
import { PAGE_TEMPLATES } from '../lib/page-template.mjs';
|
|
5
6
|
|
|
6
7
|
// Kladde, Skjult or Planlagt (Udgivet when left out), and for Planlagt when, in Danish time
|
|
7
8
|
// ("2026-11-01T08:00"): what each one is on the site is in packages/editor/lib/status.mjs.
|
|
@@ -76,10 +77,8 @@ const pages = defineCollection({
|
|
|
76
77
|
canonical: z.string().optional(),
|
|
77
78
|
// Not for search engines: noindex (Base.astro) and not in the sitemap (packages/editor/astro.mjs).
|
|
78
79
|
noindex: z.boolean().optional().default(false),
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
// `false`: without the block beside the text, which src/data/side.json chooses (PageLayout.astro).
|
|
82
|
-
side: z.boolean().optional(),
|
|
80
|
+
// The page's template, one the theme offers (lib/page-template.mjs; PageLayout.astro). Left out: the theme's first.
|
|
81
|
+
template: z.enum(PAGE_TEMPLATES as [string, ...string[]]).optional(),
|
|
83
82
|
// A new version of a page (/ny/…): what the owner should look at or answer before it replaces the
|
|
84
83
|
// current page. Shown at the top of the new version and on /ny/.
|
|
85
84
|
review: z.string().optional(),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** @typedef {import('../src/standard/buttons/fields').Button} Button */
|
|
2
|
+
/** @typedef {{ button: Button | null, menuSide: 'start' | 'end' }} Header */
|
|
3
|
+
/**
|
|
4
|
+
* Reads src/data/header.json's content. Missing or wrong → defaults.
|
|
5
|
+
* @param {unknown} raw
|
|
6
|
+
* @returns {Header}
|
|
7
|
+
*/
|
|
8
|
+
export function headerOf(raw: unknown): Header;
|
|
9
|
+
export function sortedNav<T extends NavItem>(items: T[]): T[];
|
|
10
|
+
export type Button = any;
|
|
11
|
+
export type Header = {
|
|
12
|
+
button: Button | null;
|
|
13
|
+
menuSide: "start" | "end";
|
|
14
|
+
};
|
|
15
|
+
export type NavLink = {
|
|
16
|
+
label: string;
|
|
17
|
+
href: string;
|
|
18
|
+
};
|
|
19
|
+
export type NavItem = NavLink & {
|
|
20
|
+
children?: NavLink[];
|
|
21
|
+
};
|
package/site/header.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The header's site setting (src/data/header.json, optional): a button in place of the phone icon, and the side of the
|
|
2
|
+
// header the mobile menu button sits on; and the main menu's items as the header draws them. Exposes headerOf (pure;
|
|
3
|
+
// missing or wrong data gives the defaults) and sortedNav. Used by site/site.ts and the editor's preview of the header
|
|
4
|
+
// (src/headerPreview.mjs); the Button type is src/standard/buttons/fields.ts.
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../src/standard/buttons/fields').Button} Button */
|
|
7
|
+
/** @typedef {{ button: Button | null, menuSide: 'start' | 'end' }} Header */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reads src/data/header.json's content. Missing or wrong → defaults.
|
|
11
|
+
* @param {unknown} raw
|
|
12
|
+
* @returns {Header}
|
|
13
|
+
*/
|
|
14
|
+
export function headerOf(raw) {
|
|
15
|
+
const data = raw && typeof raw === 'object' ? /** @type {Record<string, any>} */ (raw) : {};
|
|
16
|
+
const { enabled, ...button } = data.button && typeof data.button === 'object' ? data.button : { enabled: false };
|
|
17
|
+
return {
|
|
18
|
+
button: enabled === true ? button : null,
|
|
19
|
+
menuSide: data.menuSide === 'end' ? 'end' : 'start',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @typedef {{ label: string, href: string }} NavLink */
|
|
24
|
+
/** @typedef {NavLink & { children?: NavLink[] }} NavItem */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The main menu's items (menu.json) as the header draws them: the sub-items in alphabetical order (Danish: æ, ø, å last).
|
|
28
|
+
* @template {NavItem} T
|
|
29
|
+
* @param {T[]} items
|
|
30
|
+
* @returns {T[]}
|
|
31
|
+
*/
|
|
32
|
+
export const sortedNav = (items) =>
|
|
33
|
+
items.map((item) =>
|
|
34
|
+
item.children ? { ...item, children: [...item.children].sort((a, b) => a.label.localeCompare(b.label, 'da')) } : item,
|
|
35
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { headerOf, sortedNav } from './header.mjs';
|
|
4
|
+
|
|
5
|
+
test('no file', () => {
|
|
6
|
+
assert.deepEqual(headerOf(undefined), { button: null, menuSide: 'start' });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('button off', () => {
|
|
10
|
+
assert.equal(headerOf({ button: { enabled: false, to: 'booking' } }).button, null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('button on', () => {
|
|
14
|
+
assert.deepEqual(headerOf({ button: { enabled: true, to: 'booking', label: 'Book en tid' } }).button, {
|
|
15
|
+
to: 'booking',
|
|
16
|
+
label: 'Book en tid',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('menu at the end', () => {
|
|
21
|
+
assert.equal(headerOf({ menuSide: 'end' }).menuSide, 'end');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('wrong menu side', () => {
|
|
25
|
+
assert.equal(headerOf({ menuSide: 'middle' }).menuSide, 'start');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('wrong shapes', () => {
|
|
29
|
+
assert.deepEqual(headerOf('x'), { button: null, menuSide: 'start' });
|
|
30
|
+
assert.equal(headerOf({ button: 'x' }).button, null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('sortedNav sorts the sub-items A–Å and keeps the items in order', () => {
|
|
34
|
+
const items = [
|
|
35
|
+
{ label: 'Om', href: '/om/' },
|
|
36
|
+
{
|
|
37
|
+
label: 'Behandlinger',
|
|
38
|
+
href: '/b/',
|
|
39
|
+
children: [
|
|
40
|
+
{ label: 'Øre', href: '/b/oe/' },
|
|
41
|
+
{ label: 'Arm', href: '/b/a/' },
|
|
42
|
+
{ label: 'Zone', href: '/b/z/' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
const sorted = sortedNav(items);
|
|
47
|
+
assert.deepEqual(
|
|
48
|
+
sorted.map((i) => i.label),
|
|
49
|
+
['Om', 'Behandlinger'],
|
|
50
|
+
);
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
sorted[1].children.map((c) => c.label),
|
|
53
|
+
['Arm', 'Zone', 'Øre'],
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
items[1].children.map((c) => c.label),
|
|
57
|
+
['Øre', 'Arm', 'Zone'],
|
|
58
|
+
);
|
|
59
|
+
});
|
package/site/icons.d.mts
CHANGED
|
@@ -107,6 +107,7 @@ export const MATERIAL_SYMBOLS: {
|
|
|
107
107
|
'arrow-up': string;
|
|
108
108
|
'arrow-up-right': string;
|
|
109
109
|
'arrows-down-up': string;
|
|
110
|
+
'arrows-horizontal': string;
|
|
110
111
|
article: string;
|
|
111
112
|
bank: string;
|
|
112
113
|
bell: string;
|
|
@@ -142,6 +143,8 @@ export const MATERIAL_SYMBOLS: {
|
|
|
142
143
|
code: string;
|
|
143
144
|
coffee: string;
|
|
144
145
|
columns: string;
|
|
146
|
+
'columns-plus-left': string;
|
|
147
|
+
'columns-plus-right': string;
|
|
145
148
|
cookie: string;
|
|
146
149
|
copy: string;
|
|
147
150
|
'credit-card': string;
|
|
@@ -155,6 +158,7 @@ export const MATERIAL_SYMBOLS: {
|
|
|
155
158
|
'envelope-simple-fill': string;
|
|
156
159
|
eye: string;
|
|
157
160
|
'eye-slash': string;
|
|
161
|
+
file: string;
|
|
158
162
|
'file-pdf': string;
|
|
159
163
|
'file-text': string;
|
|
160
164
|
'file-text-fill': string;
|
|
@@ -290,6 +294,7 @@ export const LUCIDE: {
|
|
|
290
294
|
'arrow-up': string;
|
|
291
295
|
'arrow-up-right': string;
|
|
292
296
|
'arrows-down-up': string;
|
|
297
|
+
'arrows-horizontal': string;
|
|
293
298
|
'arrows-in-line-horizontal': string;
|
|
294
299
|
'arrows-out-line-horizontal': string;
|
|
295
300
|
article: string;
|
|
@@ -328,6 +333,8 @@ export const LUCIDE: {
|
|
|
328
333
|
code: string;
|
|
329
334
|
coffee: string;
|
|
330
335
|
columns: string;
|
|
336
|
+
'columns-plus-left': string;
|
|
337
|
+
'columns-plus-right': string;
|
|
331
338
|
cookie: string;
|
|
332
339
|
copy: string;
|
|
333
340
|
'credit-card': string;
|
|
@@ -341,6 +348,7 @@ export const LUCIDE: {
|
|
|
341
348
|
'envelope-simple-fill': string;
|
|
342
349
|
eye: string;
|
|
343
350
|
'eye-slash': string;
|
|
351
|
+
file: string;
|
|
344
352
|
'file-pdf': string;
|
|
345
353
|
'file-text': string;
|
|
346
354
|
'file-text-fill': string;
|
|
@@ -479,6 +487,7 @@ export const PACK_MAPS: {
|
|
|
479
487
|
'arrow-up': string;
|
|
480
488
|
'arrow-up-right': string;
|
|
481
489
|
'arrows-down-up': string;
|
|
490
|
+
'arrows-horizontal': string;
|
|
482
491
|
article: string;
|
|
483
492
|
bank: string;
|
|
484
493
|
bell: string;
|
|
@@ -514,6 +523,8 @@ export const PACK_MAPS: {
|
|
|
514
523
|
code: string;
|
|
515
524
|
coffee: string;
|
|
516
525
|
columns: string;
|
|
526
|
+
'columns-plus-left': string;
|
|
527
|
+
'columns-plus-right': string;
|
|
517
528
|
cookie: string;
|
|
518
529
|
copy: string;
|
|
519
530
|
'credit-card': string;
|
|
@@ -527,6 +538,7 @@ export const PACK_MAPS: {
|
|
|
527
538
|
'envelope-simple-fill': string;
|
|
528
539
|
eye: string;
|
|
529
540
|
'eye-slash': string;
|
|
541
|
+
file: string;
|
|
530
542
|
'file-pdf': string;
|
|
531
543
|
'file-text': string;
|
|
532
544
|
'file-text-fill': string;
|
|
@@ -660,6 +672,7 @@ export const PACK_MAPS: {
|
|
|
660
672
|
'arrow-up': string;
|
|
661
673
|
'arrow-up-right': string;
|
|
662
674
|
'arrows-down-up': string;
|
|
675
|
+
'arrows-horizontal': string;
|
|
663
676
|
'arrows-in-line-horizontal': string;
|
|
664
677
|
'arrows-out-line-horizontal': string;
|
|
665
678
|
article: string;
|
|
@@ -698,6 +711,8 @@ export const PACK_MAPS: {
|
|
|
698
711
|
code: string;
|
|
699
712
|
coffee: string;
|
|
700
713
|
columns: string;
|
|
714
|
+
'columns-plus-left': string;
|
|
715
|
+
'columns-plus-right': string;
|
|
701
716
|
cookie: string;
|
|
702
717
|
copy: string;
|
|
703
718
|
'credit-card': string;
|
|
@@ -711,6 +726,7 @@ export const PACK_MAPS: {
|
|
|
711
726
|
'envelope-simple-fill': string;
|
|
712
727
|
eye: string;
|
|
713
728
|
'eye-slash': string;
|
|
729
|
+
file: string;
|
|
714
730
|
'file-pdf': string;
|
|
715
731
|
'file-text': string;
|
|
716
732
|
'file-text-fill': string;
|
package/site/icons.mjs
CHANGED
|
@@ -121,6 +121,7 @@ export const MATERIAL_SYMBOLS = {
|
|
|
121
121
|
'arrow-up': 'arrow_upward',
|
|
122
122
|
'arrow-up-right': 'arrow_outward',
|
|
123
123
|
'arrows-down-up': 'swap_vert',
|
|
124
|
+
'arrows-horizontal': 'width_full',
|
|
124
125
|
article: 'article',
|
|
125
126
|
bank: 'account_balance',
|
|
126
127
|
bell: 'notifications',
|
|
@@ -156,6 +157,8 @@ export const MATERIAL_SYMBOLS = {
|
|
|
156
157
|
code: 'code',
|
|
157
158
|
coffee: 'local_cafe',
|
|
158
159
|
columns: 'view_column_2',
|
|
160
|
+
'columns-plus-left': 'dock_to_right',
|
|
161
|
+
'columns-plus-right': 'dock_to_left',
|
|
159
162
|
cookie: 'cookie',
|
|
160
163
|
copy: 'content_copy',
|
|
161
164
|
'credit-card': 'credit_card',
|
|
@@ -169,6 +172,7 @@ export const MATERIAL_SYMBOLS = {
|
|
|
169
172
|
'envelope-simple-fill': 'mail-fill',
|
|
170
173
|
eye: 'visibility',
|
|
171
174
|
'eye-slash': 'visibility_off',
|
|
175
|
+
file: 'crop_portrait',
|
|
172
176
|
'file-pdf': 'picture_as_pdf',
|
|
173
177
|
'file-text': 'description',
|
|
174
178
|
'file-text-fill': 'description-fill',
|
|
@@ -305,6 +309,7 @@ export const LUCIDE = {
|
|
|
305
309
|
'arrow-up': 'arrow-up',
|
|
306
310
|
'arrow-up-right': 'arrow-up-right',
|
|
307
311
|
'arrows-down-up': 'arrow-down-up',
|
|
312
|
+
'arrows-horizontal': 'rectangle-horizontal',
|
|
308
313
|
'arrows-in-line-horizontal': 'fold-horizontal',
|
|
309
314
|
'arrows-out-line-horizontal': 'unfold-horizontal',
|
|
310
315
|
article: 'file-text',
|
|
@@ -343,6 +348,8 @@ export const LUCIDE = {
|
|
|
343
348
|
code: 'code',
|
|
344
349
|
coffee: 'coffee',
|
|
345
350
|
columns: 'columns-2',
|
|
351
|
+
'columns-plus-left': 'panel-left',
|
|
352
|
+
'columns-plus-right': 'panel-right',
|
|
346
353
|
cookie: 'cookie',
|
|
347
354
|
copy: 'copy',
|
|
348
355
|
'credit-card': 'credit-card',
|
|
@@ -356,6 +363,7 @@ export const LUCIDE = {
|
|
|
356
363
|
'envelope-simple-fill': 'mail-fill',
|
|
357
364
|
eye: 'eye',
|
|
358
365
|
'eye-slash': 'eye-off',
|
|
366
|
+
file: 'rectangle-vertical',
|
|
359
367
|
'file-pdf': 'file-text',
|
|
360
368
|
'file-text': 'file-text',
|
|
361
369
|
'file-text-fill': 'file-text-fill',
|
package/site/site.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Name, title, phone, email, address and CVR number: contact.json; the profiles and share buttons: social.json (optional, an
|
|
2
|
-
// older site has none); the texts of "Book en tid": booking.json (the calendar is the app Calendly's,
|
|
2
|
+
// older site has none); the texts of "Book en tid": booking.json (the calendar is the app Calendly's, morpheme-apps/apps/calendly/);
|
|
3
3
|
// the menus: menu.json and mobile-menu.json; the shared texts of the other blocks: their shared.ts, with those saved in
|
|
4
4
|
// blocks.json over them (`blockTexts`).
|
|
5
5
|
// The site's owner edits them in the editor (/editor/); what the editor does not edit is the site's config
|
|
@@ -12,6 +12,7 @@ import blocks from '@data/blocks.json';
|
|
|
12
12
|
import standardPages from '@data/standard-pages.json';
|
|
13
13
|
import { config } from '@site/config';
|
|
14
14
|
import { socialOf } from './social.mjs';
|
|
15
|
+
import { headerOf, sortedNav } from './header.mjs';
|
|
15
16
|
import { sharedTexts, type SharedTexts } from '@morphemecms/cms/src/shared';
|
|
16
17
|
import { standardShared } from '@morphemecms/cms/src/standard/sharedTexts';
|
|
17
18
|
|
|
@@ -24,6 +25,11 @@ const { cvr = '' } = contact as typeof contact & { cvr?: string };
|
|
|
24
25
|
// Optional file: an older site has no social.json, so it is read with a glob, not an import.
|
|
25
26
|
const socialFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/social.json', { eager: true, import: 'default' }))[0];
|
|
26
27
|
|
|
28
|
+
// Optional file (header.json, "Header"): a site without it keeps the header as it is.
|
|
29
|
+
const headerFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/header.json', { eager: true, import: 'default' }))[0];
|
|
30
|
+
/** The header's button (in place of the phone icon, or null) and the side of the mobile menu button. */
|
|
31
|
+
export const header = headerOf(headerFile);
|
|
32
|
+
|
|
27
33
|
export const site = {
|
|
28
34
|
name: contact.name,
|
|
29
35
|
role: contact.role,
|
|
@@ -65,13 +71,12 @@ export const site = {
|
|
|
65
71
|
|
|
66
72
|
// Primary navigation (menu.json, "Hovedmenu"). Labels, URLs and sub-items started out as the old site's menu.
|
|
67
73
|
export type NavItem = { label: string; href: string; children?: { label: string; href: string }[] };
|
|
68
|
-
|
|
74
|
+
// Sub-items in alphabetical order (Danish: æ, ø, å last), in both the desktop and the mobile menu.
|
|
75
|
+
export const nav: NavItem[] = sortedNav(menu.items);
|
|
69
76
|
/** Whether the header has the menu and its button ("Aktiveret" under "Hovedmenu"). */
|
|
70
77
|
export const navShown = menu.enabled !== false;
|
|
71
78
|
/** The links in the bar under the header on a phone (mobile-menu.json, "Mobilmenu"); none when it is turned off. */
|
|
72
79
|
export const mobileNav: { label: string; href: string }[] = mobileMenu.enabled ? mobileMenu.items : [];
|
|
73
|
-
// Sub-items in alphabetical order (Danish: æ, ø, å last), in both the desktop and the mobile menu.
|
|
74
|
-
for (const item of nav) item.children?.sort((a, b) => a.label.localeCompare(b.label, 'da'));
|
|
75
80
|
|
|
76
81
|
/** The texts every copy of a block has ("Blokke" in the editor), with {…} for what differs, by
|
|
77
82
|
* the block's type: those written in its shared.ts, with the saved ones (blocks.json) over them. The
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The page templates the site's theme offers: the theme in use's ("templates" in its theme.json), else the standard theme's.
|
|
2
|
+
// Exposes offeredTemplates, read by PageLayout.astro and the check in scripts/check-side.mjs.
|
|
3
|
+
import { templatesOf } from '../lib/page-template.mjs';
|
|
4
|
+
import theme from '@theme/theme.json';
|
|
5
|
+
|
|
6
|
+
/** The templates a page can choose, the first the default. */
|
|
7
|
+
export const offeredTemplates: string[] = templatesOf(theme as { templates?: unknown });
|
package/site/theme.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { installedPacks, isIconName, packFolder, packProblems, phosphorFile, svg
|
|
|
14
14
|
import { MATERIAL_ALL, MATERIAL_SSR, materialModule, materialSsrModule } from './materialSymbols.mjs';
|
|
15
15
|
import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS, THEME_PACKS_FOLDER, packInUse, packTheme, themePackId } from './iconPacks.mjs';
|
|
16
16
|
import { MOTION_SCHEMES } from '@morphemecms/cms/lib/motion-schemes.mjs';
|
|
17
|
+
import { templateProblems } from '@morphemecms/cms/lib/page-template.mjs';
|
|
17
18
|
import { storeImportProblems } from './storeTheme.mjs';
|
|
18
19
|
|
|
19
20
|
/** The site's choice of icon pack, `{ "pack": "<id>" }` (config.icons in src/site/config.ts). */
|
|
@@ -400,6 +401,8 @@ export function themeProblems(theme) {
|
|
|
400
401
|
problems.push(
|
|
401
402
|
`${themeFolder(theme)}/theme.json: "motion" er den bevægelse, temaet bruger, når ingen er valgt: ${MOTION_SCHEMES.map((s) => `"${s}"`).join(', ')}.`,
|
|
402
403
|
);
|
|
404
|
+
// The page templates it offers (lib/page-template.mjs); without the key, the standard theme's list.
|
|
405
|
+
for (const p of templateProblems(manifest.templates)) problems.push(`${themeFolder(theme)}/theme.json: ${p}`);
|
|
403
406
|
// Its own licence and the licences of what it brings (fonts, code, pictures), for "Licenser" and the store.
|
|
404
407
|
problems.push(
|
|
405
408
|
...declarationProblems({
|
package/site/tracking.client.ts
CHANGED
|
@@ -79,7 +79,7 @@ function googleSettings() {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** Google's data layer with the visitor's answer in consent mode, for a tag of Google's (googleTag, Google Tag
|
|
82
|
-
* Manager's container,
|
|
82
|
+
* Manager's container, morpheme-apps/apps/gtm/), set up once, before the first tag loads; a new answer updates it (choose). */
|
|
83
83
|
export function googleConsent() {
|
|
84
84
|
window.dataLayer = window.dataLayer || [];
|
|
85
85
|
if (typeof window.gtag === 'function') return;
|
|
@@ -95,7 +95,7 @@ export function googleConsent() {
|
|
|
95
95
|
let tagged = false;
|
|
96
96
|
|
|
97
97
|
/** The Google tag (gtag.js) with this ID (Google Analytics' G-…, Google Ads' AW-…), for the apps that measure with it
|
|
98
|
-
* (
|
|
98
|
+
* (morpheme-apps/apps/google-analytics/, morpheme-apps/apps/google-ads/), in afterConsent: loaded once for all of them, each ID configured. */
|
|
99
99
|
export function googleTag(id: string, config: Record<string, unknown> = {}) {
|
|
100
100
|
googleConsent();
|
|
101
101
|
window.gtag('config', id, config);
|
|
@@ -202,7 +202,7 @@ export const consentOf = (meta: HTMLElement | null) => (meta?.dataset.consent ??
|
|
|
202
202
|
|
|
203
203
|
/** Runs `run` once the visitor has said yes to one of `categories` in the cookie banner (now, if they already have),
|
|
204
204
|
* on the site's own address (`hosts`, or with ?tracking=test): for an app that loads something that sets cookies
|
|
205
|
-
* (`cookies` in its index.ts, e.g. Google Analytics,
|
|
205
|
+
* (`cookies` in its index.ts, e.g. Google Analytics, morpheme-apps/apps/google-analytics/). A no to all of them after a yes
|
|
206
206
|
* reloads the page, so it stops. */
|
|
207
207
|
export function afterConsent(categories: ConsentCategory[], run: () => void) {
|
|
208
208
|
if (categories.some(granted)) start(categories, run);
|
|
@@ -213,7 +213,7 @@ export function afterConsent(categories: ConsentCategory[], run: () => void) {
|
|
|
213
213
|
removeCookies(CONSENT_CATEGORIES.filter((category) => !granted(category)));
|
|
214
214
|
|
|
215
215
|
// A time was booked: an app that books says so with the event "booking:done" on the window, with how
|
|
216
|
-
// (`method`, e.g. "calendly":
|
|
216
|
+
// (`method`, e.g. "calendly": morpheme-apps/apps/calendly/).
|
|
217
217
|
addEventListener('booking:done', (e) => track('book_appointment', { method: (e as CustomEvent).detail?.method }));
|
|
218
218
|
|
|
219
219
|
document.addEventListener('click', (e) => {
|
package/src/App.tsx
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// (empty) the start page
|
|
4
4
|
// liste/<id> the list of a part, e.g. liste/priser, and liste/<id>/<app> a tab an installed app adds to it
|
|
5
5
|
// (AppDefinition.tabs): liste/cookies/cookiescanner
|
|
6
|
-
// header what is in the header on every page (the menu)
|
|
7
6
|
// lister the short lists (the testimonials, the apps' lists)
|
|
8
7
|
// indstillinger/appstore the app store, its apps, and indstillinger/appstore/temaer its themes (AppList.tsx StorePage); the
|
|
9
8
|
// other settings are screens of their own, in the drawer's last section (MainMenu.tsx)
|
|
@@ -22,6 +21,8 @@
|
|
|
22
21
|
// udseende how the site looks (Library.tsx Theme): udseende/temaer the themes installed (Themes) and udseende/temaer/<theme>
|
|
23
22
|
// a theme's page, with the button that swaps to it (ThemePage), udseende/ikonpakker the icon packs (IconPacks),
|
|
24
23
|
// udseende/bevaegelse how it moves (MotionSchemes), udseende/sidekolonne the block beside the pages' text (SideColumn),
|
|
24
|
+
// udseende/header the header's menus and settings with its preview (HeaderSettings.tsx; once at header),
|
|
25
|
+
// udseende/footer the footer's texts and links with its preview (FooterSettings.tsx; once at src/data/footer.json),
|
|
25
26
|
// udseende/farver the colours (Colors.tsx;
|
|
26
27
|
// udseende/farver/reservefarver the site's own while an app changes the pages'), udseende/logo the logo
|
|
27
28
|
// and icon (BrandForm.tsx), udseende/dele the parts (Library.tsx PartsHome): udseende/dele/editor the editor's, every
|
|
@@ -132,12 +133,13 @@ import {
|
|
|
132
133
|
import Colors, { colorsOf, FALLBACK_ROUTE } from './Colors';
|
|
133
134
|
import { applyColors, applyMotion } from './theme';
|
|
134
135
|
import SideColumn from './SideColumn';
|
|
136
|
+
import HeaderSettings from './HeaderSettings';
|
|
137
|
+
import FooterSettings from './FooterSettings';
|
|
135
138
|
import { apps, notOffered } from './registry';
|
|
136
139
|
import {
|
|
137
140
|
Account,
|
|
138
141
|
Blocks,
|
|
139
142
|
Company,
|
|
140
|
-
Header,
|
|
141
143
|
List,
|
|
142
144
|
Lists,
|
|
143
145
|
PartsHome,
|
|
@@ -168,6 +170,7 @@ import { arrived, screenOf, up } from './nav';
|
|
|
168
170
|
import { siteT, siteText, t } from './i18n';
|
|
169
171
|
import { snackbar } from '../lib/snackbar';
|
|
170
172
|
import { appRouteOf } from '../lib/appRoute.mjs';
|
|
173
|
+
import { movedRoute } from './movedRoutes.mjs';
|
|
171
174
|
import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notificationData';
|
|
172
175
|
import Notifications from './Notifications';
|
|
173
176
|
import { EditorOwn, InEditor } from './standard/view';
|
|
@@ -262,32 +265,15 @@ function taken(address: string, relations: Record<Relation, Option[]>, redirects
|
|
|
262
265
|
return { text: t.app.addressRedirects(address, name), redirect: { end, name, via } };
|
|
263
266
|
}
|
|
264
267
|
|
|
265
|
-
//
|
|
266
|
-
const
|
|
267
|
-
farver: 'udseende/farver',
|
|
268
|
-
'virksomheden/logo': 'udseende/logo',
|
|
269
|
-
'tema/blokvalg': 'blokvalg',
|
|
270
|
-
dele: 'udseende/dele',
|
|
271
|
-
tema: 'udseende',
|
|
272
|
-
'tema/farver': 'udseende/farver',
|
|
273
|
-
'tema/logo': 'udseende/logo',
|
|
274
|
-
'tema/dele': 'udseende/dele',
|
|
275
|
-
'tema/dele/editor': 'udseende/dele/editor',
|
|
276
|
-
'virksomheden/bank': 'virksomheden/betaling/bank',
|
|
277
|
-
videresendelser: 'seo/videresendelser',
|
|
278
|
-
'videresendelser/alle': 'seo/videresendelser/alle',
|
|
279
|
-
'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
|
|
280
|
-
// The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
|
|
281
|
-
promovering: 'apps',
|
|
282
|
-
'promovering/calendly': 'apps/calendly',
|
|
283
|
-
'promovering/mailchimp': 'apps/mailchimp',
|
|
268
|
+
// The app store's screens that have moved (movedRoutes.mjs has the others).
|
|
269
|
+
const STORE_MOVED: Record<string, string> = {
|
|
284
270
|
[STORE_BEFORE]: STORE_ROUTE,
|
|
285
271
|
[`${STORE_BEFORE}/temaer`]: STORE_THEMES,
|
|
286
272
|
};
|
|
287
273
|
|
|
288
274
|
// The editor's address after the "#" (nav.ts screenOf).
|
|
289
275
|
function routeOf(hash: string) {
|
|
290
|
-
const route =
|
|
276
|
+
const route = movedRoute(screenOf(hash), STORE_MOVED);
|
|
291
277
|
// An app's settings: on the screen with its category, when it has one ("Analytics", AppList.tsx settingsRoute), and so
|
|
292
278
|
// the pages of their fields (Field.page) after them. Otherwise they are on the Settings tab of its page: apps/<id>
|
|
293
279
|
// (a field's page, apps/<id>/indstillinger/<field>, stays, drawn by the same page).
|
|
@@ -734,7 +720,6 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
|
|
|
734
720
|
if (!route) return <Start builtAt={builtAt} />;
|
|
735
721
|
if (route === 'konto') return <Account user={user} files={files} onLogout={onLogout} />;
|
|
736
722
|
if (route === 'notifikationer') return <Notifications />;
|
|
737
|
-
if (route === 'header') return <Header files={files} />;
|
|
738
723
|
if (route === 'lister') return <Lists files={files} />;
|
|
739
724
|
if (route === 'virksomheden') return <Company files={files} />;
|
|
740
725
|
if (route === 'blokke') return <Blocks files={files} />;
|
|
@@ -755,6 +740,8 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
|
|
|
755
740
|
if (route === 'udseende/ikonpakker') return <IconPacks env={env} files={files} />;
|
|
756
741
|
if (route === 'udseende/bevaegelse') return <MotionSchemes env={env} files={files} />;
|
|
757
742
|
if (route === 'udseende/sidekolonne') return <SideColumn env={env} files={files} />;
|
|
743
|
+
if (route === 'udseende/header') return <HeaderSettings env={env} />;
|
|
744
|
+
if (route === 'udseende/footer') return <FooterSettings env={env} />;
|
|
758
745
|
// A theme installed has its page; one from the store that is not (or not yet built), its page in the store.
|
|
759
746
|
const themeShown = route.startsWith('udseende/temaer/') && themes.find((x) => x.id === route.slice(16));
|
|
760
747
|
if (themeShown) return <ThemePage key={themeShown.id} of={themeShown} env={env} files={files} />;
|
|
@@ -819,7 +806,8 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
|
|
|
819
806
|
}
|
|
820
807
|
|
|
821
808
|
const found = collectionOf(collections, route);
|
|
822
|
-
|
|
809
|
+
// An optional file (the header's) that the site has not got yet opens empty; the first save makes it.
|
|
810
|
+
const file = files.find((f) => f.path === route) ?? (found?.dataFile?.optional ? { path: route, oid: '', text: '{}\n' } : undefined);
|
|
823
811
|
if (!found || !file)
|
|
824
812
|
return (
|
|
825
813
|
<Screen title={t.app.notFound} back={{ href: '#', label: siteName }}>
|