@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/defaults/collections.ts +112 -58
  3. package/defaults/company.ts +1 -1
  4. package/defaults/editor.ts +2 -2
  5. package/defaults/texts.ts +14 -5
  6. package/demo/src/content/pages/app-store.md +0 -1
  7. package/demo/src/content/pages/blocks.md +0 -1
  8. package/icons/lucide/arrows-horizontal.svg +1 -0
  9. package/icons/lucide/columns-plus-left.svg +1 -0
  10. package/icons/lucide/columns-plus-right.svg +1 -0
  11. package/icons/lucide/file.svg +1 -0
  12. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  13. package/icons/material-symbols/columns-plus-left.svg +1 -0
  14. package/icons/material-symbols/columns-plus-right.svg +1 -0
  15. package/icons/material-symbols/file.svg +1 -0
  16. package/icons/phosphor/arrows-horizontal.svg +1 -0
  17. package/icons/phosphor/columns-plus-left.svg +1 -0
  18. package/icons/phosphor/columns-plus-right.svg +1 -0
  19. package/icons/phosphor/file.svg +1 -0
  20. package/lib/fold-open.d.mts +2 -0
  21. package/lib/fold-open.mjs +22 -0
  22. package/lib/fold-open.test.mjs +24 -0
  23. package/lib/menu.test.mjs +16 -3
  24. package/lib/page-template.d.mts +54 -0
  25. package/lib/page-template.mjs +126 -0
  26. package/lib/page-template.test.mjs +129 -0
  27. package/lib/side-block.d.mts +2 -16
  28. package/lib/side-block.mjs +12 -30
  29. package/lib/side-block.test.mjs +13 -30
  30. package/package.json +1 -1
  31. package/scripts/check-side.mjs +49 -22
  32. package/scripts/editor-roundtrip.mjs +15 -6
  33. package/scripts/fixtures/side/side-full.md +8 -0
  34. package/scripts/fixtures/side/side-left.md +8 -0
  35. package/scripts/fixtures/side/side-none.md +7 -0
  36. package/scripts/fixtures/side/side-right.md +8 -0
  37. package/scripts/fixtures/side/side-single.md +8 -0
  38. package/scripts/side-width.mjs +29 -0
  39. package/scripts/side-width.test.mjs +30 -0
  40. package/site/content.config.ts +3 -4
  41. package/site/header.d.mts +21 -0
  42. package/site/header.mjs +35 -0
  43. package/site/header.test.mjs +59 -0
  44. package/site/icons.d.mts +16 -0
  45. package/site/icons.mjs +8 -0
  46. package/site/site.ts +9 -4
  47. package/site/templates.ts +7 -0
  48. package/site/theme.mjs +3 -0
  49. package/site/tracking.client.ts +4 -4
  50. package/src/App.tsx +12 -24
  51. package/src/AppList.tsx +36 -14
  52. package/src/AppSettingsTab.tsx +5 -3
  53. package/src/BlockDialog.tsx +50 -5
  54. package/src/DataFileFields.tsx +35 -0
  55. package/src/DataForm.tsx +16 -38
  56. package/src/Deleted.tsx +7 -3
  57. package/src/EntryForm.tsx +41 -88
  58. package/src/FooterSettings.tsx +76 -0
  59. package/src/FormFrame.tsx +2 -1
  60. package/src/HeaderSettings.tsx +100 -0
  61. package/src/Library.tsx +26 -14
  62. package/src/MarkdownEditor.tsx +18 -36
  63. package/src/Media.tsx +6 -3
  64. package/src/Redirects.tsx +12 -9
  65. package/src/SectionSettings.tsx +21 -0
  66. package/src/SectionSettingsIcon.tsx +10 -0
  67. package/src/SettingsSections.tsx +57 -0
  68. package/src/ThemeStore.tsx +28 -9
  69. package/src/Versions.tsx +3 -15
  70. package/src/busyAction.d.mts +3 -0
  71. package/src/busyAction.mjs +8 -0
  72. package/src/busyAction.test.mjs +16 -0
  73. package/src/dataFileValues.ts +30 -0
  74. package/src/editor.css +42 -3
  75. package/src/fields.tsx +29 -10
  76. package/src/footerPreview.d.mts +46 -0
  77. package/src/footerPreview.mjs +84 -0
  78. package/src/footerPreview.test.mjs +100 -0
  79. package/src/formSections.ts +100 -0
  80. package/src/frontmatter.mjs +1 -1
  81. package/src/frontmatter.test.mjs +11 -11
  82. package/src/headerPreview.d.mts +48 -0
  83. package/src/headerPreview.mjs +168 -0
  84. package/src/headerPreview.test.mjs +183 -0
  85. package/src/headerTheme.tsx +58 -0
  86. package/src/i18n/da-DK/common.ts +1 -0
  87. package/src/i18n/da-DK/library.ts +2 -0
  88. package/src/i18n/da-DK/preview.ts +2 -0
  89. package/src/i18n/da-DK/text.ts +1 -0
  90. package/src/i18n/en-US/common.ts +1 -0
  91. package/src/i18n/en-US/library.ts +4 -0
  92. package/src/i18n/en-US/preview.ts +3 -0
  93. package/src/i18n/en-US/text.ts +2 -1
  94. package/src/images.ts +7 -2
  95. package/src/menuChildren.ts +1 -3
  96. package/src/movedRoutes.d.mts +3 -0
  97. package/src/movedRoutes.mjs +35 -0
  98. package/src/movedRoutes.test.mjs +22 -0
  99. package/src/pagePreview.tsx +40 -29
  100. package/src/partPreview.tsx +59 -0
  101. package/src/schema.ts +7 -1
  102. package/src/sheetPlacement.d.mts +3 -0
  103. package/src/sheetPlacement.mjs +9 -0
  104. package/src/sheetPlacement.test.mjs +23 -0
  105. package/src/standard/views.ts +3 -3
  106. package/src/templateField.ts +28 -0
  107. package/src/themes.ts +3 -0
  108. package/src/ui.tsx +35 -8
  109. package/src/useDataFiles.ts +94 -0
  110. package/starter/package.json +1 -1
  111. package/theme/components/Footer.astro +14 -7
  112. package/theme/components/Header.astro +58 -33
  113. package/theme/components/MobileNav.astro +5 -4
  114. package/theme/components/RelatedPosts.astro +1 -1
  115. package/theme/components/headerParts.ts +17 -0
  116. package/theme/layouts/PageLayout.astro +20 -16
  117. package/theme/styles/base.css +6 -5
  118. package/theme/styles/blocks.css +8 -12
  119. package/theme/styles/components.css +8 -1
  120. package/theme/styles/global.css +5 -9
  121. package/theme/styles/ui.css +111 -5
  122. package/theme/theme.json +1 -0
  123. package/theme/ui/BusyContent.tsx +16 -0
  124. package/theme/ui/Button.tsx +25 -5
  125. package/theme/ui/IconButton.tsx +17 -4
  126. package/scripts/fixtures/side/side-off.md +0 -8
  127. package/scripts/fixtures/side/side-plain.md +0 -7
  128. 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('&amp;', '&')
10
+ .replaceAll('&gt;', '>')
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 [&amp;>: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
+ });
@@ -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
- // Across the whole page, without the owner's card beside the text (PageLayout.astro).
80
- wide: z.boolean().optional().default(false),
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
+ };
@@ -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, src/apps/calendly/);
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
- export const nav: NavItem[] = menu.items;
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({
@@ -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, src/apps/gtm/), set up once, before the first tag loads; a new answer updates it (choose). */
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
- * (src/apps/google-analytics/, src/apps/google-ads/), in afterConsent: loaded once for all of them, each ID configured. */
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, src/apps/google-analytics/). A no to all of them after a yes
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": src/apps/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
- // Screens that have moved, by their old address: a link or a bookmark to one opens it where it is now.
266
- const MOVED: Record<string, string> = {
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 = MOVED[screenOf(hash)] ?? screenOf(hash);
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
- const file = files.find((f) => f.path === route);
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 }}>