@morphemecms/cms 0.9.0-next-20261105122349 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/defaults/collections.ts +110 -56
  3. package/defaults/editor.ts +2 -2
  4. package/defaults/texts.ts +14 -5
  5. package/demo/src/content/pages/app-store.md +0 -1
  6. package/demo/src/content/pages/blocks.md +0 -1
  7. package/icons/lucide/arrows-horizontal.svg +1 -0
  8. package/icons/lucide/columns-plus-left.svg +1 -0
  9. package/icons/lucide/columns-plus-right.svg +1 -0
  10. package/icons/lucide/file.svg +1 -0
  11. package/icons/material-symbols/arrows-horizontal.svg +1 -0
  12. package/icons/material-symbols/columns-plus-left.svg +1 -0
  13. package/icons/material-symbols/columns-plus-right.svg +1 -0
  14. package/icons/material-symbols/file.svg +1 -0
  15. package/icons/phosphor/arrows-horizontal.svg +1 -0
  16. package/icons/phosphor/columns-plus-left.svg +1 -0
  17. package/icons/phosphor/columns-plus-right.svg +1 -0
  18. package/icons/phosphor/file.svg +1 -0
  19. package/lib/fold-open.d.mts +2 -0
  20. package/lib/fold-open.mjs +22 -0
  21. package/lib/fold-open.test.mjs +24 -0
  22. package/lib/menu.test.mjs +16 -3
  23. package/lib/page-template.d.mts +54 -0
  24. package/lib/page-template.mjs +126 -0
  25. package/lib/page-template.test.mjs +129 -0
  26. package/lib/side-block.d.mts +2 -16
  27. package/lib/side-block.mjs +12 -30
  28. package/lib/side-block.test.mjs +13 -30
  29. package/package.json +1 -1
  30. package/scripts/check-side.mjs +49 -22
  31. package/scripts/editor-roundtrip.mjs +15 -6
  32. package/scripts/fixtures/side/side-full.md +8 -0
  33. package/scripts/fixtures/side/side-left.md +8 -0
  34. package/scripts/fixtures/side/side-none.md +7 -0
  35. package/scripts/fixtures/side/side-right.md +8 -0
  36. package/scripts/fixtures/side/side-single.md +8 -0
  37. package/scripts/side-width.mjs +29 -0
  38. package/scripts/side-width.test.mjs +30 -0
  39. package/site/content.config.ts +3 -4
  40. package/site/header.d.mts +21 -0
  41. package/site/header.mjs +35 -0
  42. package/site/header.test.mjs +59 -0
  43. package/site/icons.d.mts +16 -0
  44. package/site/icons.mjs +8 -0
  45. package/site/site.ts +8 -3
  46. package/site/templates.ts +7 -0
  47. package/site/theme.mjs +3 -0
  48. package/src/App.tsx +12 -24
  49. package/src/AppList.tsx +36 -14
  50. package/src/AppSettingsTab.tsx +5 -3
  51. package/src/BlockDialog.tsx +50 -5
  52. package/src/DataFileFields.tsx +35 -0
  53. package/src/DataForm.tsx +16 -38
  54. package/src/Deleted.tsx +7 -3
  55. package/src/EntryForm.tsx +41 -88
  56. package/src/FooterSettings.tsx +76 -0
  57. package/src/FormFrame.tsx +2 -1
  58. package/src/HeaderSettings.tsx +97 -0
  59. package/src/Library.tsx +26 -14
  60. package/src/MarkdownEditor.tsx +18 -36
  61. package/src/Media.tsx +6 -3
  62. package/src/Redirects.tsx +12 -9
  63. package/src/SectionSettings.tsx +21 -0
  64. package/src/SectionSettingsIcon.tsx +10 -0
  65. package/src/SettingsSections.tsx +57 -0
  66. package/src/ThemeStore.tsx +28 -9
  67. package/src/Versions.tsx +3 -15
  68. package/src/busyAction.d.mts +3 -0
  69. package/src/busyAction.mjs +8 -0
  70. package/src/busyAction.test.mjs +16 -0
  71. package/src/dataFileValues.ts +30 -0
  72. package/src/editor.css +37 -3
  73. package/src/fields.tsx +29 -10
  74. package/src/footerPreview.d.mts +46 -0
  75. package/src/footerPreview.mjs +84 -0
  76. package/src/footerPreview.test.mjs +100 -0
  77. package/src/formSections.ts +100 -0
  78. package/src/frontmatter.mjs +1 -1
  79. package/src/frontmatter.test.mjs +11 -11
  80. package/src/headerPreview.d.mts +48 -0
  81. package/src/headerPreview.mjs +168 -0
  82. package/src/headerPreview.test.mjs +183 -0
  83. package/src/headerTheme.tsx +58 -0
  84. package/src/i18n/da-DK/common.ts +1 -0
  85. package/src/i18n/da-DK/library.ts +2 -0
  86. package/src/i18n/da-DK/preview.ts +2 -0
  87. package/src/i18n/da-DK/text.ts +1 -0
  88. package/src/i18n/en-US/common.ts +1 -0
  89. package/src/i18n/en-US/library.ts +4 -0
  90. package/src/i18n/en-US/preview.ts +3 -0
  91. package/src/i18n/en-US/text.ts +2 -1
  92. package/src/images.ts +7 -2
  93. package/src/menuChildren.ts +1 -3
  94. package/src/movedRoutes.d.mts +3 -0
  95. package/src/movedRoutes.mjs +35 -0
  96. package/src/movedRoutes.test.mjs +22 -0
  97. package/src/pagePreview.tsx +40 -29
  98. package/src/partPreview.tsx +59 -0
  99. package/src/schema.ts +7 -1
  100. package/src/sheetPlacement.d.mts +3 -0
  101. package/src/sheetPlacement.mjs +9 -0
  102. package/src/sheetPlacement.test.mjs +23 -0
  103. package/src/standard/views.ts +3 -3
  104. package/src/templateField.ts +28 -0
  105. package/src/themes.ts +3 -0
  106. package/src/ui.tsx +35 -8
  107. package/src/useDataFiles.ts +94 -0
  108. package/starter/package.json +1 -1
  109. package/theme/components/Footer.astro +14 -7
  110. package/theme/components/Header.astro +58 -33
  111. package/theme/components/MobileNav.astro +5 -4
  112. package/theme/components/RelatedPosts.astro +1 -1
  113. package/theme/components/headerParts.ts +17 -0
  114. package/theme/layouts/PageLayout.astro +20 -16
  115. package/theme/styles/base.css +6 -5
  116. package/theme/styles/blocks.css +8 -12
  117. package/theme/styles/components.css +8 -1
  118. package/theme/styles/global.css +5 -9
  119. package/theme/styles/ui.css +111 -5
  120. package/theme/theme.json +1 -0
  121. package/theme/ui/BusyContent.tsx +16 -0
  122. package/theme/ui/Button.tsx +25 -5
  123. package/theme/ui/IconButton.tsx +17 -4
  124. package/scripts/fixtures/side/side-off.md +0 -8
  125. package/scripts/fixtures/side/side-plain.md +0 -7
  126. package/scripts/fixtures/side/side-wide.md +0 -8
@@ -0,0 +1,94 @@
1
+ // Several single data files edited on one screen (Appearance › Header: the two menus and header.json): each one's values
2
+ // in the form and as they were saved, a change to them, whether any is changed, the field that keeps one from being saved,
3
+ // and "Gem ændringer", which saves each changed file in its own commit, as SideColumn.tsx saves its file. Exposes
4
+ // useDataFiles. Uses dataFileValues.ts, check.ts (problemAt), fields.tsx (useWrongField) and Env (App.tsx).
5
+ import { useMemo, useState } from 'react';
6
+ import type { DataFile, FilesCollection } from './schema';
7
+ import { ConflictError, type RepoFile } from './github';
8
+ import { problemAt } from './check';
9
+ import { useWrongField, type Wrong } from './fields';
10
+ import { dataFileIn, dataOf, fieldsOf, valuesOf } from './dataFileValues';
11
+ import { asWritten } from './site';
12
+ import { changesText, lower, savedText } from './deploy';
13
+ import type { Env } from './App';
14
+ import { siteT, t } from './i18n';
15
+
16
+ /** A file on the screen: its collection and the file. */
17
+ export type DataFilePart = { collection: FilesCollection; dataFile: DataFile };
18
+
19
+ type Values = Record<string, unknown>;
20
+
21
+ /** `onWrong`: a file's field keeps it from being saved (its place in `parts`, the field's path), before it is marked: so
22
+ * the screen can open what the field is in. */
23
+ export function useDataFiles(env: Env, parts: DataFilePart[], onWrong?: (i: number, path: string) => void) {
24
+ const { notify } = env;
25
+ // The files as last saved (or loaded); an optional one the site has not got yet is empty (dataFileIn).
26
+ const [bases, setBases] = useState<RepoFile[]>(() =>
27
+ parts.map((p) => dataFileIn(env.files, p.dataFile) ?? { path: p.dataFile.file, oid: '', text: '{}\n' }),
28
+ );
29
+ const read = (i: number, text: string) => valuesOf(text, fieldsOf(parts[i].dataFile), parts[i].dataFile, parts[i].collection, env.files);
30
+ const initial = useMemo(() => bases.map((b, i) => read(i, b.text)), [bases]);
31
+ const [values, setValues] = useState<Values[]>(initial);
32
+ const [saving, setSaving] = useState(false);
33
+ // The field that keeps a file from being saved, marked in that file's fields only (the menus have the same paths).
34
+ const wrongField = useWrongField((text) => notify({ text, error: true }));
35
+ const [wrongIn, setWrongIn] = useState(-1);
36
+ const changed = values.flatMap((v, i) => (JSON.stringify(v) !== JSON.stringify(initial[i]) ? [i] : []));
37
+
38
+ async function save() {
39
+ // Every changed file is right before any is saved.
40
+ for (const i of changed) {
41
+ const problem = problemAt(fieldsOf(parts[i].dataFile), values[i]);
42
+ if (problem) {
43
+ setWrongIn(i);
44
+ onWrong?.(i, problem.path);
45
+ wrongField.stop(problem);
46
+ return false;
47
+ }
48
+ }
49
+ const texts = changed
50
+ .map((i) => ({ i, text: `${JSON.stringify(dataOf(fieldsOf(parts[i].dataFile), values[i], parts[i].dataFile), null, 2)}\n` }))
51
+ .filter(({ i, text }) => text !== bases[i].text);
52
+ if (!texts.length) {
53
+ notify({ text: t.forms.nothingToSave });
54
+ return false;
55
+ }
56
+ setSaving(true);
57
+ notify({ text: t.common.saving });
58
+ const done: string[] = [];
59
+ try {
60
+ for (const { i, text } of texts) {
61
+ const { dataFile } = parts[i];
62
+ const path = bases[i].path;
63
+ const change = t.app.changes.edited(lower(dataFile.label));
64
+ const message = siteT.forms.commit.edit(asWritten(dataFile).label.toLowerCase());
65
+ const oids = await env.commit(message, [{ path, content: text, oid: bases[i].oid || null }], change);
66
+ const saved = { path, oid: oids[path], text };
67
+ setBases((all) => all.map((b, j) => (j === i ? saved : b)));
68
+ setValues((all) => all.map((v, j) => (j === i ? read(i, text) : v)));
69
+ env.onSaved(saved);
70
+ done.push(change);
71
+ }
72
+ notify({ text: savedText(changesText(done)), kind: 'saved' });
73
+ return true;
74
+ } catch (e) {
75
+ notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
76
+ return false;
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ }
81
+
82
+ return {
83
+ /** Each file's values in the form, and as they were saved (in the same form, for what the preview changes). */
84
+ values,
85
+ initial,
86
+ /** Sets one file's values (by its place in `parts`). */
87
+ set: (i: number, v: Values) => setValues((all) => all.map((x, j) => (j === i ? v : x))),
88
+ dirty: changed.length > 0,
89
+ saving,
90
+ save,
91
+ /** The field marked in a file's fields (WrongField), or null. */
92
+ wrong: (i: number): Wrong | null => (wrongIn === i ? wrongField.field : null),
93
+ };
94
+ }
@@ -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.8.0",
35
+ "@morphemecms/cms": "0.9.0",
36
36
  "@phosphor-icons/core": "^2.1.1",
37
37
  "@phosphor-icons/react": "^2.1.10",
38
38
  "@tailwindcss/typography": "^0.5.20",
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  // The footer in Material: a surface container with extra-large corners at the top, the business as a headline, how to
3
- // reach it as a list with leading icons, the practical links (footer.json, "På alle sider" › "Footer" in the editor) or
3
+ // reach it as a list with leading icons, the practical links (footer.json, Appearance › Footer in the editor) or
4
4
  // what a page puts in the slot `footer-column` instead (a blog post's related posts), and at the bottom the cookie
5
- // settings and light or dark as text buttons.
5
+ // settings and light or dark as text buttons. data-editor marks the footer and the parts its form changes (the editor's
6
+ // preview of Appearance › Footer, src/footerPreview.mjs).
6
7
  import Icon from '@theme/components/Icon.astro';
7
8
  import Container from '@theme/components/Container.astro';
8
9
  import LogoMark from '@theme/components/LogoMark.astro';
@@ -20,15 +21,19 @@ const contactLinks = [
20
21
  ];
21
22
  ---
22
23
 
23
- <footer class="md-footer">
24
+ <footer class="md-footer" data-editor="footer">
24
25
  <Container as="div" class="grid gap-10 py-12 expanded:grid-cols-12 medium:py-16">
25
26
  <div class="expanded:col-span-5">
26
27
  <LogoMark class="block h-12 w-11 bg-accent" />
27
28
  <p class="md-footer-name">{site.name}</p>
28
- <p class="md-footer-text">{footer.text}</p>
29
+ <p class="md-footer-text" data-editor="footer-text">
30
+ {footer.text}
31
+ </p>
29
32
  </div>
30
33
  <div class="expanded:col-span-4">
31
- <p class="md-footer-heading">{footer.contactHeading}</p>
34
+ <p class="md-footer-heading" data-editor="footer-contact-heading">
35
+ {footer.contactHeading}
36
+ </p>
32
37
  <ul class="md-footer-list">
33
38
  {contactLinks.map((c) => (
34
39
  <li>
@@ -49,8 +54,10 @@ const contactLinks = [
49
54
  </div>
50
55
  <div class="md-footer-column expanded:col-span-3">
51
56
  <slot name="footer-column">
52
- <p class="md-footer-heading">{footer.linksHeading}</p>
53
- <ul class="md-footer-list">
57
+ <p class="md-footer-heading" data-editor="footer-links-heading">
58
+ {footer.linksHeading}
59
+ </p>
60
+ <ul class="md-footer-list" data-editor="footer-links">
54
61
  {footer.links.map((l) => (
55
62
  <li>
56
63
  <a href={l.href} class="md-footer-item">
@@ -6,7 +6,10 @@
6
6
  // turned off. Under it the bar of "Mobilmenu" (MobileNav.astro), when it is on. Under it, "Book en tid"
7
7
  // as an extended FAB at the bottom right of the screen (ui/Fab.tsx, m3.material.io/components/extended-fab), which shows only its
8
8
  // icon while the page scrolls down. Not on the booking page and the thank-you page; on a phone with a bar at the
9
- // bottom of the screen, the bar's first button is the FAB instead (global.css).
9
+ // bottom of the screen, the bar's first button is the FAB instead (global.css). header.json (optional, site/header.mjs) can put a
10
+ // button in place of the phone icon (shown from the medium window size up), and the menu button at the end; data-editor marks the
11
+ // header, the menu button, the menu's items (header-nav) and the button's place (header-cta) for the editor's preview; their
12
+ // classes are in headerParts.ts, which the preview shares.
10
13
  import Icon from '@theme/components/Icon.astro';
11
14
  import MobileMenu from '@theme/components/MobileMenu.tsx';
12
15
  import Logo from '@theme/components/Logo.astro';
@@ -15,37 +18,43 @@ import MobileNav from '@theme/components/MobileNav.astro';
15
18
  import { topAppBarClass } from '@theme/ui/TopAppBar';
16
19
  import { menuClass, menuItemClass } from '@theme/ui/Menu';
17
20
  import { extendedFabClass } from '@theme/ui/Fab';
18
- import { site, nav, navShown } from '@morphemecms/cms/site/site';
21
+ import Buttons from '@theme/blocks/buttons/Buttons';
22
+ import { headerCtaClass, headerEdge, headerMenuClass } from '@theme/components/headerParts';
23
+ import { loadButtons } from '@morphemecms/cms/src/standard/buttons/load';
24
+ import { site, nav, navShown, header } from '@morphemecms/cms/site/site';
19
25
  import { siteT } from '@morphemecms/cms/src/i18n';
20
26
 
21
27
  const current = Astro.url.pathname;
22
28
  const isActive = (href: string) => current === href || (href !== '/' && current.startsWith(href));
29
+ // The header's own button (header.json), in place of the phone icon from the medium window size up; none when it is off.
30
+ const button = header.button ? (await loadButtons([{ ...header.button, kind: 'filled' }]))[0] : undefined;
31
+ const menuEnd = header.menuSide === 'end';
32
+ const menuProps = {
33
+ items: nav,
34
+ current,
35
+ bookingPath: site.bookingPath,
36
+ phone: site.phone,
37
+ phoneHref: site.phoneHref,
38
+ name: site.name,
39
+ role: site.role,
40
+ book: site.labels.book,
41
+ call: site.labels.call,
42
+ texts: {
43
+ open: siteT.theme.openMenu,
44
+ close: siteT.theme.closeMenu,
45
+ menu: siteT.theme.mainMenu,
46
+ drawer: siteT.theme.mobileMenu,
47
+ subpages: nav.map((item) => siteT.theme.subpagesOf(item.label)),
48
+ },
49
+ };
23
50
  const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
24
51
  ---
25
52
 
26
- <header data-header class:list={[topAppBarClass, 'group/header sticky top-0 z-[43]']}>
53
+ <header data-header data-editor="header" class:list={[topAppBarClass, 'group/header sticky top-0 z-[43]']}>
27
54
  <Container as="div" data-header-row class="flex h-16 items-center gap-1">
28
- {navShown && (
29
- <div class="-ml-2 mr-1 large:hidden large:group-data-crowded/header:block">
30
- <MobileMenu
31
- client:interact
32
- items={nav}
33
- current={current}
34
- bookingPath={site.bookingPath}
35
- phone={site.phone}
36
- phoneHref={site.phoneHref}
37
- name={site.name}
38
- role={site.role}
39
- book={site.labels.book}
40
- call={site.labels.call}
41
- texts={{
42
- open: siteT.theme.openMenu,
43
- close: siteT.theme.closeMenu,
44
- menu: siteT.theme.mainMenu,
45
- drawer: siteT.theme.mobileMenu,
46
- subpages: nav.map((item) => siteT.theme.subpagesOf(item.label)),
47
- }}
48
- />
55
+ {navShown && !menuEnd && (
56
+ <div data-editor="header-menu" class={headerMenuClass('start')}>
57
+ <MobileMenu client:interact {...menuProps} />
49
58
  </div>
50
59
  )}
51
60
  <a href="/" class="md-top-app-bar-title mr-auto large:shrink-0 large:whitespace-nowrap">
@@ -53,7 +62,11 @@ const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
53
62
  </a>
54
63
 
55
64
  {navShown && (
56
- <nav aria-label={siteT.theme.mainMenu} class="hidden shrink-0 large:block large:group-data-crowded/header:hidden">
65
+ <nav
66
+ aria-label={siteT.theme.mainMenu}
67
+ data-editor="header-nav"
68
+ class="hidden shrink-0 large:block large:group-data-crowded/header:hidden"
69
+ >
57
70
  <ul class="flex items-center gap-1">
58
71
  {nav.map((item, i) => (
59
72
  <li class="group relative flex items-center" data-nav-item>
@@ -102,14 +115,26 @@ const fab = !current.startsWith(site.bookingPath) && current !== '/tak/';
102
115
  </nav>
103
116
  )}
104
117
 
105
- <a
106
- href={site.phoneHref}
107
- class="icon-button icon-button-standard -mr-2 ml-1"
108
- aria-label={`${site.labels.call} ${site.phone}`}
109
- title={site.phone}
110
- >
111
- <Icon name="phone" size={24} />
112
- </a>
118
+ {button ? (
119
+ <div data-editor="header-cta" class={headerCtaClass}>
120
+ <Buttons buttons={[button]} />
121
+ </div>
122
+ ) : (
123
+ <a
124
+ href={site.phoneHref}
125
+ data-editor="header-cta"
126
+ class:list={['icon-button icon-button-standard', headerEdge.end]}
127
+ aria-label={`${site.labels.call} ${site.phone}`}
128
+ title={site.phone}
129
+ >
130
+ <Icon name="phone" size={24} />
131
+ </a>
132
+ )}
133
+ {navShown && menuEnd && (
134
+ <div data-editor="header-menu" class={headerMenuClass('end')}>
135
+ <MobileMenu client:interact {...menuProps} />
136
+ </div>
137
+ )}
113
138
  </Container>
114
139
  <MobileNav />
115
140
  </header>
@@ -3,8 +3,9 @@
3
3
  // top with it: its links on one line as scrollable tabs (m3.material.io/components/tabs), on a phone and a tablet (where
4
4
  // the menu is behind the menu button), to be swiped sideways when they do not fit, with the open page's chosen (its
5
5
  // label in its own colour) and scrolled into view: the theme's tabs (ui/Tabs.tsx, .tab in styles/ui.css). Not there when
6
- // it is turned off or has no links.
7
- import { tabsClass, tabClass } from '@theme/ui/Tabs';
6
+ // it is turned off or has no links. data-editor marks it for the editor's preview of the header (its classes: headerParts.ts).
7
+ import { tabClass } from '@theme/ui/Tabs';
8
+ import { mobileNavClass, mobileNavListClass } from '@theme/components/headerParts';
8
9
  import { mobileNav } from '@morphemecms/cms/site/site';
9
10
  import { siteT } from '@morphemecms/cms/src/i18n';
10
11
 
@@ -13,8 +14,8 @@ const isActive = (href: string) => current === href || (href !== '/' && current.
13
14
  ---
14
15
 
15
16
  {mobileNav.length > 0 && (
16
- <nav aria-label={siteT.theme.mobileMenu} data-mobile-nav class="large:hidden large:group-data-crowded/header:block">
17
- <ul class:list={[tabsClass, 'relative mx-auto max-w-6xl px-1 medium:px-4']}>
17
+ <nav aria-label={siteT.theme.mobileMenu} data-mobile-nav data-editor="mobile-nav" class={mobileNavClass}>
18
+ <ul class={mobileNavListClass}>
18
19
  {mobileNav.map((item) => (
19
20
  <li>
20
21
  <a href={item.href} class={tabClass} aria-current={isActive(item.href) ? 'page' : undefined}>
@@ -18,7 +18,7 @@ const { posts } = Astro.props;
18
18
  <h2 id="related-posts" class="type-headline-small">
19
19
  {siteT.theme.relatedPosts}
20
20
  </h2>
21
- <ul class="mt-6 grid gap-6 medium:grid-cols-3">
21
+ <ul class="mt-6 grid gap-(--card-gap) medium:grid-cols-3">
22
22
  {posts.map((post) => (
23
23
  <li>
24
24
  <PostCard post={post} />
@@ -0,0 +1,17 @@
1
+ // The classes of the header's parts that its settings move or put in (Header.astro, MobileNav.astro), shared with the
2
+ // editor's preview of the header (src/headerTheme.tsx), so both draw them the same.
3
+ // Exposes headerEdge, headerMenuClass, headerCtaClass, mobileNavClass and mobileNavListClass. Uses ui/Tabs.tsx.
4
+ import { tabsClass } from '@theme/ui/Tabs';
5
+
6
+ /** The place of a part at the start of the row (the menu button) and at its end (the phone, or the menu button). */
7
+ export const headerEdge = { start: '-ml-2 mr-1', end: '-mr-2 ml-1' } as const;
8
+
9
+ /** The menu button's box at either side: shown where the menu's items are not (below large, or when they do not fit). */
10
+ export const headerMenuClass = (side: 'start' | 'end') => `${headerEdge[side]} large:hidden large:group-data-crowded/header:block`;
11
+
12
+ /** The header's own button's box (header.json): only from the medium window size up. */
13
+ export const headerCtaClass = 'hidden shrink-0 medium:block';
14
+
15
+ /** The bar of "Mobilmenu" under the header, where the menu's items are not, and its row of tabs. */
16
+ export const mobileNavClass = 'large:hidden large:group-data-crowded/header:block';
17
+ export const mobileNavListClass = `${tabsClass} relative mx-auto max-w-6xl px-1 medium:px-4`;
@@ -1,9 +1,11 @@
1
1
  ---
2
- // A page (the standard theme's): back link, title and the text with its blocks (its pictures in it), in one column (with
3
- // the block src/data/side.json chooses in a column beside it, when there is one: sideBlock), and the booking
4
- // prompt across the page when the text ends with it. A wide page (`wide`, "Hele bredden"): its blocks go across the
5
- // page, its text keeps a readable width, and its first paragraph is the introduction under the heading (as
6
- // PageHeader.astro's).
2
+ // A page (the standard theme's): back link, title and the text with its blocks (its pictures in it), in one column, or
3
+ // with the block src/data/side.json chooses in a column beside it (sideBlock) when its template is a sidebar one
4
+ // (`template`, lib/page-template.mjs: the theme's "templates"), and the booking prompt across the page when the text ends
5
+ // with it. The wrapper carries data-template, so a theme can style a template; `full` is drawn like `single` here.
6
+ // Width: with a column beside it the text is 66ch wide, blocks and all; without one (single, full) the text column is the
7
+ // container's width, its blocks (not-prose) span it, and its other parts (paragraphs, headings) keep 66ch. Its first
8
+ // paragraph is the lead (type-lead, ui.css: the --type-lead-* tokens), muted and 52ch wide.
7
9
  // data-editor marks what the editor's preview fills in with what is in its form ("Forhåndsvis",
8
10
  // packages/editor/src/pagePreview.tsx): the heading, the text, and the booking prompt at the end.
9
11
  import Base from '@theme/layouts/Base.astro';
@@ -15,6 +17,8 @@ import { getPages, pageHeading, pageHeadingHtml, parentPage, stateOf, withoutH1,
15
17
  import { blockTexts, nav } from '@morphemecms/cms/site/site';
16
18
  import { firstPicture, picturesOf } from '@morphemecms/cms/site/images.mjs';
17
19
  import { splitBlocks } from '@morphemecms/cms/lib/blocks.mjs';
20
+ import { hasSidebar, templateOf } from '@morphemecms/cms/lib/page-template.mjs';
21
+ import { offeredTemplates } from '@morphemecms/cms/site/templates';
18
22
  import { markdownHtml } from '@morphemecms/cms/lib/inline';
19
23
  import Container from '@theme/components/Container.astro';
20
24
  import RichText from '@theme/components/RichText.astro';
@@ -36,7 +40,8 @@ const parent = parentEntry
36
40
  : parentNav
37
41
  ? { href: parentNav.href, title: parentNav.label }
38
42
  : undefined;
39
- const { description, path, wide } = page.data;
43
+ const { description, path } = page.data;
44
+ const template = templateOf(page.data, offeredTemplates);
40
45
  const heading = pageHeading(page);
41
46
  // With its colours, when a word in it has one.
42
47
  const headingHtml = pageHeadingHtml(page);
@@ -64,7 +69,7 @@ const preview = isPreview(path);
64
69
  const current = livePath(path);
65
70
  const review = (page.data.review ?? '').trim();
66
71
  // The block beside the text (sideBlock): when its type's load gives a view; else the page is drawn as without one.
67
- const sideType = sideBlock(page.data);
72
+ const sideType = sideBlock(template);
68
73
  const sideText = end ? end.html : html;
69
74
  const side =
70
75
  sideType &&
@@ -89,7 +94,7 @@ const side =
89
94
  crumbs={parent ? [{ name: parent.title, href: parent.href }] : []}
90
95
  questions={questions}
91
96
  >
92
- <article>
97
+ <article data-template={template}>
93
98
  <Container as="header" class="pt-8 medium:pt-14">
94
99
  {parent && (
95
100
  <a href={parent.href} class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
@@ -102,16 +107,15 @@ const side =
102
107
  </h1>
103
108
  </Container>
104
109
 
105
- <Container as="div" class:list={[wide ? 'mt-4' : 'mt-8 medium:mt-12']}>
106
- <div {...(side ? { class: 'page-with-side' } : {})}>
110
+ <Container as="div" class="mt-8 medium:mt-12">
111
+ <div {...(side ? { class: 'page-with-side', 'data-side': template === 'sidebar-left' ? 'left' : 'right' } : {})}>
107
112
  <div
108
113
  data-editor="text"
109
- class:list={[
110
- 'prose',
111
- wide
112
- ? 'max-w-none [&>:not(.not-prose)]:max-w-[66ch] [&>p:first-child]:max-w-[52ch] [&>p:first-child]:type-body-large [&>p:first-child]:text-muted medium:[&>p:first-child]:type-title-large'
113
- : 'max-w-[66ch]',
114
- ]}
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
+ }
115
119
  >
116
120
  <RichText html={sideText} path={current} preview={preview} />
117
121
  </div>
@@ -205,12 +205,13 @@
205
205
  font-weight: var(--type-body-large-weight);
206
206
  letter-spacing: var(--type-body-large-tracking);
207
207
  }
208
- /* A blog post's lead: its first paragraph, one step larger than the text (the title-large size, with the body weight). */
208
+ /* A blog post's lead: its first paragraph, one step larger than the text (the lead tokens, ui.css: the title large's
209
+ unless a theme sets them). */
209
210
  .prose.post-lead {
210
- font-size: var(--type-title-large-size);
211
- line-height: var(--type-title-large-line-height);
212
- font-weight: var(--type-body-large-weight);
213
- letter-spacing: var(--type-title-large-tracking);
211
+ font-size: var(--type-lead-size);
212
+ line-height: var(--type-lead-line-height);
213
+ font-weight: var(--type-lead-weight);
214
+ letter-spacing: var(--type-lead-tracking);
214
215
  }
215
216
  /* The text right after the lead (no picture between): the space the prose puts between paragraphs (1.25em), not the
216
217
  larger space of a text under a picture. Unlayered on purpose: it must beat the mt-10 / medium:mt-14 utilities (a layer). */
@@ -584,11 +584,11 @@
584
584
  /* "Udtalelser" one after another (testimonials/List.tsx): two columns where there is room. */
585
585
  .block-testimonial-list {
586
586
  columns: 2 18rem;
587
- column-gap: 1rem;
587
+ column-gap: var(--card-gap, 0.5rem);
588
588
  }
589
589
  .block-testimonial-list > * {
590
590
  break-inside: avoid;
591
- margin-bottom: 1rem;
591
+ margin-bottom: var(--card-gap, 0.5rem);
592
592
  }
593
593
  /* Filter chips (the theme's Chip, ui.css) that filter what is under them ("Udtalelser" under hinanden). */
594
594
  .block-filters {
@@ -744,7 +744,7 @@
744
744
  two side by side from small screens (cards/LinkCards.tsx). */
745
745
  .block-cards {
746
746
  display: grid;
747
- gap: 1rem;
747
+ gap: var(--card-gap, 0.5rem);
748
748
  padding: 0;
749
749
  list-style: none;
750
750
  }
@@ -770,7 +770,7 @@
770
770
  /* The picture fills the top of the card, out to its edges. */
771
771
  .block-card-picture {
772
772
  display: block;
773
- margin: -1.5rem -1.5rem 0.25rem;
773
+ margin: calc(-1 * var(--card-padding, 1rem)) calc(-1 * var(--card-padding, 1rem)) 0.25rem;
774
774
  overflow: hidden;
775
775
  border-radius: calc(var(--card-radius, var(--block-radius)) - 1px) calc(var(--card-radius, var(--block-radius)) - 1px) 0 0;
776
776
  background: var(--block-tint);
@@ -862,7 +862,7 @@
862
862
  display: grid;
863
863
  /* Two side by side where there is room for 17rem each, never more. */
864
864
  grid-template-columns: repeat(auto-fit, minmax(min(100%, max(17rem, calc(50% - 0.5rem))), 1fr));
865
- gap: 1rem;
865
+ gap: var(--card-gap, 0.5rem);
866
866
  }
867
867
  .block-panels.block-in-text {
868
868
  margin-block: 2.5rem;
@@ -1306,7 +1306,7 @@
1306
1306
  }
1307
1307
  .block-price-cards {
1308
1308
  display: grid;
1309
- gap: 1rem;
1309
+ gap: var(--card-gap, 0.5rem);
1310
1310
  }
1311
1311
  /* A big price, an elevated card (.card in ui.css). */
1312
1312
  .block-price-card {
@@ -1459,9 +1459,6 @@
1459
1459
  }
1460
1460
 
1461
1461
  @media (hover: hover) {
1462
- a.block-card:hover {
1463
- --card-outlined-line: var(--block-accent);
1464
- }
1465
1462
  a.block-card:hover .block-card-arrow {
1466
1463
  translate: 0.25rem 0;
1467
1464
  }
@@ -1536,8 +1533,7 @@
1536
1533
  font-weight: var(--type-headline-medium-weight);
1537
1534
  letter-spacing: var(--type-headline-medium-tracking);
1538
1535
  }
1539
- .block-prices,
1540
- .block-price-cards {
1536
+ .block-prices {
1541
1537
  gap: 1.25rem;
1542
1538
  }
1543
1539
  .block-price-cards {
@@ -1597,7 +1593,7 @@
1597
1593
  }
1598
1594
  .block-plans-list {
1599
1595
  display: grid;
1600
- gap: 1rem;
1596
+ gap: var(--card-gap, 0.5rem);
1601
1597
  margin: 0;
1602
1598
  padding: 0;
1603
1599
  list-style: none;
@@ -202,7 +202,7 @@
202
202
  }
203
203
 
204
204
  /* A page with a block beside its text (PageLayout.astro): the text, then the column (on a phone below the text); from medium
205
- two columns, the second one staying in view while the text is read. */
205
+ two columns, the one beside the text staying in view while the text is read; on the left for `sidebar-left`. */
206
206
  .page-with-side > .page-side {
207
207
  margin-top: 3rem;
208
208
  }
@@ -218,6 +218,13 @@
218
218
  position: sticky;
219
219
  top: 6rem;
220
220
  }
221
+ /* Template sidebar-left: the column first, though it comes after the text in the page. */
222
+ .page-with-side[data-side='left'] {
223
+ grid-template-columns: var(--theme-side-width) minmax(0, 1fr);
224
+ }
225
+ .page-with-side[data-side='left'] > .page-side {
226
+ order: -1;
227
+ }
221
228
  }
222
229
 
223
230
  /* A page's and a post's title (PageHeader.astro, PageLayout.astro, PostLayout.astro, Home.astro): the display role, by
@@ -194,6 +194,8 @@
194
194
  /* Cards (ui.css): 12px corners; outlined on the surface with the outline variant, elevated on a low container with
195
195
  the first elevation, filled on the highest container. */
196
196
  --card-radius: var(--md-shape-medium);
197
+ /* The room between cards side by side or under each other: 8px (Material 3's at most). */
198
+ --card-gap: 0.5rem;
197
199
  --card-filled-surface: var(--md-surface-container-highest);
198
200
  --card-outlined-surface: var(--surface);
199
201
  --card-outlined-line: var(--line);
@@ -851,13 +853,12 @@
851
853
  padding: 1rem 1.25rem;
852
854
  }
853
855
 
854
- /* "Kort": filled cards, as Material 3's (their outlined cards on the highest container, without the edge or a
855
- shadow, also when pointed at), with a state layer when pointed at. */
856
+ /* "Kort": filled cards, as Material 3's (their outlined cards on the highest container, with no edge and
857
+ no shadow at rest, and the first elevation when pointed at), with the card's state layer (ui.css) when pointed at or
858
+ focused. */
856
859
  .block-card {
857
860
  border: 0;
858
861
  --card-outlined-surface: var(--md-surface-container-highest);
859
- --card-outlined-hover-shadow: var(--elevation-0);
860
- transition: background-color var(--motion-effects);
861
862
  }
862
863
  .block-card-picture {
863
864
  border-radius: var(--card-radius) var(--card-radius) var(--md-shape-none) var(--md-shape-none);
@@ -865,11 +866,6 @@
865
866
  .block-card-text {
866
867
  font-weight: 400;
867
868
  }
868
- @media (hover: hover) {
869
- a.block-card:hover {
870
- --card-outlined-surface: color-mix(in srgb, var(--ink) 8%, var(--md-surface-container-highest));
871
- }
872
- }
873
869
 
874
870
  /* Panels and the cards of testimonials and prices: outlined, filled and elevated cards (their variables above). A
875
871
  list in a panel reaches out to its sides, so its items' text is in line with the panel's. */