@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/site/icons.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // PACK_SOURCES, by the maps here: Phosphor's name → the set's icon, chosen for what it means where it is used (by its own
6
6
  // name in the set, else by what it shows: Phosphor's `browser`, a window with a bar at the top, is Material Symbols'
7
7
  // `web`). An icon a set has none of (a brand's logo; Lucide has none) is Pictogrammers' Material Design Icons'
8
- // (MATERIAL_DESIGN_ICONS), with its licence beside the pack's own. check:content stops at a pack without an icon the
8
+ // (MATERIAL_DESIGN_ICONS; one neither has, Phosphor's filled icon: PHOSPHOR_FILL_ICONS), with its licence beside the pack's own. check:content stops at a pack without an icon the
9
9
  // site or the editor draws (themeProblems and iconPackProblems in packages/editor/site/theme.mjs).
10
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
11
  import { join, relative } from 'node:path';
@@ -80,18 +80,32 @@ export const MATERIAL_DESIGN_ICONS = {
80
80
  'arrows-in-line-horizontal': 'arrow-collapse-horizontal',
81
81
  'arrows-out-line-horizontal': 'arrow-expand-horizontal',
82
82
  binoculars: 'binoculars',
83
+ 'facebook-logo': 'facebook',
83
84
  'github-logo': 'github',
84
85
  'google-logo': 'google',
86
+ 'linkedin-logo': 'linkedin',
87
+ 'pinterest-logo': 'pinterest',
85
88
  'hard-drives': 'harddisk',
86
89
  scan: 'scan-helper',
87
90
  swatches: 'palette-swatch',
88
91
  },
89
92
  lucide: {
93
+ 'facebook-logo': 'facebook',
90
94
  'github-logo': 'github',
91
95
  'google-logo': 'google',
96
+ 'linkedin-logo': 'linkedin',
97
+ 'pinterest-logo': 'pinterest',
92
98
  },
93
99
  };
94
100
 
101
+ /** For each system pack but Phosphor's: Phosphor's names of the icons that neither the pack's set nor Material Design Icons has
102
+ * (X's logo: Material Design Icons has only the old Twitter bird, which X's brand rules forbid), drawn from Phosphor's filled
103
+ * icon (`fill/<name>-fill.svg`). */
104
+ export const PHOSPHOR_FILL_ICONS = {
105
+ 'material-symbols': ['x-logo'],
106
+ lucide: ['x-logo'],
107
+ };
108
+
95
109
  /** Phosphor's name → Material Symbols' (Outlined, weight 400; `-fill` the filled one), for every icon the site and the
96
110
  * editor draw (usedIcons) but those in MATERIAL_DESIGN_ICONS['material-symbols']. */
97
111
  export const MATERIAL_SYMBOLS = {
@@ -473,11 +487,19 @@ export const PACK_MAPS = { 'material-symbols': MATERIAL_SYMBOLS, lucide: LUCIDE
473
487
  * packs' maps name (an app's icon, which the CMS itself may not draw), sorted. */
474
488
  export const phosphorNames = (used) =>
475
489
  [
476
- ...new Set([...used, ...Object.values(PACK_MAPS).flatMap(Object.keys), ...Object.values(MATERIAL_DESIGN_ICONS).flatMap(Object.keys)]),
490
+ ...new Set([
491
+ ...used,
492
+ ...Object.values(PACK_MAPS).flatMap(Object.keys),
493
+ ...Object.values(MATERIAL_DESIGN_ICONS).flatMap(Object.keys),
494
+ ...Object.values(PHOSPHOR_FILL_ICONS).flat(),
495
+ ]),
477
496
  ].sort();
478
497
 
479
498
  /** The names the system's pack has an icon by in its maps (its set's and Material Design Icons'); all for Phosphor's. */
480
- const mapped = (pack) => (PACK_MAPS[pack] ? [...Object.keys(PACK_MAPS[pack]), ...Object.keys(MATERIAL_DESIGN_ICONS[pack] ?? {})] : null);
499
+ const mapped = (pack) =>
500
+ PACK_MAPS[pack]
501
+ ? [...Object.keys(PACK_MAPS[pack]), ...Object.keys(MATERIAL_DESIGN_ICONS[pack] ?? {}), ...(PHOSPHOR_FILL_ICONS[pack] ?? [])]
502
+ : null;
481
503
 
482
504
  /** Whether a name is one the site and the editor draw an icon by: Phosphor's (regular, and the filled ones as
483
505
  * `<name>-fill`). A pack's file and a theme's own icon have one of them. */
@@ -1,5 +1,11 @@
1
- /** The module's source: `{ '/node_modules/@material-symbols/svg-400/outlined/<name>.svg': '<svg …>' }`, the keys as
2
- * import.meta.glob names them (src/materialIcons.ts). With `ssr` false: an empty object. */
1
+ /** All Outlined files as text, read one at a time: `{ '/node_modules/@material-symbols/svg-400/outlined/<name>.svg': '<svg …>' }`,
2
+ * the keys as import.meta.glob named them. `dir` is the URL or the path of the outlined/ folder. */
3
+ export function materialFiles(dir: any): {};
4
+ /** The module's source: `export default` of all the files (materialFiles). */
5
+ export function materialModule(): string;
6
+ /** The SSR module's source: the same as materialModule. With `ssr` false: an empty object. */
3
7
  export function materialSsrModule(ssr: any): string;
8
+ /** The virtual module's id as imported (src/materialIcons.ts). */
9
+ export const MATERIAL_ALL: "virtual:material-symbols";
4
10
  /** The virtual module's id as imported (packages/editor/src/icon.tsx). */
5
11
  export const MATERIAL_SSR: "virtual:material-symbols-ssr";
@@ -1,19 +1,35 @@
1
- // The Material Symbols (Outlined) files as the module `virtual:material-symbols-ssr`, answered by themePlugin (theme.mjs):
2
- // all of them as text on the server render (so a backup icon, icon.tsx, is in the built page's HTML at once), nothing in the browser
3
- // (where src/materialIcons.ts is the one lazy chunk). Exposes MATERIAL_SSR and materialSsrModule(). Reads @material-symbols/svg-400.
1
+ // The Material Symbols (Outlined) files as two modules answered by themePlugin (theme.mjs): `virtual:material-symbols` (all of them
2
+ // as text, for the one lazy chunk of src/materialIcons.ts) and `virtual:material-symbols-ssr` (all of them on the server render, so
3
+ // a backup icon, icon.tsx, is in the built page's HTML at once; `{}` in the browser). Files are read one at a time, never globbed
4
+ // (7,874 open files at once stops Cloudflare's build with EMFILE). Exposes MATERIAL_ALL, MATERIAL_SSR, materialFiles, materialModule,
5
+ // materialSsrModule. Reads @material-symbols/svg-400.
4
6
  import { readdirSync, readFileSync } from 'node:fs';
5
7
  import { pathToFileURL } from 'node:url';
6
8
 
9
+ /** The virtual module's id as imported (src/materialIcons.ts). */
10
+ export const MATERIAL_ALL = 'virtual:material-symbols';
7
11
  /** The virtual module's id as imported (packages/editor/src/icon.tsx). */
8
12
  export const MATERIAL_SSR = 'virtual:material-symbols-ssr';
9
13
 
10
- /** The module's source: `{ '/node_modules/@material-symbols/svg-400/outlined/<name>.svg': '<svg …>' }`, the keys as
11
- * import.meta.glob names them (src/materialIcons.ts). With `ssr` false: an empty object. */
12
- export function materialSsrModule(ssr) {
13
- if (!ssr) return 'export default {};';
14
- const dir = new URL('node_modules/@material-symbols/svg-400/outlined/', pathToFileURL(`${process.cwd()}/`));
14
+ const PREFIX = '/node_modules/@material-symbols/svg-400/outlined/';
15
+
16
+ /** All Outlined files as text, read one at a time: `{ '/node_modules/@material-symbols/svg-400/outlined/<name>.svg': '<svg …>' }`,
17
+ * the keys as import.meta.glob named them. `dir` is the URL or the path of the outlined/ folder. */
18
+ export function materialFiles(dir) {
19
+ const base = dir instanceof URL ? dir : pathToFileURL(`${String(dir).replace(/[\/]+$/, '')}/`);
15
20
  const files = {};
16
- for (const name of readdirSync(dir))
17
- if (name.endsWith('.svg')) files[`/node_modules/@material-symbols/svg-400/outlined/${name}`] = readFileSync(new URL(name, dir), 'utf8');
18
- return `export default ${JSON.stringify(files)};`;
21
+ for (const name of readdirSync(base).sort()) if (name.endsWith('.svg')) files[PREFIX + name] = readFileSync(new URL(name, base), 'utf8');
22
+ return files;
23
+ }
24
+
25
+ const outlined = () => new URL(`node_modules/@material-symbols/svg-400/outlined/`, pathToFileURL(`${process.cwd()}/`));
26
+
27
+ /** The module's source: `export default` of all the files (materialFiles). */
28
+ export function materialModule() {
29
+ return `export default ${JSON.stringify(materialFiles(outlined()))};`;
30
+ }
31
+
32
+ /** The SSR module's source: the same as materialModule. With `ssr` false: an empty object. */
33
+ export function materialSsrModule(ssr) {
34
+ return ssr ? materialModule() : 'export default {};';
19
35
  }
@@ -0,0 +1,44 @@
1
+ // Tests of materialFiles, materialModule and materialSsrModule (site/materialSymbols.mjs): the Material Symbols files as one module.
2
+ // Run with `node --test site/materialSymbols.test.mjs`.
3
+ import { strict as assert } from 'node:assert';
4
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { materialFiles, materialModule, materialSsrModule } from './materialSymbols.mjs';
9
+
10
+ const PREFIX = '/node_modules/@material-symbols/svg-400/outlined/';
11
+ const A = '<svg viewBox="0 0 1 1"><path d="A"/></svg>';
12
+ const B = '<svg viewBox="0 0 2 2"><path d="B"/></svg>';
13
+
14
+ const folder = mkdtempSync(join(tmpdir(), 'material-'));
15
+ const dir = join(folder, 'node_modules/@material-symbols/svg-400/outlined');
16
+ mkdirSync(dir, { recursive: true });
17
+ writeFileSync(join(dir, 'b.svg'), B);
18
+ writeFileSync(join(dir, 'a.svg'), A);
19
+ writeFileSync(join(dir, 'c.txt'), 'not an icon');
20
+
21
+ test('one key per svg file, sorted, with the file text', () => {
22
+ assert.deepEqual(materialFiles(dir), { [`${PREFIX}a.svg`]: A, [`${PREFIX}b.svg`]: B });
23
+ assert.deepEqual(Object.keys(materialFiles(dir)), [`${PREFIX}a.svg`, `${PREFIX}b.svg`]);
24
+ });
25
+
26
+ test('a file that is not an svg has no key', () => {
27
+ assert.ok(!Object.keys(materialFiles(dir)).some((k) => k.endsWith('c.txt')));
28
+ });
29
+
30
+ test('the module source parses to the same object as materialFiles', () => {
31
+ const cwd = process.cwd();
32
+ process.chdir(folder);
33
+ try {
34
+ const code = materialModule();
35
+ assert.ok(code.startsWith('export default '));
36
+ assert.deepEqual(JSON.parse(code.slice('export default '.length, -1)), materialFiles(dir));
37
+ } finally {
38
+ process.chdir(cwd);
39
+ }
40
+ });
41
+
42
+ test('without ssr the SSR module is an empty object', () => {
43
+ assert.equal(materialSsrModule(false), 'export default {};');
44
+ });
package/site/schema.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // What is said of the business and the person beyond the contact details is the site's (@site/schema).
4
4
  // Only facts that are on the site; no ratings or reviews.
5
5
  import { site } from './site';
6
+ import { sameAsOf } from './social.mjs';
6
7
  import { schema as facts } from '@site/schema';
7
8
 
8
9
  /** What a site says of itself in the structured data (@site/schema). */
@@ -61,6 +62,7 @@ export const business = {
61
62
  hasMap: site.mapsUrl,
62
63
  ...(facts.offers ? { hasOfferCatalog: { '@id': abs(facts.offers) } } : {}),
63
64
  ...(facts.person ? { founder: { '@id': ids.person }, employee: { '@id': ids.person } } : {}),
65
+ ...(site.social.profiles.length ? { sameAs: sameAsOf(site.social.profiles) } : {}),
64
66
  };
65
67
 
66
68
  export const businessRef = { '@type': facts.businessType, '@id': ids.business, name: site.name, url: abs('/') };
package/site/site.ts CHANGED
@@ -1,5 +1,6 @@
1
- // Name, title, phone, email, address and CVR number: contact.json; the texts of "Book en tid": booking.json (the calendar
2
- // is the app Calendly's, src/apps/calendly/); the menus: menu.json and mobile-menu.json; the shared texts of the other blocks: their shared.ts, with those saved in
1
+ // Name, title, phone, email, address and CVR number: contact.json; the profiles and share buttons: social.json (optional, an
2
+ // older site has none); the texts of "Book en tid": booking.json (the calendar is the app Calendly's, src/apps/calendly/);
3
+ // the menus: menu.json and mobile-menu.json; the shared texts of the other blocks: their shared.ts, with those saved in
3
4
  // blocks.json over them (`blockTexts`).
4
5
  // The site's owner edits them in the editor (/editor/); what the editor does not edit is the site's config
5
6
  // (src/site/config.ts).
@@ -10,6 +11,7 @@ import mobileMenu from '@data/mobile-menu.json';
10
11
  import blocks from '@data/blocks.json';
11
12
  import standardPages from '@data/standard-pages.json';
12
13
  import { config } from '@site/config';
14
+ import { socialOf } from './social.mjs';
13
15
  import { sharedTexts, type SharedTexts } from '@morphemecms/cms/src/shared';
14
16
  import { standardShared } from '@morphemecms/cms/src/standard/sharedTexts';
15
17
 
@@ -19,6 +21,9 @@ const phoneIntl = `${contact.phone.trim().startsWith('+') ? '+' : '+45'}${contac
19
21
  // The CVR number, when it is filled in ("Virksomheden" › "Kontakt").
20
22
  const { cvr = '' } = contact as typeof contact & { cvr?: string };
21
23
 
24
+ // Optional file: an older site has no social.json, so it is read with a glob, not an import.
25
+ const socialFile = Object.values(import.meta.glob<Record<string, unknown>>('/src/data/social.json', { eager: true, import: 'default' }))[0];
26
+
22
27
  export const site = {
23
28
  name: contact.name,
24
29
  role: contact.role,
@@ -52,6 +57,8 @@ export const site = {
52
57
  /** The owner's picture (config.ts), or '' when the site has none. */
53
58
  portrait: config.picture?.src ?? '',
54
59
  portraitAlt: config.picture?.alt ?? '',
60
+ /** Profiles and share buttons (Company › Social media, social.json); all buttons on without the file. */
61
+ social: socialOf(socialFile),
55
62
  /** The logo in the structured data (config.ts), '/logo.svg' unless the site says otherwise. */
56
63
  logo: config.logo || '/logo.svg',
57
64
  };
@@ -0,0 +1,37 @@
1
+ /** @typedef {(typeof PROFILE_IDS)[number]} ProfileId */
2
+ /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
3
+ /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
4
+ /**
5
+ * @param {Record<string, unknown> | undefined} raw
6
+ * @returns {Social}
7
+ */
8
+ export function socialOf(raw: Record<string, unknown> | undefined): Social;
9
+ /**
10
+ * @param {{ url: string, title: string, picture?: string }} post
11
+ * @param {ShareId[]} share
12
+ * @returns {{ id: ShareId, href?: string }[]}
13
+ */
14
+ export function shareLinks(post: {
15
+ url: string;
16
+ title: string;
17
+ picture?: string;
18
+ }, share: ShareId[]): {
19
+ id: ShareId;
20
+ href?: string;
21
+ }[];
22
+ /**
23
+ * @param {Social['profiles']} profiles
24
+ * @returns {string[]}
25
+ */
26
+ export function sameAsOf(profiles: Social["profiles"]): string[];
27
+ export const PROFILE_IDS: readonly ["facebook", "instagram", "linkedin", "x", "youtube", "tiktok", "pinterest"];
28
+ export const SHARE_IDS: readonly ["facebook", "x", "linkedin", "pinterest", "copy-link"];
29
+ export type ProfileId = (typeof PROFILE_IDS)[number];
30
+ export type ShareId = (typeof SHARE_IDS)[number];
31
+ export type Social = {
32
+ profiles: {
33
+ id: ProfileId;
34
+ url: string;
35
+ }[];
36
+ share: ShareId[];
37
+ };
@@ -0,0 +1,92 @@
1
+ // The site's social settings (src/data/social.json, Company > Social media in the editor): its profiles, for the structured
2
+ // data (sameAs), and the share buttons under a blog post, with their addresses. Exposes socialOf, shareLinks, sameAsOf.
3
+
4
+ export const PROFILE_IDS = /** @type {const} */ (['facebook', 'instagram', 'linkedin', 'x', 'youtube', 'tiktok', 'pinterest']);
5
+ export const SHARE_IDS = /** @type {const} */ (['facebook', 'x', 'linkedin', 'pinterest', 'copy-link']);
6
+ /** @typedef {(typeof PROFILE_IDS)[number]} ProfileId */
7
+ /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
8
+ /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
9
+
10
+ /**
11
+ * @param {Record<string, unknown> | undefined} raw
12
+ * @returns {Social}
13
+ */
14
+ export function socialOf(raw) {
15
+ const data = raw || {};
16
+
17
+ // Profiles
18
+ const profiles = [];
19
+ for (const id of PROFILE_IDS) {
20
+ const value = data[id];
21
+ if (typeof value === 'string') {
22
+ const trimmed = value.trim();
23
+ if (trimmed !== '') {
24
+ profiles.push({ id, url: trimmed });
25
+ }
26
+ }
27
+ }
28
+
29
+ // Share switches
30
+ const switchMap = {
31
+ facebook: 'shareFacebook',
32
+ x: 'shareX',
33
+ linkedin: 'shareLinkedin',
34
+ pinterest: 'sharePinterest',
35
+ 'copy-link': 'shareCopyLink',
36
+ };
37
+
38
+ const share = [];
39
+ for (const id of SHARE_IDS) {
40
+ const key = switchMap[id];
41
+ const val = data[key];
42
+ if (val !== false) {
43
+ share.push(id);
44
+ }
45
+ }
46
+
47
+ return { profiles, share };
48
+ }
49
+
50
+ /**
51
+ * @param {{ url: string, title: string, picture?: string }} post
52
+ * @param {ShareId[]} share
53
+ * @returns {{ id: ShareId, href?: string }[]}
54
+ */
55
+ export function shareLinks(post, share) {
56
+ const links = [];
57
+ const url = encodeURIComponent(post.url);
58
+ const title = encodeURIComponent(post.title);
59
+ const picture = post.picture ? encodeURIComponent(post.picture) : '';
60
+
61
+ for (const id of share) {
62
+ let item = { id };
63
+
64
+ if (id === 'facebook') {
65
+ item.href = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
66
+ } else if (id === 'x') {
67
+ item.href = `https://x.com/intent/post?url=${url}&text=${title}`;
68
+ } else if (id === 'linkedin') {
69
+ item.href = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
70
+ } else if (id === 'pinterest') {
71
+ if (post.picture && post.picture !== '') {
72
+ item.href = `https://pinterest.com/pin/create/button/?url=${url}&media=${picture}&description=${title}`;
73
+ } else {
74
+ continue;
75
+ }
76
+ } else if (id === 'copy-link') {
77
+ // No href, just { id: 'copy-link' }
78
+ }
79
+
80
+ links.push(item);
81
+ }
82
+
83
+ return links;
84
+ }
85
+
86
+ /**
87
+ * @param {Social['profiles']} profiles
88
+ * @returns {string[]}
89
+ */
90
+ export function sameAsOf(profiles) {
91
+ return profiles.map((p) => p.url);
92
+ }
@@ -0,0 +1,80 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { socialOf, shareLinks, sameAsOf } from './social.mjs';
4
+
5
+ test('no file', () => {
6
+ assert.deepEqual(socialOf(undefined), { profiles: [], share: ['facebook', 'x', 'linkedin', 'pinterest', 'copy-link'] });
7
+ });
8
+
9
+ test('empty object', () => {
10
+ assert.deepEqual(socialOf({}), { profiles: [], share: ['facebook', 'x', 'linkedin', 'pinterest', 'copy-link'] });
11
+ });
12
+
13
+ test('empty profiles are dropped', () => {
14
+ assert.deepEqual(socialOf({ facebook: '', x: ' ', linkedin: ' https://linkedin.com/in/a ' }).profiles, [
15
+ { id: 'linkedin', url: 'https://linkedin.com/in/a' },
16
+ ]);
17
+ });
18
+
19
+ test('profiles keep a fixed order', () => {
20
+ assert.deepEqual(
21
+ socialOf({ pinterest: 'https://p', facebook: 'https://f' }).profiles.map((p) => p.id),
22
+ ['facebook', 'pinterest'],
23
+ );
24
+ });
25
+
26
+ test('a switch turns a button off', () => {
27
+ assert.deepEqual(socialOf({ shareX: false }).share, ['facebook', 'linkedin', 'pinterest', 'copy-link']);
28
+ });
29
+
30
+ test('all switches off', () => {
31
+ assert.deepEqual(
32
+ socialOf({ shareFacebook: false, shareX: false, shareLinkedin: false, sharePinterest: false, shareCopyLink: false }).share,
33
+ [],
34
+ );
35
+ });
36
+
37
+ test('x encodes the address and title', () => {
38
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/b c/', title: 'Hi & bye' }, ['x']), [
39
+ { id: 'x', href: 'https://x.com/intent/post?url=https%3A%2F%2Fa.dk%2Fb%20c%2F&text=Hi%20%26%20bye' },
40
+ ]);
41
+ });
42
+
43
+ test('facebook', () => {
44
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['facebook']), [
45
+ { id: 'facebook', href: 'https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fa.dk%2Fp%2F' },
46
+ ]);
47
+ });
48
+
49
+ test('linkedin', () => {
50
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['linkedin']), [
51
+ { id: 'linkedin', href: 'https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fa.dk%2Fp%2F' },
52
+ ]);
53
+ });
54
+
55
+ test('pinterest with a picture', () => {
56
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T', picture: 'https://a.dk/i.webp' }, ['pinterest']), [
57
+ {
58
+ id: 'pinterest',
59
+ href: 'https://pinterest.com/pin/create/button/?url=https%3A%2F%2Fa.dk%2Fp%2F&media=https%3A%2F%2Fa.dk%2Fi.webp&description=T',
60
+ },
61
+ ]);
62
+ });
63
+
64
+ test('pinterest without a picture is left out', () => {
65
+ assert.deepEqual(shareLinks({ url: 'https://a.dk/p/', title: 'T' }, ['pinterest', 'copy-link']), [{ id: 'copy-link' }]);
66
+ });
67
+
68
+ test('sameAs with no profiles', () => {
69
+ assert.deepEqual(sameAsOf([]), []);
70
+ });
71
+
72
+ test('sameAs keeps the order', () => {
73
+ assert.deepEqual(
74
+ sameAsOf([
75
+ { id: 'facebook', url: 'https://f' },
76
+ { id: 'x', url: 'https://x' },
77
+ ]),
78
+ ['https://f', 'https://x'],
79
+ );
80
+ });
package/site/theme.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ /** What is wrong with a theme's styles/global.css import (`css` is the file's text): null when it is right. */
2
+ export function importProblem(theme: any, css: any): string | null;
1
3
  /** The choice of icon pack made under "Indstillinger" › "Udseende" › "Ikonpakker" (src/data/icons.json, `{ "pack": "<id>"
2
4
  * }`), or undefined while none is made (then the theme's is used). */
3
5
  export function chosenPack(): any;
package/site/theme.mjs CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
  import { svgParts } from '@morphemecms/cms/lib/svg.mjs';
12
12
  import { declarationProblems, ownerId } from '@morphemecms/cms/lib/licenses.mjs';
13
13
  import { installedPacks, isIconName, packFolder, packProblems, phosphorFile, svgProblems, usedIcons } from './icons.mjs';
14
- import { MATERIAL_SSR, materialSsrModule } from './materialSymbols.mjs';
14
+ import { MATERIAL_ALL, MATERIAL_SSR, materialModule, materialSsrModule } from './materialSymbols.mjs';
15
15
  import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS, THEME_PACKS_FOLDER, packInUse, packTheme, themePackId } from './iconPacks.mjs';
16
16
  import { MOTION_SCHEMES } from '@morphemecms/cms/lib/motion-schemes.mjs';
17
17
  import { storeImportProblems } from './storeTheme.mjs';
@@ -34,12 +34,16 @@ export const themeFolder = (theme) =>
34
34
  // What the standard theme has, as its folder in src/themes/: what the site's pages (packages/editor/pages/) and the editor
35
35
  // (src/site/) take from it as @theme. Besides these, the look of its blocks (blocks/<type>/) and what they import.
36
36
  // Another theme needs only theme.json and styles/global.css (OWN_FILES); the rest it may have, else it is the
37
- // standard theme's.
37
+ // standard theme's. A theme may replace each part of the post page (components/Post*, ShareRow, RelatedPosts) by a file
38
+ // of the same name.
38
39
  export const THEME_FILES = [
39
40
  'theme.json',
40
41
  'styles/global.css',
41
42
  'layouts/PageLayout.astro',
42
43
  'layouts/PostLayout.astro',
44
+ ...['PostHeader.astro', 'PostAuthor.astro', 'PostMeta.tsx', 'ShareRow.astro', 'PostCard.tsx', 'RelatedPosts.astro'].map(
45
+ (f) => `components/${f}`,
46
+ ),
43
47
  ...['Home', 'Blog', 'Category', 'New', 'NotFound'].map((t) => `templates/${t}.astro`),
44
48
  'editor/previews.tsx',
45
49
  'editor/looks.mjs',
@@ -63,9 +67,19 @@ const CONTRACT_FILES = ['index.tsx', 'shared.ts', 'texts.ts', 'list.ts', 'fields
63
67
  // How a block looked before it was drawn from its view, once on the site and once in the editor: now View.tsx.
64
68
  const OLD_LOOK = ['Site.astro', 'draw.tsx'];
65
69
 
66
- // An import of the UI components (ui.css) or of the standard theme's CSS that has them (base.css, global.css); the
67
- // standard theme's own global.css imports ./base.css.
68
- const UI_IMPORT = /@import\s+['"](?:@morphemecms\/cms\/theme\/styles\/|\.\/)(?:ui|base|global)\.css['"]/;
70
+ // The standard theme's own global.css imports ./base.css.
71
+ const STANDARD_IMPORT = /@import\s+['"]\.\/base\.css['"]/;
72
+ // Every other theme imports the standard theme's global.css, so Material is its fallback.
73
+ const FALLBACK_IMPORT = /@import\s+['"]@morphemecms\/cms\/theme\/styles\/global\.css['"]/;
74
+
75
+ /** What is wrong with a theme's styles/global.css import (`css` is the file's text): null when it is right. */
76
+ export function importProblem(theme, css) {
77
+ if (theme === STANDARD_THEME)
78
+ return STANDARD_IMPORT.test(css) ? null : `${themeFolder(theme)}/styles/global.css: importerer ikke ./base.css.`;
79
+ return FALLBACK_IMPORT.test(css)
80
+ ? null
81
+ : `${themeFolder(theme)}/styles/global.css: importerer ikke standardtemaet, som er temaets fallback: @import '@morphemecms/cms/theme/styles/global.css' øverst.`;
82
+ }
69
83
 
70
84
  const folders = (url) =>
71
85
  existsSync(url)
@@ -227,6 +241,7 @@ const PHOSPHOR_REACT = '@phosphor-icons/react';
227
241
  const PHOSPHOR_FILE = /@phosphor-icons\/core\/assets\/(?:regular|fill)\/([a-z0-9-]+)\.svg\?raw$/;
228
242
  const REACT_ICONS = '\0site-theme-icons';
229
243
  const FILE_ICON = '\0site-theme-icon:';
244
+ const MATERIAL_ALL_ID = `\0${MATERIAL_ALL}`;
230
245
  const MATERIAL_SSR_ID = `\0${MATERIAL_SSR}`;
231
246
 
232
247
  /** The React component's name of a Phosphor icon by its file's name (`caret-right` → CaretRight), when Phosphor has it. */
@@ -311,13 +326,13 @@ export function themeProblems(theme) {
311
326
  if (theme === STANDARD_THEME)
312
327
  for (const type of standard)
313
328
  if (!has(`${themeFolder(theme)}/blocks/${type}/View.tsx`)) problems.push(`${themeFolder(theme)}/blocks/${type}/View.tsx mangler.`);
314
- // Every theme draws the UI components (styles/ui.css: the buttons and icon buttons): it imports them, itself or
315
- // through the standard theme's base.css or global.css.
329
+ // Every theme builds on the standard theme. Another theme imports the standard theme's global.css, so a style it does
330
+ // not write is Material's; the standard theme's own imports ./base.css.
316
331
  const css = `${themeFolder(theme)}/styles/global.css`;
317
- if (has(css) && !UI_IMPORT.test(readFileSync(new URL(css, root), 'utf8')))
318
- problems.push(
319
- `${css}: importerer ikke temaets UI-dele (knapperne): @import '@morphemecms/cms/theme/styles/ui.css', eller standardtemaets base.css eller global.css, der gør det.`,
320
- );
332
+ if (has(css)) {
333
+ const problem = importProblem(theme, readFileSync(new URL(css, root), 'utf8'));
334
+ if (problem !== null) problems.push(problem);
335
+ }
321
336
  for (const type of folders(new URL(`${themeFolder(theme)}/blocks/`, root))) {
322
337
  const dir = `${themeFolder(theme)}/blocks/${type}`;
323
338
  if (!standard.has(type)) problems.push(`${dir}: et tema kan kun have udseendet af en standardblok, og "${type}" er ikke en.`);
@@ -471,6 +486,7 @@ export function themePlugin() {
471
486
  // The theme's icons import Phosphor's own.
472
487
  if (source === PHOSPHOR_REACT) return own && importer !== REACT_ICONS ? REACT_ICONS : null;
473
488
  if (source === ALL_ICONS) return '\0' + ALL_ICONS;
489
+ if (source === MATERIAL_ALL) return MATERIAL_ALL_ID;
474
490
  if (source === MATERIAL_SSR) return MATERIAL_SSR_ID;
475
491
  const icon = source.match(PHOSPHOR_FILE)?.[1];
476
492
  if (icon) return svgParts(icons[icon]) ? FILE_ICON + icon : null;
@@ -485,6 +501,7 @@ export function themePlugin() {
485
501
  return null;
486
502
  },
487
503
  load(id, options) {
504
+ if (id === MATERIAL_ALL_ID) return materialModule();
488
505
  if (id === MATERIAL_SSR_ID) return materialSsrModule(options?.ssr);
489
506
  if (id === REACT_ICONS) return reactIcons(icons);
490
507
  if (id === '\0' + ALL_ICONS)
@@ -0,0 +1,40 @@
1
+ // Tests of importProblem (site/theme.mjs): every theme imports Material's global.css as its fallback.
2
+ // Run with `node --test site/themeImport.test.mjs`.
3
+ import { strict as assert } from 'node:assert';
4
+ import { registerHooks } from 'node:module';
5
+ import { test } from 'node:test';
6
+
7
+ // theme.mjs imports this package by its name, which only works from an installed copy: point the name at this folder.
8
+ const PACKAGE = '@morphemecms/cms/';
9
+ const pkg = new URL('../', import.meta.url);
10
+ registerHooks({
11
+ resolve: (specifier, context, next) =>
12
+ specifier.startsWith(PACKAGE) ? next(new URL(specifier.slice(PACKAGE.length), pkg).href, context) : next(specifier, context),
13
+ });
14
+ const { importProblem } = await import('./theme.mjs');
15
+
16
+ const GLOBAL = '@morphemecms/cms/theme/styles/global.css';
17
+
18
+ test('another theme that imports the standard theme is right', () => {
19
+ assert.equal(importProblem('venus', `@import '${GLOBAL}';`), null);
20
+ assert.equal(importProblem('venus', `@import "${GLOBAL}";`), null);
21
+ });
22
+
23
+ test('another theme that imports base.css is told to import global.css', () => {
24
+ const problem = importProblem('venus', `@import '@morphemecms/cms/theme/styles/base.css';`);
25
+ assert.ok(problem.includes(GLOBAL));
26
+ });
27
+
28
+ test('another theme that imports ui.css is told where', () => {
29
+ const problem = importProblem('venus', `@import '@morphemecms/cms/theme/styles/ui.css';`);
30
+ assert.ok(problem.includes('src/themes/venus/styles/global.css'));
31
+ });
32
+
33
+ test('another theme with no import has a problem', () => {
34
+ assert.equal(typeof importProblem('venus', ':root { --radius: 1rem; }'), 'string');
35
+ });
36
+
37
+ test('the standard theme imports ./base.css', () => {
38
+ assert.equal(importProblem('saturn', `@import './base.css';`), null);
39
+ assert.ok(importProblem('saturn', ':root {}').includes('./base.css'));
40
+ });