@morphemecms/cms 0.13.0-next-20261106213045 → 0.13.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 +11 -1
- package/defaults/collections.ts +39 -52
- package/defaults/company.ts +5 -1
- package/defaults/editor.ts +27 -14
- package/defaults/i18n/da-DK/collections.ts +11 -8
- package/defaults/i18n/da-DK/editor.ts +1 -1
- package/defaults/i18n/da-DK/rules.ts +1 -0
- package/defaults/rules.ts +13 -1
- package/lib/focusTrap.d.mts +20 -0
- package/lib/focusTrap.mjs +28 -0
- package/lib/focusTrap.test.mjs +41 -0
- package/lib/folderChanges.mjs +1 -1
- package/lib/licenses.d.mts +1 -1
- package/lib/licenses.imports.test.mjs +33 -0
- package/lib/licenses.mjs +7 -4
- package/lib/menu.mjs +9 -4
- package/lib/menu.test.mjs +45 -18
- package/lib/storeEntries.d.mts +0 -1
- package/lib/storeEntries.mjs +2 -4
- package/lib/storeEntries.test.mjs +1 -6
- package/package.json +1 -1
- package/scripts/starter-gitattributes.test.mjs +22 -0
- package/site/header.d.mts +2 -1
- package/site/header.mjs +7 -4
- package/site/header.test.mjs +8 -2
- package/site/social.d.mts +65 -8
- package/site/social.mjs +132 -14
- package/site/social.test.mjs +109 -5
- package/src/App.tsx +29 -7
- package/src/AppList.tsx +46 -200
- package/src/BrandForm.tsx +2 -2
- package/src/CompanyForm.tsx +7 -2
- package/src/HeaderSettings.tsx +4 -4
- package/src/Library.tsx +20 -13
- package/src/LinkList.tsx +165 -0
- package/src/MainMenu.tsx +24 -4
- package/src/MoveButtons.tsx +15 -0
- package/src/StoreSearchRow.tsx +67 -0
- package/src/StoreStatus.tsx +67 -0
- package/src/ThemeStore.tsx +16 -37
- package/src/ThemeStoreScreen.tsx +62 -0
- package/src/checkSite.ts +3 -2
- package/src/cmsNotice.d.mts +7 -0
- package/src/cmsNotice.mjs +11 -0
- package/src/cmsNotice.test.mjs +50 -0
- package/src/fields.tsx +38 -13
- package/src/headerPreview.d.mts +14 -4
- package/src/headerPreview.mjs +10 -8
- package/src/headerPreview.test.mjs +12 -4
- package/src/headerTheme.tsx +19 -26
- package/src/i18n/da-DK/common.ts +1 -0
- package/src/i18n/da-DK/forms.ts +12 -0
- package/src/i18n/en-US/common.ts +2 -0
- package/src/i18n/en-US/forms.ts +15 -0
- package/src/levelStep.d.mts +10 -0
- package/src/levelStep.mjs +66 -0
- package/src/levelStep.test.mjs +119 -0
- package/src/linkAddress.d.mts +21 -0
- package/src/linkAddress.mjs +50 -0
- package/src/linkAddress.test.mjs +57 -0
- package/src/moveItem.d.mts +9 -0
- package/src/moveItem.mjs +17 -0
- package/src/moveItem.test.mjs +16 -0
- package/src/movedRoutes.mjs +8 -3
- package/src/movedRoutes.test.mjs +11 -2
- package/src/schema.ts +11 -0
- package/src/site.ts +1 -0
- package/src/store.ts +5 -33
- package/src/storeSort.tsx +39 -0
- package/src/themeFilter.d.mts +17 -0
- package/src/themeFilter.mjs +16 -0
- package/src/themeFilter.test.mjs +24 -0
- package/src/themeUi.ts +1 -0
- package/src/ui.tsx +2 -1
- package/starter/.gitattributes +2 -0
- package/starter/package.json +1 -1
- package/starter/src/data/social.json +1 -7
- package/theme/blocks/buttons/Buttons.tsx +12 -2
- package/theme/components/Header.astro +21 -31
- package/theme/components/HeaderCta.tsx +21 -0
- package/theme/components/HeaderPhoneButton.tsx +28 -0
- package/theme/components/MobileMenu.tsx +56 -43
- package/theme/components/headerParts.ts +5 -4
- package/theme/styles/global.css +22 -5
- package/theme/styles/ui.css +110 -7
- package/theme/ui/NavigationDrawer.tsx +6 -1
- package/src/legacyMove.test.mjs +0 -137
- package/src/legacyMove.ts +0 -131
package/lib/menu.test.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const known = {
|
|
|
17
17
|
const treeGroups = [
|
|
18
18
|
{ id: 'home', title: 'Home', href: '#', ids: [] },
|
|
19
19
|
{ id: 'content', title: 'Content', ids: ['pages', 'lists', 'redirects'] },
|
|
20
|
-
{ id: 'appearance', title: 'Appearance', ids: ['appearance/colors', '
|
|
20
|
+
{ id: 'appearance', title: 'Appearance', ids: ['appearance/colors', 'company/logo'] },
|
|
21
21
|
];
|
|
22
22
|
const treePartOf = (id) => ({ label: `L ${id}`, href: `#${id === 'redirects' ? 'seo/redirects' : id}`, count: null });
|
|
23
23
|
const treeChildren = (id) =>
|
|
@@ -78,9 +78,8 @@ test('the screens with parts, and their parts, are the screen', () => {
|
|
|
78
78
|
assert.equal(currentPart('privacy/cookie-banner', known), 'privacy');
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
test('the app store
|
|
81
|
+
test('the app store is Find apps', () => {
|
|
82
82
|
assert.equal(currentPart('settings/appstore', known), 'appstore');
|
|
83
|
-
assert.equal(currentPart('settings/appstore/themes', known), 'appstore');
|
|
84
83
|
});
|
|
85
84
|
|
|
86
85
|
test('a screen of its own is itself, and the start screen is none', () => {
|
|
@@ -155,10 +154,10 @@ const menuGroups = [
|
|
|
155
154
|
title: 'Content',
|
|
156
155
|
ids: ['pages', 'blog', 'lists', 'popups', 'tags', 'files', 'internal-links', 'redirects'],
|
|
157
156
|
},
|
|
158
|
-
{ id: 'global', title: 'Global', ids: ['header', 'footer', 'company'] },
|
|
159
|
-
{ id: 'apps', title: 'Apps', ids: ['apps', 'appstore'] },
|
|
160
157
|
{ id: 'blocks', title: 'Blocks', ids: ['blocks', 'block-settings'] },
|
|
161
|
-
{ id: '
|
|
158
|
+
{ id: 'apps', title: 'Apps', ids: ['apps', 'appstore'] },
|
|
159
|
+
{ id: 'company', title: 'Business', ids: ['logo', 'company'] },
|
|
160
|
+
{ id: 'appearance', title: 'Appearance', ids: ['theme-store', 'header', 'footer', 'colors'] },
|
|
162
161
|
{ id: 'privacy', title: 'Privacy', ids: ['cookie-banner', 'cookies'] },
|
|
163
162
|
{ id: 'settings', title: 'Settings', ids: ['advanced'] },
|
|
164
163
|
];
|
|
@@ -178,8 +177,9 @@ const hrefs = {
|
|
|
178
177
|
appstore: '#settings/appstore',
|
|
179
178
|
blocks: '#blocks',
|
|
180
179
|
'block-settings': '#block-settings',
|
|
180
|
+
'theme-store': '#appearance/theme-store',
|
|
181
181
|
colors: '#appearance/colors',
|
|
182
|
-
logo: '#
|
|
182
|
+
logo: '#company/logo',
|
|
183
183
|
'cookie-banner': '#privacy/cookie-banner',
|
|
184
184
|
cookies: '#list/cookies',
|
|
185
185
|
advanced: '#advanced',
|
|
@@ -195,7 +195,12 @@ const menu = menuTree(
|
|
|
195
195
|
]
|
|
196
196
|
: id === 'apps'
|
|
197
197
|
? [{ id: 'calendly', label: 'Calendly', href: '#apps/calendly' }]
|
|
198
|
-
:
|
|
198
|
+
: id === 'company'
|
|
199
|
+
? [
|
|
200
|
+
{ id: 'contact', label: 'Contact', href: '#company/contact' },
|
|
201
|
+
{ id: 'booking', label: 'Booking', href: '#company/booking' },
|
|
202
|
+
]
|
|
203
|
+
: [],
|
|
199
204
|
);
|
|
200
205
|
const menuKnown = { ...known, store: 'settings/appstore' };
|
|
201
206
|
const allIds = menu.flatMap((e) => e.items.flatMap((i) => [i.id, ...(i.children ?? []).map((c) => c.id)]));
|
|
@@ -204,7 +209,7 @@ const allHrefs = menu.flatMap((e) => e.items.flatMap((i) => [i.href, ...(i.child
|
|
|
204
209
|
test('the menu has the eight sections in order', () => {
|
|
205
210
|
assert.deepEqual(
|
|
206
211
|
menu.map((e) => e.id),
|
|
207
|
-
['home', 'content', '
|
|
212
|
+
['home', 'content', 'blocks', 'apps', 'company', 'appearance', 'privacy', 'settings'],
|
|
208
213
|
);
|
|
209
214
|
});
|
|
210
215
|
|
|
@@ -256,21 +261,43 @@ test('currentPlace of a page being edited, a list, the SEO rating, an Appearance
|
|
|
256
261
|
assert.deepEqual(currentPlace('appearance/colors', menu, menuKnown), { section: 'appearance', item: 'colors' });
|
|
257
262
|
assert.deepEqual(currentPlace('apps/calendly', menu, menuKnown), { section: 'apps', item: 'calendly' });
|
|
258
263
|
assert.deepEqual(currentPlace('apps/mailchimp', menu, menuKnown), { section: 'apps' });
|
|
259
|
-
assert.deepEqual(currentPlace('settings/appstore
|
|
264
|
+
assert.deepEqual(currentPlace('settings/appstore', menu, menuKnown), { section: 'apps', item: 'appstore' });
|
|
265
|
+
assert.deepEqual(currentPlace('appearance/theme-store', menu, menuKnown), { section: 'appearance', item: 'theme-store' });
|
|
260
266
|
assert.deepEqual(currentPlace('privacy/cookie-banner', menu, menuKnown), { section: 'privacy', item: 'cookie-banner' });
|
|
261
267
|
assert.deepEqual(currentPlace('src/data/cookie-list/a.json', menu, { ...menuKnown, open: 'cookies' }), { section: 'privacy' });
|
|
262
268
|
});
|
|
263
269
|
|
|
264
|
-
test('currentPlace of Appearance › Header and its menus is
|
|
265
|
-
assert.deepEqual(currentPlace('appearance/header', menu, menuKnown), { section: '
|
|
266
|
-
assert.deepEqual(currentPlace('src/data/menu.json', menu, { ...menuKnown, open: 'menu' }), {
|
|
270
|
+
test('currentPlace of Appearance › Header and its menus is Appearance › Header', () => {
|
|
271
|
+
assert.deepEqual(currentPlace('appearance/header', menu, menuKnown), { section: 'appearance', item: 'header' });
|
|
272
|
+
assert.deepEqual(currentPlace('src/data/menu.json', menu, { ...menuKnown, open: 'menu' }), {
|
|
273
|
+
section: 'appearance',
|
|
274
|
+
item: 'header',
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('currentPlace of Appearance › Footer is Appearance › Footer, a row without children', () => {
|
|
279
|
+
assert.deepEqual(currentPlace('appearance/footer', menu, menuKnown), { section: 'appearance', item: 'footer' });
|
|
280
|
+
const appearance = menu.find((entry) => entry.id === 'appearance');
|
|
281
|
+
assert.equal(appearance.items.find((item) => item.id === 'footer').children, undefined);
|
|
282
|
+
assert.equal(appearance.items.find((item) => item.id === 'header').children, undefined);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('Business is a section: Logo and icon first, then its parts as rows, not the part Business itself', () => {
|
|
286
|
+
const business = menu.find((entry) => entry.id === 'company');
|
|
287
|
+
assert.deepEqual(
|
|
288
|
+
business.items.map((i) => [i.id, i.href]),
|
|
289
|
+
[
|
|
290
|
+
['logo', '#company/logo'],
|
|
291
|
+
['contact', '#company/contact'],
|
|
292
|
+
['booking', '#company/booking'],
|
|
293
|
+
],
|
|
294
|
+
);
|
|
295
|
+
assert.deepEqual(currentPlace('company/contact', menu, menuKnown), { section: 'company', item: 'contact' });
|
|
296
|
+
assert.deepEqual(currentPlace('company', menu, menuKnown), { section: 'company' });
|
|
267
297
|
});
|
|
268
298
|
|
|
269
|
-
test('currentPlace of
|
|
270
|
-
assert.deepEqual(currentPlace('
|
|
271
|
-
const global = menu.find((entry) => entry.id === 'global');
|
|
272
|
-
assert.equal(global.items.find((item) => item.id === 'footer').children, undefined);
|
|
273
|
-
assert.equal(global.items.find((item) => item.id === 'header').children, undefined);
|
|
299
|
+
test('currentPlace of Business › Logo and icon is that row', () => {
|
|
300
|
+
assert.deepEqual(currentPlace('company/logo', menu, menuKnown), { section: 'company', item: 'logo' });
|
|
274
301
|
});
|
|
275
302
|
|
|
276
303
|
const appsGroups = [{ id: 'apps', title: 'Apps', ids: ['apps', 'appstore'] }];
|
package/lib/storeEntries.d.mts
CHANGED
|
@@ -14,7 +14,6 @@ export function isText(v: unknown): v is string;
|
|
|
14
14
|
export function isPath(v: unknown): boolean;
|
|
15
15
|
export const ITEM_ID: RegExp;
|
|
16
16
|
export const HANDLE: RegExp;
|
|
17
|
-
export const LEGACY: RegExp;
|
|
18
17
|
export const STANDARD: "saturn";
|
|
19
18
|
export const RECORD: "store.json";
|
|
20
19
|
export function strayStoreId(name: string, kind: "app" | "theme"): boolean;
|
package/lib/storeEntries.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure validation logic for store apps and themes.
|
|
3
|
-
* Exposes isText, isPath, ITEM_ID, HANDLE,
|
|
3
|
+
* Exposes isText, isPath, ITEM_ID, HANDLE, STANDARD, RECORD, validApp, validTheme, strayStoreId.
|
|
4
4
|
* No external dependencies.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -20,15 +20,13 @@ export const isPath = (v) => isText(v) && v.split('/').every((part) => part !==
|
|
|
20
20
|
export const ITEM_ID = /^(app|theme)_[0-9a-f]{16}$/;
|
|
21
21
|
// An item's address in the store: publisher/name
|
|
22
22
|
export const HANDLE = /^[a-z0-9][a-z0-9-]{0,39}\/[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
|
-
// An item's folder name before it had a permanent id
|
|
24
|
-
export const LEGACY = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
23
|
// The standard theme, which every site has
|
|
26
24
|
export const STANDARD = 'saturn';
|
|
27
25
|
// The note in an installed folder of where it came from
|
|
28
26
|
export const RECORD = 'store.json';
|
|
29
27
|
|
|
30
28
|
const hasId = (x, kind) => ITEM_ID.test(x?.id ?? '') && x.id.startsWith(`${kind}_`);
|
|
31
|
-
const hasHandle = (x) => HANDLE.test(x?.handle ?? '')
|
|
29
|
+
const hasHandle = (x) => HANDLE.test(x?.handle ?? '');
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
32
|
* True when a site's own folder name looks like a store id of this kind (it starts with `app_` or `theme_`) but is not one.
|
|
@@ -9,7 +9,6 @@ import { validApp, validTheme, isPath, strayStoreId } from './storeEntries.mjs';
|
|
|
9
9
|
const theme = () => ({
|
|
10
10
|
id: 'theme_fa395ad84690acbd',
|
|
11
11
|
handle: 'morpheme/venus',
|
|
12
|
-
legacy: 'venus',
|
|
13
12
|
version: '1.0.0',
|
|
14
13
|
api: 4,
|
|
15
14
|
name: 'Venus',
|
|
@@ -56,12 +55,8 @@ test('valid theme bad handle', () => {
|
|
|
56
55
|
assert.equal(validTheme({ ...theme(), handle: 'Morpheme/venus' }, 4), false);
|
|
57
56
|
});
|
|
58
57
|
|
|
59
|
-
test('valid theme
|
|
58
|
+
test('valid theme with a permanent id and a handle', () => {
|
|
60
59
|
assert.equal(validTheme(theme(), 4), true);
|
|
61
|
-
assert.equal(theme().legacy, 'venus');
|
|
62
|
-
assert.equal(validTheme({ ...theme(), legacy: 'Venus!' }, 4), false);
|
|
63
|
-
const { legacy, ...rest } = theme();
|
|
64
|
-
assert.equal(validTheme(rest, 4), true);
|
|
65
60
|
});
|
|
66
61
|
|
|
67
62
|
test('valid theme bad path', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphemecms/cms",
|
|
3
|
-
"version": "0.13.0
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lars Adolfsen",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tests that the starter keeps LF line endings in every site made from it, also on Windows (core.autocrlf=true):
|
|
2
|
+
// starter/.gitattributes holds the rule and the npm package ships it. Uses node:test and `npm pack --dry-run`.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const pkgDir = fileURLToPath(new URL('../', import.meta.url));
|
|
10
|
+
|
|
11
|
+
test('starter/.gitattributes forces LF in every checkout', () => {
|
|
12
|
+
const text = readFileSync(`${pkgDir}starter/.gitattributes`, 'utf8');
|
|
13
|
+
assert.ok(!text.includes('\r'), 'the file itself has LF line breaks');
|
|
14
|
+
assert.match(text, /^\* text=auto eol=lf$/m);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('the npm package ships starter/.gitattributes', () => {
|
|
18
|
+
const run = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: pkgDir, encoding: 'utf8', shell: true });
|
|
19
|
+
assert.equal(run.status, 0, run.stderr);
|
|
20
|
+
const [{ files }] = JSON.parse(run.stdout);
|
|
21
|
+
assert.ok(files.some((f) => f.path === 'starter/.gitattributes'));
|
|
22
|
+
});
|
package/site/header.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @typedef {import('../src/standard/buttons/fields').Button} Button */
|
|
2
|
-
/** @typedef {{ button: Button | null, menuSide: 'start' | 'end' }} Header */
|
|
2
|
+
/** @typedef {{ button: Button | null, phone: boolean, menuSide: 'start' | 'end' }} Header */
|
|
3
3
|
/**
|
|
4
4
|
* Reads src/data/header.json's content. Missing or wrong → defaults.
|
|
5
5
|
* @param {unknown} raw
|
|
@@ -10,6 +10,7 @@ export function sortedNav<T extends NavItem>(items: T[]): T[];
|
|
|
10
10
|
export type Button = any;
|
|
11
11
|
export type Header = {
|
|
12
12
|
button: Button | null;
|
|
13
|
+
phone: boolean;
|
|
13
14
|
menuSide: "start" | "end";
|
|
14
15
|
};
|
|
15
16
|
export type NavLink = {
|
package/site/header.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// The header's site setting (src/data/header.json, optional):
|
|
2
|
-
//
|
|
1
|
+
// The header's site setting (src/data/header.json, optional): its call to action (a button, and the site's phone number
|
|
2
|
+
// as a text button before it; both also at the bottom of the navigation drawer), and the side of the header the mobile
|
|
3
|
+
// menu button sits on (the drawer opens from that side); and the main menu's items as the header draws them. Exposes headerOf (pure;
|
|
3
4
|
// missing or wrong data gives the defaults) and sortedNav. Used by site/site.ts and the editor's preview of the header
|
|
4
5
|
// (src/headerPreview.mjs); the Button type is src/standard/buttons/fields.ts.
|
|
5
6
|
|
|
6
7
|
/** @typedef {import('../src/standard/buttons/fields').Button} Button */
|
|
7
|
-
/** @typedef {{ button: Button | null, menuSide: 'start' | 'end' }} Header */
|
|
8
|
+
/** @typedef {{ button: Button | null, phone: boolean, menuSide: 'start' | 'end' }} Header */
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Reads src/data/header.json's content. Missing or wrong → defaults.
|
|
@@ -13,9 +14,11 @@
|
|
|
13
14
|
*/
|
|
14
15
|
export function headerOf(raw) {
|
|
15
16
|
const data = raw && typeof raw === 'object' ? /** @type {Record<string, any>} */ (raw) : {};
|
|
16
|
-
const { enabled, ...button } = data.button && typeof data.button === 'object' ? data.button : { enabled: false };
|
|
17
|
+
const { enabled, phone, ...button } = data.button && typeof data.button === 'object' ? data.button : { enabled: false };
|
|
17
18
|
return {
|
|
18
19
|
button: enabled === true ? button : null,
|
|
20
|
+
// The phone number is shown unless it is turned off (as before the setting, when the header always had it).
|
|
21
|
+
phone: phone !== false,
|
|
19
22
|
menuSide: data.menuSide === 'end' ? 'end' : 'start',
|
|
20
23
|
};
|
|
21
24
|
}
|
package/site/header.test.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { test } from 'node:test';
|
|
|
3
3
|
import { headerOf, sortedNav } from './header.mjs';
|
|
4
4
|
|
|
5
5
|
test('no file', () => {
|
|
6
|
-
assert.deepEqual(headerOf(undefined), { button: null, menuSide: 'start' });
|
|
6
|
+
assert.deepEqual(headerOf(undefined), { button: null, phone: true, menuSide: 'start' });
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
test('button off', () => {
|
|
@@ -17,6 +17,12 @@ test('button on', () => {
|
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
test('phone number on by default, off when turned off', () => {
|
|
21
|
+
assert.equal(headerOf({ button: { enabled: true, to: 'booking' } }).phone, true);
|
|
22
|
+
assert.equal(headerOf({ button: { enabled: false, phone: false } }).phone, false);
|
|
23
|
+
assert.equal(headerOf({ button: { enabled: true, phone: false, to: 'booking' } }).button?.phone, undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
20
26
|
test('menu at the end', () => {
|
|
21
27
|
assert.equal(headerOf({ menuSide: 'end' }).menuSide, 'end');
|
|
22
28
|
});
|
|
@@ -26,7 +32,7 @@ test('wrong menu side', () => {
|
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
test('wrong shapes', () => {
|
|
29
|
-
assert.deepEqual(headerOf('x'), { button: null, menuSide: 'start' });
|
|
35
|
+
assert.deepEqual(headerOf('x'), { button: null, phone: true, menuSide: 'start' });
|
|
30
36
|
assert.equal(headerOf({ button: 'x' }).button, null);
|
|
31
37
|
});
|
|
32
38
|
|
package/site/social.d.mts
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
/** @typedef {(typeof
|
|
1
|
+
/** @typedef {(typeof NETWORKS)[number]['id']} NetworkId */
|
|
2
2
|
/** @typedef {(typeof SHARE_IDS)[number]} ShareId */
|
|
3
|
-
/** @typedef {{
|
|
3
|
+
/** @typedef {{ id: NetworkId | null, url: string }} Profile */
|
|
4
|
+
/** @typedef {{ profiles: Profile[], share: ShareId[] }} Social */
|
|
5
|
+
/**
|
|
6
|
+
* The host of an address, in small letters and without "www.": '' for one that is not an address.
|
|
7
|
+
* @param {string} url
|
|
8
|
+
*/
|
|
9
|
+
export function hostOf(url: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* An address as it is compared with another: the host in small letters, no slash at the end. Two addresses with the
|
|
12
|
+
* same key are the same address. One that is not an address has its own text (trimmed, without the slash) as its key.
|
|
13
|
+
* @param {string} url
|
|
14
|
+
*/
|
|
15
|
+
export function addressKey(url: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The addresses a list of profiles has more than once (the second and later of each, as written); empty ones left out.
|
|
18
|
+
* @param {unknown[]} profiles
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
21
|
+
export function twiceIn(profiles: unknown[]): string[];
|
|
22
|
+
/**
|
|
23
|
+
* The network an address is a profile on, by its host; null for one not known (or not an address).
|
|
24
|
+
* @param {string} url
|
|
25
|
+
* @returns {(typeof NETWORKS)[number] | null}
|
|
26
|
+
*/
|
|
27
|
+
export function networkOf(url: string): (typeof NETWORKS)[number] | null;
|
|
28
|
+
/**
|
|
29
|
+
* The file's data as it is written now: one from before the profiles were a list has its filled keys
|
|
30
|
+
* ("facebook": "https://…") as links in the list, where the first of them was, in their order, after any the
|
|
31
|
+
* list has; the other keys stay as and where they were. An address already in the list is not added again (nor is
|
|
32
|
+
* one twice in the keys). A file without those keys is given back as it is.
|
|
33
|
+
* @param {Record<string, unknown>} data
|
|
34
|
+
* @returns {Record<string, unknown>}
|
|
35
|
+
*/
|
|
36
|
+
export function upgradeSocial(data: Record<string, unknown>): Record<string, unknown>;
|
|
4
37
|
/**
|
|
5
38
|
* @param {Record<string, unknown> | undefined} raw
|
|
6
39
|
* @returns {Social}
|
|
@@ -24,14 +57,38 @@ export function shareLinks(post: {
|
|
|
24
57
|
* @returns {string[]}
|
|
25
58
|
*/
|
|
26
59
|
export function sameAsOf(profiles: Social["profiles"]): string[];
|
|
27
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The networks known by their addresses: for now those a post can be shared on (SHARE_IDS). The id, the name (a brand, the
|
|
62
|
+
* same in every language) and the hosts (one ending in ".*" is that name with a country or com after it: pinterest.dk). A host matches
|
|
63
|
+
* itself and any address under it (www.facebook.com, dk.linkedin.com). A host ending in ".*" has com, a country or a second level and a
|
|
64
|
+
* country after its name (pinterest.dk, pinterest.co.uk), nothing else. A link to any other site is a profile too, shown by
|
|
65
|
+
* its host.
|
|
66
|
+
*/
|
|
67
|
+
export const NETWORKS: readonly [{
|
|
68
|
+
readonly id: "facebook";
|
|
69
|
+
readonly name: "Facebook";
|
|
70
|
+
readonly hosts: readonly ["facebook.com", "fb.com", "fb.me"];
|
|
71
|
+
}, {
|
|
72
|
+
readonly id: "x";
|
|
73
|
+
readonly name: "X";
|
|
74
|
+
readonly hosts: readonly ["x.com", "twitter.com"];
|
|
75
|
+
}, {
|
|
76
|
+
readonly id: "linkedin";
|
|
77
|
+
readonly name: "LinkedIn";
|
|
78
|
+
readonly hosts: readonly ["linkedin.com", "lnkd.in"];
|
|
79
|
+
}, {
|
|
80
|
+
readonly id: "pinterest";
|
|
81
|
+
readonly name: "Pinterest";
|
|
82
|
+
readonly hosts: readonly ["pinterest.*", "pin.it"];
|
|
83
|
+
}];
|
|
28
84
|
export const SHARE_IDS: readonly ["facebook", "x", "linkedin", "pinterest", "copy-link"];
|
|
29
|
-
export type
|
|
85
|
+
export type NetworkId = (typeof NETWORKS)[number]["id"];
|
|
30
86
|
export type ShareId = (typeof SHARE_IDS)[number];
|
|
87
|
+
export type Profile = {
|
|
88
|
+
id: NetworkId | null;
|
|
89
|
+
url: string;
|
|
90
|
+
};
|
|
31
91
|
export type Social = {
|
|
32
|
-
profiles:
|
|
33
|
-
id: ProfileId;
|
|
34
|
-
url: string;
|
|
35
|
-
}[];
|
|
92
|
+
profiles: Profile[];
|
|
36
93
|
share: ShareId[];
|
|
37
94
|
};
|
package/site/social.mjs
CHANGED
|
@@ -1,28 +1,146 @@
|
|
|
1
|
-
// The site's social settings (src/data/social.json,
|
|
2
|
-
//
|
|
1
|
+
// The site's social settings (src/data/social.json, Business › Social media in the editor): its profiles, a list of
|
|
2
|
+
// links the owner adds ({ profiles: [{ url }] }), each one's network known by its address (networkOf), for the
|
|
3
|
+
// structured data (sameAs); and the share buttons under a blog post, with their addresses. A file written before the
|
|
4
|
+
// profiles were a list has a key for each of seven networks ("facebook": "https://…"): upgradeSocial makes it a list.
|
|
5
|
+
// Exposes NETWORKS, networkOf, addressKey, twiceIn, upgradeSocial, socialOf, shareLinks, sameAsOf.
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The networks known by their addresses: for now those a post can be shared on (SHARE_IDS). The id, the name (a brand, the
|
|
9
|
+
* same in every language) and the hosts (one ending in ".*" is that name with a country or com after it: pinterest.dk). A host matches
|
|
10
|
+
* itself and any address under it (www.facebook.com, dk.linkedin.com). A host ending in ".*" has com, a country or a second level and a
|
|
11
|
+
* country after its name (pinterest.dk, pinterest.co.uk), nothing else. A link to any other site is a profile too, shown by
|
|
12
|
+
* its host.
|
|
13
|
+
*/
|
|
14
|
+
export const NETWORKS = /** @type {const} */ ([
|
|
15
|
+
{ id: 'facebook', name: 'Facebook', hosts: ['facebook.com', 'fb.com', 'fb.me'] },
|
|
16
|
+
{ id: 'x', name: 'X', hosts: ['x.com', 'twitter.com'] },
|
|
17
|
+
{ id: 'linkedin', name: 'LinkedIn', hosts: ['linkedin.com', 'lnkd.in'] },
|
|
18
|
+
{ id: 'pinterest', name: 'Pinterest', hosts: ['pinterest.*', 'pin.it'] },
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** The keys of a file from before the profiles were a list, one for each network, in the order they had. */
|
|
22
|
+
const OLD_KEYS = ['facebook', 'instagram', 'linkedin', 'x', 'youtube', 'tiktok', 'pinterest'];
|
|
5
23
|
export const SHARE_IDS = /** @type {const} */ (['facebook', 'x', 'linkedin', 'pinterest', 'copy-link']);
|
|
6
|
-
/** @typedef {(typeof
|
|
24
|
+
/** @typedef {(typeof NETWORKS)[number]['id']} NetworkId */
|
|
7
25
|
/** @typedef {(typeof SHARE_IDS)[number]} ShareId */
|
|
8
|
-
/** @typedef {{
|
|
26
|
+
/** @typedef {{ id: NetworkId | null, url: string }} Profile */
|
|
27
|
+
/** @typedef {{ profiles: Profile[], share: ShareId[] }} Social */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The host of an address, in small letters and without "www.": '' for one that is not an address.
|
|
31
|
+
* @param {string} url
|
|
32
|
+
*/
|
|
33
|
+
export function hostOf(url) {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(url.trim()).hostname.toLowerCase().replace(/^www\./, '');
|
|
36
|
+
} catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An address as it is compared with another: the host in small letters, no slash at the end. Two addresses with the
|
|
43
|
+
* same key are the same address. One that is not an address has its own text (trimmed, without the slash) as its key.
|
|
44
|
+
* @param {string} url
|
|
45
|
+
*/
|
|
46
|
+
export function addressKey(url) {
|
|
47
|
+
const text = url.trim();
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(text);
|
|
50
|
+
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}${u.search}${u.hash}`;
|
|
51
|
+
} catch {
|
|
52
|
+
return text.replace(/\/+$/, '');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The addresses a list of profiles has more than once (the second and later of each, as written); empty ones left out.
|
|
58
|
+
* @param {unknown[]} profiles
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function twiceIn(profiles) {
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
/** @type {string[]} */
|
|
64
|
+
const twice = [];
|
|
65
|
+
for (const item of profiles) {
|
|
66
|
+
const url =
|
|
67
|
+
item && typeof item === 'object' && typeof (/** @type {any} */ (item).url) === 'string' ? /** @type {any} */ (item).url.trim() : '';
|
|
68
|
+
if (!url) continue;
|
|
69
|
+
const key = addressKey(url);
|
|
70
|
+
if (seen.has(key)) twice.push(url);
|
|
71
|
+
else seen.add(key);
|
|
72
|
+
}
|
|
73
|
+
return twice;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What a host ending in ".*" has after its name: com, a country (dk), or a second level and a country (co.uk, com.au).
|
|
77
|
+
// Not any word: pinterest.evil.com is not Pinterest.
|
|
78
|
+
const ENDING = '(?:com|[a-z]{2}|(?:co|com)\\.[a-z]{2})';
|
|
79
|
+
|
|
80
|
+
/** @param {string} host @param {string} known */
|
|
81
|
+
function hostIs(host, known) {
|
|
82
|
+
if (known.endsWith('.*')) return new RegExp(`(^|\\.)${known.slice(0, -2)}\\.${ENDING}$`).test(host);
|
|
83
|
+
return host === known || host.endsWith(`.${known}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The network an address is a profile on, by its host; null for one not known (or not an address).
|
|
88
|
+
* @param {string} url
|
|
89
|
+
* @returns {(typeof NETWORKS)[number] | null}
|
|
90
|
+
*/
|
|
91
|
+
export function networkOf(url) {
|
|
92
|
+
const host = hostOf(url);
|
|
93
|
+
if (!host) return null;
|
|
94
|
+
return NETWORKS.find((n) => n.hosts.some((h) => hostIs(host, h))) ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The file's data as it is written now: one from before the profiles were a list has its filled keys
|
|
99
|
+
* ("facebook": "https://…") as links in the list, where the first of them was, in their order, after any the
|
|
100
|
+
* list has; the other keys stay as and where they were. An address already in the list is not added again (nor is
|
|
101
|
+
* one twice in the keys). A file without those keys is given back as it is.
|
|
102
|
+
* @param {Record<string, unknown>} data
|
|
103
|
+
* @returns {Record<string, unknown>}
|
|
104
|
+
*/
|
|
105
|
+
export function upgradeSocial(data) {
|
|
106
|
+
if (!OLD_KEYS.some((k) => k in data)) return data;
|
|
107
|
+
const kept = Array.isArray(data.profiles) ? data.profiles : [];
|
|
108
|
+
const old = OLD_KEYS.map((k) => data[k])
|
|
109
|
+
.filter((v) => typeof v === 'string' && v.trim() !== '')
|
|
110
|
+
.map((v) => ({ url: /** @type {string} */ (v).trim() }));
|
|
111
|
+
const seen = new Set(kept.map((p) => (p && typeof p.url === 'string' ? addressKey(p.url) : '')).filter(Boolean));
|
|
112
|
+
const added = old.filter((p) => {
|
|
113
|
+
const key = addressKey(p.url);
|
|
114
|
+
if (seen.has(key)) return false;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
/** @type {Record<string, unknown>} */
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [key, value] of Object.entries(data)) {
|
|
121
|
+
if (key === 'profiles' || OLD_KEYS.includes(key)) {
|
|
122
|
+
if (!('profiles' in out)) out.profiles = [...kept, ...added];
|
|
123
|
+
} else out[key] = value;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
9
127
|
|
|
10
128
|
/**
|
|
11
129
|
* @param {Record<string, unknown> | undefined} raw
|
|
12
130
|
* @returns {Social}
|
|
13
131
|
*/
|
|
14
132
|
export function socialOf(raw) {
|
|
15
|
-
const data = raw || {};
|
|
133
|
+
const data = upgradeSocial(raw || {});
|
|
16
134
|
|
|
17
|
-
// Profiles
|
|
135
|
+
// Profiles: the filled links, in their order, each with its network (null: one not known).
|
|
18
136
|
const profiles = [];
|
|
19
|
-
for (const
|
|
20
|
-
const value =
|
|
21
|
-
if (typeof value === 'string') {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
137
|
+
for (const item of Array.isArray(data.profiles) ? data.profiles : []) {
|
|
138
|
+
const value = item && typeof item === 'object' ? item.url : undefined;
|
|
139
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
140
|
+
const url = value.trim();
|
|
141
|
+
// Only https:// addresses: the form asks for one, and a file written by hand may have javascript: or the like.
|
|
142
|
+
if (!/^https:\/\//i.test(url)) continue;
|
|
143
|
+
profiles.push({ id: networkOf(url)?.id ?? null, url });
|
|
26
144
|
}
|
|
27
145
|
}
|
|
28
146
|
|