@morphemecms/cms 0.9.0-next-20261105115842 → 0.9.1
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 +24 -1
- package/defaults/collections.ts +112 -58
- package/defaults/company.ts +1 -1
- 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 +9 -4
- package/site/templates.ts +7 -0
- package/site/theme.mjs +3 -0
- package/site/tracking.client.ts +4 -4
- 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 +100 -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 +42 -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
package/src/editor.css
CHANGED
|
@@ -24,10 +24,14 @@
|
|
|
24
24
|
--editor-section-gap: 2rem;
|
|
25
25
|
--editor-section-rhythm: 0.625em;
|
|
26
26
|
|
|
27
|
-
/* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width, the card in it
|
|
27
|
+
/* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width (the card is as wide as the navigation drawer), the card in it
|
|
28
28
|
stands --section-pane-gap in from the end edge and from under the top bar. */
|
|
29
|
-
--section-pane-width:
|
|
29
|
+
--section-pane-width: calc(var(--navigation-drawer-width, 22.5rem) + var(--section-pane-gap));
|
|
30
30
|
--section-pane-gap: 2rem;
|
|
31
|
+
|
|
32
|
+
/* The side panel of a block's fields and the section settings (BottomSheet `panel` in ui.tsx) on large and wider: half the
|
|
33
|
+
space beside the start panel, the content column taking the other half (see below). */
|
|
34
|
+
--side-panel-width: calc((100vw - var(--column-start)) / 2);
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/* In dark mode the score's number in the light colours, which are read on the dark. */
|
|
@@ -79,6 +83,11 @@
|
|
|
79
83
|
padding-block-start: calc(var(--editor-section-rhythm) * 2);
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
/* A list keeps its own indent inside the frame, so its bullets and numbers stand clear of the line. */
|
|
87
|
+
.editor-text .ProseMirror > :is(ul, ol)[data-section-part] {
|
|
88
|
+
padding-inline-start: calc(var(--editor-section-pad) + 1.625em);
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
.editor-text .ProseMirror > :is([data-section-part='start'], [data-section-part='only']) {
|
|
83
92
|
margin-block-start: var(--editor-section-gap);
|
|
84
93
|
border-block-start: var(--editor-section-frame);
|
|
@@ -433,7 +442,9 @@
|
|
|
433
442
|
far as it must to stay clear of the panels: its start margin is the middle of the window less the start panel, but
|
|
434
443
|
not less than nothing and not more than the space left between the panels less the column. Only when that space is
|
|
435
444
|
smaller than the column does it get narrower. (Measured against the window's width, 100vw, scrollbar and all.) Below
|
|
436
|
-
medium nothing stands beside the content and the column is centred by auto margins.
|
|
445
|
+
medium nothing stands beside the content and the column is centred by auto margins. While the side panel is open (see
|
|
446
|
+
the end of this block) the panel takes the end half of the space beside the start panel and the column the other half:
|
|
447
|
+
--column-width stays the column's most, so on a very wide window it is narrower than its half. */
|
|
437
448
|
:root {
|
|
438
449
|
--column-start: 0px;
|
|
439
450
|
--column-end: 0px;
|
|
@@ -474,6 +485,34 @@
|
|
|
474
485
|
:root:has(.editor-section-pane) .editor-save-bar {
|
|
475
486
|
right: var(--section-pane-width);
|
|
476
487
|
}
|
|
488
|
+
|
|
489
|
+
/* The side panel (.editor-side-panel: BottomSheet `panel`; BlockDialog.tsx sets [data-side-panel] on the root while it is
|
|
490
|
+
open) stands at the window's end edge under the top bar, the full remaining height. It takes the "on this page" area's
|
|
491
|
+
place (that is hidden), and the content, which stays in view, inert behind the modal <dialog>, keeps clear of it. Its
|
|
492
|
+
backdrop is clear: the page is not dimmed. */
|
|
493
|
+
:root[data-side-panel] {
|
|
494
|
+
--column-end: var(--side-panel-width);
|
|
495
|
+
}
|
|
496
|
+
:root[data-side-panel] .editor-section-pane {
|
|
497
|
+
display: none;
|
|
498
|
+
}
|
|
499
|
+
:root[data-side-panel] .editor-content {
|
|
500
|
+
padding-inline-end: var(--side-panel-width);
|
|
501
|
+
}
|
|
502
|
+
:root[data-side-panel] .editor-save-bar {
|
|
503
|
+
right: var(--side-panel-width);
|
|
504
|
+
}
|
|
505
|
+
.editor-side-panel {
|
|
506
|
+
inset: var(--top-bar-height) 0 0 auto;
|
|
507
|
+
width: var(--side-panel-width);
|
|
508
|
+
height: calc(100dvh - var(--top-bar-height));
|
|
509
|
+
}
|
|
510
|
+
.editor-side-panel:dir(rtl) {
|
|
511
|
+
inset: var(--top-bar-height) auto 0 0;
|
|
512
|
+
}
|
|
513
|
+
.editor-side-panel::backdrop {
|
|
514
|
+
background: transparent;
|
|
515
|
+
}
|
|
477
516
|
}
|
|
478
517
|
|
|
479
518
|
/* Like an app: no grey flash when tapping links and buttons on a phone, and no zoom on a double tap.
|
package/src/fields.tsx
CHANGED
|
@@ -509,6 +509,16 @@ function FieldControl({ field, value, onChange }: Props) {
|
|
|
509
509
|
// that depends on one there (inUse).
|
|
510
510
|
const AroundHere = createContext<Around | undefined>(undefined);
|
|
511
511
|
|
|
512
|
+
/** An object field's own fields without its group, whose heading is elsewhere (a section of Appearance › Header: "Action
|
|
513
|
+
* button"), at the object's path, so a wrong one is marked as it is in the group (WrongField). */
|
|
514
|
+
export function ObjectFields({ field, value, onChange }: Props) {
|
|
515
|
+
return (
|
|
516
|
+
<PathHere.Provider value={joined(useContext(PathHere), field.name)}>
|
|
517
|
+
<FieldList fields={field.fields!} value={(value ?? {}) as Record<string, unknown>} onChange={onChange} />
|
|
518
|
+
</PathHere.Provider>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
512
522
|
// The fields of an object, in order.
|
|
513
523
|
export function FieldList({
|
|
514
524
|
fields,
|
|
@@ -762,18 +772,21 @@ const capitalFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
762
772
|
// ListItem), and the items under one (its list field, the submenu) indented along a line, shown with
|
|
763
773
|
// the arrow at the right. An item's text and page are changed in a sheet from the bottom,
|
|
764
774
|
// opened with a tap on it or the pencil, and saved with "Gem"; the sheet also removes it. A new item
|
|
765
|
-
// (+ in the top bar, "Tilføj punkt" under a submenu) opens its sheet at once; "Annuller"
|
|
766
|
-
// away again. The arrows move an item up and down.
|
|
775
|
+
// (+ in the top bar, "Tilføj punkt" under a submenu, and under the menu with `add`) opens its sheet at once; "Annuller"
|
|
776
|
+
// there takes it away again. The arrows move an item up and down.
|
|
767
777
|
export function MenuList({
|
|
768
778
|
field,
|
|
769
779
|
value,
|
|
770
780
|
onChange,
|
|
771
781
|
depth = 0,
|
|
782
|
+
add = false,
|
|
772
783
|
}: {
|
|
773
784
|
field: Field;
|
|
774
785
|
value: unknown[];
|
|
775
786
|
onChange: (v: unknown) => void;
|
|
776
787
|
depth?: number;
|
|
788
|
+
/** "Tilføj …" under the list too, where the top bar has no "+" for it (two menus on one screen: Appearance › Header). */
|
|
789
|
+
add?: boolean;
|
|
777
790
|
}) {
|
|
778
791
|
const [open, setOpen] = useState<number | null>(null);
|
|
779
792
|
// The item whose sheet is open, its fields as they are being changed (and as they were, written as
|
|
@@ -884,23 +897,20 @@ export function MenuList({
|
|
|
884
897
|
onChange={(v) => onChange(value.map((x, j) => (j === i ? { ...(x as Item), [sub.name]: v } : x)))}
|
|
885
898
|
depth={depth + 1}
|
|
886
899
|
/>
|
|
887
|
-
<
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
icon={Plus}
|
|
891
|
-
className="mt-1 ml-5"
|
|
900
|
+
<AddItem
|
|
901
|
+
field={sub}
|
|
902
|
+
sub
|
|
892
903
|
onClick={() =>
|
|
893
904
|
onChange(value.map((x, j) => (j === i ? { ...(x as Item), [sub.name]: [...children, newItem(sub)] } : x)))
|
|
894
905
|
}
|
|
895
|
-
|
|
896
|
-
{t.forms.addOne(sub.singular ?? t.forms.item)}
|
|
897
|
-
</Button>
|
|
906
|
+
/>
|
|
898
907
|
</div>
|
|
899
908
|
)}
|
|
900
909
|
</ListItem>
|
|
901
910
|
);
|
|
902
911
|
})}
|
|
903
912
|
</ul>
|
|
913
|
+
{add && <AddItem field={field} onClick={() => onChange([...value, newItem(field)])} />}
|
|
904
914
|
<BottomSheet
|
|
905
915
|
ref={sheetRef}
|
|
906
916
|
aria-label={editing ? nameOf(editing.value) || capitalFirst(singular) : undefined}
|
|
@@ -940,3 +950,12 @@ export function MenuList({
|
|
|
940
950
|
</div>
|
|
941
951
|
);
|
|
942
952
|
}
|
|
953
|
+
|
|
954
|
+
// "Tilføj …" (an item to a list of the menu): under a submenu (`sub`, indented as its items), or under the menu.
|
|
955
|
+
function AddItem({ field, sub = false, onClick }: { field: Field; sub?: boolean; onClick: () => void }) {
|
|
956
|
+
return (
|
|
957
|
+
<Button kind="text" size="small" icon={Plus} className={sub ? 'mt-1 ml-5' : 'mt-1'} onClick={onClick}>
|
|
958
|
+
{t.forms.addOne(field.singular ?? t.forms.item)}
|
|
959
|
+
</Button>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the form (`now`) changes from footer.json as it was saved (`saved`): only what differs is in it, so the rest of the
|
|
3
|
+
* page stays exactly as on the site.
|
|
4
|
+
* @param {FooterValues} saved
|
|
5
|
+
* @param {FooterValues} now
|
|
6
|
+
* @returns {FooterDraft}
|
|
7
|
+
*/
|
|
8
|
+
export function footerDraft(saved: FooterValues, now: FooterValues): FooterDraft;
|
|
9
|
+
/**
|
|
10
|
+
* Puts what the form changes (footerDraft) into the site's page: the texts as they are, and the links as copies of the
|
|
11
|
+
* page's own first link (or, when it has none, drawn with `theme`).
|
|
12
|
+
* @param {Document} doc
|
|
13
|
+
* @param {FooterDraft} draft
|
|
14
|
+
* @param {FooterTheme} theme
|
|
15
|
+
*/
|
|
16
|
+
export function swapFooter(doc: Document, draft: FooterDraft, theme: FooterTheme): void;
|
|
17
|
+
/**
|
|
18
|
+
* footer.json as the form has it.
|
|
19
|
+
*/
|
|
20
|
+
export type FooterValues = {
|
|
21
|
+
text?: string;
|
|
22
|
+
contactHeading?: string;
|
|
23
|
+
linksHeading?: string;
|
|
24
|
+
links?: {
|
|
25
|
+
label?: string;
|
|
26
|
+
href?: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* What the form changes in the footer; undefined leaves it as it is on the site.
|
|
31
|
+
*/
|
|
32
|
+
export type FooterDraft = {
|
|
33
|
+
text?: string;
|
|
34
|
+
contactHeading?: string;
|
|
35
|
+
linksHeading?: string;
|
|
36
|
+
links?: {
|
|
37
|
+
label: string;
|
|
38
|
+
href: string;
|
|
39
|
+
}[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* How the theme draws what the page may not have: the class of a link, for the list of a page whose footer has none.
|
|
43
|
+
*/
|
|
44
|
+
export type FooterTheme = {
|
|
45
|
+
linkClass: string;
|
|
46
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The editor's preview of the footer (FooterSettings.tsx): what the footer's form changes (footerDraft), and those changes
|
|
2
|
+
// put into the site's page (swapFooter), in the parts Footer.astro marks with data-editor: the text under the name
|
|
3
|
+
// (footer-text), the two headings (footer-contact-heading, footer-links-heading) and the links (footer-links).
|
|
4
|
+
// Exposes footerDraft and swapFooter. A DOM (the browser's, jsdom in the tests), no React.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* footer.json as the form has it.
|
|
8
|
+
* @typedef {{ text?: string, contactHeading?: string, linksHeading?: string, links?: { label?: string, href?: string }[] }} FooterValues
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the form changes in the footer; undefined leaves it as it is on the site.
|
|
13
|
+
* @typedef {{ text?: string, contactHeading?: string, linksHeading?: string, links?: { label: string, href: string }[] }} FooterDraft
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A link with only what the footer shows of it.
|
|
17
|
+
/** @param {{ label?: string, href?: string }} link */
|
|
18
|
+
const linkOf = ({ label = '', href = '' }) => ({ label, href });
|
|
19
|
+
|
|
20
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What the form (`now`) changes from footer.json as it was saved (`saved`): only what differs is in it, so the rest of the
|
|
24
|
+
* page stays exactly as on the site.
|
|
25
|
+
* @param {FooterValues} saved
|
|
26
|
+
* @param {FooterValues} now
|
|
27
|
+
* @returns {FooterDraft}
|
|
28
|
+
*/
|
|
29
|
+
export function footerDraft(saved, now) {
|
|
30
|
+
/** @type {FooterDraft} */
|
|
31
|
+
const draft = {};
|
|
32
|
+
for (const name of /** @type {const} */ (['text', 'contactHeading', 'linksHeading'])) {
|
|
33
|
+
if ((now[name] ?? '') !== (saved[name] ?? '')) draft[name] = now[name] ?? '';
|
|
34
|
+
}
|
|
35
|
+
const [links, before] = [(now.links ?? []).map(linkOf), (saved.links ?? []).map(linkOf)];
|
|
36
|
+
if (!same(links, before)) draft.links = links;
|
|
37
|
+
return draft;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How the theme draws what the page may not have: the class of a link, for the list of a page whose footer has none.
|
|
42
|
+
* @typedef {{ linkClass: string }} FooterTheme
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @param {ParentNode} doc @param {string} name @returns {HTMLElement | null} */
|
|
46
|
+
const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Puts what the form changes (footerDraft) into the site's page: the texts as they are, and the links as copies of the
|
|
50
|
+
* page's own first link (or, when it has none, drawn with `theme`).
|
|
51
|
+
* @param {Document} doc
|
|
52
|
+
* @param {FooterDraft} draft
|
|
53
|
+
* @param {FooterTheme} theme
|
|
54
|
+
*/
|
|
55
|
+
export function swapFooter(doc, draft, theme) {
|
|
56
|
+
for (const [name, key] of /** @type {const} */ ([
|
|
57
|
+
['footer-text', 'text'],
|
|
58
|
+
['footer-contact-heading', 'contactHeading'],
|
|
59
|
+
['footer-links-heading', 'linksHeading'],
|
|
60
|
+
])) {
|
|
61
|
+
const el = part(doc, name);
|
|
62
|
+
if (el && draft[key] !== undefined) el.textContent = draft[key];
|
|
63
|
+
}
|
|
64
|
+
const list = part(doc, 'footer-links');
|
|
65
|
+
if (!list || !draft.links) return;
|
|
66
|
+
const one = list.firstElementChild ?? null;
|
|
67
|
+
list.replaceChildren(
|
|
68
|
+
...draft.links.map((link) => {
|
|
69
|
+
let row = /** @type {HTMLElement | null} */ (one?.cloneNode(true) ?? null);
|
|
70
|
+
if (!row) {
|
|
71
|
+
row = doc.createElement('li');
|
|
72
|
+
const a = doc.createElement('a');
|
|
73
|
+
a.className = theme.linkClass;
|
|
74
|
+
row.append(a);
|
|
75
|
+
}
|
|
76
|
+
const a = row.querySelector('a');
|
|
77
|
+
if (a) {
|
|
78
|
+
a.textContent = link.label;
|
|
79
|
+
a.setAttribute('href', link.href);
|
|
80
|
+
}
|
|
81
|
+
return row;
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tests footerPreview.mjs: what the footer's form changes (footerDraft), and those changes put into a page drawn as
|
|
2
|
+
// Footer.astro draws it (swapFooter). jsdom stands in for the browser's DOM.
|
|
3
|
+
// Run: node --test src/footerPreview.test.mjs
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { JSDOM } from 'jsdom';
|
|
7
|
+
import { footerDraft, swapFooter } from './footerPreview.mjs';
|
|
8
|
+
|
|
9
|
+
const PAGE = `<!doctype html><html><body>
|
|
10
|
+
<footer data-editor="footer"><p class="name">Navn</p><p data-editor="footer-text">Gammel tekst</p>
|
|
11
|
+
<p data-editor="footer-contact-heading">Kontakt</p>
|
|
12
|
+
<p data-editor="footer-links-heading">Links</p>
|
|
13
|
+
<ul data-editor="footer-links"><li class="row"><a href="/a/" class="item">A</a></li><li class="row"><a href="/b/" class="item">B</a></li></ul>
|
|
14
|
+
</footer></body></html>`;
|
|
15
|
+
|
|
16
|
+
const THEME = { linkClass: 'item' };
|
|
17
|
+
const page = () => new JSDOM(PAGE).window.document;
|
|
18
|
+
const part = (doc, name) => doc.querySelector(`[data-editor="${name}"]`);
|
|
19
|
+
|
|
20
|
+
const SAVED = {
|
|
21
|
+
text: 'Gammel tekst',
|
|
22
|
+
contactHeading: 'Kontakt',
|
|
23
|
+
linksHeading: 'Links',
|
|
24
|
+
links: [
|
|
25
|
+
{ label: 'A', href: '/a/' },
|
|
26
|
+
{ label: 'B', href: '/b/' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test('footerDraft: nothing changed is an empty draft', () => {
|
|
31
|
+
assert.deepEqual(footerDraft(SAVED, structuredClone(SAVED)), {});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('footerDraft: only the texts that differ', () => {
|
|
35
|
+
assert.deepEqual(footerDraft(SAVED, { ...SAVED, text: 'Ny', linksHeading: 'Praktisk' }), { text: 'Ny', linksHeading: 'Praktisk' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('footerDraft: the links with only label and href', () => {
|
|
39
|
+
const now = { ...SAVED, links: [{ label: 'C', href: '/c/', extra: 1 }] };
|
|
40
|
+
assert.deepEqual(footerDraft(SAVED, now), { links: [{ label: 'C', href: '/c/' }] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('footerDraft: a file the site has not got is empty', () => {
|
|
44
|
+
assert.deepEqual(footerDraft({}, {}), {});
|
|
45
|
+
assert.deepEqual(footerDraft({}, { text: 'Hej' }), { text: 'Hej' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('swapFooter: an empty draft leaves the page as it is', () => {
|
|
49
|
+
const doc = page();
|
|
50
|
+
const before = doc.body.innerHTML;
|
|
51
|
+
swapFooter(doc, {}, THEME);
|
|
52
|
+
assert.equal(doc.body.innerHTML, before);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('swapFooter: the text and the two headings are redrawn', () => {
|
|
56
|
+
const doc = page();
|
|
57
|
+
swapFooter(doc, { text: 'Ny tekst', contactHeading: 'Ring', linksHeading: 'Praktisk' }, THEME);
|
|
58
|
+
assert.equal(part(doc, 'footer-text').textContent, 'Ny tekst');
|
|
59
|
+
assert.equal(part(doc, 'footer-contact-heading').textContent, 'Ring');
|
|
60
|
+
assert.equal(part(doc, 'footer-links-heading').textContent, 'Praktisk');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('swapFooter: the links are copies of the page own', () => {
|
|
64
|
+
const doc = page();
|
|
65
|
+
const links = [
|
|
66
|
+
{ label: 'X', href: '/x/' },
|
|
67
|
+
{ label: 'Y', href: '/y/' },
|
|
68
|
+
{ label: 'Z', href: '/z/' },
|
|
69
|
+
];
|
|
70
|
+
swapFooter(doc, { links }, THEME);
|
|
71
|
+
const rows = [...part(doc, 'footer-links').children];
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
rows.map((li) => [li.className, li.querySelector('a').textContent, li.querySelector('a').getAttribute('href')]),
|
|
74
|
+
[
|
|
75
|
+
['row', 'X', '/x/'],
|
|
76
|
+
['row', 'Y', '/y/'],
|
|
77
|
+
['row', 'Z', '/z/'],
|
|
78
|
+
],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('swapFooter: links with none on the page are drawn with the theme class', () => {
|
|
83
|
+
const doc = page();
|
|
84
|
+
part(doc, 'footer-links').replaceChildren();
|
|
85
|
+
swapFooter(doc, { links: [{ label: 'X', href: '/x/' }] }, THEME);
|
|
86
|
+
assert.equal(part(doc, 'footer-links').innerHTML, '<li><a class="item" href="/x/">X</a></li>');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('swapFooter: no links leaves an empty list', () => {
|
|
90
|
+
const doc = page();
|
|
91
|
+
swapFooter(doc, { links: [] }, THEME);
|
|
92
|
+
assert.equal(part(doc, 'footer-links').children.length, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('swapFooter: a page without the marked parts is left alone', () => {
|
|
96
|
+
const doc = new JSDOM('<body><footer data-editor="footer"><slot></slot></footer></body>').window.document;
|
|
97
|
+
const before = doc.body.innerHTML;
|
|
98
|
+
swapFooter(doc, { text: 'x', links: [{ label: 'a', href: '/' }] }, THEME);
|
|
99
|
+
assert.equal(doc.body.innerHTML, before);
|
|
100
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The sections of a long form, under each other on one page, each folding in and out under its heading (FoldSection in
|
|
2
|
+
// ui.tsx), with their "on this page" list (SectionList, large and wider): which are open, the one in view (marked in the
|
|
3
|
+
// list while the page scrolls), and a tap in the list, which opens one and scrolls to it. With `single` only one is open
|
|
4
|
+
// at a time: opening one folds the others (the settings screens on a phone, SettingsSections.tsx). Exposes
|
|
5
|
+
// useFormSections. Used by EntryForm.tsx and SettingsSections.tsx. Uses lib/section-in-view.mjs and lib/fold-open.mjs.
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { flushSync } from 'react-dom';
|
|
8
|
+
import type { NavigationItem } from './ui';
|
|
9
|
+
import { sectionInView, spyLine } from '../lib/section-in-view.mjs';
|
|
10
|
+
import { onlyFirst, withOpen } from '../lib/fold-open.mjs';
|
|
11
|
+
|
|
12
|
+
const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
|
|
13
|
+
|
|
14
|
+
/** `sections`: the form's sections, in their order; `start`: those open from the start (the first marked in the list);
|
|
15
|
+
* `single`: only one open at a time. */
|
|
16
|
+
export function useFormSections<S extends string>(sections: NavigationItem[], start: S[], single = false) {
|
|
17
|
+
// The section in view, marked in the sections list: while the page scrolls, the last one whose top is above a third of the
|
|
18
|
+
// screen (at the bottom of the page, the last one in view). A tap in the list marks its own and scrolls there; the
|
|
19
|
+
// mark stays meanwhile (`steering`, until the scroll ends).
|
|
20
|
+
const [section, setSection] = useState<S>(start[0]);
|
|
21
|
+
const boxes = useRef<Partial<Record<S, HTMLElement | null>>>({});
|
|
22
|
+
const order = useRef<S[]>([]);
|
|
23
|
+
order.current = sections.map((s) => s.id as S);
|
|
24
|
+
const steering = useRef(0);
|
|
25
|
+
const spy = useRef(() => {});
|
|
26
|
+
const steer = (id: S) => {
|
|
27
|
+
setSection(id);
|
|
28
|
+
steering.current = performance.now() + 1500;
|
|
29
|
+
addEventListener('scrollend', () => (steering.current = 0), { once: true });
|
|
30
|
+
};
|
|
31
|
+
const [open, setOpen] = useState<ReadonlySet<S>>(() => new Set(single ? start.slice(0, 1) : start));
|
|
32
|
+
const opened = (was: ReadonlySet<S>, id: S) => withOpen(was, id, single);
|
|
33
|
+
const unfold = (id: S) => setOpen((was) => opened(was, id));
|
|
34
|
+
// The window narrowed to `single`: only the first of those open stays open.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (single) setOpen((was) => onlyFirst(was, order.current));
|
|
37
|
+
}, [single]);
|
|
38
|
+
// A section opened by its heading is marked in the sections list; one folded, the one in view now (the page is shorter,
|
|
39
|
+
// without a scroll). With `single`, one whose heading went up out of view as the one above it folded is scrolled to.
|
|
40
|
+
const toggle = (id: S) => {
|
|
41
|
+
const opening = !open.has(id);
|
|
42
|
+
setOpen((was) => {
|
|
43
|
+
if (opening) return opened(was, id);
|
|
44
|
+
const next = new Set(was);
|
|
45
|
+
next.delete(id);
|
|
46
|
+
return next;
|
|
47
|
+
});
|
|
48
|
+
if (opening) setSection(id);
|
|
49
|
+
else requestAnimationFrame(() => spy.current());
|
|
50
|
+
if (opening && single)
|
|
51
|
+
requestAnimationFrame(() => {
|
|
52
|
+
const box = boxes.current[id];
|
|
53
|
+
if (box && box.getBoundingClientRect().top < 0) box.scrollIntoView({ block: 'start', behavior: scrolling() });
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
let frame = 0;
|
|
58
|
+
spy.current = () => {
|
|
59
|
+
frame = 0;
|
|
60
|
+
if (performance.now() < steering.current) return;
|
|
61
|
+
const atBottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 8;
|
|
62
|
+
const tops: Record<string, number | undefined> = {};
|
|
63
|
+
for (const id of order.current) tops[id] = boxes.current[id]?.getBoundingClientRect().top;
|
|
64
|
+
const here = sectionInView(order.current, tops, spyLine(innerHeight, atBottom));
|
|
65
|
+
if (here) setSection(here as S);
|
|
66
|
+
};
|
|
67
|
+
const onScroll = () => {
|
|
68
|
+
if (!frame) frame = requestAnimationFrame(spy.current);
|
|
69
|
+
};
|
|
70
|
+
addEventListener('scroll', onScroll, { passive: true });
|
|
71
|
+
return () => {
|
|
72
|
+
removeEventListener('scroll', onScroll);
|
|
73
|
+
cancelAnimationFrame(frame);
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
// Opens a section and scrolls to it (or to `box` in it). It is opened at once (flushSync), so it is there to scroll to.
|
|
77
|
+
const goTo = (id: S, box?: HTMLElement | null) => {
|
|
78
|
+
if (!open.has(id)) flushSync(() => unfold(id));
|
|
79
|
+
steer(id);
|
|
80
|
+
requestAnimationFrame(() => (box ?? boxes.current[id])?.scrollIntoView({ block: 'start', behavior: scrolling() }));
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
/** The section in view, for the sections list (SectionList `value`). */
|
|
84
|
+
section,
|
|
85
|
+
/** Marks a section in the list while the page scrolls to it (a field that keeps the form from being saved). */
|
|
86
|
+
steer,
|
|
87
|
+
/** Opens a section (a field in it keeps the form from being saved: useWrongField's `open`). */
|
|
88
|
+
unfold,
|
|
89
|
+
goTo,
|
|
90
|
+
/** A tap in the sections list (SectionList `onChange`). */
|
|
91
|
+
pick: (id: string) => goTo(id as S),
|
|
92
|
+
/** A section's FoldSection props: where it is, its heading (its name in the list), and whether it is open. */
|
|
93
|
+
sectionProps: (id: S) => ({
|
|
94
|
+
ref: (el: HTMLElement | null) => void (boxes.current[id] = el),
|
|
95
|
+
title: sections.find((s) => s.id === id)!.label,
|
|
96
|
+
open: open.has(id),
|
|
97
|
+
onToggle: () => toggle(id),
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/frontmatter.mjs
CHANGED
|
@@ -44,7 +44,7 @@ function toNode(doc, key, value) {
|
|
|
44
44
|
return node;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// A true/false field that is true when it is missing from the file (Field.missing
|
|
47
|
+
// A true/false field that is true when it is missing from the file (Field.missing): booleanRead
|
|
48
48
|
// gives the form's value for what is in the file; booleanWrite what is written for the form's value (nothing, '', when
|
|
49
49
|
// it is the missing one, so the key leaves the file).
|
|
50
50
|
export const booleanRead = (value, missing) => (value === undefined ? missing === true : value === true);
|
package/src/frontmatter.test.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Tests booleanRead and booleanWrite (frontmatter.mjs):
|
|
2
|
-
// key is missing when on. Run: node --test src/frontmatter.test.mjs
|
|
1
|
+
// Tests booleanRead and booleanWrite (frontmatter.mjs): a switch that is on when its key is missing (Field.missing) is
|
|
2
|
+
// `key: false` when off and the key is missing when on. Run: node --test src/frontmatter.test.mjs
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { test } from 'node:test';
|
|
5
5
|
import { booleanRead, booleanWrite, writeFields } from './frontmatter.mjs';
|
|
6
6
|
|
|
7
|
-
const yaml = 'title: "Om os"\
|
|
7
|
+
const yaml = 'title: "Om os"\ndraft: false';
|
|
8
8
|
|
|
9
|
-
test('a missing key reads as on,
|
|
9
|
+
test('a missing key reads as on, key: false as off', () => {
|
|
10
10
|
assert.equal(booleanRead(undefined, true), true);
|
|
11
11
|
assert.equal(booleanRead(false, true), false);
|
|
12
12
|
assert.equal(booleanRead(true, true), true);
|
|
@@ -14,11 +14,11 @@ test('a missing key reads as on, side: false as off', () => {
|
|
|
14
14
|
assert.equal(booleanRead(true, undefined), true);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test('off writes
|
|
18
|
-
const off = writeFields(yaml, {
|
|
19
|
-
assert.equal(off, `${yaml}\
|
|
20
|
-
assert.equal(writeFields(off, {
|
|
21
|
-
assert.equal(writeFields(yaml, {
|
|
17
|
+
test('off writes key: false, on removes the key and leaves the rest as it was', () => {
|
|
18
|
+
const off = writeFields(yaml, { pinned: booleanWrite(false, true) });
|
|
19
|
+
assert.equal(off, `${yaml}\npinned: false`);
|
|
20
|
+
assert.equal(writeFields(off, { pinned: booleanWrite(true, true) }), yaml);
|
|
21
|
+
assert.equal(writeFields(yaml, { pinned: booleanWrite(true, true) }), yaml);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
test('a switch without a missing value is written as it is', () => {
|
|
@@ -27,7 +27,7 @@ test('a switch without a missing value is written as it is', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
test('front matter with CRLF line breaks is written back unchanged, and added fields use CRLF too', () => {
|
|
30
|
-
const crlf = 'title: "Om os"\r\
|
|
30
|
+
const crlf = 'title: "Om os"\r\ndraft: false';
|
|
31
31
|
assert.equal(writeFields(crlf, {}), crlf);
|
|
32
|
-
assert.equal(writeFields(crlf, {
|
|
32
|
+
assert.equal(writeFields(crlf, { pinned: false }), `${crlf}\r\npinned: false`);
|
|
33
33
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the form (`now`) changes from the files as they were saved (`saved`): only what differs is in it, so the rest of
|
|
3
|
+
* the page stays exactly as on the site.
|
|
4
|
+
* @param {HeaderValues} saved
|
|
5
|
+
* @param {HeaderValues} now
|
|
6
|
+
* @returns {HeaderDraft}
|
|
7
|
+
*/
|
|
8
|
+
export function headerDraft(saved: HeaderValues, now: HeaderValues): HeaderDraft;
|
|
9
|
+
/**
|
|
10
|
+
* Puts what the form changes (headerDraft) into the site's page, drawn as the theme draws it (`theme`).
|
|
11
|
+
* @param {Document} doc
|
|
12
|
+
* @param {HeaderDraft} draft
|
|
13
|
+
* @param {HeaderTheme} theme
|
|
14
|
+
*/
|
|
15
|
+
export function swapHeader(doc: Document, draft: HeaderDraft, theme: HeaderTheme): void;
|
|
16
|
+
export type NavLink = import("../site/header.mjs").NavLink;
|
|
17
|
+
export type NavItem = import("../site/header.mjs").NavItem;
|
|
18
|
+
export type Button = import("../site/header.mjs").Button;
|
|
19
|
+
/**
|
|
20
|
+
* The header's three files as the form has them: menu.json, mobile-menu.json and header.json.
|
|
21
|
+
*/
|
|
22
|
+
export type HeaderValues = {
|
|
23
|
+
menu: Record<string, any>;
|
|
24
|
+
mobileMenu: Record<string, any>;
|
|
25
|
+
header: Record<string, any>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What the form changes in the header; undefined leaves it as it is on the site. `nav` null: the menu is off (no items,
|
|
29
|
+
* no menu button); `mobileNav` empty: no bar; `button` null: the phone icon.
|
|
30
|
+
*/
|
|
31
|
+
export type HeaderDraft = {
|
|
32
|
+
nav?: NavItem[] | null;
|
|
33
|
+
mobileNav?: NavLink[];
|
|
34
|
+
menuSide?: "start" | "end";
|
|
35
|
+
button?: Button | null;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* How the theme draws what the page may not have: the menu button's classes on each side, the button's place as HTML
|
|
39
|
+
* (when `button` changed; with data-editor="header-cta"), an empty bar of "Mobilmenu" with one tab (for a page without
|
|
40
|
+
* one), the name of an item's sub-pages button, and the page's address (for the open page's item).
|
|
41
|
+
*/
|
|
42
|
+
export type HeaderTheme = {
|
|
43
|
+
menuClass: Record<"start" | "end", string>;
|
|
44
|
+
cta?: string;
|
|
45
|
+
mobileNav: string;
|
|
46
|
+
subpagesOf: (label: string) => string;
|
|
47
|
+
current: string;
|
|
48
|
+
};
|