@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
@@ -35,7 +35,7 @@ export default function testimonialsList(say: Messages): OwnedList {
35
35
  to: 'categories',
36
36
  multiple: true,
37
37
  add: own.addTag,
38
- edit: { to: `ny/${config.lists.tags}`, text: own.entries.createTag },
38
+ edit: { to: `new/${config.lists.tags}`, text: own.entries.createTag },
39
39
  },
40
40
  { name: 'frontPage', label: own.entries.frontPage, type: 'boolean', default: true },
41
41
  ],
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The app store ("Indstillinger" › "Appstore", #indstillinger/appstore, AppList.tsx): the apps that are not in the site
1
+ // The app store ("Indstillinger" › "Appstore", #settings/appstore, AppList.tsx): the apps that are not in the site
2
2
  // and can be installed. The store is reached through the site's Worker (/api/store/catalog, /api/store/files/<oid>;
3
3
  // the Worker holds the site's key to it, and the editor never calls the store itself): each app of its catalog with
4
4
  // its id, its version and the version of the contract it is written to (APP_API), its name, who made it, its icon
@@ -29,6 +29,8 @@ import type { StoreLicenses } from './licenseData';
29
29
  import { APP_API, THEME_API } from '../lib/contracts.mjs';
30
30
  import { RECORD, STANDARD, isText, validApp, validTheme } from '../lib/storeEntries.mjs';
31
31
  import { timedFetch } from './storeFetch';
32
+ import { folderChanges } from '../lib/folderChanges.mjs';
33
+ import { moveChanges as moveItem, type SitePaths } from './legacyMove';
32
34
 
33
35
  // The contracts this editor runs (lib/contracts.mjs, also read by the site's Worker).
34
36
  export { APP_API, THEME_API };
@@ -111,6 +113,9 @@ export const SCREENSHOTS = 'screenshots';
111
113
  /** An app in the store's catalog. */
112
114
  export type StoreApp = {
113
115
  id: string;
116
+ /** Its readable name for addresses (`publisher/name`) and the id it had before its permanent one (`legacy`). */
117
+ handle?: string;
118
+ legacy?: string;
114
119
  version: string;
115
120
  api: number;
116
121
  label: string;
@@ -145,6 +150,8 @@ export type StoreApp = {
145
150
  * site in it (screenshots/<n>-<key>.jpg, t.library.themeShots) and its folder, file by file. */
146
151
  export type StoreTheme = {
147
152
  id: string;
153
+ handle?: string;
154
+ legacy?: string;
148
155
  version: string;
149
156
  api: number;
150
157
  name: string;
@@ -183,10 +190,10 @@ export type StoreRecord = { version: string; ref: string; files: StoreFile[] };
183
190
  /** The store's address in the editor, under "Indstillinger" (its apps; its themes at STORE_THEMES), its address
184
191
  * before (STORE_BEFORE, which opens it, App.tsx MOVED), and that of a developer's page (DEVELOPER_ROUTE/<slug>); no
185
192
  * app can have the ids of the last two. */
186
- export const STORE_ROUTE = 'indstillinger/appstore';
187
- export const STORE_THEMES = 'indstillinger/appstore/temaer';
193
+ export const STORE_ROUTE = 'settings/appstore';
194
+ export const STORE_THEMES = 'settings/appstore/themes';
188
195
  export const STORE_BEFORE = 'apps/butik';
189
- export const DEVELOPER_ROUTE = 'apps/udvikler';
196
+ export const DEVELOPER_ROUTE = 'apps/developer';
190
197
 
191
198
  /** An app's folder in the site, and the note in it of where it came from. */
192
199
  export const appFolder = (id: string) => `${config.apps.folder}/${id}`;
@@ -420,33 +427,6 @@ export function themeChanges(
420
427
  return folderChanges(themeFolder(id), current, next);
421
428
  }
422
429
 
423
- /** The changes that make a folder in the site (`current`, its files now) the store's version of an app or a theme, with
424
- * the note of where it came from, or remove it (`next` null). */
425
- function folderChanges(
426
- folder: string,
427
- current: RepoFile[],
428
- next: { files: { path: string; content: string | Uint8Array }[]; record: StoreRecord } | null,
429
- ): Change[] {
430
- const now = new Map(current.map((f) => [f.path, f.oid]));
431
- const wanted: { path: string; content: string | Uint8Array; oid?: string }[] = next
432
- ? [
433
- ...next.files.map((f) => ({
434
- path: `${folder}/${f.path}`,
435
- content: f.content,
436
- oid: next.record.files.find((r) => r.path === f.path)?.oid,
437
- })),
438
- { path: `${folder}/${RECORD}`, content: `${JSON.stringify(next.record, null, 2)}\n` },
439
- ]
440
- : [];
441
- return [
442
- // A file the site has as it is in the store is left as it is.
443
- ...wanted
444
- .filter((w) => !w.oid || now.get(w.path) !== w.oid)
445
- .map((w) => ({ path: w.path, content: w.content, oid: now.get(w.path) ?? null })),
446
- ...current.filter((f) => !wanted.some((w) => w.path === f.path)).map((f) => ({ path: f.path, content: null, oid: f.oid })),
447
- ];
448
- }
449
-
450
430
  /** The changes that make an app's folder in the site (`current`, its files now) the store's version, with the note of
451
431
  * where it came from, or remove it (`next` null: uninstalling); and the site's file of apps with it among those
452
432
  * installed or not (`installed`: their ids now, `file`: the file). In one commit. */
@@ -474,6 +454,31 @@ export function recordIn(current: RepoFile[], root: string): StoreRecord | undef
474
454
  }
475
455
  }
476
456
 
457
+ /** Where the site keeps what names an app or a theme (docs/store-ids.md); the popups' folder is the collection's. */
458
+ const sitePaths: SitePaths = {
459
+ appsFolder: config.apps.folder,
460
+ themesFolder: config.themes,
461
+ installed: config.apps.installed,
462
+ settings: config.apps.settings,
463
+ theme: config.theme,
464
+ icons: config.icons,
465
+ blockSettings: config.blockSettings,
466
+ popups: 'src/data/popups',
467
+ cookieList: config.cookieList,
468
+ };
469
+
470
+ /** The changes that update a store app or theme to its permanent folder: from its legacy folder (`legacy`, its files now
471
+ * `current`) with every id in the site's files (`site`) rewritten, or, without `legacy`, as appChanges or themeChanges
472
+ * do for the folder. In one commit (legacyMove.ts). */
473
+ export const moveChanges = (
474
+ entry: { id: string },
475
+ legacy: string | null,
476
+ next: Parameters<typeof moveItem>[2],
477
+ current: RepoFile[],
478
+ site: RepoFile[],
479
+ ) => moveItem(entry, legacy, next, current, site, sitePaths);
480
+ export { appliedTo, installedHere, legacyOf, mergeChanges } from './legacyMove';
481
+
477
482
  /** The files of an app installed from the store that were changed in the site since (in its folder, `current`, not
478
483
  * as its note says), by their paths in the folder: an update writes over them. */
479
484
  export function changedFiles(id: string, record: StoreRecord, current: RepoFile[], root = appFolder(id)): string[] {
package/src/tagUses.d.mts CHANGED
@@ -11,7 +11,7 @@ export function promotionTags(d: Record<string, unknown>): string[];
11
11
  */
12
12
  export function tagUses<F extends TagFile>(files: F[], folders: TagFolders): Map<string, Map<string, F>>;
13
13
  /**
14
- * A file record's use of a tag: the Filer address the file record is for, as a path under filer/.
14
+ * A file record's use of a tag: the Filer address the file record is for, as a path under files/.
15
15
  * @param {TagFile} file
16
16
  * @param {string} folder the file records folder
17
17
  * @returns {{ path: string; name: string } | null}
package/src/tagUses.mjs CHANGED
@@ -87,7 +87,7 @@ export function tagUses(files, folders) {
87
87
  }
88
88
 
89
89
  /**
90
- * A file record's use of a tag: the Filer address the file record is for, as a path under filer/.
90
+ * A file record's use of a tag: the Filer address the file record is for, as a path under files/.
91
91
  * @param {TagFile} file
92
92
  * @param {string} folder the file records folder
93
93
  * @returns {{ path: string; name: string } | null}
@@ -95,7 +95,7 @@ export function tagUses(files, folders) {
95
95
  export function recordUse(file, folder) {
96
96
  const record = folder !== undefined && isRecordPath(file.path, folder) ? parseRecord(file.text) : null;
97
97
  if (!record) return null;
98
- return { path: `filer${record.src}`, name: record.src.slice(record.src.lastIndexOf('/') + 1) };
98
+ return { path: `files${record.src}`, name: record.src.slice(record.src.lastIndexOf('/') + 1) };
99
99
  }
100
100
 
101
101
  /**
@@ -127,7 +127,7 @@ test('taggedWith lists the file records with a tag in their categories', () => {
127
127
 
128
128
  test('recordUse gives the Filer path and the file name of a file record', () => {
129
129
  assert.deepEqual(recordUse(andromeda, 'src/data/files'), {
130
- path: 'filer/images/uploads/2026/10/andromeda.webp',
130
+ path: 'files/images/uploads/2026/10/andromeda.webp',
131
131
  name: 'andromeda.webp',
132
132
  });
133
133
  });
@@ -1,7 +1,8 @@
1
- // Where the editor's top bar stands: one sticky place at the top of the content column, beside the full-height navigation
2
- // rail or drawer (`aside`; App.tsx wraps the layout in TopBarLayout), which Screen.tsx draws its bar into. Exposes
3
- // TopBarLayout and useTopBarSlot. It keeps `--top-bar-height` (the bar's height) on the layout, for the sections pane.
4
- import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
1
+ // Where the editor's top bar stands: one place at the top of the content column, beside the full-height navigation
2
+ // rail or drawer (`aside`; App.tsx wraps the layout in TopBarLayout), which Screen.tsx draws its bar into. The bar scrolls
3
+ // away with the page. Exposes TopBarLayout and useTopBarSlot.
4
+ // While the side panel is open the place ends at the panel's start edge (.editor-top-bar-slot in editor.css).
5
+ import { createContext, useContext, useState, type ReactNode } from 'react';
5
6
 
6
7
  const Slot = createContext<HTMLElement | null>(null);
7
8
 
@@ -13,22 +14,13 @@ export const useTopBarSlot = () => useContext(Slot);
13
14
  * screen's bar does not first stand in the screen and then move. */
14
15
  export function TopBarLayout({ above, aside, children }: { above?: ReactNode; aside?: ReactNode; children: ReactNode }) {
15
16
  const [slot, setSlot] = useState<HTMLDivElement | null>(null);
16
- const [layout, setLayout] = useState<HTMLDivElement | null>(null);
17
- useEffect(() => {
18
- if (!slot || !layout) return;
19
- const measure = () => layout.style.setProperty('--top-bar-height', `${slot.offsetHeight}px`);
20
- measure();
21
- const watch = new ResizeObserver(measure);
22
- watch.observe(slot);
23
- return () => watch.disconnect();
24
- }, [slot, layout]);
25
17
  return (
26
- <div ref={setLayout} className="[--top-bar-height:4rem]">
18
+ <div>
27
19
  {above}
28
20
  <div className="flex">
29
21
  {aside}
30
22
  <div className="min-w-0 flex-1">
31
- <div ref={setSlot} className="sticky top-0 z-20" />
23
+ <div ref={setSlot} className="editor-top-bar-slot" />
32
24
  <Slot.Provider value={slot}>{slot && children}</Slot.Provider>
33
25
  </div>
34
26
  </div>
package/src/ui.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  // The editor's parts: every button, field, switch, chip and row a screen is made of, each with one
2
2
  // look, set here. A screen uses these and never writes the classes of a button or a field itself
3
3
  // (CLAUDE.md), so the editor looks the same everywhere and a change here changes it everywhere. They
4
- // are all shown, in every state, on the screen #udseende/dele (Parts.tsx). The screen itself, with its top bar,
4
+ // are all shown, in every state, on the screen #appearance/parts (Parts.tsx). The screen itself, with its top bar,
5
5
  // is Screen.tsx.
6
6
  import {
7
7
  useEffect,
@@ -72,7 +72,7 @@ import {
72
72
  SiteDatePicker,
73
73
  SiteDialog,
74
74
  siteDialogModalClass,
75
- SiteExtendedFab,
75
+ SiteFab,
76
76
  SiteFabMenu,
77
77
  SiteIconButton,
78
78
  SiteLoadingIndicator,
@@ -1096,7 +1096,7 @@ const PAGE_STATES: PageState[] = ['published', 'draft', 'unlisted', 'scheduled']
1096
1096
  * menu links to it and its date. */
1097
1097
  export type Pickable = { value: string; label: string; state?: PageState; inMenu?: boolean; date?: string };
1098
1098
 
1099
- type PickSort = 'nyeste' | 'aeldste' | 'az' | 'za';
1099
+ type PickSort = 'newest' | 'oldest' | 'az' | 'za';
1100
1100
  type InMenu = '' | 'in' | 'out';
1101
1101
 
1102
1102
  /** The order and the filters of a sheet of choices (PagePicker, MultiChoice), as over the lists: the arrows with A–Å and
@@ -1106,7 +1106,7 @@ type InMenu = '' | 'in' | 'out';
1106
1106
  * and `arrange` orders and filters choices. */
1107
1107
  export function usePickTools(all: Pickable[], keep: string) {
1108
1108
  const dated = all.some((o) => o.date);
1109
- const sorts: PickSort[] = dated ? ['nyeste', 'aeldste', 'az', 'za'] : ['az', 'za'];
1109
+ const sorts: PickSort[] = dated ? ['newest', 'oldest', 'az', 'za'] : ['az', 'za'];
1110
1110
  const sortKey = `editor-sort-valg-${keep}`;
1111
1111
  const [savedSort, setSort] = useState<PickSort | null>(() => {
1112
1112
  try {
@@ -1151,8 +1151,8 @@ export function usePickTools(all: Pickable[], keep: string) {
1151
1151
 
1152
1152
  const byDate = (a: Pickable, b: Pickable) => (b.date ?? '').localeCompare(a.date ?? '');
1153
1153
  const compare: Record<PickSort, (a: Pickable, b: Pickable) => number> = {
1154
- nyeste: (a, b) => byDate(a, b) || byText(a.label, b.label),
1155
- aeldste: (a, b) => byDate(b, a) || byText(a.label, b.label),
1154
+ newest: (a, b) => byDate(a, b) || byText(a.label, b.label),
1155
+ oldest: (a, b) => byDate(b, a) || byText(a.label, b.label),
1156
1156
  az: (a, b) => byText(a.label, b.label),
1157
1157
  za: (a, b) => byText(b.label, a.label),
1158
1158
  };
@@ -2541,6 +2541,9 @@ export function useAtLeast(name: Exclude<Breakpoint, 'compact'>) {
2541
2541
  return useMedia(atLeast(name));
2542
2542
  }
2543
2543
 
2544
+ /** Whether the window is at least as wide as the breakpoint right now (not kept up to date: for a tap's handler). */
2545
+ export const atLeastNow = (name: Exclude<Breakpoint, 'compact'>) => matchMedia(atLeast(name)).matches;
2546
+
2544
2547
  /** How the main menu is laid out in this window (lib/menu-layout.mjs): whether the navigation rail stands, which way it
2545
2548
  * expands, and whether the top bar has the menu button. */
2546
2549
  export function useMenuLayout() {
@@ -2602,7 +2605,7 @@ export function SideSheet({ title, onClose, children }: { title: string; onClose
2602
2605
  headlineId={id}
2603
2606
  closeLabel={t.common.close}
2604
2607
  onClose={onClose}
2605
- className="editor-reveal sticky top-24 max-h-[calc(100dvh-7rem)] self-start"
2608
+ className="editor-reveal sticky top-8 max-h-[calc(100dvh-3rem)] self-start"
2606
2609
  >
2607
2610
  <div className="space-y-5">{children}</div>
2608
2611
  </SiteSideSheet>
@@ -2649,12 +2652,12 @@ export function SectionList({
2649
2652
  }
2650
2653
 
2651
2654
  /** The "on this page" card at the end of a long form on large and wider screens: the theme's outlined card, as tall as
2652
- * its content (it scrolls inside only when that is taller than the window less the top bar), with the small `caption`
2655
+ * its content (it scrolls inside only when that is taller than the window), with the small `caption`
2653
2656
  * (Afsnit), the form's `title` under it and the form's SectionList (`children`). It stays in view while the form
2654
2657
  * scrolls. Its place is FormColumns' `pane`, against the window's end edge; below large it is not drawn. */
2655
2658
  export function SectionPane({ caption, title, children }: { caption: string; title: string; children: ReactNode }) {
2656
2659
  return (
2657
- <div className={`${siteCardClass('outlined')} max-h-[calc(100dvh-var(--top-bar-height)-2*var(--section-pane-gap))] overflow-y-auto`}>
2660
+ <div className={`${siteCardClass('outlined')} max-h-[calc(100dvh-2*var(--section-pane-gap))] overflow-y-auto`}>
2658
2661
  <p className="section-pane-caption m-0 text-muted">{caption}</p>
2659
2662
  <h2 className="section-pane-title m-0 mb-3 text-ink">{title}</h2>
2660
2663
  {children}
@@ -2737,11 +2740,11 @@ function MainRail({ label, onOpen, children }: { label: string; onOpen: () => vo
2737
2740
  // menu button. The header cell starts at the drawer items' outer edge: the row has no start padding, and the header (the
2738
2741
  // account row, the back row) brings the items' inline padding itself, so its pill (editor.css, .editor-main-drawer-header)
2739
2742
  // has the shape of an item's and the avatar or arrow stands over the items' icons. The button is centred on the 1.5rem box
2740
- // of the items' trailing arrows (a negative end margin from the button's own width). A divider (middle inset) stands under the row.
2743
+ // of the items' trailing arrows (a negative end margin from the button's own width). The row is the top bar's height (`--top-app-bar-height`), so the divider (middle inset) under it stands level with the top bar's line.
2741
2744
  function MainDrawerMenuButton({ onClick, header }: { onClick: () => void; header?: ReactNode }) {
2742
2745
  return (
2743
2746
  <>
2744
- <div className="editor-main-drawer-header mt-2 mb-3 flex items-center gap-1 p-(--navigation-drawer-item-padding,0_1rem) py-0 ps-0">
2747
+ <div className="editor-main-drawer-header flex min-h-(--top-app-bar-height,4rem) items-center gap-1 p-(--navigation-drawer-item-padding,0_1rem) py-0 ps-0">
2745
2748
  <div className="min-w-0 flex-1">{header}</div>
2746
2749
  <MenuButton label={t.library.closeMenu} open onClick={onClick} className="-me-[calc((var(--icon-button-width)-1.5rem)/2)]" />
2747
2750
  </div>
@@ -3114,13 +3117,13 @@ export function FormColumns({
3114
3117
  cards?: boolean;
3115
3118
  sticky?: boolean;
3116
3119
  /** A SectionPane, on large and wider screens against the window's end edge under the top bar (--section-pane-width
3117
- * wide, fixed while the form scrolls; editor.css lets the content column keep clear of it); below large nothing.
3120
+ * wide, in an absolute strip along the content that its card sticks in while the form scrolls; editor.css lets the content column keep clear of it); below large nothing.
3118
3121
  * Instead of `first` and `side`. */
3119
3122
  pane?: ReactNode;
3120
3123
  }) {
3121
3124
  // On a wide computer screen (large) each of the right column's parts on the theme's outlined card.
3122
3125
  const carded = useAtLeast('large') && cards;
3123
- const right = `space-y-6 large:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'large:sticky large:top-24' : ''}`;
3126
+ const right = `space-y-6 large:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'large:sticky large:top-8' : ''}`;
3124
3127
  return (
3125
3128
  <div
3126
3129
  className={`flex flex-col gap-6 ${
@@ -3131,8 +3134,8 @@ export function FormColumns({
3131
3134
  <div className="min-w-0 space-y-6 large:col-start-1 large:row-span-2 large:row-start-1">{main}</div>
3132
3135
  {side && <div className={`${right} ${first ? 'large:row-start-2' : 'large:row-span-2 large:row-start-1'}`}>{side}</div>}
3133
3136
  {pane && (
3134
- <div className="editor-section-pane hidden large:fixed large:end-0 large:top-[calc(var(--top-bar-height)+var(--section-pane-gap))] large:z-10 large:block large:w-(--section-pane-width) large:pe-(--section-pane-gap)">
3135
- {pane}
3137
+ <div className="editor-section-pane hidden large:absolute large:inset-y-0 large:end-0 large:z-10 large:block large:w-(--section-pane-width) large:pe-(--section-pane-gap)">
3138
+ <div className="large:sticky large:top-(--section-pane-gap) large:mt-(--section-pane-gap)">{pane}</div>
3136
3139
  </div>
3137
3140
  )}
3138
3141
  </div>
@@ -3183,7 +3186,7 @@ export function LeadingToken({ kind, value, weight }: { kind: TokenKind; value:
3183
3186
  // ---------------------------------------------------------------------------------------------------
3184
3187
  // The sheet from the bottom
3185
3188
 
3186
- /** A screen's top bar (Screen.tsx): the theme's top app bar (Material 3's), at the top of the screen while it scrolls,
3189
+ /** A screen's top bar (Screen.tsx): the theme's top app bar (Material 3's), at the top of the screen (it scrolls away with the page),
3187
3190
  * below a phone's clock, and marked as the theme marks it once the screen has scrolled under it (data-stuck). */
3188
3191
  export function TopAppBar({ children }: { children: ReactNode }) {
3189
3192
  const [stuck, setStuck] = useState(false);
@@ -3194,7 +3197,7 @@ export function TopAppBar({ children }: { children: ReactNode }) {
3194
3197
  return () => removeEventListener('scroll', check);
3195
3198
  }, []);
3196
3199
  return (
3197
- <SiteTopAppBar className="sticky top-0 z-20 pt-[env(safe-area-inset-top)]" data-stuck={stuck || undefined}>
3200
+ <SiteTopAppBar className="pt-[env(safe-area-inset-top)]" data-stuck={stuck || undefined}>
3198
3201
  {children}
3199
3202
  </SiteTopAppBar>
3200
3203
  );
@@ -3855,14 +3858,22 @@ export function NewChoice<T extends string>({
3855
3858
  );
3856
3859
  }
3857
3860
 
3858
- /** One action of a FAB menu: its icon, its name and where it goes. */
3859
- export type FabAction = { icon: Icon; label: string; href: string };
3861
+ /** One action of a FAB menu: its icon, its name and what it does: a link (`href`) or a button (`onClick`). While `busy` its
3862
+ * action runs, and it is disabled as with `disabled`. */
3863
+ export type FabAction = { icon: Icon; label: string; href?: string; onClick?: () => void; busy?: boolean; disabled?: boolean };
3860
3864
 
3861
3865
  // An action's icon, drawn.
3862
3866
  const iconOf = ({ icon: Item }: FabAction) => <Item />;
3863
3867
 
3868
+ // What an action's element gets besides its icon and name: busy is disabled and aria-busy (a link has neither).
3869
+ const fabProps = ({ href, onClick, busy, disabled }: FabAction) => ({
3870
+ href,
3871
+ onClick,
3872
+ ...(href === undefined ? { disabled: disabled || busy, 'aria-busy': busy || undefined } : {}),
3873
+ });
3874
+
3864
3875
  /** The screen's main actions at the bottom right (.editor-fab, editor.css): the theme's FAB menu, opened and closed as
3865
- * on the site (setUpFabMenu), or with one action that action as an extended FAB; nothing without any. Drawn in a portal
3876
+ * on the site (setUpFabMenu), or with one action that action as a FAB (its icon, its name as its tooltip and read by screen readers; a link, or a button); nothing without any. Drawn in a portal
3866
3877
  * on <body>, since the screen's entrance animation (a transform) would make its <main> the containing block of a fixed FAB. */
3867
3878
  export function FabMenu({ icon: Glyph, label, items }: { icon: Icon; label: string; items: FabAction[] }) {
3868
3879
  const ref = useRef<HTMLDivElement>(null);
@@ -3879,10 +3890,10 @@ export function FabMenu({ icon: Glyph, label, items }: { icon: Icon; label: stri
3879
3890
  icon={<Glyph />}
3880
3891
  label={label}
3881
3892
  closeLabel={t.common.close}
3882
- items={items.map((item) => ({ ...item, icon: iconOf(item) }))}
3893
+ items={items.map((item) => ({ label: item.label, icon: iconOf(item), ...fabProps(item) }))}
3883
3894
  />
3884
3895
  ) : (
3885
- <SiteExtendedFab icon={iconOf(items[0])} label={items[0].label} href={items[0].href} />
3896
+ <SiteFab icon={iconOf(items[0])} label={items[0].label} title={items[0].label} {...fabProps(items[0])} />
3886
3897
  )}
3887
3898
  </div>,
3888
3899
  document.body,
package/src/virtual.d.ts CHANGED
@@ -27,3 +27,9 @@ declare module 'virtual:theme-coverage' {
27
27
  } | null;
28
28
  export default coverage;
29
29
  }
30
+
31
+ // The id of the app whose folder the importing file is in (appSelf in site/appSelf.mjs; app contract 3).
32
+ declare module '@app/self' {
33
+ const self: { readonly id: string };
34
+ export default self;
35
+ }
@@ -32,7 +32,7 @@
32
32
  "@material-symbols/svg-400": "0.48.2",
33
33
  "@material/material-color-utilities": "^0.3.0",
34
34
  "@milkdown/kit": "^7.22.3",
35
- "@morphemecms/cms": "0.11.0",
35
+ "@morphemecms/cms": "0.12.0",
36
36
  "@phosphor-icons/core": "^2.1.1",
37
37
  "@phosphor-icons/react": "^2.1.10",
38
38
  "@tailwindcss/typography": "^0.5.20",
@@ -0,0 +1,22 @@
1
+ ---
2
+ // The top of a page's text (PageLayout.astro): the link back to its parent page and the page's H1 (data-editor="heading", which
3
+ // the editor's preview fills in). Props: parent ({ href, title }), heading, headingHtml (the heading with its coloured words).
4
+ import Icon from '@theme/components/Icon.astro';
5
+
6
+ interface Props {
7
+ parent?: { href: string; title: string };
8
+ heading: string;
9
+ headingHtml?: string;
10
+ }
11
+ const { parent, heading, headingHtml } = Astro.props;
12
+ ---
13
+
14
+ {parent && (
15
+ <a href={parent.href} class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
16
+ <Icon name="arrow-left" size={16} />
17
+ {parent.title}
18
+ </a>
19
+ )}
20
+ <h1 data-editor="heading" class="theme-title mt-3 max-w-[22ch]">
21
+ {headingHtml ? <Fragment set:html={headingHtml} /> : heading}
22
+ </h1>
@@ -164,7 +164,7 @@ const jsonLd = {
164
164
  </head>
165
165
  <body class="flex min-h-dvh flex-col">
166
166
  <a
167
- href="#hovedindhold"
167
+ href="#main-content"
168
168
  class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-full focus:bg-accent focus:px-4 focus:py-2 focus:text-on-accent"
169
169
  >
170
170
  {siteT.theme.skipToContent}
@@ -172,7 +172,7 @@ const jsonLd = {
172
172
 
173
173
  <Header />
174
174
 
175
- <main id="hovedindhold" class="flex-1">
175
+ <main id="main-content" class="flex-1">
176
176
  <slot />
177
177
  </main>
178
178
 
@@ -8,12 +8,23 @@
8
8
  // paragraph is the lead (type-lead, ui.css: the --type-lead-* tokens), muted and 52ch wide.
9
9
  // data-editor marks what the editor's preview fills in with what is in its form ("Forhåndsvis",
10
10
  // packages/editor/src/pagePreview.tsx): the heading, the text, and the booking prompt at the end.
11
+ // A page whose H1 has a section colour (the marker before it, lib/section-marker.mjs) draws the heading and the text up to the
12
+ // first H2 in one panel (data-editor="h1-section", sectionClass); with a column beside the text the panel holds the heading only.
11
13
  import Base from '@theme/layouts/Base.astro';
12
- import Icon from '@theme/components/Icon.astro';
14
+ import PageHeading from '@theme/components/PageHeading.astro';
13
15
  import EndBlock from '@theme/components/EndBlock.astro';
14
16
  import Block from '@morphemecms/cms/src/standard/Block.astro';
15
17
  import { blockLoads, endBlock, sideBlock } from '@morphemecms/cms/src/standard/views';
16
- import { getPages, pageHeading, pageHeadingHtml, parentPage, stateOf, withoutH1, type Page } from '@morphemecms/cms/site/content';
18
+ import {
19
+ getPages,
20
+ h1SectionColor,
21
+ pageHeading,
22
+ pageHeadingHtml,
23
+ parentPage,
24
+ stateOf,
25
+ withoutH1,
26
+ type Page,
27
+ } from '@morphemecms/cms/site/content';
17
28
  import { blockTexts, nav } from '@morphemecms/cms/site/site';
18
29
  import { firstPicture, picturesOf } from '@morphemecms/cms/site/images.mjs';
19
30
  import { splitBlocks } from '@morphemecms/cms/lib/blocks.mjs';
@@ -22,6 +33,7 @@ import { offeredTemplates } from '@morphemecms/cms/site/templates';
22
33
  import { markdownHtml } from '@morphemecms/cms/lib/inline';
23
34
  import Container from '@theme/components/Container.astro';
24
35
  import RichText from '@theme/components/RichText.astro';
36
+ import { introOf, sectionClass } from '@morphemecms/cms/lib/text-sections.mjs';
25
37
  import Snackbar from '@theme/ui/Snackbar';
26
38
  import { isPreview, livePath, PREVIEW, previewId } from '@morphemecms/cms/site/noindex.mjs';
27
39
  import { siteT } from '@morphemecms/cms/src/i18n';
@@ -51,7 +63,10 @@ const picture = firstPicture(page.body);
51
63
  // e.g. "Book en tid", as on most pages) is shown across the page below the text and the column next to it; not when
52
64
  // it is hidden (RichText.astro leaves hidden blocks out).
53
65
  // Without the H1 at the top of the text (the page's heading, drawn above).
54
- const html = withoutH1(await Astro.slots.render('default'));
66
+ const text = await Astro.slots.render('default');
67
+ const html = withoutH1(text);
68
+ // The colour of the H1's section (the marker before the H1): the heading and the text up to the first H2 are one panel.
69
+ const h1Color = h1SectionColor(text);
55
70
  const end = endBlock(html);
56
71
  // The questions in the text's "Spørgsmål og svar" blocks (not the hidden ones), for the structured data
57
72
  // (an FAQPage, packages/editor/site/schema.ts); the answers as HTML, as on the page (the block's load.ts).
@@ -76,6 +91,12 @@ const side =
76
91
  (await blockLoads[sideType].load({}, { texts: blockTexts[sideType] ?? {}, index: 0, path: current, preview, html: sideText })) !== null
77
92
  ? sideType
78
93
  : null;
94
+ // In a coloured H1 section the text up to the first H2 (intro) is in the panel with the heading; with a column beside the text
95
+ // the text stays whole in its grid and the panel holds the heading only.
96
+ const { intro, rest } = h1Color && !side ? introOf(sideText) : { intro: '', rest: sideText };
97
+ const textClass = side
98
+ ? 'prose max-w-[66ch]'
99
+ : 'prose max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:type-lead [&>p:first-child]:text-muted [&>p:first-child]:max-w-[52ch]';
79
100
  ---
80
101
 
81
102
  <Base
@@ -96,28 +117,24 @@ const side =
96
117
  >
97
118
  <article data-template={template}>
98
119
  <Container as="header" class="pt-8 medium:pt-14">
99
- {parent && (
100
- <a href={parent.href} class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
101
- <Icon name="arrow-left" size={16} />
102
- {parent.title}
103
- </a>
120
+ {h1Color ? (
121
+ <section class={sectionClass(h1Color)} data-editor="h1-section">
122
+ <PageHeading parent={parent} heading={heading} headingHtml={headingHtml} />
123
+ {intro && (
124
+ <div data-editor="intro" class={textClass}>
125
+ <RichText html={intro} path={current} preview={preview} />
126
+ </div>
127
+ )}
128
+ </section>
129
+ ) : (
130
+ <PageHeading parent={parent} heading={heading} headingHtml={headingHtml} />
104
131
  )}
105
- <h1 data-editor="heading" class="theme-title mt-3 max-w-[22ch]">
106
- {headingHtml ? <Fragment set:html={headingHtml} /> : heading}
107
- </h1>
108
132
  </Container>
109
133
 
110
134
  <Container as="div" class="mt-8 medium:mt-12">
111
135
  <div {...(side ? { class: 'page-with-side', 'data-side': template === 'sidebar-left' ? 'left' : 'right' } : {})}>
112
- <div
113
- data-editor="text"
114
- class={
115
- side
116
- ? 'prose max-w-[66ch]'
117
- : 'prose max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:type-lead [&>p:first-child]:text-muted [&>p:first-child]:max-w-[52ch]'
118
- }
119
- >
120
- <RichText html={sideText} path={current} preview={preview} />
136
+ <div data-editor="text" class={textClass}>
137
+ <RichText html={rest} path={current} preview={preview} />
121
138
  </div>
122
139
  {side && (
123
140
  <aside class="page-side">
@@ -752,7 +752,8 @@
752
752
  }
753
753
 
754
754
  /* An M3 side sheet's headline in on-surface-variant. */
755
- .side-sheet-headline {
755
+ .side-sheet-headline,
756
+ .bottom-sheet-container[data-wide='side-sheet'] .bottom-sheet-headline {
756
757
  color: var(--muted);
757
758
  }
758
759