@morphemecms/cms 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/defaults/collections.ts +56 -0
- package/defaults/company.ts +13 -4
- package/defaults/texts.ts +17 -0
- package/icons/lucide/LICENSE-phosphor +21 -0
- package/icons/lucide/facebook-logo.svg +1 -0
- package/icons/lucide/linkedin-logo.svg +1 -0
- package/icons/lucide/pack.json +16 -1
- package/icons/lucide/pinterest-logo.svg +1 -0
- package/icons/lucide/x-logo.svg +1 -0
- package/icons/material-symbols/LICENSE-phosphor +21 -0
- package/icons/material-symbols/facebook-logo.svg +1 -0
- package/icons/material-symbols/linkedin-logo.svg +1 -0
- package/icons/material-symbols/pack.json +15 -0
- package/icons/material-symbols/pinterest-logo.svg +1 -0
- package/icons/material-symbols/x-logo.svg +1 -0
- package/icons/phosphor/facebook-logo.svg +1 -0
- package/icons/phosphor/linkedin-logo.svg +1 -0
- package/icons/phosphor/pinterest-logo.svg +1 -0
- package/icons/phosphor/x-logo.svg +1 -0
- package/lib/lead.d.mts +5 -0
- package/lib/lead.mjs +10 -0
- package/lib/lead.test.mjs +65 -0
- package/lib/licenses.mjs +1 -1
- package/package.json +2 -1
- package/scripts/icon-packs.mjs +51 -13
- package/scripts/material-fallback.test.mjs +49 -0
- package/site/icons.d.mts +13 -0
- package/site/icons.mjs +25 -3
- package/site/materialSymbols.d.mts +8 -2
- package/site/materialSymbols.mjs +27 -11
- package/site/materialSymbols.test.mjs +44 -0
- package/site/schema.ts +2 -0
- package/site/site.ts +9 -2
- package/site/social.d.mts +37 -0
- package/site/social.mjs +92 -0
- package/site/social.test.mjs +80 -0
- package/site/theme.d.mts +2 -0
- package/site/theme.mjs +28 -11
- package/site/themeImport.test.mjs +40 -0
- package/src/EntryForm.tsx +21 -58
- package/src/FormFrame.tsx +5 -5
- package/src/Parts.tsx +8 -30
- package/src/Screen.tsx +12 -5
- package/src/editor.css +70 -5
- package/src/i18n/da-DK/parts.ts +1 -1
- package/src/i18n/da-DK/theme.ts +4 -0
- package/src/i18n/en-US/parts.ts +1 -1
- package/src/i18n/en-US/theme.ts +4 -0
- package/src/icon.tsx +1 -1
- package/src/materialIcons.ts +5 -7
- package/src/pagePreview.tsx +21 -1
- package/src/postLead.d.mts +7 -0
- package/src/postLead.mjs +9 -0
- package/src/postLead.test.mjs +28 -0
- package/src/ui.tsx +41 -96
- package/src/virtual.d.ts +5 -0
- package/starter/package.json +1 -1
- package/starter/src/data/social.json +14 -0
- package/theme/components/PostAuthor.astro +20 -0
- package/theme/components/PostCard.tsx +26 -0
- package/theme/components/PostHeader.astro +36 -0
- package/theme/components/PostMeta.tsx +27 -2
- package/theme/components/RelatedPosts.astro +29 -0
- package/theme/components/ShareRow.astro +48 -0
- package/theme/layouts/PostLayout.astro +28 -54
- package/theme/styles/base.css +164 -127
- package/theme/styles/blocks.css +160 -155
- package/theme/styles/components.css +43 -34
- package/theme/styles/global.css +316 -303
- package/theme/styles/ui.css +43 -34
- package/theme/ui/copyLink.script.ts +17 -0
package/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([
|
|
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) =>
|
|
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
|
-
/**
|
|
2
|
-
* import.meta.glob
|
|
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";
|
package/site/materialSymbols.mjs
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
// The Material Symbols (Outlined) files as
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
+
};
|
package/site/social.mjs
ADDED
|
@@ -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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
315
|
-
//
|
|
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)
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
});
|