@morphemecms/cms 0.7.1 → 0.8.0-next-20261105074335

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 (58) hide show
  1. package/CHANGELOG.md +11 -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/package.json +1 -1
  25. package/scripts/icon-packs.mjs +51 -13
  26. package/site/icons.d.mts +13 -0
  27. package/site/icons.mjs +25 -3
  28. package/site/schema.ts +2 -0
  29. package/site/site.ts +9 -2
  30. package/site/social.d.mts +37 -0
  31. package/site/social.mjs +92 -0
  32. package/site/social.test.mjs +80 -0
  33. package/site/theme.mjs +5 -1
  34. package/src/EntryForm.tsx +21 -58
  35. package/src/FormFrame.tsx +5 -5
  36. package/src/Parts.tsx +8 -30
  37. package/src/Screen.tsx +12 -5
  38. package/src/editor.css +70 -5
  39. package/src/i18n/da-DK/parts.ts +1 -1
  40. package/src/i18n/da-DK/theme.ts +4 -0
  41. package/src/i18n/en-US/parts.ts +1 -1
  42. package/src/i18n/en-US/theme.ts +4 -0
  43. package/src/icon.tsx +1 -1
  44. package/src/pagePreview.tsx +21 -1
  45. package/src/postLead.d.mts +7 -0
  46. package/src/postLead.mjs +9 -0
  47. package/src/postLead.test.mjs +28 -0
  48. package/src/ui.tsx +41 -96
  49. package/starter/src/data/social.json +14 -0
  50. package/theme/components/PostAuthor.astro +20 -0
  51. package/theme/components/PostCard.tsx +26 -0
  52. package/theme/components/PostHeader.astro +36 -0
  53. package/theme/components/PostMeta.tsx +27 -2
  54. package/theme/components/RelatedPosts.astro +29 -0
  55. package/theme/components/ShareRow.astro +48 -0
  56. package/theme/layouts/PostLayout.astro +28 -54
  57. package/theme/styles/base.css +12 -0
  58. package/theme/ui/copyLink.script.ts +17 -0
@@ -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>
@@ -181,6 +181,18 @@
181
181
  font-weight: var(--type-body-large-weight);
182
182
  letter-spacing: var(--type-body-large-tracking);
183
183
  }
184
+ /* A blog post's lead: its first paragraph, one step larger than the text (the title-large size, with the body weight). */
185
+ .prose.post-lead {
186
+ font-size: var(--type-title-large-size);
187
+ line-height: var(--type-title-large-line-height);
188
+ font-weight: var(--type-body-large-weight);
189
+ letter-spacing: var(--type-title-large-tracking);
190
+ }
191
+ /* The text right after the lead (no picture between): the space the prose puts between paragraphs (1.25em), not the
192
+ larger space of a text under a picture. Unlayered on purpose: it must beat the mt-10 / medium:mt-14 utilities (a layer). */
193
+ .prose.post-lead + .prose {
194
+ margin-top: 1.25em;
195
+ }
184
196
  /* The text's headings: a headline, a title under it and a smaller title under that (the type scale, ui.css). */
185
197
  .prose :where(h2) {
186
198
  font-size: var(--type-headline-medium-size);
@@ -0,0 +1,17 @@
1
+ // What a copy-link button (ShareRow.astro, [data-copy-link]) does on the site: it writes its address to the clipboard and
2
+ // says so in a snackbar (its text in data-copied). Run on every page by Base.astro (blockScripts, by `ui/copyLink`);
3
+ // without such a button on the page it does nothing.
4
+ import { snackbar } from '@morphemecms/cms/lib/snackbar';
5
+
6
+ export default function copyLinks() {
7
+ document.querySelectorAll<HTMLButtonElement>('button[data-copy-link]').forEach((button) => {
8
+ button.addEventListener('click', async () => {
9
+ try {
10
+ await navigator.clipboard.writeText(button.dataset.copyLink ?? '');
11
+ } catch {
12
+ return;
13
+ }
14
+ snackbar({ text: button.dataset.copied ?? '' });
15
+ });
16
+ });
17
+ }