@morphemecms/cms 0.7.1 → 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 +12 -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/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/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 +24 -10
- 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/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/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/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/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
|
+
});
|
package/src/ui.tsx
CHANGED
|
@@ -38,14 +38,12 @@ import {
|
|
|
38
38
|
CaretUp,
|
|
39
39
|
Check,
|
|
40
40
|
Funnel,
|
|
41
|
-
Layout,
|
|
42
41
|
LockSimple,
|
|
43
42
|
LockSimpleOpen,
|
|
44
43
|
PencilSimple,
|
|
45
44
|
Plus,
|
|
46
45
|
Sidebar,
|
|
47
46
|
SidebarSimple,
|
|
48
|
-
SquareHalf,
|
|
49
47
|
X,
|
|
50
48
|
type Icon,
|
|
51
49
|
} from '@phosphor-icons/react';
|
|
@@ -90,8 +88,6 @@ import {
|
|
|
90
88
|
SiteNavigationDrawerToggle,
|
|
91
89
|
siteNavigationDrawerDialogClass,
|
|
92
90
|
siteFocusNavigationDrawerItem,
|
|
93
|
-
siteSideSheet,
|
|
94
|
-
siteSideSheetDialogClass,
|
|
95
91
|
SiteSegmentedButton,
|
|
96
92
|
SiteSideSheet,
|
|
97
93
|
SiteTab,
|
|
@@ -131,15 +127,15 @@ import { menuLayout } from '../lib/menu-layout.mjs';
|
|
|
131
127
|
|
|
132
128
|
/** The theme's button (Material 3's buttons): filled, the one thing to do; tonal, an important step that is not the
|
|
133
129
|
* main one; elevated, one on a coloured background; outlined, another choice beside it ("Annuller" too); text, a small
|
|
134
|
-
* step on the side (Fjern, Tilføj). medium (the default): side by side, and across the screen or a sheet
|
|
135
|
-
* (Gem ændringer); small: in a line of text or by a field (Skift billede, Tilføj tag). */
|
|
130
|
+
* step on the side (Fjern, Tilføj). medium (the default): side by side, and with `wide` across the screen or a sheet on a
|
|
131
|
+
* phone (compact) and the width of its text from medium (Gem ændringer); small: in a line of text or by a field (Skift billede, Tilføj tag). */
|
|
136
132
|
export type { ButtonKind };
|
|
137
133
|
export type ButtonSize = 'medium' | 'small';
|
|
138
134
|
|
|
139
135
|
type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
|
|
140
136
|
kind?: ButtonKind;
|
|
141
137
|
size?: ButtonSize;
|
|
142
|
-
/** Across the screen or the sheet. */
|
|
138
|
+
/** Across the screen or the sheet on a phone (compact), the width of its text from medium. */
|
|
143
139
|
wide?: boolean;
|
|
144
140
|
/** Before the text. */
|
|
145
141
|
icon?: Icon;
|
|
@@ -148,7 +144,7 @@ type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
|
|
|
148
144
|
|
|
149
145
|
export function Button({ icon: Glyph, wide, className, children, ...rest }: ButtonProps) {
|
|
150
146
|
return (
|
|
151
|
-
<SiteButton icon={Glyph && <Glyph />} className={[wide && 'w-full', className].filter(Boolean).join(' ')} {...rest}>
|
|
147
|
+
<SiteButton icon={Glyph && <Glyph />} className={[wide && 'w-full medium:w-auto', className].filter(Boolean).join(' ')} {...rest}>
|
|
152
148
|
{children}
|
|
153
149
|
</SiteButton>
|
|
154
150
|
);
|
|
@@ -164,7 +160,12 @@ export function LinkButton({
|
|
|
164
160
|
...rest
|
|
165
161
|
}: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type'> & { kind?: ButtonKind; size?: ButtonSize; wide?: boolean; icon?: Icon }) {
|
|
166
162
|
return (
|
|
167
|
-
<SiteButton
|
|
163
|
+
<SiteButton
|
|
164
|
+
href={href}
|
|
165
|
+
icon={Glyph && <Glyph />}
|
|
166
|
+
className={[wide && 'w-full medium:w-auto', className].filter(Boolean).join(' ')}
|
|
167
|
+
{...rest}
|
|
168
|
+
>
|
|
168
169
|
{children}
|
|
169
170
|
</SiteButton>
|
|
170
171
|
);
|
|
@@ -2610,14 +2611,13 @@ export function SideSheet({ title, onClose, children }: { title: string; onClose
|
|
|
2610
2611
|
);
|
|
2611
2612
|
}
|
|
2612
2613
|
|
|
2613
|
-
/** One entry of a SectionList: its id, its name and
|
|
2614
|
-
|
|
2615
|
-
export type NavigationItem = { id: string; label: string; icon: Icon; badge?: number | boolean };
|
|
2614
|
+
/** One entry of a SectionList: its id, its name and evt. the theme's Badge (how many are new there; or a dot). */
|
|
2615
|
+
export type NavigationItem = { id: string; label: string; badge?: number | boolean };
|
|
2616
2616
|
|
|
2617
|
-
/** The sections of a long form (a page's, a post's: Indhold, Status, Andre felter …)
|
|
2618
|
-
*
|
|
2619
|
-
* calls `onChange` with its id (EntryForm scrolls to it).
|
|
2620
|
-
*
|
|
2617
|
+
/** The sections of a long form (a page's, a post's: Indhold, Status, Andre felter …) as an "on this page" list, as on
|
|
2618
|
+
* m3.material.io: plain text, no icons and no surface; the one in view (`value`) in the accent colour and medium
|
|
2619
|
+
* weight (`aria-current="location"`). A tap calls `onChange` with its id (EntryForm scrolls to it). Evt. the theme's
|
|
2620
|
+
* Badge after the name. `title` names the navigation. In a SectionPane. */
|
|
2621
2621
|
export function SectionList({
|
|
2622
2622
|
title,
|
|
2623
2623
|
items,
|
|
@@ -2631,94 +2631,39 @@ export function SectionList({
|
|
|
2631
2631
|
}) {
|
|
2632
2632
|
return (
|
|
2633
2633
|
<nav aria-label={title}>
|
|
2634
|
-
<ul className=
|
|
2635
|
-
{items.map(({ id, label,
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
icon={icon}
|
|
2641
|
-
iconSelected={open}
|
|
2642
|
-
selected={open}
|
|
2643
|
-
aria-current={open ? 'location' : undefined}
|
|
2644
|
-
headline={label}
|
|
2645
|
-
trailing={badge ? <SiteBadge count={badge === true ? undefined : badge} /> : undefined}
|
|
2634
|
+
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
|
2635
|
+
{items.map(({ id, label, badge }) => (
|
|
2636
|
+
<li key={id}>
|
|
2637
|
+
<button
|
|
2638
|
+
type="button"
|
|
2639
|
+
aria-current={id === value ? 'location' : undefined}
|
|
2646
2640
|
onClick={() => onChange(id)}
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2641
|
+
className="section-list-item flex w-full items-center gap-2 rounded-(--block-radius-small) px-2 py-2 text-start text-muted transition-colors hover:text-ink focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none aria-[current=location]:text-accent"
|
|
2642
|
+
>
|
|
2643
|
+
<span className="min-w-0">{label}</span>
|
|
2644
|
+
{badge ? <SiteBadge count={badge === true ? undefined : badge} /> : null}
|
|
2645
|
+
</button>
|
|
2646
|
+
</li>
|
|
2647
|
+
))}
|
|
2650
2648
|
</ul>
|
|
2651
2649
|
</nav>
|
|
2652
2650
|
);
|
|
2653
2651
|
}
|
|
2654
2652
|
|
|
2655
|
-
/** The
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
* FormColumns' `pane`, against the window's end edge; below large it is not drawn
|
|
2659
|
-
export function SectionPane({
|
|
2660
|
-
const id = useId();
|
|
2661
|
-
if (!open) return null;
|
|
2653
|
+
/** The "on this page" card at the end of a long form on large and wider screens: the theme's outlined card, as tall as
|
|
2654
|
+
* its content (it scrolls inside only when that is taller than the window less the top bar), with the small `caption`
|
|
2655
|
+
* (Afsnit), the form's `title` under it and the form's SectionList (`children`). It stays in view while the form
|
|
2656
|
+
* scrolls. Its place is FormColumns' `pane`, against the window's end edge; below large it is not drawn. */
|
|
2657
|
+
export function SectionPane({ caption, title, children }: { caption: string; title: string; children: ReactNode }) {
|
|
2662
2658
|
return (
|
|
2663
|
-
<
|
|
2659
|
+
<div className={`${siteCardClass('outlined')} max-h-[calc(100dvh-var(--top-bar-height)-2*var(--section-pane-gap))] overflow-y-auto`}>
|
|
2660
|
+
<p className="section-pane-caption m-0 text-muted">{caption}</p>
|
|
2661
|
+
<h2 className="section-pane-title m-0 mb-3 text-ink">{title}</h2>
|
|
2664
2662
|
{children}
|
|
2665
|
-
</
|
|
2666
|
-
);
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
/** The sections below large: the theme's modal side sheet (Material 3's), from the end edge over the screen, titled
|
|
2670
|
-
* `title`, with the form's SectionList in it (`children`). Controlled: `open` shows it (the theme's `sideSheet()` slides
|
|
2671
|
-
* it in; with reduced motion it just appears), and `onClose` is called when it has been closed by its cross, Escape or a
|
|
2672
|
-
* tap on the scrim; a choice closes it by the caller setting `open` to false. The page does not scroll while it is
|
|
2673
|
-
* open (the theme's control), and it is given back if the sheet is taken away while open (the window grows to large). */
|
|
2674
|
-
export function SectionSheet({
|
|
2675
|
-
title,
|
|
2676
|
-
open,
|
|
2677
|
-
onClose,
|
|
2678
|
-
children,
|
|
2679
|
-
}: {
|
|
2680
|
-
title: string;
|
|
2681
|
-
open: boolean;
|
|
2682
|
-
onClose: () => void;
|
|
2683
|
-
children: ReactNode;
|
|
2684
|
-
}) {
|
|
2685
|
-
const id = useId();
|
|
2686
|
-
const dialog = useRef<HTMLDialogElement>(null);
|
|
2687
|
-
const control = useRef<ReturnType<typeof siteSideSheet> | undefined>(undefined);
|
|
2688
|
-
const closed = useRef(onClose);
|
|
2689
|
-
closed.current = onClose;
|
|
2690
|
-
useEffect(() => {
|
|
2691
|
-
const el = dialog.current!;
|
|
2692
|
-
control.current = siteSideSheet(el);
|
|
2693
|
-
const done = () => closed.current();
|
|
2694
|
-
el.addEventListener('close', done);
|
|
2695
|
-
return () => {
|
|
2696
|
-
el.removeEventListener('close', done);
|
|
2697
|
-
if (el.open) el.close();
|
|
2698
|
-
document.documentElement.style.overflow = '';
|
|
2699
|
-
};
|
|
2700
|
-
}, []);
|
|
2701
|
-
useEffect(() => {
|
|
2702
|
-
if (open) control.current?.open();
|
|
2703
|
-
else void control.current?.close();
|
|
2704
|
-
}, [open]);
|
|
2705
|
-
return (
|
|
2706
|
-
<dialog ref={dialog} aria-labelledby={id} className={siteSideSheetDialogClass}>
|
|
2707
|
-
<SiteSideSheet kind="modal" headline={title} headlineId={id} closeLabel={t.common.close}>
|
|
2708
|
-
{children}
|
|
2709
|
-
</SiteSideSheet>
|
|
2710
|
-
</dialog>
|
|
2663
|
+
</div>
|
|
2711
2664
|
);
|
|
2712
2665
|
}
|
|
2713
2666
|
|
|
2714
|
-
/** The button at the end of the top bar (Screen's `drawer`) for a form's sections: on large and wider it shows and hides
|
|
2715
|
-
* the SectionPane (`open`: it is shown, so the icon is `right_panel_close` (`square-half`), else `right_panel_open` (`layout`)),
|
|
2716
|
-
* below it opens the SectionSheet, so the icon is always `right_panel_open`. `label` names it. */
|
|
2717
|
-
export function SectionsButton({ label, open, onClick }: { label: string; open?: boolean; onClick: () => void }) {
|
|
2718
|
-
const pane = useAtLeast('large');
|
|
2719
|
-
return <IconButton icon={pane && open ? SquareHalf : Layout} label={label} aria-expanded={open} onClick={onClick} />;
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
2667
|
/** The editor's main navigation (Material 3's navigation drawer, the theme's, in its design): the site's parts in sections
|
|
2723
2668
|
* (`MainDrawerSection`, `MainDrawerItem`), at the full height of the window, with the menu button at the top. From medium
|
|
2724
2669
|
* and wider a navigation rail (`rail`: its destinations, `MainRailItem` and `MainRailSectionItem`) stands in its place while
|
|
@@ -3152,8 +3097,8 @@ export function FormColumns({
|
|
|
3152
3097
|
side?: ReactNode;
|
|
3153
3098
|
cards?: boolean;
|
|
3154
3099
|
sticky?: boolean;
|
|
3155
|
-
/** A SectionPane, on large and wider screens against the window's end edge under the top bar (
|
|
3156
|
-
* scrolls; editor.css lets the content column keep clear of it
|
|
3100
|
+
/** A SectionPane, on large and wider screens against the window's end edge under the top bar (--section-pane-width
|
|
3101
|
+
* wide, fixed while the form scrolls; editor.css lets the content column keep clear of it); below large nothing.
|
|
3157
3102
|
* Instead of `first` and `side`. */
|
|
3158
3103
|
pane?: ReactNode;
|
|
3159
3104
|
}) {
|
|
@@ -3170,7 +3115,7 @@ export function FormColumns({
|
|
|
3170
3115
|
<div className="min-w-0 space-y-6 large:col-start-1 large:row-span-2 large:row-start-1">{main}</div>
|
|
3171
3116
|
{side && <div className={`${right} ${first ? 'large:row-start-2' : 'large:row-span-2 large:row-start-1'}`}>{side}</div>}
|
|
3172
3117
|
{pane && (
|
|
3173
|
-
<div className="editor-section-pane hidden large:fixed large:end-0 large:top-(--top-bar-height) large:z-10 large:block large:
|
|
3118
|
+
<div className="editor-section-pane hidden large:fixed large:end-0 large:top-[calc(var(--top-bar-height)+var(--section-pane-gap))] large:z-10 large:block large:w-(--section-pane-width) large:pe-(--section-pane-gap)">
|
|
3174
3119
|
{pane}
|
|
3175
3120
|
</div>
|
|
3176
3121
|
)}
|
package/starter/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@material-symbols/svg-400": "0.48.2",
|
|
33
33
|
"@material/material-color-utilities": "^0.3.0",
|
|
34
34
|
"@milkdown/kit": "^7.22.3",
|
|
35
|
-
"@morphemecms/cms": "0.
|
|
35
|
+
"@morphemecms/cms": "0.8.0",
|
|
36
36
|
"@phosphor-icons/core": "^2.1.1",
|
|
37
37
|
"@phosphor-icons/react": "^2.1.10",
|
|
38
38
|
"@tailwindcss/typography": "^0.5.20",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The author of a blog post: a round picture (when the site has one), the name and the role under it.
|
|
3
|
+
// Props: name, role, portrait. A theme may replace this file to redesign the author.
|
|
4
|
+
import Img from '@theme/components/Img.astro';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
name: string;
|
|
8
|
+
role?: string;
|
|
9
|
+
portrait?: string;
|
|
10
|
+
}
|
|
11
|
+
const { name, role, portrait } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="flex items-center gap-3">
|
|
15
|
+
{portrait && <Img src={portrait} alt="" loading="eager" class="size-11 rounded-full object-cover object-[50%_20%]" sizes="44px" />}
|
|
16
|
+
<div class="leading-snug">
|
|
17
|
+
<span class="block font-medium">{name}</span>
|
|
18
|
+
{role && <span class="block type-body-medium text-muted">{role}</span>}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// One related post under a blog post (RelatedPosts.astro): a Material card (ui/Card.tsx) with the post's picture, its
|
|
2
|
+
// title, and its date and reading time (PostMeta.tsx). A theme may replace this file to redesign the card.
|
|
3
|
+
import { BREAKPOINTS } from '@morphemecms/cms/lib/breakpoints.mjs';
|
|
4
|
+
import Card from '@theme/ui/Card';
|
|
5
|
+
import Img from '@theme/components/Img';
|
|
6
|
+
import PostMeta from '@theme/components/PostMeta';
|
|
7
|
+
import type { PostView } from '@theme/components/posts';
|
|
8
|
+
|
|
9
|
+
export default function PostCard({ post }: { post: PostView }) {
|
|
10
|
+
return (
|
|
11
|
+
<Card as="article" href={post.href} className="h-full overflow-hidden">
|
|
12
|
+
{post.image && (
|
|
13
|
+
<Img
|
|
14
|
+
image={post.image}
|
|
15
|
+
alt={post.imageAlt ?? ''}
|
|
16
|
+
sizes={`(min-width: ${BREAKPOINTS.medium}px) 33vw, 100vw`}
|
|
17
|
+
className="aspect-[4/3] w-full object-cover"
|
|
18
|
+
/>
|
|
19
|
+
)}
|
|
20
|
+
<div className="p-4">
|
|
21
|
+
<h3 className="type-title-medium">{post.title}</h3>
|
|
22
|
+
<PostMeta post={post} className="mt-2" />
|
|
23
|
+
</div>
|
|
24
|
+
</Card>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The header of a blog post: back link, title, author (from site), byline with tags, and share row, left-aligned.
|
|
3
|
+
// Props: title, post (PostView), tags, share (ShareRow's props). A theme may replace this file to redesign the header.
|
|
4
|
+
import Icon from '@theme/components/Icon.astro';
|
|
5
|
+
import PostAuthor from '@theme/components/PostAuthor.astro';
|
|
6
|
+
import PostMeta from '@theme/components/PostMeta';
|
|
7
|
+
import ShareRow from '@theme/components/ShareRow.astro';
|
|
8
|
+
import type { PostView } from '@theme/components/posts';
|
|
9
|
+
import type { ShareId } from '@morphemecms/cms/site/social.mjs';
|
|
10
|
+
import { site } from '@morphemecms/cms/site/site';
|
|
11
|
+
import { siteT } from '@morphemecms/cms/src/i18n';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
title: string;
|
|
15
|
+
post: PostView;
|
|
16
|
+
tags: { label: string; href: string }[];
|
|
17
|
+
share: { url: string; title: string; picture?: string; share: ShareId[] };
|
|
18
|
+
}
|
|
19
|
+
const { title, post, tags, share } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<header class="mx-auto max-w-3xl px-5 pt-8 medium:px-8 medium:pt-14">
|
|
23
|
+
<a href="/blog/" class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
|
|
24
|
+
<Icon name="arrow-left" size={16} /> {siteT.theme.blog}
|
|
25
|
+
</a>
|
|
26
|
+
<h1 data-editor="heading" class="theme-title mt-3">
|
|
27
|
+
{title}
|
|
28
|
+
</h1>
|
|
29
|
+
<div class="mt-6">
|
|
30
|
+
<PostAuthor name={site.name} role={site.role} portrait={site.portrait} />
|
|
31
|
+
</div>
|
|
32
|
+
<PostMeta post={post} tags={tags} className="mt-3" />
|
|
33
|
+
<div class="mt-4">
|
|
34
|
+
<ShareRow {...share} />
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|