@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.
- package/CHANGELOG.md +17 -1
- package/defaults/collections.ts +110 -56
- package/defaults/editor.ts +2 -2
- package/defaults/texts.ts +14 -5
- package/demo/src/content/pages/app-store.md +0 -1
- package/demo/src/content/pages/blocks.md +0 -1
- package/icons/lucide/arrows-horizontal.svg +1 -0
- package/icons/lucide/columns-plus-left.svg +1 -0
- package/icons/lucide/columns-plus-right.svg +1 -0
- package/icons/lucide/file.svg +1 -0
- package/icons/material-symbols/arrows-horizontal.svg +1 -0
- package/icons/material-symbols/columns-plus-left.svg +1 -0
- package/icons/material-symbols/columns-plus-right.svg +1 -0
- package/icons/material-symbols/file.svg +1 -0
- package/icons/phosphor/arrows-horizontal.svg +1 -0
- package/icons/phosphor/columns-plus-left.svg +1 -0
- package/icons/phosphor/columns-plus-right.svg +1 -0
- package/icons/phosphor/file.svg +1 -0
- package/lib/fold-open.d.mts +2 -0
- package/lib/fold-open.mjs +22 -0
- package/lib/fold-open.test.mjs +24 -0
- package/lib/menu.test.mjs +16 -3
- package/lib/page-template.d.mts +54 -0
- package/lib/page-template.mjs +126 -0
- package/lib/page-template.test.mjs +129 -0
- package/lib/side-block.d.mts +2 -16
- package/lib/side-block.mjs +12 -30
- package/lib/side-block.test.mjs +13 -30
- package/package.json +1 -1
- package/scripts/check-side.mjs +49 -22
- package/scripts/editor-roundtrip.mjs +15 -6
- package/scripts/fixtures/side/side-full.md +8 -0
- package/scripts/fixtures/side/side-left.md +8 -0
- package/scripts/fixtures/side/side-none.md +7 -0
- package/scripts/fixtures/side/side-right.md +8 -0
- package/scripts/fixtures/side/side-single.md +8 -0
- package/scripts/side-width.mjs +29 -0
- package/scripts/side-width.test.mjs +30 -0
- package/site/content.config.ts +3 -4
- package/site/header.d.mts +21 -0
- package/site/header.mjs +35 -0
- package/site/header.test.mjs +59 -0
- package/site/icons.d.mts +16 -0
- package/site/icons.mjs +8 -0
- package/site/site.ts +8 -3
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- package/src/App.tsx +12 -24
- package/src/AppList.tsx +36 -14
- package/src/AppSettingsTab.tsx +5 -3
- package/src/BlockDialog.tsx +50 -5
- package/src/DataFileFields.tsx +35 -0
- package/src/DataForm.tsx +16 -38
- package/src/Deleted.tsx +7 -3
- package/src/EntryForm.tsx +41 -88
- package/src/FooterSettings.tsx +76 -0
- package/src/FormFrame.tsx +2 -1
- package/src/HeaderSettings.tsx +97 -0
- package/src/Library.tsx +26 -14
- package/src/MarkdownEditor.tsx +18 -36
- package/src/Media.tsx +6 -3
- package/src/Redirects.tsx +12 -9
- package/src/SectionSettings.tsx +21 -0
- package/src/SectionSettingsIcon.tsx +10 -0
- package/src/SettingsSections.tsx +57 -0
- package/src/ThemeStore.tsx +28 -9
- package/src/Versions.tsx +3 -15
- package/src/busyAction.d.mts +3 -0
- package/src/busyAction.mjs +8 -0
- package/src/busyAction.test.mjs +16 -0
- package/src/dataFileValues.ts +30 -0
- package/src/editor.css +37 -3
- package/src/fields.tsx +29 -10
- package/src/footerPreview.d.mts +46 -0
- package/src/footerPreview.mjs +84 -0
- package/src/footerPreview.test.mjs +100 -0
- package/src/formSections.ts +100 -0
- package/src/frontmatter.mjs +1 -1
- package/src/frontmatter.test.mjs +11 -11
- package/src/headerPreview.d.mts +48 -0
- package/src/headerPreview.mjs +168 -0
- package/src/headerPreview.test.mjs +183 -0
- package/src/headerTheme.tsx +58 -0
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/library.ts +2 -0
- package/src/i18n/da-DK/preview.ts +2 -0
- package/src/i18n/da-DK/text.ts +1 -0
- package/src/i18n/en-US/common.ts +1 -0
- package/src/i18n/en-US/library.ts +4 -0
- package/src/i18n/en-US/preview.ts +3 -0
- package/src/i18n/en-US/text.ts +2 -1
- package/src/images.ts +7 -2
- package/src/menuChildren.ts +1 -3
- package/src/movedRoutes.d.mts +3 -0
- package/src/movedRoutes.mjs +35 -0
- package/src/movedRoutes.test.mjs +22 -0
- package/src/pagePreview.tsx +40 -29
- package/src/partPreview.tsx +59 -0
- package/src/schema.ts +7 -1
- package/src/sheetPlacement.d.mts +3 -0
- package/src/sheetPlacement.mjs +9 -0
- package/src/sheetPlacement.test.mjs +23 -0
- package/src/standard/views.ts +3 -3
- package/src/templateField.ts +28 -0
- package/src/themes.ts +3 -0
- package/src/ui.tsx +35 -8
- package/src/useDataFiles.ts +94 -0
- package/starter/package.json +1 -1
- package/theme/components/Footer.astro +14 -7
- package/theme/components/Header.astro +58 -33
- package/theme/components/MobileNav.astro +5 -4
- package/theme/components/RelatedPosts.astro +1 -1
- package/theme/components/headerParts.ts +17 -0
- package/theme/layouts/PageLayout.astro +20 -16
- package/theme/styles/base.css +6 -5
- package/theme/styles/blocks.css +8 -12
- package/theme/styles/components.css +8 -1
- package/theme/styles/global.css +5 -9
- package/theme/styles/ui.css +111 -5
- package/theme/theme.json +1 -0
- package/theme/ui/BusyContent.tsx +16 -0
- package/theme/ui/Button.tsx +25 -5
- package/theme/ui/IconButton.tsx +17 -4
- package/scripts/fixtures/side/side-off.md +0 -8
- package/scripts/fixtures/side/side-plain.md +0 -7
- 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',
|
package/src/i18n/en-US/text.ts
CHANGED
|
@@ -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
|
|
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;
|
|
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>;
|
package/src/menuChildren.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The second level of the main menu (MainMenu.tsx, lib/menu.mjs menuTree): the lists under Lists,
|
|
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,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
|
+
});
|
package/src/pagePreview.tsx
CHANGED
|
@@ -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
|
-
|
|
60
|
-
function
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
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 (
|
|
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,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
|
+
});
|
package/src/standard/views.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
31
|
-
sideBlockOf(
|
|
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()}
|
|
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
|
-
|
|
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
|
|
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:
|
|
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>
|