@morphemecms/cms 0.9.1 → 0.10.0-next-20261105172247

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 (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/defaults/popup.tsx +44 -33
  3. package/defaults/texts.ts +1 -1
  4. package/icons/lucide/desktop.svg +1 -0
  5. package/icons/material-symbols/desktop.svg +1 -0
  6. package/icons/phosphor/desktop.svg +1 -0
  7. package/lib/danish.d.mts +24 -0
  8. package/lib/danish.mjs +134 -0
  9. package/lib/danish.test.mjs +102 -0
  10. package/lib/google-result.test.mjs +70 -0
  11. package/lib/heading-levels.d.mts +1 -0
  12. package/lib/heading-levels.mjs +6 -1
  13. package/lib/heading-levels.test.mjs +15 -1
  14. package/lib/translate.d.mts +8 -0
  15. package/lib/translate.mjs +15 -0
  16. package/lib/translate.test.mjs +38 -0
  17. package/package.json +9 -1
  18. package/scripts/check-language.mjs +105 -0
  19. package/scripts/editor-roundtrip.mjs +9 -14
  20. package/scripts/template-round-trip.mjs +22 -0
  21. package/scripts/template-round-trip.test.mjs +26 -0
  22. package/site/icons.d.mts +4 -0
  23. package/site/icons.mjs +2 -0
  24. package/src/DataForm.tsx +31 -11
  25. package/src/EntryForm.tsx +22 -3
  26. package/src/Library.tsx +2 -8
  27. package/src/MarkdownEditor.tsx +17 -11
  28. package/src/SearchPreview.tsx +75 -0
  29. package/src/editor.css +36 -6
  30. package/src/googleResult.d.mts +20 -0
  31. package/src/googleResult.mjs +30 -0
  32. package/src/i18n/da-DK/forms.ts +6 -0
  33. package/src/i18n/da-DK/text.ts +1 -0
  34. package/src/i18n/en-US/forms.ts +9 -1
  35. package/src/i18n/en-US/parts.ts +1 -1
  36. package/src/i18n/en-US/text.ts +2 -0
  37. package/src/i18n/index.ts +16 -2
  38. package/src/seo.ts +11 -7
  39. package/src/ui.tsx +3 -2
  40. package/src/when.ts +6 -0
  41. package/theme/components/ActionBar.astro +26 -15
  42. package/theme/layouts/Base.astro +3 -1
  43. package/theme/styles/components.css +33 -17
  44. package/theme/styles/global.css +1 -7
  45. package/theme/ui/BottomSheet.tsx +6 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.10.0-next-20261105172247
4
+
5
+ ### Minor Changes
6
+
7
+ - 999142f: The action bar at the bottom of a phone's screen is a standard bottom sheet, on the site and in the editor.
8
+ - 358c778: The page and post forms show a Google result preview for phone and computer; the shared-link card keeps its shared width.
9
+
10
+ ### Patch Changes
11
+
12
+ - 22d5764: The editor check no longer fails every page on Linux builds after the page template choice.
13
+ - 6dc373f: The text editor's empty-line hint follows the editor's language and says that the first heading is an H1.
14
+ - 1478765: On a page, the heading menus offer only H1 until the text has one, then H2 and H3.
15
+
3
16
  ## 0.9.1
4
17
 
5
18
  ### Patch Changes
@@ -8,6 +8,7 @@ import type { KindPart } from '../src/schema';
8
8
  import { buttonsOf } from '../src/standard/buttons/view';
9
9
  import { shownPicture } from '../src/standard/view';
10
10
  import PopupView from '@theme/components/PopupView';
11
+ import BottomSheet from '@theme/ui/BottomSheet';
11
12
  import Button from '@theme/ui/Button';
12
13
  import Toolbar from '@theme/ui/Toolbar';
13
14
  import List, { ListItem } from '@theme/ui/List';
@@ -43,7 +44,8 @@ export function drawPopup(p: Record<string, any>, data: BlockData, part?: KindPa
43
44
  }
44
45
 
45
46
  // An action bar ("Promovering", the kind "Handlingslinje") as it looks on a phone (ActionBar.astro in the
46
- // theme): its one or two items side by side, each a text (a list row of two lines: its heading over its text) or a button, the first button filled, the buttons with their own texts or those of their target
47
+ // theme): the same standard bottom sheet (BottomSheet, .theme-action-bar-drawn in styles/components.css: in its place,
48
+ // a phone wide) with the same toolbar row in it, its one or two items side by side, each a text (a list row of two lines: its heading over its text) or a button, the first button filled, the buttons with their own texts or those of their target
47
49
  // ("Book en tid", "Ring"), of what they open (a pop-up's heading, an app's booking sheet: "Book en tid") or of their
48
50
  // page; the calendar for what books, the phone for "Ring", the arrow after a page or a pop-up.
49
51
  export function drawActionBar(p: Record<string, any>, data: BlockData) {
@@ -55,39 +57,48 @@ export function drawActionBar(p: Record<string, any>, data: BlockData) {
55
57
  if (!buttons.length) return null;
56
58
  const first = buttons.findIndex((b) => b.type !== 'text');
57
59
  return (
58
- <Toolbar label={siteT.theme.actions} className="theme-action-bar-drawn not-prose">
59
- {buttons.map((b, i) => {
60
- if (b.type === 'text')
60
+ <BottomSheet
61
+ kind="standard"
62
+ wide="sheet"
63
+ dragHandle={false}
64
+ states={['expanded']}
65
+ label={siteT.theme.actions}
66
+ className="theme-action-bar-drawn not-prose"
67
+ >
68
+ <Toolbar label={siteT.theme.actions} className="theme-action-bar-row">
69
+ {buttons.map((b, i) => {
70
+ if (b.type === 'text')
71
+ return (
72
+ <List key={i} dividers="none" className="theme-action-bar-text">
73
+ <ListItem headline={String(b.heading ?? '').trim()} supporting={String(b.text ?? '').trim() || undefined} />
74
+ </List>
75
+ );
76
+ const own = String(b.label ?? '').trim();
77
+ const opens = b.to === 'promotion' ? data.relations.promotions?.find((o) => o.value === b.open) : undefined;
78
+ // What it does: "Book en tid" also for a promotion that stands for it (an app's booking sheet).
79
+ const to = opens?.as ?? b.to;
80
+ const text =
81
+ to === 'phone'
82
+ ? own || data.site.labels.call
83
+ : to === 'page'
84
+ ? own || pageName(b.href ?? '') || ''
85
+ : to === 'promotion'
86
+ ? own || opens?.text || ''
87
+ : own || data.site.labels.book;
88
+ const arrow = to === 'page' || to === 'promotion';
61
89
  return (
62
- <List key={i} dividers="none" className="theme-action-bar-text">
63
- <ListItem headline={String(b.heading ?? '').trim()} supporting={String(b.text ?? '').trim() || undefined} />
64
- </List>
90
+ <Button
91
+ key={i}
92
+ href=""
93
+ kind={i === first ? 'filled' : 'outlined'}
94
+ icon={arrow ? undefined : to === 'phone' ? 'phone' : 'calendar-blank'}
95
+ trailingIcon={arrow ? 'arrow-right' : undefined}
96
+ >
97
+ {text}
98
+ </Button>
65
99
  );
66
- const own = String(b.label ?? '').trim();
67
- const opens = b.to === 'promotion' ? data.relations.promotions?.find((o) => o.value === b.open) : undefined;
68
- // What it does: "Book en tid" also for a promotion that stands for it (an app's booking sheet).
69
- const to = opens?.as ?? b.to;
70
- const text =
71
- to === 'phone'
72
- ? own || data.site.labels.call
73
- : to === 'page'
74
- ? own || pageName(b.href ?? '') || ''
75
- : to === 'promotion'
76
- ? own || opens?.text || ''
77
- : own || data.site.labels.book;
78
- const arrow = to === 'page' || to === 'promotion';
79
- return (
80
- <Button
81
- key={i}
82
- href=""
83
- kind={i === first ? 'filled' : 'outlined'}
84
- icon={arrow ? undefined : to === 'phone' ? 'phone' : 'calendar-blank'}
85
- trailingIcon={arrow ? 'arrow-right' : undefined}
86
- >
87
- {text}
88
- </Button>
89
- );
90
- })}
91
- </Toolbar>
100
+ })}
101
+ </Toolbar>
102
+ </BottomSheet>
92
103
  );
93
104
  }
package/defaults/texts.ts CHANGED
@@ -15,7 +15,7 @@ export const texts: Record<string, Record<string, string>> = {
15
15
  'Nyt blogindlæg': 'New blog post',
16
16
  Titel: 'Title',
17
17
  Dato: 'Date',
18
- Adresse: 'Address',
18
+ Adresse: 'URL',
19
19
  'Kort beskrivelse': 'Short description',
20
20
  Tags: 'Tags',
21
21
  'Opret et tag': 'Create a tag',
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2" /> <line x1="8" x2="16" y1="21" y2="21" /> <line x1="12" x2="12" y1="17" y2="21" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M334-120v-60h86v-100H140q-24 0-42-18t-18-42v-440q0-24 18-42t42-18h680q24 0 42 18t18 42v440q0 24-18 42t-42 18H540v100h86v60H334ZM140-340h680v-440H140v440Zm0 0v-440 440Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24h72v16H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V200h72a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40ZM48,56H208a8,8,0,0,1,8,8v80H40V64A8,8,0,0,1,48,56ZM208,184H48a8,8,0,0,1-8-8V160H216v16A8,8,0,0,1,208,184Z"/></svg>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Finds Danish text in a source text: a 'letter' match is the first word on a line with a Danish letter, a 'value' match is a quoted string equal to a Danish dictionary value.
3
+ * @param {string} text
4
+ * @param {Set<string>} danishValues
5
+ * @returns {{ line: number, match: string, kind: 'letter' | 'value' }[]}
6
+ */
7
+ export function findDanish(text: string, danishValues: Set<string>): {
8
+ line: number;
9
+ match: string;
10
+ kind: "letter" | "value";
11
+ }[];
12
+ /**
13
+ * Collects the Danish dictionary values, and skips values equal to their key, values with no letter, and values that are also English.
14
+ * @param {Record<string, unknown>[]} dictionaries
15
+ * @param {Record<string, unknown>[]} [englishDictionaries]
16
+ * @returns {Set<string>}
17
+ */
18
+ export function danishValuesOf(dictionaries: Record<string, unknown>[], englishDictionaries?: Record<string, unknown>[]): Set<string>;
19
+ /**
20
+ * Tells whether a path is a Danish language file.
21
+ * @param {string} path
22
+ * @returns {boolean}
23
+ */
24
+ export function isLanguagePath(path: string): boolean;
package/lib/danish.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // Finds Danish text for scripts/check-language.mjs.
2
+ // Exposes findDanish, danishValuesOf, isLanguagePath.
3
+ // Pure: no packages, no file access.
4
+
5
+ const DANISH_LETTERS = new Set(['\u00e6', '\u00f8', '\u00e5', '\u00c6', '\u00d8', '\u00c5']);
6
+
7
+ const WORD_RE = /[\p{L}\p{N}_-]+/gu;
8
+
9
+ /**
10
+ * Finds Danish text in a source text: a 'letter' match is the first word on a line with a Danish letter, a 'value' match is a quoted string equal to a Danish dictionary value.
11
+ * @param {string} text
12
+ * @param {Set<string>} danishValues
13
+ * @returns {{ line: number, match: string, kind: 'letter' | 'value' }[]}
14
+ */
15
+ export function findDanish(text, danishValues) {
16
+ const results = [];
17
+ const lines = text.split('\n');
18
+
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const line = lines[i];
21
+ const lineNum = i + 1;
22
+
23
+ // Find first word containing a Danish letter
24
+ let letterMatch = null;
25
+ const wordMatches = line.match(WORD_RE);
26
+ if (wordMatches) {
27
+ for (const word of wordMatches) {
28
+ for (const ch of word) {
29
+ if (DANISH_LETTERS.has(ch)) {
30
+ letterMatch = word;
31
+ break;
32
+ }
33
+ }
34
+ if (letterMatch) break;
35
+ }
36
+ }
37
+ if (letterMatch) {
38
+ results.push({ line: lineNum, match: letterMatch, kind: 'letter' });
39
+ }
40
+
41
+ // Find value matches (quoted strings)
42
+ const quoteRe = /'((?:[^'\\]|\\.)*?)'|\"((?:[^\"\\]|\\.)*?)\"|`((?:[^`\\]|\\.)*?)`/g;
43
+ let qMatch;
44
+ while ((qMatch = quoteRe.exec(line)) !== null) {
45
+ let content = null;
46
+ let isBacktick = false;
47
+ if (qMatch[0].startsWith("'")) {
48
+ content = qMatch[1];
49
+ } else if (qMatch[0].startsWith('"')) {
50
+ content = qMatch[2];
51
+ } else if (qMatch[0].startsWith('`')) {
52
+ content = qMatch[3];
53
+ isBacktick = true;
54
+ }
55
+
56
+ if (content === null) continue;
57
+
58
+ if (isBacktick && content.includes('${')) continue;
59
+
60
+ const unescaped = content.replace(/\\(.)/g, '$1');
61
+
62
+ if (danishValues.has(unescaped)) {
63
+ results.push({ line: lineNum, match: unescaped, kind: 'value' });
64
+ }
65
+ }
66
+ }
67
+
68
+ results.sort((a, b) => {
69
+ if (a.line !== b.line) return a.line - b.line;
70
+ if (a.kind === 'letter' && b.kind === 'value') return -1;
71
+ if (a.kind === 'value' && b.kind === 'letter') return 1;
72
+ return 0;
73
+ });
74
+
75
+ return results;
76
+ }
77
+
78
+ function collectStringLeaves(obj, set) {
79
+ if (obj === null || typeof obj !== 'object') return;
80
+ if (Array.isArray(obj)) {
81
+ for (const item of obj) {
82
+ if (typeof item === 'string') {
83
+ set.add(item);
84
+ }
85
+ }
86
+ return;
87
+ }
88
+ for (const key of Object.keys(obj)) {
89
+ const val = obj[key];
90
+ if (typeof val === 'string') {
91
+ if (val !== key) {
92
+ set.add(val);
93
+ }
94
+ } else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
95
+ collectStringLeaves(val, set);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Collects the Danish dictionary values, and skips values equal to their key, values with no letter, and values that are also English.
102
+ * @param {Record<string, unknown>[]} dictionaries
103
+ * @param {Record<string, unknown>[]} [englishDictionaries]
104
+ * @returns {Set<string>}
105
+ */
106
+ export function danishValuesOf(dictionaries, englishDictionaries = []) {
107
+ const danishSet = new Set();
108
+ for (const dict of dictionaries) {
109
+ collectStringLeaves(dict, danishSet);
110
+ }
111
+
112
+ const englishSet = new Set();
113
+ for (const dict of englishDictionaries) {
114
+ collectStringLeaves(dict, englishSet);
115
+ }
116
+
117
+ const result = new Set();
118
+ for (const value of danishSet) {
119
+ if (!/\p{L}/u.test(value)) continue;
120
+ if (englishSet.has(value)) continue;
121
+ result.add(value);
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Tells whether a path is a Danish language file.
129
+ * @param {string} path
130
+ * @returns {boolean}
131
+ */
132
+ export function isLanguagePath(path) {
133
+ return path.includes('/i18n/da-DK/') || path.startsWith('i18n/da-DK/') || path.endsWith('i18n/da-DK.js');
134
+ }
@@ -0,0 +1,102 @@
1
+ // Tests for lib/danish.mjs. Run: node --test lib/danish.test.mjs
2
+ // Depends on: node:test, node:assert/strict.
3
+
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { findDanish, danishValuesOf, isLanguagePath } from './danish.mjs';
7
+
8
+ describe('findDanish', () => {
9
+ it('finds Danish letter in word', () => {
10
+ const result = findDanish('a\nb \u00f8g c', new Set());
11
+ assert.deepEqual(result, [{ line: 2, match: '\u00f8g', kind: 'letter' }]);
12
+ });
13
+
14
+ it('finds first word with Danish letter', () => {
15
+ const result = findDanish('\u00f8rer f\u00f8r', new Set());
16
+ assert.deepEqual(result, [{ line: 1, match: '\u00f8rer', kind: 'letter' }]);
17
+ });
18
+
19
+ it('finds value in single quotes', () => {
20
+ const result = findDanish("label: 'Indhold'", new Set(['Indhold']));
21
+ assert.deepEqual(result, [{ line: 1, match: 'Indhold', kind: 'value' }]);
22
+ });
23
+
24
+ it('finds value in double quotes', () => {
25
+ const result = findDanish('x = "Gem"', new Set(['Gem']));
26
+ assert.deepEqual(result, [{ line: 1, match: 'Gem', kind: 'value' }]);
27
+ });
28
+
29
+ it('does not find value in comment', () => {
30
+ const result = findDanish('// Indhold', new Set(['Indhold']));
31
+ assert.deepEqual(result, []);
32
+ });
33
+
34
+ it('skips backtick with template literal', () => {
35
+ const result = findDanish('`Indhold ${x}`', new Set(['Indhold']));
36
+ assert.deepEqual(result, []);
37
+ });
38
+
39
+ it('finds value in backticks without template', () => {
40
+ const result = findDanish('`Indhold`', new Set(['Indhold']));
41
+ assert.deepEqual(result, [{ line: 1, match: 'Indhold', kind: 'value' }]);
42
+ });
43
+
44
+ it('does not match non-Danish value', () => {
45
+ const result = findDanish("'Content'", new Set(['Indhold']));
46
+ assert.deepEqual(result, []);
47
+ });
48
+
49
+ it('does not match substring', () => {
50
+ const result = findDanish("'Indholdet'", new Set(['Indhold']));
51
+ assert.deepEqual(result, []);
52
+ });
53
+
54
+ it('handles empty string', () => {
55
+ const result = findDanish('', new Set());
56
+ assert.deepEqual(result, []);
57
+ });
58
+ });
59
+
60
+ describe('danishValuesOf', () => {
61
+ it('collects Danish values skipping key equals value', () => {
62
+ const result = danishValuesOf([{ Tags: 'Tags', Content: 'Indhold' }]);
63
+ assert.deepEqual([...result].sort(), ['Indhold']);
64
+ });
65
+
66
+ it('handles nested objects and skips functions/numbers', () => {
67
+ const result = danishValuesOf([{ a: { b: 'Gem' }, f: () => 'x', n: 3 }]);
68
+ assert.deepEqual([...result].sort(), ['Gem']);
69
+ });
70
+
71
+ it('skips values with no letters', () => {
72
+ const result = danishValuesOf([{ a: '', b: '\u00b7', c: 'Ja' }]);
73
+ assert.deepEqual([...result].sort(), ['Ja']);
74
+ });
75
+
76
+ it('skips values also in English', () => {
77
+ const result = danishValuesOf([{ tags: 'Tags', save: 'Gem' }], [{ tags: 'Tags', save: 'Save' }]);
78
+ assert.deepEqual([...result].sort(), ['Gem']);
79
+ });
80
+ });
81
+
82
+ describe('isLanguagePath', () => {
83
+ it('matches path with /i18n/da-DK/', () => {
84
+ assert.equal(isLanguagePath('src/i18n/da-DK/common.ts'), true);
85
+ });
86
+
87
+ it('matches path starting with i18n/da-DK/', () => {
88
+ assert.equal(isLanguagePath('i18n/da-DK/home.md'), true);
89
+ });
90
+
91
+ it('matches path ending with i18n/da-DK.js', () => {
92
+ assert.equal(isLanguagePath('worker/i18n/da-DK.js'), true);
93
+ });
94
+
95
+ it('does not match other i18n paths', () => {
96
+ assert.equal(isLanguagePath('src/i18n/index.ts'), false);
97
+ });
98
+
99
+ it('does not match en-US path', () => {
100
+ assert.equal(isLanguagePath('src/i18n/en-US/common.ts'), false);
101
+ });
102
+ });
@@ -0,0 +1,70 @@
1
+ // Run: node --test lib/google-result.test.mjs
2
+ // Tests src/googleResult.mjs: the title, description and address line of a page's Google result.
3
+ // Depends on node:test, node:assert/strict and ../src/googleResult.mjs.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { pageTitle, searchedAs, searchCrumbs } from '../src/googleResult.mjs';
7
+
8
+ const site = { name: 'Morpheme', description: 'The site.' };
9
+
10
+ test('a page title gets the site name after it', () => {
11
+ assert.equal(pageTitle({ title: 'About', front: false, siteName: 'Morpheme' }), 'About | Morpheme');
12
+ });
13
+
14
+ test('the page title is trimmed before the site name is added', () => {
15
+ assert.equal(pageTitle({ title: ' About ', front: false, siteName: 'Morpheme' }), 'About | Morpheme');
16
+ });
17
+
18
+ test('the front page title has no site name', () => {
19
+ assert.equal(pageTitle({ title: 'Welcome', front: true, siteName: 'Morpheme' }), 'Welcome');
20
+ });
21
+
22
+ test('without a title the site name is the title', () => {
23
+ assert.equal(pageTitle({ title: '', front: false, siteName: 'Morpheme' }), 'Morpheme');
24
+ });
25
+
26
+ test('a blank title on the front page is the site name', () => {
27
+ assert.equal(pageTitle({ title: ' ', front: true, siteName: 'Morpheme' }), 'Morpheme');
28
+ });
29
+
30
+ test('the SEO title is the result title when it is set', () => {
31
+ assert.equal(searchedAs({ title: 'About', seoTitle: 'Our story', site }).title, 'Our story');
32
+ });
33
+
34
+ test('a blank SEO title falls back to the page title', () => {
35
+ assert.equal(searchedAs({ title: 'About', seoTitle: ' ', site }).title, 'About | Morpheme');
36
+ });
37
+
38
+ test('without any title the result title is the site name', () => {
39
+ assert.equal(searchedAs({ title: '', site }).title, 'Morpheme');
40
+ });
41
+
42
+ test('the SEO description is the result description when it is set', () => {
43
+ const result = searchedAs({ title: 'A', seoDescription: 'Google text', description: 'Short', site });
44
+ assert.equal(result.description, 'Google text');
45
+ });
46
+
47
+ test('the page description is used when the SEO description is blank', () => {
48
+ const result = searchedAs({ title: 'A', seoDescription: '', description: ' Short ', site });
49
+ assert.equal(result.description, 'Short');
50
+ });
51
+
52
+ test('the site description is used when the page has no description', () => {
53
+ assert.equal(searchedAs({ title: 'A', site }).description, 'The site.');
54
+ });
55
+
56
+ test('the address line is the domain and the path parts', () => {
57
+ assert.equal(searchCrumbs('morpheme.example', '/blog/my-post/'), 'morpheme.example › blog › my-post');
58
+ });
59
+
60
+ test('the front page address line is only the domain', () => {
61
+ assert.equal(searchCrumbs('morpheme.example', '/'), 'morpheme.example');
62
+ });
63
+
64
+ test('an empty path gives only the domain', () => {
65
+ assert.equal(searchCrumbs('morpheme.example', ''), 'morpheme.example');
66
+ });
67
+
68
+ test('a path without a leading slash still gives its parts', () => {
69
+ assert.equal(searchCrumbs('morpheme.example', 'about'), 'morpheme.example › about');
70
+ });
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export function headingLevels(levels: number[]): number[];
7
7
  export function showsH1Choice(levels: number[]): boolean;
8
+ export function headingChoices(levels: number[]): number[];
@@ -1,5 +1,5 @@
1
1
  // The levels of a page's headings as the editor keeps them: the first heading is the page's H1, and no later one is.
2
- // Exposes headingLevels and showsH1Choice (pure); the editor's plugin that applies it is src/firstHeading.mjs. No dependencies.
2
+ // Exposes headingLevels, showsH1Choice and headingChoices (pure); the editor's plugin that applies it is src/firstHeading.mjs. No dependencies.
3
3
 
4
4
  /**
5
5
  * The levels with the first set to 1 and every later 1 set to 2 (the others as they are).
@@ -14,3 +14,8 @@ export function headingLevels(levels) {
14
14
  * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
15
15
  * @returns {boolean} */
16
16
  export const showsH1Choice = (levels) => !levels.includes(1);
17
+
18
+ /** The heading levels the editor offers on a page: only the H1 while the text has no H1, after that H2 and H3.
19
+ * @param {number[]} levels The levels (1 to 6) of the headings of a text, in order.
20
+ * @returns {number[]} */
21
+ export const headingChoices = (levels) => (showsH1Choice(levels) ? [1] : [2, 3]);
@@ -2,7 +2,7 @@
2
2
  // Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
- import { headingLevels, showsH1Choice } from './heading-levels.mjs';
5
+ import { headingLevels, showsH1Choice, headingChoices } from './heading-levels.mjs';
6
6
 
7
7
  test('no headings, no levels', () => {
8
8
  assert.deepEqual(headingLevels([]), []);
@@ -35,3 +35,17 @@ test('the H1 choice is shown only while no heading is an H1', () => {
35
35
  assert.equal(showsH1Choice([1]), false);
36
36
  assert.equal(showsH1Choice([2, 1, 3]), false);
37
37
  });
38
+
39
+ test('only H1 is offered until the page has one', () => {
40
+ assert.deepEqual(headingChoices([]), [1]);
41
+ assert.deepEqual(headingChoices([2, 3]), [1]);
42
+ assert.deepEqual(headingChoices([1]), [2, 3]);
43
+ assert.deepEqual(headingChoices([1, 2, 3]), [2, 3]);
44
+ assert.deepEqual(headingChoices([2, 1]), [2, 3]);
45
+ });
46
+
47
+ test('the list given to headingChoices is not changed', () => {
48
+ const given = [2, 1];
49
+ headingChoices(given);
50
+ assert.deepEqual(given, [2, 1]);
51
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Takes dictionary[text] when the dictionary has that own key, else the text. Then fills each {name} from values.
3
+ * @param {Record<string, string> | undefined} dictionary
4
+ * @param {string} text
5
+ * @param {Record<string, string | number>} [values]
6
+ * @returns {string}
7
+ */
8
+ export function translate(dictionary: Record<string, string> | undefined, text: string, values?: Record<string, string | number>): string;
@@ -0,0 +1,15 @@
1
+ // The text lookup of the editor: a dictionary maps a text as written to its translation.
2
+ // Exposes translate. Pure: no packages, no file access.
3
+
4
+ /**
5
+ * Takes dictionary[text] when the dictionary has that own key, else the text. Then fills each {name} from values.
6
+ * @param {Record<string, string> | undefined} dictionary
7
+ * @param {string} text
8
+ * @param {Record<string, string | number>} [values]
9
+ * @returns {string}
10
+ */
11
+ export function translate(dictionary, text, values) {
12
+ const taken = dictionary && Object.hasOwn(dictionary, text) ? dictionary[text] : text;
13
+ if (!values) return taken;
14
+ return taken.replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
15
+ }
@@ -0,0 +1,38 @@
1
+ // Tests for lib/translate.mjs. Run: node --test lib/translate.test.mjs
2
+ // Danish letters are written as \u escapes.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import { test } from 'node:test';
6
+ import { translate } from './translate.mjs';
7
+
8
+ test('takes the dictionary entry', () => {
9
+ assert.equal(translate({ 'Read more': 'L\u00e6s mere' }, 'Read more'), 'L\u00e6s mere');
10
+ });
11
+
12
+ test('returns the text when there is no dictionary', () => {
13
+ assert.equal(translate(undefined, 'Read more'), 'Read more');
14
+ });
15
+
16
+ test('fills in a value', () => {
17
+ assert.equal(translate({}, 'Hi {name}', { name: 'Sara' }), 'Hi Sara');
18
+ });
19
+
20
+ test('fills in a value after the lookup', () => {
21
+ assert.equal(translate({ 'Hi {name}': 'Hej {name}' }, 'Hi {name}', { name: 'Sara' }), 'Hej Sara');
22
+ });
23
+
24
+ test('keeps a name that has no value', () => {
25
+ assert.equal(translate({}, 'Hi {name}', {}), 'Hi {name}');
26
+ });
27
+
28
+ test('turns a number into text', () => {
29
+ assert.equal(translate({}, '{n} pages', { n: 3 }), '3 pages');
30
+ });
31
+
32
+ test('ignores inherited keys', () => {
33
+ assert.equal(translate({}, 'toString'), 'toString');
34
+ });
35
+
36
+ test('keeps a name when no values are given', () => {
37
+ assert.equal(translate({}, 'Hi {name}'), 'Hi {name}');
38
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.9.1",
3
+ "version": "0.10.0-next-20261105172247",
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",
@@ -40,9 +40,17 @@
40
40
  },
41
41
  "scripts": {
42
42
  "build": "node scripts/demo-build.mjs",
43
+ "check": "npm run format:check && npm run check:language && npm test && npm run check:types && npm run build && npm run check:editor",
44
+ "check:types": "tsc -p tsconfig.types.json --noEmit",
45
+ "check:editor": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:editor",
46
+ "check:screens": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:screens",
47
+ "check:demo": "node bin/morpheme.mjs check:demo",
48
+ "check:starter": "node scripts/check-starter.mjs",
49
+ "test": "node --test \"{lib,scripts,site,src,theme}/**/*.test.mjs\"",
43
50
  "icons": "node scripts/icon-packs.mjs",
44
51
  "format": "prettier --write .",
45
52
  "format:check": "prettier --check .",
53
+ "check:language": "node scripts/check-language.mjs",
46
54
  "release:version": "changeset version && node scripts/starter.mjs",
47
55
  "prepack": "node scripts/types.mjs write",
48
56
  "postpack": "node scripts/types.mjs clean"