@morphemecms/cms 0.9.0-next-20261105122349 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/defaults/collections.ts +110 -56
  3. package/defaults/editor.ts +2 -2
  4. package/defaults/texts.ts +14 -5
  5. package/demo/src/content/pages/app-store.md +0 -1
  6. package/demo/src/content/pages/blocks.md +0 -1
  7. package/icons/lucide/arrows-horizontal.svg +1 -0
  8. package/icons/lucide/columns-plus-left.svg +1 -0
  9. package/icons/lucide/columns-plus-right.svg +1 -0
  10. package/icons/lucide/file.svg +1 -0
  11. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  12. package/icons/material-symbols/columns-plus-left.svg +1 -0
  13. package/icons/material-symbols/columns-plus-right.svg +1 -0
  14. package/icons/material-symbols/file.svg +1 -0
  15. package/icons/phosphor/arrows-horizontal.svg +1 -0
  16. package/icons/phosphor/columns-plus-left.svg +1 -0
  17. package/icons/phosphor/columns-plus-right.svg +1 -0
  18. package/icons/phosphor/file.svg +1 -0
  19. package/lib/fold-open.d.mts +2 -0
  20. package/lib/fold-open.mjs +22 -0
  21. package/lib/fold-open.test.mjs +24 -0
  22. package/lib/menu.test.mjs +16 -3
  23. package/lib/page-template.d.mts +54 -0
  24. package/lib/page-template.mjs +126 -0
  25. package/lib/page-template.test.mjs +129 -0
  26. package/lib/side-block.d.mts +2 -16
  27. package/lib/side-block.mjs +12 -30
  28. package/lib/side-block.test.mjs +13 -30
  29. package/package.json +1 -1
  30. package/scripts/check-side.mjs +49 -22
  31. package/scripts/editor-roundtrip.mjs +15 -6
  32. package/scripts/fixtures/side/side-full.md +8 -0
  33. package/scripts/fixtures/side/side-left.md +8 -0
  34. package/scripts/fixtures/side/side-none.md +7 -0
  35. package/scripts/fixtures/side/side-right.md +8 -0
  36. package/scripts/fixtures/side/side-single.md +8 -0
  37. package/scripts/side-width.mjs +29 -0
  38. package/scripts/side-width.test.mjs +30 -0
  39. package/site/content.config.ts +3 -4
  40. package/site/header.d.mts +21 -0
  41. package/site/header.mjs +35 -0
  42. package/site/header.test.mjs +59 -0
  43. package/site/icons.d.mts +16 -0
  44. package/site/icons.mjs +8 -0
  45. package/site/site.ts +8 -3
  46. package/site/templates.ts +7 -0
  47. package/site/theme.mjs +3 -0
  48. package/src/App.tsx +12 -24
  49. package/src/AppList.tsx +36 -14
  50. package/src/AppSettingsTab.tsx +5 -3
  51. package/src/BlockDialog.tsx +50 -5
  52. package/src/DataFileFields.tsx +35 -0
  53. package/src/DataForm.tsx +16 -38
  54. package/src/Deleted.tsx +7 -3
  55. package/src/EntryForm.tsx +41 -88
  56. package/src/FooterSettings.tsx +76 -0
  57. package/src/FormFrame.tsx +2 -1
  58. package/src/HeaderSettings.tsx +97 -0
  59. package/src/Library.tsx +26 -14
  60. package/src/MarkdownEditor.tsx +18 -36
  61. package/src/Media.tsx +6 -3
  62. package/src/Redirects.tsx +12 -9
  63. package/src/SectionSettings.tsx +21 -0
  64. package/src/SectionSettingsIcon.tsx +10 -0
  65. package/src/SettingsSections.tsx +57 -0
  66. package/src/ThemeStore.tsx +28 -9
  67. package/src/Versions.tsx +3 -15
  68. package/src/busyAction.d.mts +3 -0
  69. package/src/busyAction.mjs +8 -0
  70. package/src/busyAction.test.mjs +16 -0
  71. package/src/dataFileValues.ts +30 -0
  72. package/src/editor.css +37 -3
  73. package/src/fields.tsx +29 -10
  74. package/src/footerPreview.d.mts +46 -0
  75. package/src/footerPreview.mjs +84 -0
  76. package/src/footerPreview.test.mjs +100 -0
  77. package/src/formSections.ts +100 -0
  78. package/src/frontmatter.mjs +1 -1
  79. package/src/frontmatter.test.mjs +11 -11
  80. package/src/headerPreview.d.mts +48 -0
  81. package/src/headerPreview.mjs +168 -0
  82. package/src/headerPreview.test.mjs +183 -0
  83. package/src/headerTheme.tsx +58 -0
  84. package/src/i18n/da-DK/common.ts +1 -0
  85. package/src/i18n/da-DK/library.ts +2 -0
  86. package/src/i18n/da-DK/preview.ts +2 -0
  87. package/src/i18n/da-DK/text.ts +1 -0
  88. package/src/i18n/en-US/common.ts +1 -0
  89. package/src/i18n/en-US/library.ts +4 -0
  90. package/src/i18n/en-US/preview.ts +3 -0
  91. package/src/i18n/en-US/text.ts +2 -1
  92. package/src/images.ts +7 -2
  93. package/src/menuChildren.ts +1 -3
  94. package/src/movedRoutes.d.mts +3 -0
  95. package/src/movedRoutes.mjs +35 -0
  96. package/src/movedRoutes.test.mjs +22 -0
  97. package/src/pagePreview.tsx +40 -29
  98. package/src/partPreview.tsx +59 -0
  99. package/src/schema.ts +7 -1
  100. package/src/sheetPlacement.d.mts +3 -0
  101. package/src/sheetPlacement.mjs +9 -0
  102. package/src/sheetPlacement.test.mjs +23 -0
  103. package/src/standard/views.ts +3 -3
  104. package/src/templateField.ts +28 -0
  105. package/src/themes.ts +3 -0
  106. package/src/ui.tsx +35 -8
  107. package/src/useDataFiles.ts +94 -0
  108. package/starter/package.json +1 -1
  109. package/theme/components/Footer.astro +14 -7
  110. package/theme/components/Header.astro +58 -33
  111. package/theme/components/MobileNav.astro +5 -4
  112. package/theme/components/RelatedPosts.astro +1 -1
  113. package/theme/components/headerParts.ts +17 -0
  114. package/theme/layouts/PageLayout.astro +20 -16
  115. package/theme/styles/base.css +6 -5
  116. package/theme/styles/blocks.css +8 -12
  117. package/theme/styles/components.css +8 -1
  118. package/theme/styles/global.css +5 -9
  119. package/theme/styles/ui.css +111 -5
  120. package/theme/theme.json +1 -0
  121. package/theme/ui/BusyContent.tsx +16 -0
  122. package/theme/ui/Button.tsx +25 -5
  123. package/theme/ui/IconButton.tsx +17 -4
  124. package/scripts/fixtures/side/side-off.md +0 -8
  125. package/scripts/fixtures/side/side-plain.md +0 -7
  126. package/scripts/fixtures/side/side-wide.md +0 -8
@@ -0,0 +1,129 @@
1
+ // Tests for page-template helpers.
2
+ // Uses node:test and node:assert/strict.
3
+ // No other dependencies.
4
+
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ PAGE_TEMPLATES,
9
+ STANDARD_TEMPLATES,
10
+ templatesOf,
11
+ templateOf,
12
+ hasSidebar,
13
+ templateChoices,
14
+ templateProblems,
15
+ templateWritten,
16
+ } from './page-template.mjs';
17
+
18
+ test('templatesOf undefined returns standard', () => {
19
+ assert.deepEqual(templatesOf(undefined), ['single', 'sidebar-right', 'sidebar-left']);
20
+ });
21
+
22
+ test('templatesOf empty object returns standard', () => {
23
+ assert.deepEqual(templatesOf({}), ['single', 'sidebar-right', 'sidebar-left']);
24
+ });
25
+
26
+ test('templatesOf valid custom list returns that list', () => {
27
+ assert.deepEqual(templatesOf({ templates: ['sidebar-right', 'single'] }), ['sidebar-right', 'single']);
28
+ });
29
+
30
+ test('templatesOf empty array returns standard', () => {
31
+ assert.deepEqual(templatesOf({ templates: [] }), ['single', 'sidebar-right', 'sidebar-left']);
32
+ });
33
+
34
+ test('templatesOf invalid item returns standard', () => {
35
+ assert.deepEqual(templatesOf({ templates: ['nope'] }), ['single', 'sidebar-right', 'sidebar-left']);
36
+ });
37
+
38
+ test('templateOf no template returns first offered', () => {
39
+ assert.equal(templateOf({}, ['sidebar-right', 'single']), 'sidebar-right');
40
+ });
41
+
42
+ test('templateOf valid template in offered returns it', () => {
43
+ assert.equal(templateOf({ template: 'single' }, ['sidebar-right', 'single']), 'single');
44
+ });
45
+
46
+ test('templateOf valid template not in offered returns first offered', () => {
47
+ assert.equal(templateOf({ template: 'full' }, ['sidebar-right', 'single']), 'sidebar-right');
48
+ });
49
+
50
+ test('templateOf non-string template returns first offered', () => {
51
+ assert.equal(templateOf({ template: 3 }, ['single']), 'single');
52
+ });
53
+
54
+ test('templateOf null page returns first offered', () => {
55
+ assert.equal(templateOf(null, ['single']), 'single');
56
+ });
57
+
58
+ test('hasSidebar sidebar-left', () => {
59
+ assert.equal(hasSidebar('sidebar-left'), true);
60
+ });
61
+
62
+ test('hasSidebar sidebar-right', () => {
63
+ assert.equal(hasSidebar('sidebar-right'), true);
64
+ });
65
+
66
+ test('hasSidebar single', () => {
67
+ assert.equal(hasSidebar('single'), false);
68
+ });
69
+
70
+ test('hasSidebar full', () => {
71
+ assert.equal(hasSidebar('full'), false);
72
+ });
73
+
74
+ test('hasSidebar undefined', () => {
75
+ assert.equal(hasSidebar(undefined), false);
76
+ });
77
+
78
+ test('templateChoices false removes sidebar', () => {
79
+ assert.deepEqual(templateChoices(['single', 'sidebar-right'], false), ['single']);
80
+ });
81
+
82
+ test('templateChoices true keeps all', () => {
83
+ assert.deepEqual(templateChoices(['single', 'sidebar-right'], true), ['single', 'sidebar-right']);
84
+ });
85
+
86
+ test('templateChoices false removes sidebar-left', () => {
87
+ assert.deepEqual(templateChoices(['sidebar-left', 'full'], false), ['full']);
88
+ });
89
+
90
+ test('templateProblems undefined returns empty', () => {
91
+ assert.deepEqual(templateProblems(undefined), []);
92
+ });
93
+
94
+ test('templateProblems valid list returns empty', () => {
95
+ assert.deepEqual(templateProblems(['single', 'sidebar-right']), []);
96
+ });
97
+
98
+ test('templateProblems non-array', () => {
99
+ assert.deepEqual(templateProblems('single'), ['"templates" must be a list of template ids.']);
100
+ });
101
+
102
+ test('templateProblems empty array', () => {
103
+ assert.deepEqual(templateProblems([]), ['"templates" must name at least one template.']);
104
+ });
105
+
106
+ test('templateProblems unknown template', () => {
107
+ assert.deepEqual(templateProblems(['single', 'nope']), ['"templates" has an unknown template: nope.']);
108
+ });
109
+
110
+ test('templateProblems duplicate template', () => {
111
+ assert.deepEqual(templateProblems(['single', 'single']), ['"templates" names single twice.']);
112
+ });
113
+
114
+ test('templatesOf returns new array', () => {
115
+ const result = templatesOf(undefined);
116
+ assert.deepEqual(result, STANDARD_TEMPLATES);
117
+ result.push('extra');
118
+ assert.equal(STANDARD_TEMPLATES.length, 3);
119
+ assert.deepEqual(STANDARD_TEMPLATES, ['single', 'sidebar-right', 'sidebar-left']);
120
+ });
121
+
122
+ test('templateWritten leaves out the default and an empty value', () => {
123
+ const offered = ['sidebar-right', 'single'];
124
+ assert.equal(templateWritten('sidebar-right', offered), '');
125
+ assert.equal(templateWritten('', offered), '');
126
+ assert.equal(templateWritten(undefined, offered), '');
127
+ assert.equal(templateWritten('single', offered), 'single');
128
+ assert.equal(templateWritten('full', offered), 'full');
129
+ });
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * The block type to draw beside a page's text, or null.
3
- * @param {{ wide?: boolean, side?: boolean }} page the page's front matter
3
+ * @param {string} template the page's template (templateOf in lib/page-template.mjs)
4
4
  * @param {{ block: string | null } | null} setting the contents of src/data/side.json, or null when the file is missing
5
5
  * @param {(type: string) => boolean} beside whether that type's load.ts has beside: true
6
6
  * @returns {string | null}
7
7
  */
8
- export function sideBlockOf(page: {
9
- wide?: boolean;
10
- side?: boolean;
11
- }, setting: {
8
+ export function sideBlockOf(template: string, setting: {
12
9
  block: string | null;
13
10
  } | null, beside: (type: string) => boolean): string | null;
14
11
  /**
@@ -32,14 +29,3 @@ export function sideChoices(blocks: {
32
29
  type: string;
33
30
  label: string;
34
31
  }[];
35
- /**
36
- * Whether the page form shows the switch "Sidekolonne": src/data/side.json chooses a block and the page is not wide.
37
- * @param {{ block?: unknown } | null | undefined} setting the contents of src/data/side.json
38
- * @param {{ wide?: unknown }} page the page's values
39
- * @returns {boolean}
40
- */
41
- export function sideSwitchShown(setting: {
42
- block?: unknown;
43
- } | null | undefined, page: {
44
- wide?: unknown;
45
- }): boolean;
@@ -1,44 +1,36 @@
1
1
  // Determines the side block type to draw beside a page's text, and what the editor offers about it.
2
- // Exposes sideBlockOf (the site), besideInSource, sideChoices and sideSwitchShown (the editor: Sidekolonne).
3
- // No dependencies.
2
+ // Exposes sideBlockOf (the site), besideInSource and sideChoices (the editor: Sidekolonne).
3
+ // Depends on hasSidebar in lib/page-template.mjs.
4
+ import { hasSidebar } from './page-template.mjs';
4
5
 
5
6
  /**
6
7
  * The block type to draw beside a page's text, or null.
7
- * @param {{ wide?: boolean, side?: boolean }} page the page's front matter
8
+ * @param {string} template the page's template (templateOf in lib/page-template.mjs)
8
9
  * @param {{ block: string | null } | null} setting the contents of src/data/side.json, or null when the file is missing
9
10
  * @param {(type: string) => boolean} beside whether that type's load.ts has beside: true
10
11
  * @returns {string | null}
11
12
  */
12
- export function sideBlockOf(page, setting, beside) {
13
- // Treat null or undefined page as empty object
14
- const p = page || {};
15
-
16
- // Rule 1: When setting is null or undefined, return null
17
- if (setting === null || setting === undefined) {
13
+ export function sideBlockOf(template, setting, beside) {
14
+ // Only the sidebar templates have a column
15
+ if (!hasSidebar(template)) {
18
16
  return null;
19
17
  }
20
18
 
21
- // Rule 2: When setting.block is not a non-empty string, return null
22
- if (typeof setting.block !== 'string' || setting.block.length === 0) {
23
- return null;
24
- }
25
-
26
- // Rule 3: When page.wide === true, return null
27
- if (p.wide === true) {
19
+ // When setting is null or undefined, return null
20
+ if (setting === null || setting === undefined) {
28
21
  return null;
29
22
  }
30
23
 
31
- // Rule 4: When page.side === false, return null
32
- if (p.side === false) {
24
+ // When setting.block is not a non-empty string, return null
25
+ if (typeof setting.block !== 'string' || setting.block.length === 0) {
33
26
  return null;
34
27
  }
35
28
 
36
- // Rule 5: When beside(setting.block) is not true, return null
29
+ // When beside(setting.block) is not true, return null
37
30
  if (beside(setting.block) !== true) {
38
31
  return null;
39
32
  }
40
33
 
41
- // Rule 6: Return setting.block
42
34
  return setting.block;
43
35
  }
44
36
 
@@ -63,13 +55,3 @@ export function sideChoices(blocks, sources, installed) {
63
55
  .filter((b) => (!b.app || installed.includes(b.app)) && besideInSource(sources[b.type] ?? ''))
64
56
  .map((b) => ({ type: b.type, label: b.label }));
65
57
  }
66
-
67
- /**
68
- * Whether the page form shows the switch "Sidekolonne": src/data/side.json chooses a block and the page is not wide.
69
- * @param {{ block?: unknown } | null | undefined} setting the contents of src/data/side.json
70
- * @param {{ wide?: unknown }} page the page's values
71
- * @returns {boolean}
72
- */
73
- export function sideSwitchShown(setting, page) {
74
- return typeof setting?.block === 'string' && setting.block.length > 0 && page?.wide !== true;
75
- }
@@ -1,54 +1,44 @@
1
- // Tests for sideBlockOf (the site) and besideInSource, sideChoices and sideSwitchShown (the editor's Sidekolonne).
1
+ // Tests for sideBlockOf (the site) and besideInSource and sideChoices (the editor's Sidekolonne).
2
2
  // Validates side block selection logic.
3
- // Depends on lib/side-block.mjs.
3
+ // Depends on lib/side-block.mjs and, through it, lib/page-template.mjs.
4
4
  import { test } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import { besideInSource, sideBlockOf, sideChoices, sideSwitchShown } from './side-block.mjs';
6
+ import { besideInSource, sideBlockOf, sideChoices } from './side-block.mjs';
7
7
 
8
8
  const yes = () => true;
9
9
  const no = () => false;
10
10
 
11
11
  test('returns null when setting is null', () => {
12
- assert.equal(sideBlockOf({}, null, yes), null);
12
+ assert.equal(sideBlockOf('sidebar-right', null, yes), null);
13
13
  });
14
14
 
15
15
  test('returns null when setting.block is null', () => {
16
- assert.equal(sideBlockOf({}, { block: null }, yes), null);
16
+ assert.equal(sideBlockOf('sidebar-right', { block: null }, yes), null);
17
17
  });
18
18
 
19
- test('returns block type when valid', () => {
20
- assert.equal(sideBlockOf({}, { block: 'side-card' }, yes), 'side-card');
19
+ test('returns block type for both sidebar templates', () => {
20
+ assert.equal(sideBlockOf('sidebar-right', { block: 'side-card' }, yes), 'side-card');
21
+ assert.equal(sideBlockOf('sidebar-left', { block: 'side-card' }, yes), 'side-card');
21
22
  });
22
23
 
23
- test('returns null when page.wide is true', () => {
24
- assert.equal(sideBlockOf({ wide: true }, { block: 'side-card' }, yes), null);
25
- });
26
-
27
- test('returns null when page.side is false', () => {
28
- assert.equal(sideBlockOf({ side: false }, { block: 'side-card' }, yes), null);
29
- });
30
-
31
- test('returns block type when page.side is true', () => {
32
- assert.equal(sideBlockOf({ side: true }, { block: 'side-card' }, yes), 'side-card');
24
+ test('returns null for templates without a sidebar', () => {
25
+ assert.equal(sideBlockOf('single', { block: 'side-card' }, yes), null);
26
+ assert.equal(sideBlockOf('full', { block: 'side-card' }, yes), null);
33
27
  });
34
28
 
35
29
  test('returns null when beside returns false', () => {
36
- assert.equal(sideBlockOf({}, { block: 'side-card' }, no), null);
30
+ assert.equal(sideBlockOf('sidebar-right', { block: 'side-card' }, no), null);
37
31
  });
38
32
 
39
33
  test('beside is called with the chosen type', () => {
40
34
  const seen = [];
41
- sideBlockOf({}, { block: 'side-card' }, (t) => {
35
+ sideBlockOf('sidebar-left', { block: 'side-card' }, (t) => {
42
36
  seen.push(t);
43
37
  return true;
44
38
  });
45
39
  assert.deepEqual(seen, ['side-card']);
46
40
  });
47
41
 
48
- test('treats undefined page as empty object', () => {
49
- assert.equal(sideBlockOf(undefined, { block: 'side-card' }, yes), 'side-card');
50
- });
51
-
52
42
  test('besideInSource reads export const beside = true', () => {
53
43
  assert.equal(besideInSource('import x from "y";\nexport const beside = true;\nexport const load = 1;'), true);
54
44
  assert.equal(besideInSource('export const beside = false;'), false);
@@ -70,10 +60,3 @@ test('sideChoices lists installed blocks with beside, by label', () => {
70
60
  { type: 'chat', label: 'Chat' },
71
61
  ]);
72
62
  });
73
-
74
- test('sideSwitchShown needs a chosen block and a page that is not wide', () => {
75
- assert.equal(sideSwitchShown({ block: 'toc' }, {}), true);
76
- assert.equal(sideSwitchShown({ block: 'toc' }, { wide: true }), false);
77
- assert.equal(sideSwitchShown({ block: null }, {}), false);
78
- assert.equal(sideSwitchShown(null, {}), false);
79
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.9.0-next-20261105122349",
3
+ "version": "0.9.0",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -1,52 +1,79 @@
1
1
  // Checks the column beside a page's text (src/data/side.json, `beside` in a block's load.ts, PageLayout.astro) with the
2
- // theme now in src/data/theme.json: puts a TEST-ONLY block and three pages in the site (scripts/fixtures/side/), builds it
3
- // in a folder of its own and looks in the HTML. Exposes checkSide(root, theme); used by check-themes.mjs. Removes what it
2
+ // theme now in src/data/theme.json: puts a TEST-ONLY block and five pages, one for each template (scripts/fixtures/side/),
3
+ // in the site, builds it in a folder of its own and looks in the HTML for the template and the column the theme's
4
+ // "templates" give each, and for the width of the text (PageLayout.astro: 66ch with a column; without one the blocks span
5
+ // the column and the first paragraph is the lead). Exposes checkSide(root), expectedFor and columnOf; used by check-themes.mjs. Removes what it
4
6
  // put in the site, and the side.json it wrote, however it ends.
5
7
  import { execFileSync } from 'node:child_process';
6
8
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
9
  import { tmpdir } from 'node:os';
8
10
  import { join } from 'node:path';
9
11
  import { fileURLToPath } from 'node:url';
12
+ import { hasSidebar, templateOf, templatesOf } from '@morphemecms/cms/lib/page-template.mjs';
13
+ import { activeTheme, themeFolder } from '@morphemecms/cms/site/theme.mjs';
10
14
  import { binOf } from './bin.mjs';
15
+ import { leadProblem, textClasses, widthProblem } from './side-width.mjs';
11
16
 
12
17
  const fixtures = fileURLToPath(new URL('fixtures/side/', import.meta.url));
13
18
  const BLOCK = 'src/apps/side-fixture/blocks/side-test';
14
- const PAGES = ['side-plain', 'side-wide', 'side-off'];
15
19
 
16
- /** What the fixture's pages must show: [page, whether the column is on it]. */
17
- export const expected = [
18
- ['side-plain', true],
19
- ['side-wide', false],
20
- ['side-off', false],
20
+ /** The fixture's pages: [page, its `template` in the front matter, or undefined]. */
21
+ const PAGES = [
22
+ ['side-none', undefined],
23
+ ['side-single', 'single'],
24
+ ['side-left', 'sidebar-left'],
25
+ ['side-right', 'sidebar-right'],
26
+ ['side-full', 'full'],
21
27
  ];
22
28
 
23
- /** Whether the built page has the block beside its text, and its text's length (data-side-length). */
24
- export const columnOf = (html) => ({
25
- column: /<aside class="page-side">[\s\S]*?data-side-test="\/side-plain\/"[\s\S]*?<\/aside>/.test(html),
26
- grid: html.includes('class="page-with-side"'),
29
+ /** What each fixture page must show with a theme that offers `offered`: [page, its template, the side of the column or null].
30
+ * The template is the one the theme gives the page (templateOf); only a sidebar template has a column. */
31
+ export const expectedFor = (offered) =>
32
+ PAGES.map(([page, key]) => {
33
+ const template = templateOf({ template: key }, offered);
34
+ return [page, template, hasSidebar(template) ? (template === 'sidebar-left' ? 'left' : 'right') : null];
35
+ });
36
+
37
+ /** What the built page shows: its template, the side of the column beside its text (with the test block in it), and whether the block is anywhere. */
38
+ export const columnOf = (html, page) => ({
39
+ template: html.match(/<article[^>]*\sdata-template="([\w-]+)"/)?.[1] ?? null,
40
+ side: html.match(/class="page-with-side" data-side="(left|right)"/)?.[1] ?? null,
41
+ column: new RegExp(String.raw`<aside class="page-side">[\s\S]*?data-side-test="/${page}/"[\s\S]*?</aside>`).test(html),
27
42
  anywhere: html.includes('data-side-test'),
43
+ text: textClasses(html),
28
44
  });
29
45
 
30
- /** Builds the site with the fixture and checks the three cases for the theme in use; throws with what is wrong. */
46
+ /** The templates the theme in use offers (its theme.json "templates", else the standard theme's). */
47
+ function offeredBy(root) {
48
+ const theme = activeTheme();
49
+ const manifest = JSON.parse(readFileSync(join(root, themeFolder(theme), 'theme.json'), 'utf8'));
50
+ return templatesOf(manifest);
51
+ }
52
+
53
+ /** Builds the site with the fixture and checks the cases for the theme in use; throws with what is wrong. */
31
54
  export function checkSide(root) {
32
- const put = [BLOCK, 'src/data/side.json', ...PAGES.map((p) => `src/content/pages/${p}.md`)].map((f) => join(root, f));
55
+ const put = [BLOCK, 'src/data/side.json', ...PAGES.map(([p]) => `src/content/pages/${p}.md`)].map((f) => join(root, f));
33
56
  if (put.some(existsSync)) throw new Error('Remove the side-fixture files from src/ first (scripts/check-side.mjs puts them there).');
34
57
  const out = mkdtempSync(join(tmpdir(), 'side-'));
35
58
  try {
59
+ const offered = offeredBy(root);
36
60
  mkdirSync(join(root, BLOCK), { recursive: true });
37
61
  cpSync(join(fixtures, 'block'), join(root, BLOCK), { recursive: true });
38
62
  cpSync(join(fixtures, 'side.json'), join(root, 'src/data/side.json'));
39
- for (const p of PAGES) cpSync(join(fixtures, `${p}.md`), join(root, `src/content/pages/${p}.md`));
63
+ for (const [p] of PAGES) cpSync(join(fixtures, `${p}.md`), join(root, `src/content/pages/${p}.md`));
40
64
  execFileSync(process.execPath, [binOf('astro'), 'build', '--outDir', out], { cwd: root, stdio: 'inherit' });
41
65
  const problems = [];
42
- for (const [page, on] of expected) {
43
- const html = readFileSync(join(out, page, 'index.html'), 'utf8');
44
- const found = columnOf(html);
45
- if (on && !(found.column && found.grid)) problems.push(`/${page}/ has no column beside its text`);
46
- if (!on && (found.anywhere || found.grid)) problems.push(`/${page}/ has the column, and should not`);
66
+ let drawn = false;
67
+ for (const [page, template, side] of expectedFor(offered)) {
68
+ const found = columnOf(readFileSync(join(out, page, 'index.html'), 'utf8'), page);
69
+ if (found.template !== template) problems.push(`/${page}/ has the template ${found.template}, and should have ${template}`);
70
+ if (side && !(found.column && found.side === side)) problems.push(`/${page}/ has no column on the ${side}, beside its text`);
71
+ if (!side && (found.anywhere || found.side)) problems.push(`/${page}/ has the column, and should not`);
72
+ for (const problem of [widthProblem(found.text, side), leadProblem(found.text, side)])
73
+ if (problem) problems.push(`/${page}/: ${problem}`);
74
+ drawn ||= found.anywhere;
47
75
  }
48
- const plain = columnOf(readFileSync(join(out, 'side-plain/index.html'), 'utf8'));
49
- if (!plain.anywhere) problems.push('the block was not drawn at all');
76
+ if (offered.some(hasSidebar) && !drawn) problems.push('the block was not drawn at all');
50
77
  if (problems.length) throw new Error(problems.join('\n'));
51
78
  } finally {
52
79
  rmSync(out, { recursive: true, force: true });
@@ -5,7 +5,7 @@
5
5
  // sections as blocks (packages/editor/src/sections.mjs) and read back: the sections must be the same, a section
6
6
  // of text with the same heading and the same HTML. Every page is also read into sections as the front page (as the
7
7
  // site reads the page that is the front page without the editor, partsOfMarkdown in sections.mjs, packages/editor/site/home.ts):
8
- // they must be those the editor reads. A page with the switch "Sidekolonne" off and on again has the same front matter. Run: npm run check:editor [files...]
8
+ // they must be those the editor reads. A page whose template is changed and changed back has the same front matter (the default leaves no key). Run: npm run check:editor [files...]
9
9
  // A built-in sample with section colours checks that the editor and the site give the front page's sections the same colours.
10
10
  // Milkdown needs a DOM; jsdom stands in for the browser.
11
11
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
@@ -46,7 +46,8 @@ const textOf = Object.fromEntries(
46
46
  .flatMap((c) => c.files)
47
47
  .map((f) => [f.file, f.fields.find((x) => x.type === 'blocks')]),
48
48
  );
49
- const { booleanWrite, splitFrontmatter, writeFields } = await import('@morphemecms/cms/src/frontmatter.mjs');
49
+ const { splitFrontmatter, writeFields } = await import('@morphemecms/cms/src/frontmatter.mjs');
50
+ const { STANDARD_TEMPLATES, templateWritten } = await import('@morphemecms/cms/lib/page-template.mjs');
50
51
 
51
52
  const walk = (dir) =>
52
53
  (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []).flatMap((e) =>
@@ -158,14 +159,22 @@ for (const file of files) {
158
159
  console.log(`\n✗ ${file}\n front matter skrives ikke tilbage uændret`);
159
160
  continue;
160
161
  }
161
- // The switch "Sidekolonne" (side): off writes `side: false`; on again removes the key, and the front matter is as before.
162
+ // The template (templateWritten): the theme's default leaves no key; another one is written; changing back gives the
163
+ // front matter as it was (without the key).
162
164
  if (file.includes('src/content/pages/')) {
163
- const off = writeFields(yaml, { side: booleanWrite(false, true) });
164
- if (!/^side: false$/m.test(off) || writeFields(off, { side: booleanWrite(true, true) }) !== writeFields(yaml, { side: '' })) {
165
+ const [fallback] = STANDARD_TEMPLATES;
166
+ const plain = writeFields(yaml, { template: '' });
167
+ const chosen = writeFields(plain, { template: templateWritten('sidebar-left', STANDARD_TEMPLATES) });
168
+ const back = writeFields(chosen, { template: templateWritten(fallback, STANDARD_TEMPLATES) });
169
+ if (
170
+ !/^template: sidebar-left$/m.test(chosen) ||
171
+ back !== plain ||
172
+ writeFields(plain, { template: templateWritten(fallback, STANDARD_TEMPLATES) }) !== plain
173
+ ) {
165
174
  failed++;
166
175
  console.log(`
167
176
  ✗ ${file}
168
- Sidekolonne slået fra og til giver ikke det samme front matter`);
177
+ Skabelonen skiftet og skiftet tilbage giver ikke det samme front matter`);
169
178
  continue;
170
179
  }
171
180
  }
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side full"
3
+ template: full
4
+ ---
5
+
6
+ # Side full
7
+
8
+ A page with full: the column follows the theme's offer.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side left"
3
+ template: sidebar-left
4
+ ---
5
+
6
+ # Side left
7
+
8
+ A page with sidebar-left: the column follows the theme's offer.
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: "Side none"
3
+ ---
4
+
5
+ # Side none
6
+
7
+ A page with no template: the column follows the theme's offer.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side right"
3
+ template: sidebar-right
4
+ ---
5
+
6
+ # Side right
7
+
8
+ A page with sidebar-right: the column follows the theme's offer.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side single"
3
+ template: single
4
+ ---
5
+
6
+ # Side single
7
+
8
+ A page with single: the column follows the theme's offer.
@@ -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
+ };