@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/defaults/collections.ts +56 -0
  3. package/defaults/company.ts +13 -4
  4. package/defaults/texts.ts +17 -0
  5. package/icons/lucide/LICENSE-phosphor +21 -0
  6. package/icons/lucide/facebook-logo.svg +1 -0
  7. package/icons/lucide/linkedin-logo.svg +1 -0
  8. package/icons/lucide/pack.json +16 -1
  9. package/icons/lucide/pinterest-logo.svg +1 -0
  10. package/icons/lucide/x-logo.svg +1 -0
  11. package/icons/material-symbols/LICENSE-phosphor +21 -0
  12. package/icons/material-symbols/facebook-logo.svg +1 -0
  13. package/icons/material-symbols/linkedin-logo.svg +1 -0
  14. package/icons/material-symbols/pack.json +15 -0
  15. package/icons/material-symbols/pinterest-logo.svg +1 -0
  16. package/icons/material-symbols/x-logo.svg +1 -0
  17. package/icons/phosphor/facebook-logo.svg +1 -0
  18. package/icons/phosphor/linkedin-logo.svg +1 -0
  19. package/icons/phosphor/pinterest-logo.svg +1 -0
  20. package/icons/phosphor/x-logo.svg +1 -0
  21. package/lib/lead.d.mts +5 -0
  22. package/lib/lead.mjs +10 -0
  23. package/lib/lead.test.mjs +65 -0
  24. package/lib/licenses.mjs +1 -1
  25. package/package.json +2 -1
  26. package/scripts/icon-packs.mjs +51 -13
  27. package/scripts/material-fallback.test.mjs +49 -0
  28. package/site/icons.d.mts +13 -0
  29. package/site/icons.mjs +25 -3
  30. package/site/materialSymbols.d.mts +8 -2
  31. package/site/materialSymbols.mjs +27 -11
  32. package/site/materialSymbols.test.mjs +44 -0
  33. package/site/schema.ts +2 -0
  34. package/site/site.ts +9 -2
  35. package/site/social.d.mts +37 -0
  36. package/site/social.mjs +92 -0
  37. package/site/social.test.mjs +80 -0
  38. package/site/theme.d.mts +2 -0
  39. package/site/theme.mjs +28 -11
  40. package/site/themeImport.test.mjs +40 -0
  41. package/src/EntryForm.tsx +21 -58
  42. package/src/FormFrame.tsx +5 -5
  43. package/src/Parts.tsx +8 -30
  44. package/src/Screen.tsx +12 -5
  45. package/src/editor.css +70 -5
  46. package/src/i18n/da-DK/parts.ts +1 -1
  47. package/src/i18n/da-DK/theme.ts +4 -0
  48. package/src/i18n/en-US/parts.ts +1 -1
  49. package/src/i18n/en-US/theme.ts +4 -0
  50. package/src/icon.tsx +1 -1
  51. package/src/materialIcons.ts +5 -7
  52. package/src/pagePreview.tsx +21 -1
  53. package/src/postLead.d.mts +7 -0
  54. package/src/postLead.mjs +9 -0
  55. package/src/postLead.test.mjs +28 -0
  56. package/src/ui.tsx +41 -96
  57. package/src/virtual.d.ts +5 -0
  58. package/starter/package.json +1 -1
  59. package/starter/src/data/social.json +14 -0
  60. package/theme/components/PostAuthor.astro +20 -0
  61. package/theme/components/PostCard.tsx +26 -0
  62. package/theme/components/PostHeader.astro +36 -0
  63. package/theme/components/PostMeta.tsx +27 -2
  64. package/theme/components/RelatedPosts.astro +29 -0
  65. package/theme/components/ShareRow.astro +48 -0
  66. package/theme/layouts/PostLayout.astro +28 -54
  67. package/theme/styles/base.css +164 -127
  68. package/theme/styles/blocks.css +160 -155
  69. package/theme/styles/components.css +43 -34
  70. package/theme/styles/global.css +316 -303
  71. package/theme/styles/ui.css +43 -34
  72. package/theme/ui/copyLink.script.ts +17 -0
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 with `wide`
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 href={href} icon={Glyph && <Glyph />} className={[wide && 'w-full', className].filter(Boolean).join(' ')} {...rest}>
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 its icon, and evt. the theme's Badge (how many are new there; or a
2614
- * dot). */
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 …), as the theme's list (Material 3's
2618
- * List and ListItem): each with its icon, its name and evt. its badge, the one open or in view (`value`) selected; a tap
2619
- * calls `onChange` with its id (EntryForm scrolls to it). `title` names the navigation. In a SectionPane on large and
2620
- * wider screens, in a SectionSheet below. */
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={siteListClasses('standard', 'none')}>
2635
- {items.map(({ id, label, icon, badge }) => {
2636
- const open = id === value;
2637
- return (
2638
- <ListItem
2639
- key={id}
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 pane at the end of a long form on large and wider screens (Material 3's 412dp pane): the theme's standard side
2656
- * sheet titled `title`, with the form's SectionList in it (`children`); it stays in view while the form scrolls. Nothing
2657
- * when it is not `open`: the button in the top bar (SectionsButton) shows and hides it. Its place in the form is
2658
- * FormColumns' `pane`, against the window's end edge; below large it is not drawn (a SectionSheet is). */
2659
- export function SectionPane({ title, open, children }: { title: string; open: boolean; children: ReactNode }) {
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
- <SiteSideSheet headline={title} headlineId={id} className="editor-reveal h-full w-full [--side-sheet-width:var(--section-pane-width)]">
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
- </SiteSideSheet>
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 (412dp, fixed while the form
3156
- * scrolls; editor.css lets the content column keep clear of it, so `main` is centred to its left); below large nothing.
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:h-[calc(100dvh-var(--top-bar-height))] large:w-(--section-pane-width)">
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/src/virtual.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ declare module 'virtual:material-symbols' {
2
+ const files: Record<string, string>;
3
+ export default files;
4
+ }
5
+
1
6
  declare module 'virtual:material-symbols-ssr' {
2
7
  const files: Record<string, string>;
3
8
  export default files;
@@ -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.7.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,14 @@
1
+ {
2
+ "facebook": "",
3
+ "instagram": "",
4
+ "linkedin": "",
5
+ "x": "",
6
+ "youtube": "",
7
+ "tiktok": "",
8
+ "pinterest": "",
9
+ "shareFacebook": true,
10
+ "shareX": true,
11
+ "shareLinkedin": true,
12
+ "sharePinterest": true,
13
+ "shareCopyLink": true
14
+ }
@@ -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>
@@ -1,9 +1,19 @@
1
- // Date and reading time for a blog post: "12. marts 2024 · 5 min. læsetid" in Danish (posts.ts makes them).
1
+ // Date and reading time for a blog post, then its tags as links: "12. marts 2024 · 5 min. læsetid · News, Tips" in Danish
2
+ // (posts.ts makes them). Props: post, optional tags and className. A theme may replace this file.
3
+ import { Fragment } from 'react';
2
4
  import { Svg } from '@morphemecms/cms/src/icon';
3
5
  import type { PostView } from '@theme/components/posts';
4
6
  import { siteT } from '@morphemecms/cms/src/i18n';
5
7
 
6
- export default function PostMeta({ post, className }: { post: PostView; className?: string }) {
8
+ export default function PostMeta({
9
+ post,
10
+ tags,
11
+ className,
12
+ }: {
13
+ post: PostView;
14
+ tags?: { label: string; href: string }[];
15
+ className?: string;
16
+ }) {
7
17
  return (
8
18
  <p className={['flex flex-wrap items-center gap-x-2 type-body-medium text-muted', className].filter(Boolean).join(' ')}>
9
19
  <time dateTime={post.date}>{post.dateText}</time>
@@ -12,6 +22,21 @@ export default function PostMeta({ post, className }: { post: PostView; classNam
12
22
  <Svg name="clock" size={15} />
13
23
  {siteT.theme.readingTime(post.minutes)}
14
24
  </span>
25
+ {tags && tags.length > 0 && (
26
+ <>
27
+ <span aria-hidden="true">·</span>
28
+ <span>
29
+ {tags.map((tag, i) => (
30
+ <Fragment key={tag.href}>
31
+ {i > 0 && ', '}
32
+ <a href={tag.href} className="underline hover:text-accent">
33
+ {tag.label}
34
+ </a>
35
+ </Fragment>
36
+ ))}
37
+ </span>
38
+ </>
39
+ )}
15
40
  </p>
16
41
  );
17
42
  }
@@ -0,0 +1,29 @@
1
+ ---
2
+ // A "Related posts" section under a blog post: up to three PostCard components in a responsive grid. A theme may replace
3
+ // this file to redesign the section.
4
+ import Container from '@theme/components/Container.astro';
5
+ import PostCard from '@theme/components/PostCard';
6
+ import { siteT } from '@morphemecms/cms/src/i18n';
7
+ import type { PostView } from '@theme/components/posts';
8
+
9
+ interface Props {
10
+ posts: PostView[];
11
+ }
12
+
13
+ const { posts } = Astro.props;
14
+ ---
15
+
16
+ {posts.length > 0 && (
17
+ <Container class="mt-12 medium:mt-16" aria-labelledby="related-posts">
18
+ <h2 id="related-posts" class="type-headline-small">
19
+ {siteT.theme.relatedPosts}
20
+ </h2>
21
+ <ul class="mt-6 grid gap-6 medium:grid-cols-3">
22
+ {posts.map((post) => (
23
+ <li>
24
+ <PostCard post={post} />
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ </Container>
29
+ )}
@@ -0,0 +1,48 @@
1
+ ---
2
+ // The share buttons under a blog post: plain links to Facebook, X, LinkedIn and Pinterest, and a button that copies the
3
+ // post's address (theme/ui/copyLink.script.ts). Props: the post's address, title and picture, and which buttons are on
4
+ // (site.social.share). Draws nothing without buttons. Uses shareLinks (site/social.mjs), Icon and IconButton's classes.
5
+ import { shareLinks, type ShareId } from '@morphemecms/cms/site/social.mjs';
6
+ import Icon from '@theme/components/Icon.astro';
7
+ import { iconButtonClass } from '@theme/ui/IconButton';
8
+ import { siteT } from '@morphemecms/cms/src/i18n';
9
+
10
+ interface Props {
11
+ url: string;
12
+ title: string;
13
+ picture?: string;
14
+ share: ShareId[];
15
+ }
16
+ const { url, title, picture, share } = Astro.props;
17
+
18
+ const links = shareLinks({ url, title, picture }, share);
19
+ const networks: Record<string, { name: string; icon: string }> = {
20
+ facebook: { name: 'Facebook', icon: 'facebook-logo' },
21
+ x: { name: 'X', icon: 'x-logo' },
22
+ linkedin: { name: 'LinkedIn', icon: 'linkedin-logo' },
23
+ pinterest: { name: 'Pinterest', icon: 'pinterest-logo' },
24
+ };
25
+ const buttonClass = iconButtonClass('standard');
26
+ ---
27
+
28
+ {links.length > 0 && (
29
+ <div role="group" aria-label={siteT.theme.share} class="flex items-center gap-x-2">
30
+ {links.map(({ id, href }) =>
31
+ id === 'copy-link' ? (
32
+ <button
33
+ type="button"
34
+ class={buttonClass}
35
+ aria-label={siteT.theme.copyLink}
36
+ data-copy-link={url}
37
+ data-copied={siteT.theme.linkCopied}
38
+ >
39
+ <Icon name="link-simple" size={22} />
40
+ </button>
41
+ ) : (
42
+ <a href={href} class={buttonClass} aria-label={siteT.theme.shareOn(networks[id].name)} target="_blank" rel="noopener">
43
+ <Icon name={networks[id].icon} size={22} />
44
+ </a>
45
+ ),
46
+ )}
47
+ </div>
48
+ )}
@@ -1,16 +1,18 @@
1
1
  ---
2
- // Blog post template. A narrow reading column, the site's owner as author; related posts in the footer.
2
+ // Blog post template. A narrow reading column, the site's owner as author. Order: PostHeader (back link, title, author,
3
+ // byline with tags, share row), the lead (the text's first paragraph), the featured picture, the rest of the text, the
4
+ // end block, RelatedPosts. Without a lead the picture comes before the text.
3
5
  // data-editor marks what the editor's preview fills in with what is in its form ("Forhåndsvis",
4
- // packages/editor/src/pagePreview.tsx): the heading, the picture and the text. The text goes through RichText (blocks={false}: a post draws no blocks, but its coloured sections are panels).
6
+ // packages/editor/src/pagePreview.tsx): the heading, the lead, the picture and the text. The text goes through RichText (blocks={false}: a post draws no blocks, but its coloured sections are panels).
7
+ import { splitLead } from '@morphemecms/cms/lib/lead.mjs';
5
8
  import { firstImage, heroImage, picturesOf } from '@morphemecms/cms/site/images.mjs';
6
9
  import Base from '@theme/layouts/Base.astro';
7
- import Icon from '@theme/components/Icon.astro';
8
10
  import Img from '@theme/components/Img.astro';
9
11
  import EndBlock from '@theme/components/EndBlock.astro';
10
12
  import RichText from '@theme/components/RichText.astro';
11
13
  import { categoryName, getPosts, postPath, stateOf, type Post } from '@morphemecms/cms/site/content';
12
- import PostMeta from '@theme/components/PostMeta';
13
- import Chip from '@theme/ui/Chip';
14
+ import PostHeader from '@theme/components/PostHeader.astro';
15
+ import RelatedPosts from '@theme/components/RelatedPosts.astro';
14
16
  import { postViewOf } from '@theme/components/loadPosts';
15
17
  import { site } from '@morphemecms/cms/site/site';
16
18
  import { isBlogCategory } from '@morphemecms/cms/site/categories';
@@ -24,11 +26,20 @@ const { post } = Astro.props;
24
26
  const { title, description, date, updated, categories } = post.data;
25
27
  // A decorative picture (imageDecorative) has no alternative text.
26
28
  const imageAlt = post.data.imageDecorative ? undefined : post.data.imageAlt;
27
- // The pills under the post: only the blog categories (the others have no page).
29
+ // The tags in the byline: only the blog categories (the others have no page).
28
30
  const onBlog = categories.filter(isBlogCategory);
29
31
  // Some posts also have their featured image (or another version of it) inside the text; never show it twice.
30
32
  const image = heroImage(post.data.image, post.body);
31
33
 
34
+ // The picture a share to Pinterest pins: the one chosen for sharing, else the post's own, with the site's address in front.
35
+ const sharePicture = post.data.shareImage || post.data.image || firstImage(post.body);
36
+ const picture = sharePicture?.startsWith('/') ? site.url + sharePicture : sharePicture || undefined;
37
+
38
+ // The text's first paragraph is the lead, drawn before the featured picture; without one the picture comes first.
39
+ const { lead, rest } = splitLead(await Astro.slots.render('default'));
40
+ // The lead has the text's class too (the editor's preview copies the text's class to the lead) and post-lead.
41
+ const textClass = 'prose mx-auto mt-10 max-w-3xl px-5 medium:mt-14 medium:px-8';
42
+
32
43
  // Related: most shared categories first, then newest.
33
44
  const related = (await getPosts())
34
45
  .filter((p) => p.id !== post.id)
@@ -69,23 +80,14 @@ const related = (await getPosts())
69
80
  ]}
70
81
  >
71
82
  <article>
72
- <header class="mx-auto max-w-3xl px-5 pt-8 medium:px-8 medium:pt-14">
73
- <a href="/blog/" class="inline-flex items-center gap-1.5 type-body-medium text-muted hover:text-accent">
74
- <Icon name="arrow-left" size={16} /> {siteT.theme.blog}
75
- </a>
76
- <h1 data-editor="heading" class="theme-title mt-3">
77
- {title}
78
- </h1>
79
- <div class="mt-6 flex items-center gap-3">
80
- {site.portrait && (
81
- <Img src={site.portrait} alt="" loading="eager" class="size-11 rounded-full object-cover object-[50%_20%]" sizes="44px" />
82
- )}
83
- <div class="leading-snug">
84
- <span class="block font-medium">{site.name}</span>
85
- <PostMeta post={postViewOf(post)} />
86
- </div>
87
- </div>
88
- </header>
83
+ <PostHeader
84
+ title={title}
85
+ post={postViewOf(post)}
86
+ tags={onBlog.map((c) => ({ label: categoryName(c), href: `/blog/kategori/${c}/` }))}
87
+ share={{ url: site.url + postPath(post), title, picture, share: site.social.share }}
88
+ />
89
+
90
+ {lead && <div data-editor="lead" class={`${textClass} post-lead`} set:html={lead} />}
89
91
 
90
92
  {image && (
91
93
  <div data-editor="image" class="mx-auto mt-8 max-w-5xl px-5 medium:mt-12 medium:px-8">
@@ -99,40 +101,12 @@ const related = (await getPosts())
99
101
  </div>
100
102
  )}
101
103
 
102
- <div data-editor="text" class="prose mx-auto mt-10 max-w-3xl px-5 medium:mt-14 medium:px-8">
103
- <RichText html={await Astro.slots.render('default')} blocks={false} />
104
+ <div data-editor="text" class={textClass}>
105
+ <RichText html={rest} blocks={false} />
104
106
  </div>
105
-
106
- <footer class="mx-auto mt-12 max-w-3xl px-5 medium:px-8">
107
- {onBlog.length > 0 && (
108
- <ul class="flex flex-wrap gap-2" aria-label="Tags">
109
- {onBlog.map((c) => (
110
- <li>
111
- <Chip href={`/blog/kategori/${c}/`} label={categoryName(c)} />
112
- </li>
113
- ))}
114
- </ul>
115
- )}
116
- </footer>
117
107
  </article>
118
108
 
119
109
  {site.postEnd && <EndBlock type={site.postEnd} />}
120
110
 
121
- {related.length > 0 && (
122
- <Fragment slot="footer-column">
123
- <p class="footer-heading font-medium">{siteT.theme.relatedPosts}</p>
124
- <ul class="mt-3 space-y-2.5 text-muted">
125
- {related.map((p) => (
126
- <li>
127
- <a href={postPath(p)} class="hover:text-accent">
128
- {p.data.title}
129
- </a>
130
- </li>
131
- ))}
132
- </ul>
133
- <a href="/blog/" class="mt-4 inline-flex items-center gap-1.5 font-medium text-accent">
134
- {siteT.theme.seeAll} <Icon name="arrow-right" size={16} />
135
- </a>
136
- </Fragment>
137
- )}
111
+ <RelatedPosts posts={related.map(postViewOf)} />
138
112
  </Base>