@morphemecms/cms 0.11.0 → 0.13.0-next-20261106203613

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 (186) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/astro.mjs +2 -1
  3. package/defaults/blockTexts.ts +9 -9
  4. package/defaults/collections.ts +257 -257
  5. package/defaults/colors.ts +74 -74
  6. package/defaults/company.ts +9 -9
  7. package/defaults/editor.ts +65 -65
  8. package/defaults/i18n/da-DK/blockTexts.ts +12 -0
  9. package/defaults/i18n/da-DK/collections.ts +220 -0
  10. package/defaults/i18n/da-DK/colors.ts +102 -0
  11. package/defaults/i18n/da-DK/company.ts +7 -0
  12. package/defaults/i18n/da-DK/editor.ts +13 -0
  13. package/defaults/i18n/da-DK/index.ts +31 -0
  14. package/defaults/i18n/da-DK/internalLinks.ts +12 -0
  15. package/defaults/i18n/da-DK/opens.ts +7 -0
  16. package/defaults/i18n/da-DK/popup.ts +9 -0
  17. package/defaults/i18n/da-DK/privacy.ts +38 -0
  18. package/defaults/i18n/da-DK/rules.ts +56 -0
  19. package/defaults/i18n/da-DK/sitedata.ts +8 -0
  20. package/defaults/i18n/da-DK/standard.ts +8 -0
  21. package/defaults/i18n/da-DK/standardPages.ts +17 -0
  22. package/defaults/internalLinks.ts +8 -8
  23. package/defaults/opens.ts +2 -2
  24. package/defaults/popup.tsx +7 -7
  25. package/defaults/privacy.ts +37 -37
  26. package/defaults/rules.ts +68 -66
  27. package/defaults/schema.ts +1 -1
  28. package/defaults/sitedata.ts +4 -4
  29. package/defaults/standard.ts +9 -6
  30. package/defaults/standardPages.ts +14 -14
  31. package/defaults/texts.ts +7 -510
  32. package/lib/appId.d.mts +1 -0
  33. package/lib/appId.mjs +6 -0
  34. package/lib/appRoute.d.mts +2 -2
  35. package/lib/appRoute.mjs +3 -3
  36. package/lib/appRoute.test.mjs +8 -8
  37. package/lib/apps.mjs +13 -3
  38. package/lib/apps.test.mjs +10 -1
  39. package/lib/contracts.d.mts +3 -2
  40. package/lib/contracts.mjs +3 -2
  41. package/lib/folderChanges.d.mts +29 -0
  42. package/lib/folderChanges.mjs +34 -0
  43. package/lib/licenses.mjs +2 -2
  44. package/lib/licenses.owner.test.mjs +9 -0
  45. package/lib/menu.mjs +4 -4
  46. package/lib/menu.test.mjs +101 -101
  47. package/lib/scheduled.mjs +1 -1
  48. package/lib/section-ranges.d.mts +1 -1
  49. package/lib/section-ranges.mjs +8 -14
  50. package/lib/section-ranges.test.mjs +26 -5
  51. package/lib/storeEntries.d.mts +3 -1
  52. package/lib/storeEntries.mjs +22 -8
  53. package/lib/storeEntries.test.mjs +59 -7
  54. package/lib/text-sections.d.mts +11 -1
  55. package/lib/text-sections.mjs +18 -8
  56. package/lib/text-sections.test.mjs +34 -1
  57. package/lib/translate.d.mts +11 -0
  58. package/lib/translate.mjs +13 -1
  59. package/lib/translate.test.mjs +23 -2
  60. package/lib/video-id.d.mts +6 -2
  61. package/lib/video-id.mjs +5 -1
  62. package/lib/video.d.mts +6 -2
  63. package/lib/video.mjs +5 -1
  64. package/lib/without-h1.d.mts +1 -0
  65. package/lib/without-h1.mjs +19 -4
  66. package/lib/without-h1.test.mjs +30 -1
  67. package/package.json +3 -2
  68. package/scripts/app-screenshots.mjs +9 -8
  69. package/scripts/check-content.mjs +12 -2
  70. package/scripts/check-language.mjs +0 -1
  71. package/scripts/check-site-types.mjs +26 -0
  72. package/scripts/editor-screens.mjs +3 -3
  73. package/scripts/site-types.mjs +14 -0
  74. package/scripts/site-types.test.mjs +21 -0
  75. package/site/activeTheme.test.mjs +52 -0
  76. package/site/appSelf.d.mts +2 -0
  77. package/site/appSelf.mjs +23 -0
  78. package/site/appSelf.test.mjs +35 -0
  79. package/site/content.ts +5 -2
  80. package/site/defineSite.mjs +2 -2
  81. package/site/schema.ts +3 -3
  82. package/site/siteModules.mjs +2 -1
  83. package/site/theme.mjs +19 -8
  84. package/src/Advanced.tsx +2 -2
  85. package/src/App.tsx +79 -78
  86. package/src/AppAdds.tsx +1 -1
  87. package/src/AppList.tsx +39 -24
  88. package/src/BlockDialog.tsx +39 -18
  89. package/src/BlockParts.tsx +6 -6
  90. package/src/BlockSettings.tsx +2 -2
  91. package/src/BrandForm.tsx +2 -2
  92. package/src/Colors.tsx +6 -6
  93. package/src/CompanyForm.tsx +1 -1
  94. package/src/Consents.tsx +3 -3
  95. package/src/CreateFab.tsx +8 -0
  96. package/src/DataForm.tsx +10 -20
  97. package/src/Deleted.tsx +3 -3
  98. package/src/EditorFooter.tsx +2 -2
  99. package/src/EntryForm.tsx +3 -3
  100. package/src/FooterSettings.tsx +2 -2
  101. package/src/FormFrame.tsx +4 -3
  102. package/src/HeaderSettings.tsx +3 -3
  103. package/src/Keywords.tsx +5 -5
  104. package/src/Library.tsx +75 -82
  105. package/src/Licenses.tsx +8 -8
  106. package/src/ListFab.tsx +13 -0
  107. package/src/MainDrawerParts.tsx +3 -3
  108. package/src/MainMenu.tsx +1 -1
  109. package/src/MarkdownEditor.tsx +88 -47
  110. package/src/Media.tsx +11 -20
  111. package/src/MediaTags.tsx +2 -2
  112. package/src/Notifications.tsx +2 -2
  113. package/src/Parts.tsx +17 -17
  114. package/src/Redirects.tsx +14 -22
  115. package/src/Screen.tsx +16 -6
  116. package/src/SeoScores.tsx +6 -6
  117. package/src/SideColumn.tsx +2 -2
  118. package/src/StartFab.tsx +6 -8
  119. package/src/ThemeStore.tsx +37 -16
  120. package/src/Tokens.tsx +2 -2
  121. package/src/apps.ts +7 -8
  122. package/src/checkSite.ts +86 -20
  123. package/src/drawerYield.d.mts +3 -0
  124. package/src/drawerYield.mjs +10 -0
  125. package/src/drawerYield.test.mjs +22 -0
  126. package/src/editor.css +59 -38
  127. package/src/examples.ts +1 -1
  128. package/src/fabScreens.test.mjs +28 -0
  129. package/src/fabScreens.ts +16 -0
  130. package/src/i18n/da-DK/library.ts +2 -2
  131. package/src/i18n/da-DK/text.ts +1 -1
  132. package/src/i18n/en-US/apps.ts +1 -1
  133. package/src/i18n/en-US/common.ts +8 -8
  134. package/src/i18n/en-US/library.ts +5 -5
  135. package/src/i18n/en-US/parts.ts +3 -3
  136. package/src/i18n/en-US/text.ts +1 -1
  137. package/src/i18n/index.ts +4 -8
  138. package/src/images.ts +2 -2
  139. package/src/legacyMove.test.mjs +137 -0
  140. package/src/legacyMove.ts +131 -0
  141. package/src/licenseData.ts +3 -3
  142. package/src/markdown.mjs +10 -7
  143. package/src/markdown.test.mjs +16 -2
  144. package/src/menuChildren.ts +5 -5
  145. package/src/menuOpen.ts +20 -4
  146. package/src/movedRoutes.d.mts +8 -1
  147. package/src/movedRoutes.mjs +102 -21
  148. package/src/movedRoutes.test.mjs +128 -6
  149. package/src/nav.ts +1 -1
  150. package/src/notificationData.ts +1 -1
  151. package/src/pageH1Panel.d.mts +4 -0
  152. package/src/pageH1Panel.mjs +31 -0
  153. package/src/pageH1Panel.test.mjs +56 -0
  154. package/src/pagePreview.tsx +22 -12
  155. package/src/schema.ts +2 -2
  156. package/src/sectionFrame.ts +3 -3
  157. package/src/sectionState.d.mts +3 -3
  158. package/src/sectionState.mjs +6 -6
  159. package/src/sectionState.test.mjs +14 -2
  160. package/src/sheetPlacement.d.mts +6 -4
  161. package/src/sheetPlacement.mjs +15 -10
  162. package/src/sheetPlacement.test.mjs +16 -9
  163. package/src/sidePanel.ts +38 -0
  164. package/src/site.ts +37 -13
  165. package/src/standard/accordion/index.tsx +1 -1
  166. package/src/standard/accordion/shared.ts +1 -1
  167. package/src/standard/buttons/index.tsx +1 -1
  168. package/src/standard/cards/index.tsx +1 -1
  169. package/src/standard/cards/shared.ts +1 -1
  170. package/src/standard/cookies/index.tsx +1 -1
  171. package/src/standard/posts/index.tsx +1 -1
  172. package/src/standard/testimonials/index.tsx +1 -1
  173. package/src/standard/testimonials/list.ts +1 -1
  174. package/src/store.ts +36 -31
  175. package/src/tagUses.d.mts +1 -1
  176. package/src/tagUses.mjs +2 -2
  177. package/src/tagUses.test.mjs +1 -1
  178. package/src/topBarSlot.tsx +7 -15
  179. package/src/ui.tsx +33 -22
  180. package/src/virtual.d.ts +6 -0
  181. package/starter/package.json +1 -1
  182. package/theme/components/PageHeading.astro +22 -0
  183. package/theme/layouts/Base.astro +2 -2
  184. package/theme/layouts/PageLayout.astro +37 -20
  185. package/theme/styles/global.css +2 -1
  186. package/theme/styles/ui.css +40 -10
@@ -1,7 +1,7 @@
1
1
  /** The kind of a part, from its ProseMirror node name and heading level; the label is the eyebrow line before a heading. */
2
2
  export function partKind(typeName: any, level: any): "h1" | "h2" | "other" | "label";
3
3
  /**
4
- * The sections of a text: each H2 (with a label directly before it) and every part after it up to the next H2, the next H1
4
+ * The sections of a text: each H1 or H2 (with a label directly before it) and every part after it up to the next H1 or H2
5
5
  * or the end. `parts` is the top-level parts in order; the result has the index of each section's first and last part.
6
6
  */
7
7
  export function sectionRanges(parts: any): any[];
@@ -1,4 +1,4 @@
1
- // Which top-level parts of a page's or blog post's text form H2 sections, for the editor's dotted frame.
1
+ // Which top-level parts of a page's or blog post's text form sections (an H1 or an H2 starts one), for the editor's dotted frame.
2
2
  // Exposes sectionRanges, sectionPart and partKind. Pure, no dependencies (used by src/sectionFrame.ts).
3
3
 
4
4
  /** The kind of a part, from its ProseMirror node name and heading level; the label is the eyebrow line before a heading. */
@@ -7,8 +7,10 @@ export function partKind(typeName, level) {
7
7
  return typeName === 'eyebrow' ? 'label' : 'other';
8
8
  }
9
9
 
10
+ const startsSection = (kind) => kind === 'h1' || kind === 'h2';
11
+
10
12
  /**
11
- * The sections of a text: each H2 (with a label directly before it) and every part after it up to the next H2, the next H1
13
+ * The sections of a text: each H1 or H2 (with a label directly before it) and every part after it up to the next H1 or H2
12
14
  * or the end. `parts` is the top-level parts in order; the result has the index of each section's first and last part.
13
15
  */
14
16
  export function sectionRanges(parts) {
@@ -19,18 +21,10 @@ export function sectionRanges(parts) {
19
21
  from = -1;
20
22
  };
21
23
  parts.forEach((part, i) => {
22
- const next = parts[i + 1]?.kind;
23
- if (part.kind === 'label') {
24
- if (next === 'h2') {
25
- close(i - 1);
26
- from = i;
27
- } else if (next === 'h1') close(i - 1);
28
- } else if (part.kind === 'h2') {
29
- if (parts[i - 1]?.kind !== 'label') {
30
- close(i - 1);
31
- from = i;
32
- }
33
- } else if (part.kind === 'h1') close(i - 1);
24
+ const starts = part.kind === 'label' ? startsSection(parts[i + 1]?.kind) : startsSection(part.kind) && parts[i - 1]?.kind !== 'label';
25
+ if (!starts) return;
26
+ close(i - 1);
27
+ from = i;
34
28
  });
35
29
  close(parts.length - 1);
36
30
  return ranges;
@@ -1,4 +1,4 @@
1
- // Tests lib/section-ranges.mjs: which top-level parts of a text form H2 sections (the editor's dotted frame).
1
+ // Tests lib/section-ranges.mjs: which top-level parts of a text form sections, H1 or H2 (the editor's dotted frame).
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';
@@ -15,13 +15,26 @@ const ranges = (text) =>
15
15
  );
16
16
 
17
17
  test('no parts, no sections', () => assert.deepEqual(ranges(''), []));
18
- test('no H2, no sections', () => assert.deepEqual(ranges('1 o o'), []));
18
+ test('no heading, no sections', () => assert.deepEqual(ranges('o o'), []));
19
+ test('an H1 starts a section', () => assert.deepEqual(ranges('1 o o'), [{ from: 0, to: 2 }]));
20
+ test('an H1 then an H2 are a section each', () =>
21
+ assert.deepEqual(ranges('1 o 2 o'), [
22
+ { from: 0, to: 1 },
23
+ { from: 2, to: 3 },
24
+ ]));
25
+ test('a label before an H1 starts the section', () => assert.deepEqual(ranges('l 1 o'), [{ from: 0, to: 2 }]));
26
+ test('consecutive H1s are a section each', () =>
27
+ assert.deepEqual(ranges('o 1 1'), [
28
+ { from: 1, to: 1 },
29
+ { from: 2, to: 2 },
30
+ ]));
19
31
  test('an H2 at the start runs to the end', () => assert.deepEqual(ranges('2 o o'), [{ from: 0, to: 2 }]));
20
32
  test('text before the first H2 is outside', () => assert.deepEqual(ranges('o 2 o'), [{ from: 1, to: 2 }]));
21
33
  test('an H2 as the last part is a section of one', () => assert.deepEqual(ranges('o o 2'), [{ from: 2, to: 2 }]));
22
- test('an H1 ends a section', () =>
34
+ test('an H1 ends the section above it and starts its own', () =>
23
35
  assert.deepEqual(ranges('2 o 1 o 2 o'), [
24
36
  { from: 0, to: 1 },
37
+ { from: 2, to: 3 },
25
38
  { from: 4, to: 5 },
26
39
  ]));
27
40
  test('consecutive H2s are a section each', () =>
@@ -36,8 +49,16 @@ test('a label before an H2 ends the section above it', () =>
36
49
  { from: 0, to: 1 },
37
50
  { from: 2, to: 4 },
38
51
  ]));
39
- test('a label before an H1 belongs to the H1', () => assert.deepEqual(ranges('2 o l 1 o'), [{ from: 0, to: 1 }]));
40
- test('a label at the start before an H1 starts nothing', () => assert.deepEqual(ranges('l 1 2 o'), [{ from: 2, to: 3 }]));
52
+ test('a label before an H1 belongs to the H1', () =>
53
+ assert.deepEqual(ranges('2 o l 1 o'), [
54
+ { from: 0, to: 1 },
55
+ { from: 2, to: 4 },
56
+ ]));
57
+ test('a label at the start before an H1 starts its section', () =>
58
+ assert.deepEqual(ranges('l 1 2 o'), [
59
+ { from: 0, to: 1 },
60
+ { from: 2, to: 3 },
61
+ ]));
41
62
  test('a label not before a heading is an ordinary part', () => {
42
63
  assert.deepEqual(ranges('2 l o'), [{ from: 0, to: 2 }]);
43
64
  assert.deepEqual(ranges('l o 2'), [{ from: 2, to: 2 }]);
@@ -13,6 +13,8 @@ export function validTheme(x: any, api: number): boolean;
13
13
  export function isText(v: unknown): v is string;
14
14
  export function isPath(v: unknown): boolean;
15
15
  export const ITEM_ID: RegExp;
16
- export const REFUSED_APPS: string[];
16
+ export const HANDLE: RegExp;
17
+ export const LEGACY: RegExp;
17
18
  export const STANDARD: "saturn";
18
19
  export const RECORD: "store.json";
20
+ export function strayStoreId(name: string, kind: "app" | "theme"): boolean;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pure validation logic for store apps and themes.
3
- * Exposes isText, isPath, ITEM_ID, REFUSED_APPS, STANDARD, RECORD, validApp, validTheme.
3
+ * Exposes isText, isPath, ITEM_ID, HANDLE, LEGACY, STANDARD, RECORD, validApp, validTheme, strayStoreId.
4
4
  * No external dependencies.
5
5
  */
6
6
 
@@ -16,14 +16,28 @@ export const isText = (v) => typeof v === 'string' && v.trim() !== '';
16
16
  */
17
17
  export const isPath = (v) => isText(v) && v.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
18
18
 
19
- export const ITEM_ID = /^[a-z0-9][a-z0-9-]*$/;
20
- // The store's address before and a developer's page (STORE_BEFORE and DEVELOPER_ROUTE in src/store.ts)
21
- export const REFUSED_APPS = ['butik', 'udvikler'];
19
+ // A permanent id: the kind, then 16 lowercase hex characters
20
+ export const ITEM_ID = /^(app|theme)_[0-9a-f]{16}$/;
21
+ // An item's address in the store: publisher/name
22
+ export const HANDLE = /^[a-z0-9][a-z0-9-]{0,39}\/[a-z0-9][a-z0-9-]{0,39}$/;
23
+ // An item's folder name before it had a permanent id
24
+ export const LEGACY = /^[a-z0-9][a-z0-9-]*$/;
22
25
  // The standard theme, which every site has
23
26
  export const STANDARD = 'saturn';
24
27
  // The note in an installed folder of where it came from
25
28
  export const RECORD = 'store.json';
26
29
 
30
+ const hasId = (x, kind) => ITEM_ID.test(x?.id ?? '') && x.id.startsWith(`${kind}_`);
31
+ const hasHandle = (x) => HANDLE.test(x?.handle ?? '') && (x.legacy === undefined || LEGACY.test(x.legacy));
32
+
33
+ /**
34
+ * True when a site's own folder name looks like a store id of this kind (it starts with `app_` or `theme_`) but is not one.
35
+ * @param {string} name
36
+ * @param {'app' | 'theme'} kind
37
+ * @returns {boolean}
38
+ */
39
+ export const strayStoreId = (name, kind) => /^(app|theme)_/.test(name) && !(ITEM_ID.test(name) && name.startsWith(`${kind}_`));
40
+
27
41
  const isAdd = (v) => isText(v?.label) && isText(v?.icon);
28
42
  const isGoodFile = (f) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD;
29
43
 
@@ -34,8 +48,8 @@ const isGoodFile = (f) => isText(f?.oid) && isPath(f?.path) && f.path !== RECORD
34
48
  */
35
49
  export function validApp(x, api) {
36
50
  return (
37
- ITEM_ID.test(x?.id ?? '') &&
38
- !REFUSED_APPS.includes(x.id) &&
51
+ hasId(x, 'app') &&
52
+ hasHandle(x) &&
39
53
  isText(x.version) &&
40
54
  x.api === api &&
41
55
  [x.label, x.publisher, x.icon, x.hint].every(isText) &&
@@ -53,8 +67,8 @@ export function validApp(x, api) {
53
67
  */
54
68
  export function validTheme(x, api) {
55
69
  return (
56
- ITEM_ID.test(x?.id ?? '') &&
57
- x.id !== STANDARD &&
70
+ hasId(x, 'theme') &&
71
+ hasHandle(x) &&
58
72
  x.api === api &&
59
73
  [x.version, x.name, x.author, x.icon].every(isText) &&
60
74
  Array.isArray(x.files) &&
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Tests for store entry validation logic (validApp, validTheme, isPath).
2
+ * Tests for store entry validation logic (validApp, validTheme, isPath, strayStoreId).
3
3
  * Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
4
4
  */
5
5
  import { test } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { validApp, validTheme, isPath } from './storeEntries.mjs';
7
+ import { validApp, validTheme, isPath, strayStoreId } from './storeEntries.mjs';
8
8
 
9
9
  const theme = () => ({
10
- id: 'venus',
10
+ id: 'theme_fa395ad84690acbd',
11
+ handle: 'morpheme/venus',
12
+ legacy: 'venus',
11
13
  version: '1.0.0',
12
14
  api: 4,
13
15
  name: 'Venus',
@@ -16,7 +18,8 @@ const theme = () => ({
16
18
  files: [{ path: 'theme.json', oid: 'a'.repeat(40) }],
17
19
  });
18
20
  const app = () => ({
19
- id: 'calendly',
21
+ id: 'app_a030dfba69793996',
22
+ handle: 'morpheme/calendly',
20
23
  version: '1.0.0',
21
24
  api: 2,
22
25
  label: 'Calendly',
@@ -35,8 +38,30 @@ test('valid theme wrong api', () => {
35
38
  assert.equal(validTheme({ ...theme(), api: 3 }, 4), false);
36
39
  });
37
40
 
38
- test('valid theme standard id', () => {
39
- assert.equal(validTheme({ ...theme(), id: 'saturn' }, 4), false);
41
+ test('valid theme old-style id', () => {
42
+ assert.equal(validTheme({ ...theme(), id: 'venus' }, 4), false);
43
+ });
44
+
45
+ test('valid theme app id (wrong kind)', () => {
46
+ assert.equal(validTheme({ ...theme(), id: 'app_a030dfba69793996' }, 4), false);
47
+ });
48
+
49
+ test('valid theme without handle', () => {
50
+ const { handle, ...rest } = theme();
51
+ assert.equal(validTheme(rest, 4), false);
52
+ });
53
+
54
+ test('valid theme bad handle', () => {
55
+ assert.equal(validTheme({ ...theme(), handle: 'venus' }, 4), false);
56
+ assert.equal(validTheme({ ...theme(), handle: 'Morpheme/venus' }, 4), false);
57
+ });
58
+
59
+ test('valid theme keeps legacy, and drops a bad one', () => {
60
+ assert.equal(validTheme(theme(), 4), true);
61
+ assert.equal(theme().legacy, 'venus');
62
+ assert.equal(validTheme({ ...theme(), legacy: 'Venus!' }, 4), false);
63
+ const { legacy, ...rest } = theme();
64
+ assert.equal(validTheme(rest, 4), true);
40
65
  });
41
66
 
42
67
  test('valid theme bad path', () => {
@@ -55,8 +80,26 @@ test('valid app', () => {
55
80
  assert.equal(validApp(app(), 2), true);
56
81
  });
57
82
 
58
- test('valid app refused id', () => {
83
+ test('valid app old-style id', () => {
84
+ assert.equal(validApp({ ...app(), id: 'calendly' }, 2), false);
59
85
  assert.equal(validApp({ ...app(), id: 'butik' }, 2), false);
86
+ assert.equal(validApp({ ...app(), id: 'store' }, 2), false);
87
+ assert.equal(validApp({ ...app(), id: 'udvikler' }, 2), false);
88
+ assert.equal(validApp({ ...app(), id: 'developer' }, 2), false);
89
+ });
90
+
91
+ test('valid app theme id (wrong kind)', () => {
92
+ assert.equal(validApp({ ...app(), id: 'theme_fa395ad84690acbd' }, 2), false);
93
+ });
94
+
95
+ test('valid app without handle', () => {
96
+ const { handle, ...rest } = app();
97
+ assert.equal(validApp(rest, 2), false);
98
+ });
99
+
100
+ test('valid app bad id length', () => {
101
+ assert.equal(validApp({ ...app(), id: 'app_a030dfba6979399' }, 2), false);
102
+ assert.equal(validApp({ ...app(), id: 'app_A030DFBA69793996' }, 2), false);
60
103
  });
61
104
 
62
105
  test('valid app missing adds kinds', () => {
@@ -69,3 +112,12 @@ test('isPath cases', () => {
69
112
  assert.equal(isPath('./a'), false);
70
113
  assert.equal(isPath(''), false);
71
114
  });
115
+
116
+ test('strayStoreId: a store-looking name that is not a store id of the kind', () => {
117
+ assert.equal(strayStoreId('app_a030dfba69793996', 'app'), false);
118
+ assert.equal(strayStoreId('app_xyz', 'app'), true);
119
+ assert.equal(strayStoreId('theme_fa395ad84690acbd', 'app'), true);
120
+ assert.equal(strayStoreId('theme_xyz', 'theme'), true);
121
+ assert.equal(strayStoreId('calendly', 'app'), false);
122
+ assert.equal(strayStoreId('my_theme', 'theme'), false);
123
+ });
@@ -1,5 +1,5 @@
1
1
  /** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
2
- * A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
2
+ * A marker with an unknown colour, or not followed by an H1 or H2, is dropped. Without any coloured section, the HTML is
3
3
  * returned as it came, in one run.
4
4
  * @param {string} html
5
5
  * @returns {{ color: string | null, html: string }[]} */
@@ -7,4 +7,14 @@ export function sectionsOf(html: string): {
7
7
  color: string | null;
8
8
  html: string;
9
9
  }[];
10
+ /** The text before its first section starts (a marker, a label or an H1 or H2), and the rest from there. Without a heading
11
+ * the whole text is the intro. For a page whose coloured H1 panel holds the header and this intro (PageLayout.astro).
12
+ * @param {string} html
13
+ * @returns {{ intro: string, rest: string }} */
14
+ export function introOf(html: string): {
15
+ intro: string;
16
+ rest: string;
17
+ };
18
+ export const MARKER: string;
19
+ export const LABEL: string;
10
20
  export function sectionClass(color: any): string;
@@ -1,14 +1,15 @@
1
- // A page's or post's rendered text cut into runs for the site: a section with a colour (the marker before its H2,
2
- // lib/section-marker.mjs) is one run from the marker to the next H2, H1 or the end. Exposes sectionsOf and sectionClass,
3
- // used by theme/components/RichText.astro and src/pagePreview.tsx. Depends on section-marker.mjs.
1
+ // A page's or post's rendered text cut into runs for the site: a section with a colour (the marker before its H1 or H2,
2
+ // lib/section-marker.mjs) is one run from the marker to the next H1, H2 or the end. Exposes sectionsOf, introOf,
3
+ // sectionClass and the marker and label patterns (MARKER, LABEL), used by theme/components/RichText.astro, lib/without-h1.mjs
4
+ // and src/pagePreview.tsx. Depends on section-marker.mjs.
4
5
  import { SECTION_COLORS } from './section-marker.mjs';
5
6
 
6
7
  // The marker as the site's HTML pipeline writes it (rehype-raw keeps it as written; a space before `>` is tolerated).
7
- const MARKER = String.raw`<div data-section="([^"]*)"\s*><\/div>\s*`;
8
+ export const MARKER = String.raw`<div data-section="([^"]*)"\s*><\/div>\s*`;
8
9
  // A label line (text-menu's <p class="block-eyebrow">) directly before a heading starts the heading's section.
9
- const LABEL = String.raw`(?:<p class="block-eyebrow">(?:(?!<\/p>)[\s\S])*<\/p>\s*)?`;
10
- // A marker followed by an H2 (with its label between): where a coloured section starts. Ends just after `<h2`.
11
- const START = new RegExp(`${MARKER}${LABEL}<h2\\b`, 'g');
10
+ export const LABEL = String.raw`(?:<p class="block-eyebrow">(?:(?!<\/p>)[\s\S])*<\/p>\s*)?`;
11
+ // A marker followed by an H1 or H2 (with its label between): where a coloured section starts. Ends just after `<h1` or `<h2`.
12
+ const START = new RegExp(`${MARKER}${LABEL}<h[12]\\b`, 'g');
12
13
  // Where the next section (or an H1) starts: its marker and label if it has them, else its heading.
13
14
  const NEXT = new RegExp(`(?:${MARKER})?${LABEL}<h[12]\\b`);
14
15
  const MARKERS = new RegExp(MARKER, 'g');
@@ -16,7 +17,7 @@ const MARKERS = new RegExp(MARKER, 'g');
16
17
  const withoutMarkers = (html) => html.replace(MARKERS, '');
17
18
 
18
19
  /** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
19
- * A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
20
+ * A marker with an unknown colour, or not followed by an H1 or H2, is dropped. Without any coloured section, the HTML is
20
21
  * returned as it came, in one run.
21
22
  * @param {string} html
22
23
  * @returns {{ color: string | null, html: string }[]} */
@@ -41,5 +42,14 @@ export function sectionsOf(html) {
41
42
  return runs;
42
43
  }
43
44
 
45
+ /** The text before its first section starts (a marker, a label or an H1 or H2), and the rest from there. Without a heading
46
+ * the whole text is the intro. For a page whose coloured H1 panel holds the header and this intro (PageLayout.astro).
47
+ * @param {string} html
48
+ * @returns {{ intro: string, rest: string }} */
49
+ export function introOf(html) {
50
+ const at = html.search(NEXT);
51
+ return at < 0 ? { intro: html, rest: '' } : { intro: html.slice(0, at), rest: html.slice(at) };
52
+ }
53
+
44
54
  /** The classes of a coloured section on the site (blocks.css). */
45
55
  export const sectionClass = (color) => `text-section text-section-${color}`;
@@ -4,7 +4,7 @@ import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { createMarkdownProcessor } from '@astrojs/markdown-remark';
6
6
  import rehypeRaw from 'rehype-raw';
7
- import { sectionClass, sectionsOf } from './text-sections.mjs';
7
+ import { introOf, sectionClass, sectionsOf } from './text-sections.mjs';
8
8
 
9
9
  const M = (color) => `<div data-section="${color}"></div>`;
10
10
 
@@ -36,6 +36,23 @@ test('a run ends at an h1', () => {
36
36
  ]);
37
37
  });
38
38
 
39
+ test('a marker before an h1 starts a coloured run that ends at the next h2', () => {
40
+ const html = `${M('tint')}<h1>A</h1><p>x</p><h2>B</h2>`;
41
+ assert.deepEqual(sectionsOf(html), [
42
+ { color: 'tint', html: '<h1>A</h1><p>x</p>' },
43
+ { color: null, html: '<h2>B</h2>' },
44
+ ]);
45
+ });
46
+
47
+ test('a marker and a label before an h1 start a coloured run', () => {
48
+ const html = `<p>Intro</p>${M('accent')}<p class="block-eyebrow">Hi</p><h1>A</h1><p>x</p><h1>B</h1>`;
49
+ assert.deepEqual(sectionsOf(html), [
50
+ { color: null, html: '<p>Intro</p>' },
51
+ { color: 'accent', html: '<p class="block-eyebrow">Hi</p><h1>A</h1><p>x</p>' },
52
+ { color: null, html: '<h1>B</h1>' },
53
+ ]);
54
+ });
55
+
39
56
  test('two coloured sections after each other are two runs', () => {
40
57
  const html = `${M('tint')}\n<h2>A</h2>\n<p>x</p>\n${M('accent')}\n<h2>B</h2>\n<p>y</p>`;
41
58
  assert.deepEqual(sectionsOf(html), [
@@ -61,6 +78,22 @@ test('a marker not followed by an h2 is dropped', () => {
61
78
  assert.deepEqual(sectionsOf(`<p>a</p>\n${M('tint')}\n<h3>c</h3>`), [{ color: null, html: '<p>a</p>\n<h3>c</h3>' }]);
62
79
  });
63
80
 
81
+ test('the intro is the text before the first section starts', () => {
82
+ assert.deepEqual(introOf(`<p>a</p>\n<p>b</p>\n${M('tint')}\n<h2>c</h2>\n<p>d</p>`), {
83
+ intro: '<p>a</p>\n<p>b</p>\n',
84
+ rest: `${M('tint')}\n<h2>c</h2>\n<p>d</p>`,
85
+ });
86
+ assert.deepEqual(introOf('<p>a</p><p class="block-eyebrow">L</p><h1>c</h1>'), {
87
+ intro: '<p>a</p>',
88
+ rest: '<p class="block-eyebrow">L</p><h1>c</h1>',
89
+ });
90
+ });
91
+
92
+ test('without a heading the text is all intro, and a text that starts with one has none', () => {
93
+ assert.deepEqual(introOf('<p>a</p>'), { intro: '<p>a</p>', rest: '' });
94
+ assert.deepEqual(introOf('<h2>a</h2><p>b</p>'), { intro: '', rest: '<h2>a</h2><p>b</p>' });
95
+ });
96
+
64
97
  test('the class of a coloured section', () => {
65
98
  assert.equal(sectionClass('tint'), 'text-section text-section-tint');
66
99
  });
@@ -6,3 +6,14 @@
6
6
  * @returns {string}
7
7
  */
8
8
  export function translate(dictionary: Record<string, string> | undefined, text: string, values?: Record<string, string | number>): string;
9
+ /**
10
+ * Splits the texts that have no translation in a language: the standard parts' (English texts of
11
+ * defaults/, whose translations the package must have: they stop the check) and the others (the site's and the apps' own).
12
+ * @param {Iterable<string>} missing The texts with no translation.
13
+ * @param {ReadonlySet<string>} standard The standard parts' texts (the keys of defaults/i18n/da-DK/).
14
+ * @returns {{ standard: string[], own: string[] }}
15
+ */
16
+ export function splitMissing(missing: Iterable<string>, standard: ReadonlySet<string>): {
17
+ standard: string[];
18
+ own: string[];
19
+ };
package/lib/translate.mjs CHANGED
@@ -1,5 +1,5 @@
1
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.
2
+ // Exposes translate, splitMissing. Pure: no packages, no file access.
3
3
 
4
4
  /**
5
5
  * Takes dictionary[text] when the dictionary has that own key, else the text. Then fills each {name} from values.
@@ -13,3 +13,15 @@ export function translate(dictionary, text, values) {
13
13
  if (!values) return taken;
14
14
  return taken.replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
15
15
  }
16
+
17
+ /**
18
+ * Splits the texts that have no translation in a language: the standard parts' (English texts of
19
+ * defaults/, whose translations the package must have: they stop the check) and the others (the site's and the apps' own).
20
+ * @param {Iterable<string>} missing The texts with no translation.
21
+ * @param {ReadonlySet<string>} standard The standard parts' texts (the keys of defaults/i18n/da-DK/).
22
+ * @returns {{ standard: string[], own: string[] }}
23
+ */
24
+ export function splitMissing(missing, standard) {
25
+ const all = [...missing];
26
+ return { standard: all.filter((text) => standard.has(text)), own: all.filter((text) => !standard.has(text)) };
27
+ }
@@ -1,9 +1,9 @@
1
- // Tests for lib/translate.mjs. Run: node --test lib/translate.test.mjs
1
+ // Tests for lib/translate.mjs (translate, splitMissing). Run: node --test lib/translate.test.mjs
2
2
  // Danish letters are written as \u escapes.
3
3
 
4
4
  import assert from 'node:assert/strict';
5
5
  import { test } from 'node:test';
6
- import { translate } from './translate.mjs';
6
+ import { splitMissing, translate } from './translate.mjs';
7
7
 
8
8
  test('takes the dictionary entry', () => {
9
9
  assert.equal(translate({ 'Read more': 'L\u00e6s mere' }, 'Read more'), 'L\u00e6s mere');
@@ -36,3 +36,24 @@ test('ignores inherited keys', () => {
36
36
  test('keeps a name when no values are given', () => {
37
37
  assert.equal(translate({}, 'Hi {name}'), 'Hi {name}');
38
38
  });
39
+
40
+ // splitMissing: a standard text with no translation stops the check, the site's own only warns.
41
+ test('splits the missing texts into the standard parts and the own', () => {
42
+ const split = splitMissing(['Content', 'Indhold', 'Pris'], new Set(['Content', 'Media']));
43
+ assert.deepEqual(split, { standard: ['Content'], own: ['Indhold', 'Pris'] });
44
+ });
45
+
46
+ test('has nothing to split when nothing is missing', () => {
47
+ assert.deepEqual(splitMissing([], new Set(['Content'])), { standard: [], own: [] });
48
+ });
49
+
50
+ test('an own text of an English site with no Danish only warns', () => {
51
+ const split = splitMissing(['Opening hours'], new Set(['Content']));
52
+ assert.deepEqual(split, { standard: [], own: ['Opening hours'] });
53
+ });
54
+
55
+ test('a standard text whose Danish entry was removed stops the check', () => {
56
+ // The standard set is built from the standard definitions, not from the Danish dictionary.
57
+ const split = splitMissing(['Button text', 'Opening hours'], new Set(['Button text', 'Content']));
58
+ assert.deepEqual(split, { standard: ['Button text'], own: ['Opening hours'] });
59
+ });
@@ -7,8 +7,12 @@ export function videoOf(text: any): {
7
7
  id: string;
8
8
  hash: string | null;
9
9
  } | null;
10
- /** The address that plays the video, with the settings in the address of the iframe it came from. */
11
- export function player(video: any, frame: any): string;
10
+ /** The address that plays the video, with the settings in the address of the iframe it came from.
11
+ * @param {any} video
12
+ * @param {string | null} [frame]
13
+ * @returns {string}
14
+ */
15
+ export function player(video: any, frame?: string | null): string;
12
16
  export function videoPage(video: any): string;
13
17
  export function stillName(video: any): string;
14
18
  export function vimeoInfo(video: any, width: any): string;
package/lib/video-id.mjs CHANGED
@@ -43,7 +43,11 @@ export function videoOf(text) {
43
43
  return null;
44
44
  }
45
45
 
46
- /** The address that plays the video, with the settings in the address of the iframe it came from. */
46
+ /** The address that plays the video, with the settings in the address of the iframe it came from.
47
+ * @param {any} video
48
+ * @param {string | null} [frame]
49
+ * @returns {string}
50
+ */
47
51
  export function player(video, frame) {
48
52
  let params;
49
53
  try {
package/lib/video.d.mts CHANGED
@@ -1,7 +1,11 @@
1
1
  /** What stands in for a video (videoOf) on the site: its page, the address that plays it (`frame`: the address of the
2
2
  * iframe it replaces, if any), its title, and its still from the site, when it could be fetched. The video block's
3
- * view too (src/standard/video/load.ts). */
4
- export function facadeOf(video: any, title: any, frame: any): {
3
+ * view too (src/standard/video/load.ts).
4
+ * @param {any} video
5
+ * @param {any} [title]
6
+ * @param {string | null} [frame]
7
+ */
8
+ export function facadeOf(video: any, title?: any, frame?: string | null): {
5
9
  href: string;
6
10
  player: string;
7
11
  title: any;
package/lib/video.mjs CHANGED
@@ -39,7 +39,11 @@ function facade(video, title, frame, play) {
39
39
 
40
40
  /** What stands in for a video (videoOf) on the site: its page, the address that plays it (`frame`: the address of the
41
41
  * iframe it replaces, if any), its title, and its still from the site, when it could be fetched. The video block's
42
- * view too (src/standard/video/load.ts). */
42
+ * view too (src/standard/video/load.ts).
43
+ * @param {any} video
44
+ * @param {any} [title]
45
+ * @param {string | null} [frame]
46
+ */
43
47
  export function facadeOf(video, title, frame) {
44
48
  const still = `/images/_video/${stillName(video)}`;
45
49
  return {
@@ -1 +1,2 @@
1
1
  export function withoutH1(html: string): string;
2
+ export function h1SectionColor(html: string): string | null;
@@ -1,7 +1,22 @@
1
- // A page's text as HTML without its H1, which the layout draws in its own place (above the picture, PageLayout.astro).
2
- // Exposes withoutH1 (pure); the H1 is the first one wherever it stands, as pageH1 (src/sections.mjs) reads it. No dependencies.
1
+ // A page's text as HTML without its H1, which the layout draws in its own place (above the picture, PageLayout.astro), and the
2
+ // colour of the H1's section (the marker before it, lib/section-marker.mjs). Exposes withoutH1 and h1SectionColor (pure); the H1
3
+ // is the first one wherever it stands, as pageH1 (src/sections.mjs) reads it. Depends on text-sections.mjs and section-marker.mjs.
4
+ import { LABEL, MARKER } from './text-sections.mjs';
5
+ import { SECTION_COLORS } from './section-marker.mjs';
3
6
 
4
- /** The html without its first <h1>…</h1> and the white space after it.
7
+ // The first <h1>…</h1> and the white space after it, with the marker before it and the label between them, if any.
8
+ const H1 = new RegExp(`(?:${MARKER})?(${LABEL})<h1\\b[^>]*>[\\s\\S]*?<\\/h1>\\s*`);
9
+
10
+ /** The html without its first <h1>…</h1> and the white space after it, and without the marker before it (a label above it
11
+ * stays in the text).
5
12
  * @param {string} html
6
13
  * @returns {string} */
7
- export const withoutH1 = (html) => html.replace(/<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
14
+ export const withoutH1 = (html) => html.replace(H1, (_, __, label) => label);
15
+
16
+ /** The colour of the section the first H1 starts (the marker before it, or before the label above it), or null.
17
+ * @param {string} html
18
+ * @returns {string | null} */
19
+ export const h1SectionColor = (html) => {
20
+ const color = html.match(H1)?.[1];
21
+ return SECTION_COLORS.includes(color) ? color : null;
22
+ };
@@ -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 { withoutH1 } from './without-h1.mjs';
5
+ import { h1SectionColor, withoutH1 } from './without-h1.mjs';
6
6
 
7
7
  test('an H1 at the top is taken out, with the space after it', () => {
8
8
  assert.equal(withoutH1('<h1 id="title">Title</h1>\n<p>Text</p>'), '<p>Text</p>');
@@ -20,6 +20,35 @@ test('only the first H1 goes', () => {
20
20
  assert.equal(withoutH1('<h1>A</h1><p>x</p><h1>B</h1>'), '<p>x</p><h1>B</h1>');
21
21
  });
22
22
 
23
+ const M = (color) => `<div data-section="${color}"></div>`;
24
+
25
+ test('the marker before the H1 is taken out with it', () => {
26
+ assert.equal(withoutH1(`${M('tint')}\n<h1 id="t">Title</h1>\n<p>Text</p>`), '<p>Text</p>');
27
+ assert.equal(withoutH1(`<p>Intro</p>\n${M('tint')}\n<h1>Title</h1>\n<h2>A</h2>`), '<p>Intro</p>\n<h2>A</h2>');
28
+ });
29
+
30
+ test('a label between the marker and the H1 stays in the text', () => {
31
+ const label = '<p class="block-eyebrow">Hello</p>';
32
+ assert.equal(withoutH1(`${M('accent')}\n${label}\n<h1>Title</h1>\n<p>Text</p>`), `${label}\n<p>Text</p>`);
33
+ });
34
+
35
+ test('the marker of an H2 is not taken out', () => {
36
+ const html = `<h1>Title</h1>\n${M('tint')}\n<h2>A</h2>`;
37
+ assert.equal(withoutH1(html), `${M('tint')}\n<h2>A</h2>`);
38
+ });
39
+
40
+ test('the colour of the H1 section is the marker before the H1', () => {
41
+ assert.equal(h1SectionColor(`${M('tint')}\n<h1>Title</h1>\n<p>x</p>`), 'tint');
42
+ assert.equal(h1SectionColor(`<p>a</p>${M('accent')}<p class="block-eyebrow">L</p><h1>T</h1>`), 'accent');
43
+ });
44
+
45
+ test('without a marker before the H1 there is no colour', () => {
46
+ assert.equal(h1SectionColor('<h1>Title</h1><p>x</p>'), null);
47
+ assert.equal(h1SectionColor(`<h1>Title</h1>${M('tint')}<h2>A</h2>`), null);
48
+ assert.equal(h1SectionColor(`${M('red')}<h1>Title</h1>`), null);
49
+ assert.equal(h1SectionColor('<p>x</p>'), null);
50
+ });
51
+
23
52
  test('without an H1 the html is as it was', () => {
24
53
  assert.equal(withoutH1('<p>Text</p><h2>Section</h2>'), '<p>Text</p><h2>Section</h2>');
25
54
  assert.equal(withoutH1('<header>x</header>'), '<header>x</header>');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.11.0",
3
+ "version": "0.13.0-next-20261106203613",
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,7 +40,8 @@
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",
43
+ "check": "npm run format:check && npm run check:language && npm test && npm run check:types && npm run build && npm run check:site-types && npm run check:editor",
44
+ "check:site-types": "node scripts/check-site-types.mjs",
44
45
  "check:types": "tsc -p tsconfig.types.json --noEmit",
45
46
  "check:editor": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:editor",
46
47
  "check:screens": "cd .morpheme/demo/site && node ../../../bin/morpheme.mjs check:screens",