@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.
- package/CHANGELOG.md +17 -1
- package/defaults/collections.ts +110 -56
- 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 +8 -3
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- 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 +97 -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 +37 -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,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
|
+
});
|
package/lib/side-block.d.mts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The block type to draw beside a page's text, or null.
|
|
3
|
-
* @param {
|
|
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(
|
|
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;
|
package/lib/side-block.mjs
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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 {
|
|
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(
|
|
13
|
-
//
|
|
14
|
-
|
|
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
|
-
//
|
|
22
|
-
if (
|
|
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
|
-
//
|
|
32
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
}
|
package/lib/side-block.test.mjs
CHANGED
|
@@ -1,54 +1,44 @@
|
|
|
1
|
-
// Tests for sideBlockOf (the site) and besideInSource
|
|
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
|
|
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(
|
|
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(
|
|
16
|
+
assert.equal(sideBlockOf('sidebar-right', { block: null }, yes), null);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test('returns block type
|
|
20
|
-
assert.equal(sideBlockOf(
|
|
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
|
|
24
|
-
assert.equal(sideBlockOf(
|
|
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(
|
|
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(
|
|
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
|
|
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",
|
package/scripts/check-side.mjs
CHANGED
|
@@ -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
|
|
3
|
-
// in a folder of its own and looks in the HTML
|
|
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
|
-
/**
|
|
17
|
-
|
|
18
|
-
['side-
|
|
19
|
-
['side-
|
|
20
|
-
['side-
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
const found = columnOf(html);
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
177
|
+
Skabelonen skiftet og skiftet tilbage giver ikke det samme front matter`);
|
|
169
178
|
continue;
|
|
170
179
|
}
|
|
171
180
|
}
|
|
@@ -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
|
+
};
|