@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
@@ -100,6 +100,10 @@ export const library = {
100
100
  changeMotion: 'Change motion',
101
101
  motionSaved: (change: string) => `${change}. The website gets the new motion in a few minutes.`,
102
102
  motionCommit: (name: string) => `Editor: change motion to ${name}`,
103
+ /** Appearance › Header (HeaderSettings.tsx): the group with the menu button's side. */
104
+ headerMenuButton: 'Menu button',
105
+ /** Appearance › Footer (FooterSettings.tsx): the groups of the footer's fields. */
106
+ footerSections: { text: 'Text', contact: 'Contact', links: 'Links' },
103
107
  /** Under "Sidekolonne" (SideColumn.tsx): the choice of no block and what is under it, what is said when no block can be
104
108
  * drawn there, asked before one is chosen, the question's button, what is said after it is saved, and the commit, in
105
109
  * the site's language (siteT). */
@@ -7,6 +7,9 @@ export const preview = {
7
7
  /** How wide the page is shown: Material 3's window sizes, each at its narrowest. */
8
8
  size: 'Window size',
9
9
  sizes: { compact: 'Compact', medium: 'Medium', expanded: 'Expanded', large: 'Large', extraLarge: 'Extra-large' },
10
+ /** The preview of a part every page has (partPreview.tsx, the header): on a phone and on a computer. */
11
+ phone: 'Phone',
12
+ computer: 'Computer',
10
13
  making: 'Loading the preview…',
11
14
  failed: (error: string) => `Couldn’t create the preview: ${error}`,
12
15
  notHere: 'That page can’t be previewed',
@@ -26,7 +26,8 @@ export const text = {
26
26
  color: 'Color',
27
27
  colors: { accent: 'Accent color', muted: 'Muted text' },
28
28
  noColor: 'No color',
29
- /** The button for the colour of a section (an H2 and what follows it), and the choices in its menu (packages/editor/lib/section-marker.mjs). */
29
+ /** The button for the settings of a section (an H2 and what follows it), and its sheet's colour choices (packages/editor/lib/section-marker.mjs). */
30
+ sectionSettings: 'Section settings',
30
31
  sectionColor: 'Section color',
31
32
  sectionColors: { none: 'None', surface: 'Surface', tint: 'Tint', accent: 'Accent' },
32
33
  /** The sheet of a link (LinkSheet.tsx). */
package/src/images.ts CHANGED
@@ -132,15 +132,18 @@ export async function prepareFile(file: File, taken: string[]): Promise<Upload>
132
132
  export const publicPath = (path: string) => path.replace(/^public/, '');
133
133
 
134
134
  // The pictures added in a form (a page, a fixed page), kept in the browser until "Gem", which saves
135
- // them in the same commit. upload: pick one and make it ready; added: those not saved yet, newest
135
+ // them in the same commit. upload: pick one and make it ready; uploading: true while a chosen picture
136
+ // is made ready (a button shows busy); added: those not saved yet, newest
136
137
  // first (offered in the gallery, MediaPicker.tsx); saving: the pictures a text uses.
137
138
  export function useUploads(notify: (status: Status) => void) {
138
139
  const [uploads, setUploads] = useState<Upload[]>([]);
140
+ const [uploading, setUploading] = useState(false);
139
141
 
140
142
  async function upload(): Promise<Upload | null> {
141
143
  const file = await pickImage();
142
144
  if (!file) return null;
143
145
  notify({ text: t.app.preparingPicture });
146
+ setUploading(true);
144
147
  try {
145
148
  const prepared = await prepareImage(file);
146
149
  setUploads((u) => [...u, prepared]);
@@ -149,6 +152,8 @@ export function useUploads(notify: (status: Status) => void) {
149
152
  } catch (e) {
150
153
  notify({ text: (e as Error).message, error: true });
151
154
  return null;
155
+ } finally {
156
+ setUploading(false);
152
157
  }
153
158
  }
154
159
 
@@ -157,7 +162,7 @@ export function useUploads(notify: (status: Status) => void) {
157
162
  const usedIn = (text: string) => uploads.filter((u) => !u.saved && text.includes(publicPath(u.path)));
158
163
  const markSaved = (pictures: Upload[]) => setUploads((all) => all.map((u) => (pictures.includes(u) ? { ...u, saved: true } : u)));
159
164
 
160
- return { upload, added, usedIn, markSaved };
165
+ return { upload, uploading, added, usedIn, markSaved };
161
166
  }
162
167
 
163
168
  export type Uploads = ReturnType<typeof useUploads>;
@@ -1,7 +1,6 @@
1
- // The second level of the main menu (MainMenu.tsx, lib/menu.mjs menuTree): the lists under Lists, the menus under Header,
1
+ // The second level of the main menu (MainMenu.tsx, lib/menu.mjs menuTree): the lists under Lists,
2
2
  // the parts under Business, the apps installed under Apps and the two lists under Redirects. Exposes childrenOf.
3
3
  // Uses site.ts (collections, apps, companyParts), Library.tsx (hrefOf, listsShown) and Redirects.tsx (TAB_ROUTES).
4
- import { header } from '@site/editor';
5
4
  import { installedApps } from './apps';
6
5
  import { t } from './i18n';
7
6
  import { hrefOf } from './Library';
@@ -20,7 +19,6 @@ const listOf = (ids: string[]): MenuChild[] =>
20
19
  /** The sub-items of a menu item, in their order: none for most. */
21
20
  export function childrenOf(id: string, files: RepoFile[]): MenuChild[] {
22
21
  if (id === 'lister') return listOf(listsShown(files));
23
- if (id === 'header') return listOf(header);
24
22
  if (id === 'virksomheden') return companyParts.map((p) => ({ id: p.id, label: p.label, href: `#virksomheden/${p.id}` }));
25
23
  if (id === 'apps') {
26
24
  const installed = installedApps(files);
@@ -0,0 +1,3 @@
1
+ /** Where each moved screen is now, by its old address. */
2
+ export const MOVED: Record<string, string>;
3
+ export function movedRoute(route: string, more?: Record<string, string>): string;
@@ -0,0 +1,35 @@
1
+ // The editor's screens that have moved, by their old address after the "#": a link or a bookmark to one opens it where
2
+ // it is now. Exposes MOVED and movedRoute (pure). Used by App.tsx (routeOf), which adds the app store's own.
3
+
4
+ /** Where each moved screen is now, by its old address. */
5
+ export const MOVED = /** @type {Record<string, string>} */ ({
6
+ farver: 'udseende/farver',
7
+ 'virksomheden/logo': 'udseende/logo',
8
+ 'tema/blokvalg': 'blokvalg',
9
+ dele: 'udseende/dele',
10
+ tema: 'udseende',
11
+ 'tema/farver': 'udseende/farver',
12
+ 'tema/logo': 'udseende/logo',
13
+ 'tema/dele': 'udseende/dele',
14
+ 'tema/dele/editor': 'udseende/dele/editor',
15
+ 'virksomheden/bank': 'virksomheden/betaling/bank',
16
+ videresendelser: 'seo/videresendelser',
17
+ 'videresendelser/alle': 'seo/videresendelser/alle',
18
+ 'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
19
+ // The settings of the booking and newsletter apps were on a screen "Promovering"; they are on the apps' own pages now.
20
+ promovering: 'apps',
21
+ 'promovering/calendly': 'apps/calendly',
22
+ 'promovering/mailchimp': 'apps/mailchimp',
23
+ // The header's menus and settings were on a screen of their own under "Globalt"; they are under "Udseende" now.
24
+ header: 'udseende/header',
25
+ // The footer's form was the file's own screen; it is under "Udseende" now.
26
+ 'src/data/footer.json': 'udseende/footer',
27
+ });
28
+
29
+ /**
30
+ * The address a screen is at now: where it moved to (`more` first, then MOVED), else the same address.
31
+ * @param {string} route
32
+ * @param {Record<string, string>} [more]
33
+ * @returns {string}
34
+ */
35
+ export const movedRoute = (route, more = {}) => more[route] ?? MOVED[route] ?? route;
@@ -0,0 +1,22 @@
1
+ // Tests movedRoutes.mjs: an old address opens the screen where it is now, others stay.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { movedRoute } from './movedRoutes.mjs';
5
+
6
+ test('the old header screen opens Appearance › Header', () => {
7
+ assert.equal(movedRoute('header'), 'udseende/header');
8
+ });
9
+
10
+ test('the old footer form opens Appearance › Footer', () => {
11
+ assert.equal(movedRoute('src/data/footer.json'), 'udseende/footer');
12
+ });
13
+
14
+ test('an address that has not moved stays', () => {
15
+ assert.equal(movedRoute('udseende/header'), 'udseende/header');
16
+ assert.equal(movedRoute('lister'), 'lister');
17
+ });
18
+
19
+ test('the extra moves come first', () => {
20
+ assert.equal(movedRoute('butik', { butik: 'indstillinger/appstore' }), 'indstillinger/appstore');
21
+ assert.equal(movedRoute('tema', { butik: 'x' }), 'udseende');
22
+ });
@@ -10,6 +10,9 @@
10
10
  // (Astro's pipeline, with the HTML written in it), and its blocks are drawn as in the editor
11
11
  // (registry.tsx preview), with the site's classes; a coloured section is a panel as on the site (lib/text-sections.mjs).
12
12
  //
13
+ // Exposes pageDocument, usePagePreview and PageDraft, and for the preview of a part every page has (partPreview.tsx) siteShell,
14
+ // documentHtml, htmlOf and PREVIEW_WIDTH.
15
+ //
13
16
  // The page is shown in a frame without scripts (sandbox), so nothing of the site runs on the editor's
14
17
  // address, where its login is kept, and its links lead nowhere. A new page or post is shown in the
15
18
  // page of another one. As on the site but for this: a changed text has the pictures themselves (not
@@ -56,9 +59,8 @@ export type PageDraft = {
56
59
  // but those that read the site's files.
57
60
  const markdownHtml = async (markdown: string) => String(await processor.process(markdown));
58
61
 
59
- // A block drawn as in the editor, as HTML.
60
- function blockHtml(type: string, props: Record<string, unknown>, data: BlockData) {
61
- const drawn: ReactNode = drawBlock(type, props, data);
62
+ /** What React draws (a block, a part of the header drawn with the theme's components), as HTML. */
63
+ export function htmlOf(drawn: ReactNode) {
62
64
  if (!drawn) return '';
63
65
  const box = document.createElement('div');
64
66
  const root = createRoot(box);
@@ -68,6 +70,9 @@ function blockHtml(type: string, props: Record<string, unknown>, data: BlockData
68
70
  return html;
69
71
  }
70
72
 
73
+ // A block drawn as in the editor, as HTML.
74
+ const blockHtml = (type: string, props: Record<string, unknown>, data: BlockData) => htmlOf(drawBlock(type, props, data));
75
+
71
76
  // A picture made ready in this visit, which the site does not have yet (images.ts shownAt), in the
72
77
  // page itself.
73
78
  async function inline(src: string) {
@@ -89,25 +94,34 @@ async function sitePage(address: string) {
89
94
  return new DOMParser().parseFromString(await res.text(), 'text/html');
90
95
  }
91
96
 
97
+ /** The first of the site's pages (their addresses) that has a part (`has`, a selector), without its scripts and with its own
98
+ * addresses as on the site, for a frame (PageFrame); a link in it opens nothing (target, and no popups in the frame).
99
+ * Also for the preview of a part on every page (partPreview.tsx). */
100
+ export async function siteShell(addresses: string | string[], has: string): Promise<Document> {
101
+ for (const address of [addresses].flat()) {
102
+ const doc = await sitePage(address);
103
+ if (!doc.querySelector(has)) continue;
104
+ doc.querySelectorAll('script').forEach((s) => s.remove());
105
+ const base = doc.createElement('base');
106
+ base.href = new URL(address, location.href).href;
107
+ base.target = '_blank';
108
+ doc.head.prepend(base);
109
+ return doc;
110
+ }
111
+ throw new Error(t.preview.notHere);
112
+ }
113
+
114
+ /** A page made ready (siteShell) as HTML for a frame, with the pictures made ready in this visit in it. */
115
+ export async function documentHtml(doc: Document): Promise<string> {
116
+ await Promise.all(
117
+ [...doc.querySelectorAll('img')].map(async (img) => img.setAttribute('src', await inline(img.getAttribute('src') ?? ''))),
118
+ );
119
+ return `<!doctype html>\n${doc.documentElement.outerHTML}`;
120
+ }
121
+
92
122
  /** The page with the draft in it, as HTML for a frame (PageFrame). */
93
123
  export async function pageDocument(draft: PageDraft): Promise<string> {
94
- let doc: Document | null = null;
95
- let shell = '';
96
- for (const address of [draft.shell].flat()) {
97
- const page = await sitePage(address);
98
- if (page.querySelector('[data-editor="text"]')) {
99
- doc = page;
100
- shell = address;
101
- break;
102
- }
103
- }
104
- if (!doc) throw new Error(t.preview.notHere);
105
- doc.querySelectorAll('script').forEach((s) => s.remove());
106
- // Its own addresses as on the site; a link opens nothing (target, and no popups in the frame).
107
- const base = doc.createElement('base');
108
- base.href = new URL(shell, location.href).href;
109
- base.target = '_blank';
110
- doc.head.prepend(base);
124
+ const doc = await siteShell(draft.shell, '[data-editor="text"]');
111
125
 
112
126
  const heading = doc.querySelector('[data-editor="heading"]');
113
127
  if (heading && draft.headingHtml) heading.innerHTML = draft.headingHtml;
@@ -166,17 +180,14 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
166
180
  })
167
181
  .join('');
168
182
  }
169
-
170
- await Promise.all(
171
- [...doc.querySelectorAll('img')].map(async (img) => img.setAttribute('src', await inline(img.getAttribute('src') ?? ''))),
172
- );
173
- return `<!doctype html>\n${doc.documentElement.outerHTML}`;
183
+ return documentHtml(doc);
174
184
  }
175
185
 
176
- // The widths the page is shown at: Material 3's breakpoints, each at the narrowest window it has, so its tightest
177
- // layout is seen; compact at a common phone's.
186
+ // The sizes the page is shown at: Material 3's breakpoints.
178
187
  const SIZES: Breakpoint[] = ['compact', 'medium', 'expanded', 'large', 'extraLarge'];
179
- const WIDTH: Record<Breakpoint, number> = { ...BREAKPOINTS, compact: 390 };
188
+ /** The width of the page at each size: the narrowest window it has, so its tightest layout is seen; compact at a common
189
+ * phone's. Also the preview of a part (partPreview.tsx). */
190
+ export const PREVIEW_WIDTH: Record<Breakpoint, number> = { ...BREAKPOINTS, compact: 390 };
180
191
 
181
192
  /** The sheet with the preview, and how to open it with a draft (made when it opens). */
182
193
  export function usePagePreview(): [(draft: () => PageDraft | null) => void, ReactNode] {
@@ -218,7 +229,7 @@ export function usePagePreview(): [(draft: () => PageDraft | null) => void, Reac
218
229
  />
219
230
  </div>
220
231
  {html ? (
221
- <PageFrame html={html} width={WIDTH[size]} title={t.preview.title} />
232
+ <PageFrame html={html} width={PREVIEW_WIDTH[size]} title={t.preview.title} />
222
233
  ) : (
223
234
  <p className="px-5 text-muted">{problem || t.preview.making}</p>
224
235
  )}
@@ -0,0 +1,59 @@
1
+ // The preview of a part every page has (the header: HeaderSettings.tsx; the footer next) as the form has it now, at once
2
+ // and without saving: the site's front page with only that part changed (`swap`, on a copy of the page each time the form
3
+ // changes), on a phone and on a computer (lib/breakpoints.mjs), each frame cut to the part (PageFrame `part`). Exposes
4
+ // PartPreview. Uses pagePreview.tsx (siteShell, documentHtml, PREVIEW_WIDTH) and ui.tsx.
5
+ import { useEffect, useState } from 'react';
6
+ import { siteShell, documentHtml, PREVIEW_WIDTH } from './pagePreview';
7
+ import { EmptyNote, FieldGroup, FormField, PageFrame } from './ui';
8
+ import { t } from './i18n';
9
+
10
+ // A pause after the last change before the page is drawn again, so typing does not draw it at every key.
11
+ const SETTLE = 250;
12
+
13
+ /** `part`: its data-editor name on the site's pages ("header"); `swap`: puts what the form changes into a copy of the
14
+ * page (a new function when the form changes). */
15
+ export function PartPreview({ part, swap }: { part: string; swap: (doc: Document) => void }) {
16
+ const selector = `[data-editor="${part}"]`;
17
+ const [shell, setShell] = useState<Document | null>(null);
18
+ const [html, setHtml] = useState<string | null>(null);
19
+ const [problem, setProblem] = useState('');
20
+
21
+ useEffect(() => {
22
+ siteShell('/', selector).then(setShell, (e) => setProblem(t.preview.failed((e as Error).message)));
23
+ }, [selector]);
24
+
25
+ useEffect(() => {
26
+ if (!shell) return;
27
+ let stopped = false;
28
+ const timer = setTimeout(() => {
29
+ const doc = shell.cloneNode(true) as Document;
30
+ try {
31
+ swap(doc);
32
+ } catch (e) {
33
+ return setProblem(t.preview.failed((e as Error).message));
34
+ }
35
+ documentHtml(doc).then((page) => !stopped && setHtml(page));
36
+ }, SETTLE);
37
+ return () => {
38
+ stopped = true;
39
+ clearTimeout(timer);
40
+ };
41
+ }, [shell, swap]);
42
+
43
+ return (
44
+ <FieldGroup label={t.preview.title}>
45
+ {html ? (
46
+ <>
47
+ <FormField label={t.preview.phone}>
48
+ <PageFrame html={html} width={PREVIEW_WIDTH.compact} title={t.preview.phone} part={selector} />
49
+ </FormField>
50
+ <FormField label={t.preview.computer}>
51
+ <PageFrame html={html} width={PREVIEW_WIDTH.large} title={t.preview.computer} part={selector} />
52
+ </FormField>
53
+ </>
54
+ ) : (
55
+ <EmptyNote busy={!problem}>{problem || t.preview.making}</EmptyNote>
56
+ )}
57
+ </FieldGroup>
58
+ );
59
+ }
package/src/schema.ts CHANGED
@@ -63,6 +63,10 @@ export type Field = {
63
63
  * chosen, in place of the field's own (the status of a page: what each one means). `onPage`: offered only while
64
64
  * that relation has a choice on the page being edited (a button that opens a promotion); chosen, it is still shown. */
65
65
  options?: { label: string; value: string; icon?: Icon; hint?: string; onPage?: Relation }[];
66
+ /** select: the options come from the theme in use. 'templates': a page's templates, the theme's "templates" (lib/page-template.mjs); the
67
+ * field shows those of its options the theme offers (templateFieldOf in templateField.ts), its default is the theme's first, which
68
+ * is written as no key, and it is hidden while at most one is left. */
69
+ offered?: 'templates';
66
70
  /** select: the options are icons (their names, `icons`), shown as the icons to tap (fields.tsx). */
67
71
  icons?: boolean;
68
72
  /** select: the options side by side in a bar, the chosen one raised (SegmentedButton in ui.tsx), for two or
@@ -106,7 +110,7 @@ export type Field = {
106
110
  only?: { field: string; is: string[] };
107
111
  /** boolean: shown as a switch, with what it says when on and when off. */
108
112
  toggle?: [string, string];
109
- /** Its value changes how the page is laid out (a page across the whole width): "Forhåndsvis" shows a page whose value
113
+ /** Its value changes how the page is laid out (its template): "Forhåndsvis" shows a page whose value
110
114
  * is changed in another page saved with the same value (EntryForm.tsx). */
111
115
  layout?: boolean;
112
116
  /** blocks: a list where each item has a `type`, one of these, which gives its fields. */
@@ -288,6 +292,8 @@ export type DataFile = {
288
292
  root?: Field;
289
293
  /** A fixed page called by its label, not its H1 (the front page). */
290
294
  byLabel?: boolean;
295
+ /** The site may have no such file: its screen opens on an empty one, and the first save makes the file (the header). */
296
+ optional?: boolean;
291
297
  };
292
298
 
293
299
  /** A fixed page's name in the editor: its H1 (`heading`), as the page has it now; the front page
@@ -0,0 +1,3 @@
1
+ /** Whether a step ('choose', 'fields', 'section' or none) is shown in the side panel; large: the window is at least
2
+ * the `large` breakpoint. */
3
+ export function inPanel(step: any, large: any): boolean;
@@ -0,0 +1,9 @@
1
+ // Where a step of the block sheet (BlockDialog.tsx) is shown: in a side panel at the window's end edge on a large screen
2
+ // for the fields of a block and the section settings, else in the dialog (the choice of block always is).
3
+ // Exposes inPanel(step, large); no dependencies.
4
+
5
+ /** Whether a step ('choose', 'fields', 'section' or none) is shown in the side panel; large: the window is at least
6
+ * the `large` breakpoint. */
7
+ export function inPanel(step, large) {
8
+ return !!large && (step === 'fields' || step === 'section');
9
+ }
@@ -0,0 +1,23 @@
1
+ // Tests for sheetPlacement.mjs: which steps of the block sheet are shown in the side panel.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { inPanel } from './sheetPlacement.mjs';
5
+
6
+ test('the fields and the section settings are in the panel on a large screen', () => {
7
+ assert.equal(inPanel('fields', true), true);
8
+ assert.equal(inPanel('section', true), true);
9
+ });
10
+
11
+ test('the choice of block is always a dialog', () => {
12
+ assert.equal(inPanel('choose', true), false);
13
+ assert.equal(inPanel('choose', false), false);
14
+ });
15
+
16
+ test('below large everything is a dialog', () => {
17
+ assert.equal(inPanel('fields', false), false);
18
+ assert.equal(inPanel('section', false), false);
19
+ });
20
+
21
+ test('a closed sheet is not in the panel', () => {
22
+ assert.equal(inPanel(undefined, true), false);
23
+ });
@@ -25,10 +25,10 @@ export const blockLoads: Record<string, Contract<any>> = {
25
25
  };
26
26
 
27
27
  /** The block type drawn in a column beside the text of a page, or null: the one src/data/side.json chooses, when its
28
- * load.ts says `beside`, unless the page is `wide` or has `side: false` (lib/side-block.mjs). The theme's page layout
28
+ * load.ts says `beside` and the page's template has a sidebar (lib/side-block.mjs). The theme's page layout
29
29
  * (PageLayout.astro) draws it with Block.astro, given the page's text as `html`. */
30
- export const sideBlock = (page: { wide?: boolean; side?: boolean }): string | null =>
31
- sideBlockOf(page, sideSetting, (type) => blockLoads[type]?.beside === true);
30
+ export const sideBlock = (template: string): string | null =>
31
+ sideBlockOf(template, sideSetting, (type) => blockLoads[type]?.beside === true);
32
32
 
33
33
  // A block's tag in the text, at its very end (blocks.mjs writes it so).
34
34
  const END = /<div data-block="([\w-]+)"(?: data-props="[^"]*")?\s*><\/div>\s*$/;
@@ -0,0 +1,28 @@
1
+ // The page form's field of the templates the chosen theme offers (Field.offered: 'templates'): which templates the theme
2
+ // offers, which of them the form shows, and what the form shows as chosen. Exposes offeredTemplatesOf, templateFieldOf, shownTemplate.
3
+ // Uses chosenTheme (ThemeStore.tsx), themes.ts, sideSettingOf (SideColumn.tsx) and lib/page-template.mjs.
4
+ import type { RepoFile } from './github';
5
+ import type { Field } from './schema';
6
+ import { chosenTheme } from './ThemeStore';
7
+ import { sideSettingOf } from './SideColumn';
8
+ import { themes } from './themes';
9
+ import { templateChoices, templateOf, templatesOf } from '../lib/page-template.mjs';
10
+
11
+ /** The templates the theme chosen (src/data/theme.json) offers, the first the default. */
12
+ export const offeredTemplatesOf = (files: RepoFile[]): string[] =>
13
+ templatesOf(themes.find((x) => x.id === chosenTheme(files)) ?? null) as string[];
14
+
15
+ /** The field with only the options the form can offer: those of the theme, without the sidebar ones while no block is
16
+ * chosen for the column (Appearance › Side column). Null when the field has no `offered` or at most one is left. */
17
+ export function templateFieldOf(field: Field, files: RepoFile[]): Field | null {
18
+ if (field.offered !== 'templates') return null;
19
+ const choices: string[] = templateChoices(offeredTemplatesOf(files), sideSettingOf(files) !== null);
20
+ return choices.length > 1 ? { ...field, options: field.options?.filter((o) => choices.includes(o.value)) } : null;
21
+ }
22
+
23
+ /** What the form shows as chosen: the value if the field offers it, else its first option (the theme's default). */
24
+ export const shownTemplate = (field: Field, value: unknown): string =>
25
+ templateOf(
26
+ { template: value },
27
+ (field.options ?? []).map((o) => o.value),
28
+ );
package/src/themes.ts CHANGED
@@ -25,6 +25,8 @@ export type ThemeInfo = {
25
25
  icons?: string;
26
26
  /** How it moves while the owner has chosen none ("motion" in its theme.json: a scheme of lib/motion.ts). */
27
27
  motion?: string;
28
+ /** The page templates it offers, the first the default ("templates" in its theme.json: ids of lib/page-template.mjs). */
29
+ templates?: string[];
28
30
  /** What it looks like, in a sentence or two, by the editor's language ("da-DK"), on its page. */
29
31
  description?: Record<string, string>;
30
32
  /** What it gives the site, a few short points by the editor's language, on its page. */
@@ -79,6 +81,7 @@ export const themes: ThemeInfo[] = Object.entries(manifests)
79
81
  thirdParty: m.thirdParty,
80
82
  ...(m.icons ? { icons: m.icons } : {}),
81
83
  ...(m.motion ? { motion: m.motion } : {}),
84
+ ...(m.templates ? { templates: m.templates } : {}),
82
85
  description: m.description,
83
86
  highlights: m.highlights,
84
87
  screenshots: screenshotsOf(idOf(path)),
package/src/ui.tsx CHANGED
@@ -135,6 +135,8 @@ type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
135
135
  size?: ButtonSize;
136
136
  /** Before the text. */
137
137
  icon?: Icon;
138
+ /** Its action runs: disabled, and after a moment a loading indicator. */
139
+ busy?: boolean;
138
140
  type?: 'button' | 'submit';
139
141
  };
140
142
 
@@ -171,6 +173,8 @@ type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'
171
173
  kind?: IconButtonKind;
172
174
  selected?: boolean;
173
175
  badge?: number | boolean;
176
+ /** Its action runs: disabled, and after a moment a loading indicator. */
177
+ busy?: boolean;
174
178
  type?: 'button' | 'submit';
175
179
  };
176
180
 
@@ -3207,28 +3211,36 @@ export function Dialog({ ref, className, children, ...rest }: DialogHTMLAttribut
3207
3211
 
3208
3212
  /** The sheet from the bottom (a dialog in the middle on a computer): the theme's bottom sheet, as high as what is in
3209
3213
  * it, on a <dialog> (`ref`; opened with showModal), with a SheetTitle and SheetBody, SheetList or SheetActions in it.
3210
- * `full`: as high as the screen, and wider on a computer, with a whole page in it (the preview, PageFrame). */
3214
+ * `full`: as high as the screen, and wider on a computer, with a whole page in it (the preview, PageFrame).
3215
+ * `panel`: the theme's modal side sheet (data-wide="side-sheet") at the window's end edge under the top bar, half the space
3216
+ * beside the start panel (.editor-side-panel in editor.css); the rest of the page stays in view, inert as under a dialog. */
3211
3217
  export function BottomSheet({
3212
3218
  ref,
3213
3219
  full,
3220
+ panel,
3214
3221
  className,
3215
3222
  children,
3216
3223
  ...rest
3217
- }: DialogHTMLAttributes<HTMLDialogElement> & { ref?: Ref<HTMLDialogElement>; full?: boolean }) {
3224
+ }: DialogHTMLAttributes<HTMLDialogElement> & { ref?: Ref<HTMLDialogElement>; full?: boolean; panel?: boolean }) {
3218
3225
  const sheet = full ? siteBottomSheetFullClass : siteBottomSheetFitClass;
3219
3226
  return (
3220
- <dialog ref={ref} className={[sheet, 'editor-sheet', className].filter(Boolean).join(' ')} {...rest}>
3221
- <div className={siteBottomSheetContainerClass()}>{children}</div>
3227
+ <dialog ref={ref} className={[sheet, 'editor-sheet', panel && 'editor-side-panel', className].filter(Boolean).join(' ')} {...rest}>
3228
+ <div className={siteBottomSheetContainerClass()} data-wide={panel ? 'side-sheet' : undefined}>
3229
+ {children}
3230
+ </div>
3222
3231
  </dialog>
3223
3232
  );
3224
3233
  }
3225
3234
 
3226
3235
  /** A web page in a frame, in a full BottomSheet (the preview of a page): `html` is the whole page, drawn as
3227
3236
  * wide as `width` (one of Material 3's window sizes) and made smaller to fit, scrolled in the frame. Nothing
3228
- * in it runs (no scripts), and its links lead nowhere. */
3229
- export function PageFrame({ html, width, title }: { html: string; width: number; title: string }) {
3237
+ * in it runs (no scripts), and its links lead nowhere. With `part` (a selector: the header) only that part of the page is
3238
+ * shown, as high as it is (measured when the page is in the frame), in the frame's own place: Appearance › Header. */
3239
+ export function PageFrame({ html, width, title, part }: { html: string; width: number; title: string; part?: string }) {
3230
3240
  const box = useRef<HTMLDivElement>(null);
3231
3241
  const [size, setSize] = useState({ width: 0, height: 0 });
3242
+ // Where the part is in the page (px of the page), once it is in the frame.
3243
+ const [cut, setCut] = useState<{ top: number; height: number } | null>(null);
3232
3244
  useEffect(() => {
3233
3245
  const el = box.current;
3234
3246
  if (!el) return;
@@ -3237,15 +3249,30 @@ export function PageFrame({ html, width, title }: { html: string; width: number;
3237
3249
  return () => watch.disconnect();
3238
3250
  }, []);
3239
3251
  const scale = size.width ? Math.min(1, size.width / width) : 1;
3252
+ const measure = (frame: HTMLIFrameElement) => {
3253
+ const doc = frame.contentDocument;
3254
+ const el = part && doc?.querySelector(part);
3255
+ if (!doc || !el) return;
3256
+ // Only the part is shown: the page does not scroll in its frame.
3257
+ doc.documentElement.style.overflow = 'hidden';
3258
+ const r = el.getBoundingClientRect();
3259
+ setCut({ top: r.top, height: r.height });
3260
+ };
3261
+ const height = part ? (cut ? cut.top + cut.height : width) : size.height / scale;
3240
3262
  return (
3241
- <div ref={box} className="relative min-h-0 flex-1 overflow-hidden border-t border-line bg-line/40">
3263
+ <div
3264
+ ref={box}
3265
+ className="relative min-h-0 flex-1 overflow-hidden border-t border-line bg-line/40"
3266
+ style={part ? { height: (cut?.height ?? 0) * scale } : undefined}
3267
+ >
3242
3268
  {size.width > 0 && (
3243
3269
  <iframe
3244
3270
  title={title}
3245
3271
  srcDoc={html}
3246
3272
  sandbox="allow-same-origin"
3247
3273
  className="absolute top-0 left-1/2 origin-top border-0 bg-white"
3248
- style={{ width, height: size.height / scale, transform: `translateX(-50%) scale(${scale})` }}
3274
+ style={{ width, height, top: -(cut?.top ?? 0) * scale, transform: `translateX(-50%) scale(${scale})` }}
3275
+ onLoad={part ? (e) => measure(e.currentTarget) : undefined}
3249
3276
  />
3250
3277
  )}
3251
3278
  </div>