@morphemecms/cms 0.7.0 → 0.8.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 +18 -0
- package/defaults/collections.ts +56 -0
- package/defaults/company.ts +13 -4
- package/defaults/texts.ts +17 -0
- package/icons/lucide/LICENSE-phosphor +21 -0
- package/icons/lucide/facebook-logo.svg +1 -0
- package/icons/lucide/linkedin-logo.svg +1 -0
- package/icons/lucide/pack.json +16 -1
- package/icons/lucide/pinterest-logo.svg +1 -0
- package/icons/lucide/x-logo.svg +1 -0
- package/icons/material-symbols/LICENSE-phosphor +21 -0
- package/icons/material-symbols/facebook-logo.svg +1 -0
- package/icons/material-symbols/linkedin-logo.svg +1 -0
- package/icons/material-symbols/pack.json +15 -0
- package/icons/material-symbols/pinterest-logo.svg +1 -0
- package/icons/material-symbols/x-logo.svg +1 -0
- package/icons/phosphor/facebook-logo.svg +1 -0
- package/icons/phosphor/linkedin-logo.svg +1 -0
- package/icons/phosphor/pinterest-logo.svg +1 -0
- package/icons/phosphor/x-logo.svg +1 -0
- package/lib/lead.d.mts +5 -0
- package/lib/lead.mjs +10 -0
- package/lib/lead.test.mjs +65 -0
- package/lib/licenses.mjs +1 -1
- package/package.json +2 -1
- package/scripts/icon-packs.mjs +51 -13
- package/scripts/material-fallback.test.mjs +49 -0
- package/site/icons.d.mts +13 -0
- package/site/icons.mjs +25 -3
- package/site/materialSymbols.d.mts +8 -2
- package/site/materialSymbols.mjs +27 -11
- package/site/materialSymbols.test.mjs +44 -0
- package/site/schema.ts +2 -0
- package/site/site.ts +9 -2
- package/site/social.d.mts +37 -0
- package/site/social.mjs +92 -0
- package/site/social.test.mjs +80 -0
- package/site/theme.d.mts +2 -0
- package/site/theme.mjs +28 -11
- package/site/themeImport.test.mjs +40 -0
- package/src/EntryForm.tsx +21 -58
- package/src/FormFrame.tsx +5 -5
- package/src/Parts.tsx +8 -30
- package/src/Screen.tsx +12 -5
- package/src/editor.css +70 -5
- package/src/i18n/da-DK/parts.ts +1 -1
- package/src/i18n/da-DK/theme.ts +4 -0
- package/src/i18n/en-US/parts.ts +1 -1
- package/src/i18n/en-US/theme.ts +4 -0
- package/src/icon.tsx +1 -1
- package/src/materialIcons.ts +5 -7
- package/src/pagePreview.tsx +21 -1
- package/src/postLead.d.mts +7 -0
- package/src/postLead.mjs +9 -0
- package/src/postLead.test.mjs +28 -0
- package/src/ui.tsx +41 -96
- package/src/virtual.d.ts +5 -0
- package/starter/package.json +1 -1
- package/starter/src/data/social.json +14 -0
- package/theme/components/PostAuthor.astro +20 -0
- package/theme/components/PostCard.tsx +26 -0
- package/theme/components/PostHeader.astro +36 -0
- package/theme/components/PostMeta.tsx +27 -2
- package/theme/components/RelatedPosts.astro +29 -0
- package/theme/components/ShareRow.astro +48 -0
- package/theme/layouts/PostLayout.astro +28 -54
- package/theme/styles/base.css +164 -127
- package/theme/styles/blocks.css +160 -155
- package/theme/styles/components.css +43 -34
- package/theme/styles/global.css +316 -303
- package/theme/styles/ui.css +43 -34
- package/theme/ui/copyLink.script.ts +17 -0
package/src/EntryForm.tsx
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// gives the same page). A new address (Adresse) moves the page or post, with a redirect from the
|
|
5
5
|
// old one (addresses.ts). The Filer files the page newly refers to get the page's tags in the same commit (autoTags.mjs).
|
|
6
6
|
// An earlier version (Versions.tsx) is opened as changes that are not saved,
|
|
7
|
-
// at the address the page or post has now.
|
|
8
|
-
// The form's sections are listed in a
|
|
7
|
+
// at the address the page or post has now. Its preview draft says whether it is a post (post, for the lead).
|
|
8
|
+
// The form's sections are listed as "on this page" in a card at the end of the form (large and wider only): SectionList in ui.tsx.
|
|
9
9
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
10
10
|
import { flushSync } from 'react-dom';
|
|
11
11
|
import { slugify, slugOf, type Field, type MarkdownCollection } from './schema';
|
|
@@ -34,20 +34,7 @@ import { isOver } from '../lib/notice.mjs';
|
|
|
34
34
|
import { splitPicture } from '../lib/crop.mjs';
|
|
35
35
|
import { asStandardTemplate, blockProblems, notOffered, siteBlocks } from './registry';
|
|
36
36
|
import { FieldInput, Fields, useWrongField, WrongField, type FieldContext } from './fields';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
CheckList,
|
|
40
|
-
FoldSection,
|
|
41
|
-
FormColumns,
|
|
42
|
-
type NavigationItem,
|
|
43
|
-
SectionList,
|
|
44
|
-
SectionPane,
|
|
45
|
-
SectionSheet,
|
|
46
|
-
SectionsButton,
|
|
47
|
-
SharePreview,
|
|
48
|
-
useAtLeast,
|
|
49
|
-
useInView,
|
|
50
|
-
} from './ui';
|
|
37
|
+
import { CheckList, FoldSection, FormColumns, type NavigationItem, SectionList, SectionPane, SharePreview, useInView } from './ui';
|
|
51
38
|
import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
|
|
52
39
|
import { usePagePreview } from './pagePreview';
|
|
53
40
|
import { savedOn, VersionList, VersionNotice, WithVersions, type ShownVersion } from './Versions';
|
|
@@ -67,8 +54,8 @@ const SHARE = SHARE_FIELDS;
|
|
|
67
54
|
// The sections of the form, under each other on one page, each folding in and out under its heading (FoldSection in
|
|
68
55
|
// ui.tsx): the text with the title and heading (open from the start), the status, the other fields, those for search
|
|
69
56
|
// engines, those for social media, and the earlier versions (folded from the start, so the page is short; nothing is remembered till
|
|
70
|
-
// the next time). Its list (on
|
|
71
|
-
//
|
|
57
|
+
// the next time). Its list (an "on this page" card at the end of the form, on large and wider screens only) opens one and
|
|
58
|
+
// scrolls to it, and marks the one in view. A field that keeps the form from being
|
|
72
59
|
// saved has its section opened and is scrolled to (FieldInput in fields.tsx).
|
|
73
60
|
type Section = 'content' | 'status' | 'other' | 'seo' | 'share' | 'versions';
|
|
74
61
|
const sectionOf = (path: string): Section => {
|
|
@@ -226,19 +213,16 @@ function Form({
|
|
|
226
213
|
(text) => notify({ text, error: true }),
|
|
227
214
|
(path) => unfold(sectionOf(path)),
|
|
228
215
|
);
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
const large = useAtLeast('large');
|
|
232
|
-
const [pane, setPane] = useState(true);
|
|
233
|
-
const [sheet, setSheet] = useState(false);
|
|
216
|
+
// The form's name, in the top bar and over the sections list (the "on this page" card, on large and wider screens).
|
|
217
|
+
const formTitle = isNew ? collection.create! : String(initial.title || collection.singular);
|
|
234
218
|
const has = (id: Section) => collection.fields.some(inSection(id));
|
|
235
219
|
const sections: NavigationItem[] = [
|
|
236
|
-
{ id: 'content', label: t.forms.sections.content
|
|
237
|
-
...(has('status') ? [{ id: 'status', label: t.forms.sections.status
|
|
238
|
-
...(has('other') ? [{ id: 'other', label: t.forms.otherFields
|
|
239
|
-
...(has('seo') ? [{ id: 'seo', label: t.forms.seo
|
|
240
|
-
...(has('share') ? [{ id: 'share', label: t.forms.share
|
|
241
|
-
...(isNew ? [] : [{ id: 'versions', label: t.history.versions
|
|
220
|
+
{ id: 'content', label: t.forms.sections.content },
|
|
221
|
+
...(has('status') ? [{ id: 'status', label: t.forms.sections.status }] : []),
|
|
222
|
+
...(has('other') ? [{ id: 'other', label: t.forms.otherFields }] : []),
|
|
223
|
+
...(has('seo') ? [{ id: 'seo', label: t.forms.seo }] : []),
|
|
224
|
+
...(has('share') ? [{ id: 'share', label: t.forms.share }] : []),
|
|
225
|
+
...(isNew ? [] : [{ id: 'versions', label: t.history.versions }]),
|
|
242
226
|
];
|
|
243
227
|
const shows = (id: Section) => sections.some((s) => s.id === id);
|
|
244
228
|
// The section in view, marked in the sections list: while the page scrolls, the last one whose top is above a third of the
|
|
@@ -291,8 +275,7 @@ function Form({
|
|
|
291
275
|
cancelAnimationFrame(frame);
|
|
292
276
|
};
|
|
293
277
|
}, []);
|
|
294
|
-
// Opens a section and scrolls to it (or to `box` in it: the text)
|
|
295
|
-
// not scroll while a dialog is open). It is opened at once (flushSync), so it is there to scroll to.
|
|
278
|
+
// Opens a section and scrolls to it (or to `box` in it: the text). It is opened at once (flushSync), so it is there to scroll to.
|
|
296
279
|
const textBox = useRef<HTMLDivElement>(null);
|
|
297
280
|
const goTo = (id: Section, box?: HTMLElement | null) => {
|
|
298
281
|
if (!open.has(id)) flushSync(() => unfold(id));
|
|
@@ -644,7 +627,9 @@ function Form({
|
|
|
644
627
|
shell: isNew || (relaid && others.length) ? others : url,
|
|
645
628
|
heading: h1?.heading ?? (changes('title', 'heading') ? String(values.heading || named().title) : undefined),
|
|
646
629
|
headingHtml: h1?.html,
|
|
647
|
-
// A post
|
|
630
|
+
// A post has a picture field (a page has its pictures in its text); the preview splits only a post's lead.
|
|
631
|
+
post: collection.fields.some((f) => f.name === 'image'),
|
|
632
|
+
// A post's picture at the top.
|
|
648
633
|
image:
|
|
649
634
|
collection.fields.some((f) => f.name === 'image') && changes('image', 'imageAlt', 'imageDecorative')
|
|
650
635
|
? values.image
|
|
@@ -673,17 +658,10 @@ function Form({
|
|
|
673
658
|
<Fields.Provider value={fields}>
|
|
674
659
|
<WrongField.Provider value={wrongField.field}>
|
|
675
660
|
<FormFrame
|
|
676
|
-
title={
|
|
661
|
+
title={formTitle}
|
|
677
662
|
back={{ href: `#liste/${collection.id}`, label: collection.label }}
|
|
678
663
|
status={savedStatus({ savedAt, saving, deleting, isNew })}
|
|
679
664
|
view={isNew ? undefined : url}
|
|
680
|
-
drawer={
|
|
681
|
-
<SectionsButton
|
|
682
|
-
label={t.forms.sections.title}
|
|
683
|
-
open={large ? pane : sheet}
|
|
684
|
-
onClick={() => (large ? setPane(!pane) : setSheet(true))}
|
|
685
|
-
/>
|
|
686
|
-
}
|
|
687
665
|
dirty={dirty}
|
|
688
666
|
saving={saving || deleting}
|
|
689
667
|
deleting={deleting}
|
|
@@ -757,28 +735,13 @@ function Form({
|
|
|
757
735
|
</>
|
|
758
736
|
}
|
|
759
737
|
pane={
|
|
760
|
-
|
|
761
|
-
<
|
|
762
|
-
|
|
763
|
-
</SectionPane>
|
|
764
|
-
) : undefined
|
|
738
|
+
<SectionPane caption={t.forms.sections.title} title={formTitle}>
|
|
739
|
+
<SectionList title={t.forms.sections.title} items={sections} value={section} onChange={pick} />
|
|
740
|
+
</SectionPane>
|
|
765
741
|
}
|
|
766
742
|
/>
|
|
767
743
|
</FormFrame>
|
|
768
744
|
{blockDialog}
|
|
769
|
-
{!large && (
|
|
770
|
-
<SectionSheet title={t.forms.sections.title} open={sheet} onClose={() => setSheet(false)}>
|
|
771
|
-
<SectionList
|
|
772
|
-
title={t.forms.sections.title}
|
|
773
|
-
items={sections}
|
|
774
|
-
value={section}
|
|
775
|
-
onChange={(id) => {
|
|
776
|
-
setSheet(false);
|
|
777
|
-
pick(id);
|
|
778
|
-
}}
|
|
779
|
-
/>
|
|
780
|
-
</SectionSheet>
|
|
781
|
-
)}
|
|
782
745
|
{picker}
|
|
783
746
|
{previewSheet}
|
|
784
747
|
</WrongField.Provider>
|
package/src/FormFrame.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What every form in the editor has around its fields: the top bar with the way back and a link to
|
|
2
2
|
// the page on the site, "Slet" below the fields where entries can be deleted, and a bar at the bottom
|
|
3
|
-
// of the screen with "Gem", which is only there when something
|
|
4
|
-
// by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
|
|
3
|
+
// of the screen with "Gem" (across the bar on a phone, at its end from medium), which is only there when something
|
|
4
|
+
// has been changed. Messages are shown by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
|
|
5
5
|
// Leaving with changes that are not saved asks first, to save them or not (in the editor's dialog;
|
|
6
6
|
// closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and in the top bar shows the page with
|
|
7
7
|
// what is in the form, before it is saved (pagePreview.tsx).
|
|
@@ -32,7 +32,7 @@ type Props<T extends string> = {
|
|
|
32
32
|
view?: string;
|
|
33
33
|
/** More in the top bar, before the link to the site. */
|
|
34
34
|
actions?: ReactNode;
|
|
35
|
-
/** The button at the end of the top bar, after the account
|
|
35
|
+
/** The button at the end of the top bar, after the account. */
|
|
36
36
|
drawer?: ReactNode;
|
|
37
37
|
/** The form's tabs, in the top bar (Screen). */
|
|
38
38
|
tabs?: TabChoice<T>;
|
|
@@ -206,8 +206,8 @@ export default function FormFrame<T extends string>({
|
|
|
206
206
|
{(dirty || saving) && (
|
|
207
207
|
<div className="editor-save-bar fixed inset-x-0 bottom-0 z-20 border-t border-line bg-surface/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md">
|
|
208
208
|
<div className="mx-auto max-w-3xl px-4 py-3 medium:px-6">
|
|
209
|
-
{/* "Forhåndsvis" beside "Gem". */}
|
|
210
|
-
<div className={onPreview
|
|
209
|
+
{/* "Forhåndsvis" beside "Gem": across the bar on a phone, at its end from medium. */}
|
|
210
|
+
<div className={['gap-2 medium:flex medium:justify-end', onPreview && 'grid grid-cols-[auto_1fr]'].filter(Boolean).join(' ')}>
|
|
211
211
|
{onPreview && (
|
|
212
212
|
<Button kind="outlined" icon={Binoculars} aria-haspopup="dialog" onClick={onPreview}>
|
|
213
213
|
{t.preview.button}
|
package/src/Parts.tsx
CHANGED
|
@@ -7,9 +7,6 @@
|
|
|
7
7
|
import { useMemo, useRef, useState } from 'react';
|
|
8
8
|
import {
|
|
9
9
|
AppWindow,
|
|
10
|
-
Article,
|
|
11
|
-
Eye,
|
|
12
|
-
SlidersHorizontal,
|
|
13
10
|
Gear,
|
|
14
11
|
ArrowDown,
|
|
15
12
|
ArrowsDownUp,
|
|
@@ -97,8 +94,6 @@ import {
|
|
|
97
94
|
MenuRow,
|
|
98
95
|
SectionList,
|
|
99
96
|
SectionPane,
|
|
100
|
-
SectionSheet,
|
|
101
|
-
SectionsButton,
|
|
102
97
|
} from './ui';
|
|
103
98
|
import { FieldList, Fields, useWrongField, WrongField, type FieldContext } from './fields';
|
|
104
99
|
import { newValue, problemAt } from './check';
|
|
@@ -146,12 +141,11 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
146
141
|
const [picture, setPicture] = useState(examplePicture);
|
|
147
142
|
const [folded, setFolded] = useState<number | null>(0);
|
|
148
143
|
const [sectionOpen, setSectionOpen] = useState<number | null>(0);
|
|
149
|
-
const [sectionsSheet, setSectionsSheet] = useState(false);
|
|
150
144
|
const [sectionPicked, setSectionPicked] = useState('content');
|
|
151
145
|
const sectionItems = [
|
|
152
|
-
{ id: 'content', label: t.forms.sections.content
|
|
153
|
-
{ id: 'status', label: t.forms.sections.status,
|
|
154
|
-
{ id: 'other', label: t.forms.otherFields
|
|
146
|
+
{ id: 'content', label: t.forms.sections.content },
|
|
147
|
+
{ id: 'status', label: t.forms.sections.status, badge: 2 },
|
|
148
|
+
{ id: 'other', label: t.forms.otherFields },
|
|
155
149
|
];
|
|
156
150
|
const [tags, setTags] = useState(['a', 'b']);
|
|
157
151
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
@@ -707,28 +701,12 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
707
701
|
</div>
|
|
708
702
|
</Part>
|
|
709
703
|
|
|
710
|
-
<Part name="SectionList, SectionPane
|
|
711
|
-
<div className="
|
|
712
|
-
<
|
|
713
|
-
<
|
|
714
|
-
</
|
|
715
|
-
<div className="max-w-sm">
|
|
716
|
-
<SectionPane title={t.forms.sections.title} open>
|
|
717
|
-
<SectionList title={t.forms.sections.title} items={sectionItems} value={sectionPicked} onChange={setSectionPicked} />
|
|
718
|
-
</SectionPane>
|
|
719
|
-
</div>
|
|
704
|
+
<Part name="SectionList, SectionPane" use={use.sectionList}>
|
|
705
|
+
<div className="max-w-xs">
|
|
706
|
+
<SectionPane caption={t.forms.sections.title} title={examples.pages[0]}>
|
|
707
|
+
<SectionList title={t.forms.sections.title} items={sectionItems} value={sectionPicked} onChange={setSectionPicked} />
|
|
708
|
+
</SectionPane>
|
|
720
709
|
</div>
|
|
721
|
-
<SectionSheet title={t.forms.sections.title} open={sectionsSheet} onClose={() => setSectionsSheet(false)}>
|
|
722
|
-
<SectionList
|
|
723
|
-
title={t.forms.sections.title}
|
|
724
|
-
items={sectionItems}
|
|
725
|
-
value={sectionPicked}
|
|
726
|
-
onChange={(id) => {
|
|
727
|
-
setSectionPicked(id);
|
|
728
|
-
setSectionsSheet(false);
|
|
729
|
-
}}
|
|
730
|
-
/>
|
|
731
|
-
</SectionSheet>
|
|
732
710
|
</Part>
|
|
733
711
|
|
|
734
712
|
<Part name="SharePreview" use={use.sharePreview}>
|
package/src/Screen.tsx
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// scrolling and keeps clear of a phone's notch. A screen's tabs (`tabs`) are the bottom of the bar, under the title, as wide
|
|
9
9
|
// as the content, and stay with it. The back arrow goes to the screen above, back in the history when that is the screen
|
|
10
10
|
// before (nav.ts up). The content is as wide as a form reads well, or wider for lists and the grid of files (`wide`) and for
|
|
11
|
-
// a form with its fields beside the text (`panel`).
|
|
11
|
+
// a form with its fields beside the text (`panel`). The column (editor.css .editor-column) stands centred on the window,
|
|
12
|
+
// off-centre only as far as the panels beside it require.
|
|
12
13
|
// Under the content of every screen is the editor's footer (EditorFooter.tsx).
|
|
13
14
|
import { useId, type ReactNode } from 'react';
|
|
14
15
|
import { createPortal } from 'react-dom';
|
|
@@ -30,7 +31,7 @@ export type Back = { href: string; label: string; guard?: (go: () => void) => vo
|
|
|
30
31
|
// `status`: more about the screen, e.g. when it was last saved: under the title, on a computer after it.
|
|
31
32
|
// `wide`: the content is wider on a computer (lists, the files); `panel`: a form with its other fields
|
|
32
33
|
// beside the text on a wide computer screen (FormColumns in ui.tsx). `drawer`: a button at the end of the
|
|
33
|
-
// bar, after the account
|
|
34
|
+
// bar, after the account. `home`: the start screen,
|
|
34
35
|
// with the account on a phone too. `tabs`: the screen's tabs, in the bar, with the open one's content.
|
|
35
36
|
type Props<T extends string> = {
|
|
36
37
|
title: string;
|
|
@@ -93,7 +94,13 @@ export default function Screen<T extends string>({
|
|
|
93
94
|
const id = useId();
|
|
94
95
|
const menu = useMenuOpen();
|
|
95
96
|
const { menuButtonInTopBar } = useMenuLayout();
|
|
96
|
-
|
|
97
|
+
// The column's widest width (--column-width: 48rem; 64rem from medium for `wide`, 72rem from large for `panel`); editor.css
|
|
98
|
+
// (.editor-column) keeps it centred on the window.
|
|
99
|
+
const width = wide
|
|
100
|
+
? 'editor-column [--column-width:48rem] medium:[--column-width:64rem]'
|
|
101
|
+
: panel
|
|
102
|
+
? 'editor-column [--column-width:48rem] large:[--column-width:72rem]'
|
|
103
|
+
: 'editor-column [--column-width:48rem]';
|
|
97
104
|
// A link out of the screen, through `guard`: the back arrow goes up (nav.ts), the other links as links.
|
|
98
105
|
const leave = (go?: () => void) => (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
99
106
|
if (!go && !back?.guard) return;
|
|
@@ -145,7 +152,7 @@ export default function Screen<T extends string>({
|
|
|
145
152
|
</div>
|
|
146
153
|
{tabs && (
|
|
147
154
|
<div className="editor-bar-tabs">
|
|
148
|
-
<div className={`
|
|
155
|
+
<div className={`medium:px-6 ${width}`}>
|
|
149
156
|
<TabRow id={id} {...tabs} />
|
|
150
157
|
</div>
|
|
151
158
|
</div>
|
|
@@ -155,7 +162,7 @@ export default function Screen<T extends string>({
|
|
|
155
162
|
return (
|
|
156
163
|
<div className="min-h-dvh">
|
|
157
164
|
{slot ? createPortal(bar, slot) : bar}
|
|
158
|
-
<main className={`editor-screen
|
|
165
|
+
<main className={`editor-screen px-4 pt-6 pb-[calc(6rem+env(safe-area-inset-bottom))] medium:px-6 medium:pt-8 ${width}`}>
|
|
159
166
|
{/* At expanded and wider: the way here, the title and the status, under the bar. */}
|
|
160
167
|
<div className="mb-6 hidden min-w-0 items-center gap-x-2 expanded:flex">
|
|
161
168
|
{crumbs.length > 0 && (
|
package/src/editor.css
CHANGED
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
--editor-section-gap: 2rem;
|
|
25
25
|
--editor-section-rhythm: 0.625em;
|
|
26
26
|
|
|
27
|
-
/* A form's
|
|
28
|
-
|
|
29
|
-
--section-pane-width:
|
|
27
|
+
/* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width, the card in it
|
|
28
|
+
stands --section-pane-gap in from the end edge and from under the top bar. */
|
|
29
|
+
--section-pane-width: 16rem;
|
|
30
|
+
--section-pane-gap: 2rem;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/* In dark mode the score's number in the light colours, which are read on the dark. */
|
|
@@ -372,9 +373,73 @@
|
|
|
372
373
|
}
|
|
373
374
|
}
|
|
374
375
|
|
|
375
|
-
/* The
|
|
376
|
-
|
|
376
|
+
/* The type of the "on this page" card (SectionPane and SectionList in ui.tsx), from the theme's type roles: the small
|
|
377
|
+
caption, the form's name under it, and a section's name (the one in view in medium weight). */
|
|
378
|
+
.section-pane-caption {
|
|
379
|
+
font-size: var(--type-label-small-size);
|
|
380
|
+
line-height: var(--type-label-small-line-height);
|
|
381
|
+
font-weight: var(--type-label-small-weight);
|
|
382
|
+
letter-spacing: var(--type-label-small-tracking);
|
|
383
|
+
}
|
|
384
|
+
.section-pane-title {
|
|
385
|
+
font-size: var(--type-headline-small-size);
|
|
386
|
+
line-height: var(--type-headline-small-line-height);
|
|
387
|
+
font-weight: var(--type-headline-small-weight);
|
|
388
|
+
letter-spacing: var(--type-headline-small-tracking);
|
|
389
|
+
}
|
|
390
|
+
.section-list-item {
|
|
391
|
+
font-size: var(--type-body-medium-size);
|
|
392
|
+
line-height: var(--type-body-medium-line-height);
|
|
393
|
+
font-weight: var(--type-body-medium-weight);
|
|
394
|
+
letter-spacing: var(--type-body-medium-tracking);
|
|
395
|
+
}
|
|
396
|
+
.section-list-item[aria-current='location'] {
|
|
397
|
+
font-weight: var(--type-label-medium-weight);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* The content column (.editor-column: Screen.tsx's tabs row and its main) stands centred on the window, not in the space
|
|
401
|
+
beside the panels. --column-start and --column-end are the widths of the panels beside the content: the navigation
|
|
402
|
+
rail from medium, the standard drawer instead of it from expanded while it is open (:root[data-sidebar='open']; the
|
|
403
|
+
modal drawer below expanded is over the content, so the rail counts), and the "on this page" area at the end from large
|
|
404
|
+
(.editor-section-pane). The column keeps its width (--column-width, set by Screen.tsx) and moves off the middle only as
|
|
405
|
+
far as it must to stay clear of the panels: its start margin is the middle of the window less the start panel, but
|
|
406
|
+
not less than nothing and not more than the space left between the panels less the column. Only when that space is
|
|
407
|
+
smaller than the column does it get narrower. (Measured against the window's width, 100vw, scrollbar and all.) Below
|
|
408
|
+
medium nothing stands beside the content and the column is centred by auto margins. */
|
|
409
|
+
:root {
|
|
410
|
+
--column-start: 0px;
|
|
411
|
+
--column-end: 0px;
|
|
412
|
+
}
|
|
413
|
+
.editor-column {
|
|
414
|
+
max-width: var(--column-width);
|
|
415
|
+
margin-inline: auto;
|
|
416
|
+
}
|
|
417
|
+
@media (width >= theme(--breakpoint-medium)) {
|
|
418
|
+
:root {
|
|
419
|
+
--column-start: var(--navigation-rail-width, 5.5rem);
|
|
420
|
+
}
|
|
421
|
+
.editor-column {
|
|
422
|
+
margin-inline-start: clamp(
|
|
423
|
+
0px,
|
|
424
|
+
(100vw - var(--column-width)) / 2 - var(--column-start),
|
|
425
|
+
100vw - var(--column-start) - var(--column-end) - var(--column-width)
|
|
426
|
+
);
|
|
427
|
+
margin-inline-end: auto;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
@media (width >= theme(--breakpoint-expanded)) {
|
|
431
|
+
:root[data-sidebar='open'] {
|
|
432
|
+
--column-start: var(--navigation-drawer-width, 22.5rem);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* The "on this page" area (FormColumns' `pane` in ui.tsx) stands against the window's end edge on large and wider: the
|
|
437
|
+
content keeps clear of it (its end padding; --column-end tells the column, so it stays centred on the window while
|
|
438
|
+
there is room), and the save bar ends where the area begins. */
|
|
377
439
|
@media (width >= theme(--breakpoint-large)) {
|
|
440
|
+
:root:has(.editor-section-pane) {
|
|
441
|
+
--column-end: var(--section-pane-width);
|
|
442
|
+
}
|
|
378
443
|
.editor-content:has(.editor-section-pane) {
|
|
379
444
|
padding-inline-end: var(--section-pane-width);
|
|
380
445
|
}
|
package/src/i18n/da-DK/parts.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const parts: Messages['parts'] = {
|
|
|
60
60
|
editorFooter:
|
|
61
61
|
'Linjen nederst på hver skærm, under indholdet, med et link til licenserne for det, hjemmesiden og editoren er lavet med.',
|
|
62
62
|
sectionList:
|
|
63
|
-
'Afsnittene i en lang formular som en liste,
|
|
63
|
+
'Afsnittene i en lang formular som en »på denne side«-liste på et kort: almindelig tekst, det afsnit, der ses, i den primære farve, og formularens navn over. Den bliver i billedet for enden af formularen på en stor skærm; på en mindre skærm vises den ikke.',
|
|
64
64
|
},
|
|
65
65
|
/** Over the parts: the search, and the groups to show one of (as Material 3 sorts its components). */
|
|
66
66
|
filter: {
|
package/src/i18n/da-DK/theme.ts
CHANGED
|
@@ -21,6 +21,10 @@ export const theme: Messages['theme'] = {
|
|
|
21
21
|
seeAll: 'Se alle',
|
|
22
22
|
blog: 'Blog',
|
|
23
23
|
relatedPosts: 'Relaterede indlæg',
|
|
24
|
+
share: 'Del',
|
|
25
|
+
shareOn: (network: string) => `Del på ${network}`,
|
|
26
|
+
copyLink: 'Kopiér link',
|
|
27
|
+
linkCopied: 'Linket er kopieret',
|
|
24
28
|
readingTime: (minutes) => `${minutes} min. læsetid`,
|
|
25
29
|
postsAbout: (count, tag) => `${count} indlæg om ${tag}`,
|
|
26
30
|
notFound: 'Siden findes ikke',
|
package/src/i18n/en-US/parts.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const parts = {
|
|
|
60
60
|
editorFooter:
|
|
61
61
|
'The line at the bottom of every screen, under the content, with a link to the licenses of what the website and the editor are made with.',
|
|
62
62
|
sectionList:
|
|
63
|
-
'The sections of a long form as
|
|
63
|
+
'The sections of a long form as an "on this page" list on a card: plain text, the section in view in the accent color, and the form’s name over it. It stays in view at the end of the form on a large screen; on a smaller screen it is not shown.',
|
|
64
64
|
},
|
|
65
65
|
/** Over the parts: the search, and the groups to show one of (as Material 3 sorts its components). */
|
|
66
66
|
filter: {
|
package/src/i18n/en-US/theme.ts
CHANGED
|
@@ -21,6 +21,10 @@ export const theme = {
|
|
|
21
21
|
seeAll: 'See all',
|
|
22
22
|
blog: 'Blog',
|
|
23
23
|
relatedPosts: 'Related posts',
|
|
24
|
+
share: 'Share',
|
|
25
|
+
shareOn: (network: string) => `Share on ${network}`,
|
|
26
|
+
copyLink: 'Copy link',
|
|
27
|
+
linkCopied: 'Link copied',
|
|
24
28
|
readingTime: (minutes: number) => `${minutes} min read`,
|
|
25
29
|
postsAbout: (count: number, tag: string) => `${count} ${count === 1 ? 'post' : 'posts'} about ${tag}`,
|
|
26
30
|
notFound: 'Page not found',
|
package/src/icon.tsx
CHANGED
|
@@ -37,7 +37,7 @@ function useMaterialLoaded(wanted: boolean) {
|
|
|
37
37
|
|
|
38
38
|
const PHOSPHOR = '/node_modules/@phosphor-icons/core/assets/';
|
|
39
39
|
const regular = import.meta.glob<string>(
|
|
40
|
-
'/node_modules/@phosphor-icons/core/assets/regular/{arrow-left,arrow-right,arrow-square-out,arrow-up-right,bank,bicycle,book-open,book-open-text,bus,calendar-blank,car,caret-down,caret-left,caret-right,caret-up,certificate,chats,check,clock,coffee,cookie,copy,device-mobile,door-open,download-simple,envelope-simple,eye,eye-slash,flower-lotus,graduation-cap,hand-heart,handshake,heart,house,info,keyboard,leaf,lifebuoy,lightbulb,list,magnifying-glass,map-pin,map-trifold,medal,minus,moon,pencil-simple,phone,question,sun,ticket,train,trash,user,users,users-three,video-camera,warning-circle,wheelchair,x}.svg',
|
|
40
|
+
'/node_modules/@phosphor-icons/core/assets/regular/{arrow-left,arrow-right,arrow-square-out,arrow-up-right,bank,bicycle,book-open,book-open-text,bus,calendar-blank,car,caret-down,caret-left,caret-right,caret-up,certificate,chats,check,clock,coffee,cookie,copy,device-mobile,door-open,download-simple,envelope-simple,eye,eye-slash,facebook-logo,flower-lotus,graduation-cap,hand-heart,handshake,heart,house,info,keyboard,leaf,lifebuoy,lightbulb,link-simple,linkedin-logo,list,magnifying-glass,map-pin,map-trifold,medal,minus,moon,pencil-simple,phone,pinterest-logo,question,sun,ticket,train,trash,user,users,users-three,video-camera,warning-circle,wheelchair,x,x-logo}.svg',
|
|
41
41
|
{ query: '?raw', import: 'default', eager: true },
|
|
42
42
|
);
|
|
43
43
|
// The filled ones ("Video": its play button; the navigation drawer's active item).
|
package/src/materialIcons.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// All of Material Symbols (Outlined, regular and filled) as text, by their file, for the icons icon.tsx draws that are not on
|
|
2
|
-
// its list: one lazy chunk, loaded in the browser the first time one is drawn
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
eager: true,
|
|
8
|
-
});
|
|
2
|
+
// its list: one lazy chunk, loaded in the browser the first time one is drawn. The set comes from virtual:material-symbols
|
|
3
|
+
// (packages/editor/site/materialSymbols.mjs), one module read file by file. Imported by icon.tsx only dynamically.
|
|
4
|
+
import all from 'virtual:material-symbols';
|
|
5
|
+
|
|
6
|
+
export const materialIcons: Record<string, string> = all;
|
package/src/pagePreview.tsx
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// page of another one. As on the site but for this: a changed text has the pictures themselves (not
|
|
16
16
|
// the sizes the build makes), its words are not yet links to other pages (packages/editor/site/autolinks.mjs), and
|
|
17
17
|
// a video written in it as HTML is the video.
|
|
18
|
+
//
|
|
19
|
+
// A post's text starts with its lead, a paragraph drawn larger and before the picture (data-editor="lead",
|
|
20
|
+
// theme/layouts/PostLayout.astro; lib/lead.mjs). The preview splits the text the same way, for a post only (postLead.mjs).
|
|
18
21
|
import { useRef, useState, type ReactNode } from 'react';
|
|
19
22
|
import { createRoot } from 'react-dom/client';
|
|
20
23
|
import { flushSync } from 'react-dom';
|
|
@@ -23,6 +26,7 @@ import { sectionClass, sectionsOf } from '../lib/text-sections.mjs';
|
|
|
23
26
|
import { BREAKPOINTS, breakpointOf, type Breakpoint } from '../lib/breakpoints.mjs';
|
|
24
27
|
import { processor } from '../lib/markdown';
|
|
25
28
|
import { splitPicture } from '../lib/crop.mjs';
|
|
29
|
+
import { isPostPage, leadFill } from './postLead.mjs';
|
|
26
30
|
import { preview as drawBlock, siteBlocks } from './registry';
|
|
27
31
|
import { shownAt } from './images';
|
|
28
32
|
import { pageH1 } from './sections.mjs';
|
|
@@ -40,6 +44,8 @@ export type PageDraft = {
|
|
|
40
44
|
headingHtml?: string;
|
|
41
45
|
/** The picture at the top (a blog post's); null: none. */
|
|
42
46
|
image?: { src: string; alt: string } | null;
|
|
47
|
+
/** A blog post (not a page): only its text starts with a lead. */
|
|
48
|
+
post?: boolean;
|
|
43
49
|
/** The text, in Markdown with its blocks. */
|
|
44
50
|
body?: string;
|
|
45
51
|
/** What the blocks are drawn with. */
|
|
@@ -136,8 +142,22 @@ export async function pageDocument(draft: PageDraft): Promise<string> {
|
|
|
136
142
|
end.innerHTML = blockHtml(block.type, block.props, draft.data);
|
|
137
143
|
} else end?.remove();
|
|
138
144
|
const text = doc.querySelector('[data-editor="text"]')!;
|
|
145
|
+
// A post's lead goes before its picture (or before its text when it has none), as in PostLayout.astro.
|
|
146
|
+
const { lead, rest } = leadFill(html, isPostPage(draft));
|
|
147
|
+
let leadBox = doc.querySelector('[data-editor="lead"]');
|
|
148
|
+
if (!lead) leadBox?.remove();
|
|
149
|
+
else {
|
|
150
|
+
if (!leadBox) {
|
|
151
|
+
// The text's own tag and class plus post-lead, as the layout does (a theme may replace the layout).
|
|
152
|
+
leadBox = doc.createElement(text.tagName);
|
|
153
|
+
leadBox.setAttribute('data-editor', 'lead');
|
|
154
|
+
leadBox.className = `${text.className} post-lead`;
|
|
155
|
+
(doc.querySelector('[data-editor="image"]') ?? text).before(leadBox);
|
|
156
|
+
}
|
|
157
|
+
leadBox.innerHTML = lead;
|
|
158
|
+
}
|
|
139
159
|
const types = siteBlocks.map((b) => b.type);
|
|
140
|
-
text.innerHTML = sectionsOf(
|
|
160
|
+
text.innerHTML = sectionsOf(rest)
|
|
141
161
|
.map((run) => {
|
|
142
162
|
const inner = splitBlocks(run.html, types)
|
|
143
163
|
.map((p) => ('html' in p ? p.html : blockHtml(p.type, p.props, draft.data)))
|
package/src/postLead.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// What the editor's preview puts in a post's lead and in its text: a post's text is split with splitLead, a page's never is.
|
|
2
|
+
// Exposes isPostPage (reads draft.post from pagePreview.tsx) and leadFill. Depends on lib/lead.mjs.
|
|
3
|
+
import { splitLead } from '../lib/lead.mjs';
|
|
4
|
+
|
|
5
|
+
/** @param {{ post?: boolean }} draft */
|
|
6
|
+
export const isPostPage = (draft) => draft.post === true;
|
|
7
|
+
|
|
8
|
+
/** @param {string} html the text as HTML @param {boolean} isPost @returns {{ lead: string, rest: string }} */
|
|
9
|
+
export const leadFill = (html, isPost) => (isPost ? splitLead(html) : { lead: '', rest: html });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Tests for isPostPage (draft.post) and leadFill (postLead.mjs).
|
|
2
|
+
// Run with: node --test src/postLead.test.mjs
|
|
3
|
+
import { strict as assert } from 'node:assert';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { isPostPage, leadFill } from './postLead.mjs';
|
|
6
|
+
|
|
7
|
+
test('a post with a paragraph first has a lead', () => {
|
|
8
|
+
assert.deepEqual(leadFill('<p>Hi</p><h2>A</h2>', true), { lead: '<p>Hi</p>', rest: '<h2>A</h2>' });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('a post with a heading first has no lead', () => {
|
|
12
|
+
assert.deepEqual(leadFill('<h2>A</h2><p>Hi</p>', true), { lead: '', rest: '<h2>A</h2><p>Hi</p>' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('a page with a paragraph first stays unsplit', () => {
|
|
16
|
+
assert.deepEqual(leadFill('<p>Hi</p><h2>A</h2>', false), { lead: '', rest: '<p>Hi</p><h2>A</h2>' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a post without a picture, text starting with a paragraph, has a lead', () => {
|
|
20
|
+
const isPost = isPostPage({ post: true });
|
|
21
|
+
assert.deepEqual(leadFill('<p>Hi</p>', isPost), { lead: '<p>Hi</p>', rest: '' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('only a draft marked post is a post', () => {
|
|
25
|
+
assert.equal(isPostPage({ post: true }), true);
|
|
26
|
+
assert.equal(isPostPage({ post: false }), false);
|
|
27
|
+
assert.equal(isPostPage({}), false);
|
|
28
|
+
});
|