@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/defaults/collections.ts +39 -52
  3. package/defaults/company.ts +5 -1
  4. package/defaults/editor.ts +27 -14
  5. package/defaults/i18n/da-DK/collections.ts +11 -8
  6. package/defaults/i18n/da-DK/editor.ts +1 -1
  7. package/defaults/i18n/da-DK/rules.ts +1 -0
  8. package/defaults/rules.ts +13 -1
  9. package/lib/focusTrap.d.mts +20 -0
  10. package/lib/focusTrap.mjs +28 -0
  11. package/lib/focusTrap.test.mjs +41 -0
  12. package/lib/folderChanges.mjs +1 -1
  13. package/lib/licenses.d.mts +1 -1
  14. package/lib/licenses.imports.test.mjs +33 -0
  15. package/lib/licenses.mjs +7 -4
  16. package/lib/menu.mjs +9 -4
  17. package/lib/menu.test.mjs +45 -18
  18. package/lib/storeEntries.d.mts +0 -1
  19. package/lib/storeEntries.mjs +2 -4
  20. package/lib/storeEntries.test.mjs +1 -6
  21. package/package.json +1 -1
  22. package/scripts/starter-gitattributes.test.mjs +22 -0
  23. package/site/header.d.mts +2 -1
  24. package/site/header.mjs +7 -4
  25. package/site/header.test.mjs +8 -2
  26. package/site/social.d.mts +65 -8
  27. package/site/social.mjs +132 -14
  28. package/site/social.test.mjs +109 -5
  29. package/src/App.tsx +29 -7
  30. package/src/AppList.tsx +46 -200
  31. package/src/BrandForm.tsx +2 -2
  32. package/src/CompanyForm.tsx +7 -2
  33. package/src/HeaderSettings.tsx +4 -4
  34. package/src/Library.tsx +20 -13
  35. package/src/LinkList.tsx +165 -0
  36. package/src/MainMenu.tsx +24 -4
  37. package/src/MoveButtons.tsx +15 -0
  38. package/src/StoreSearchRow.tsx +67 -0
  39. package/src/StoreStatus.tsx +67 -0
  40. package/src/ThemeStore.tsx +16 -37
  41. package/src/ThemeStoreScreen.tsx +62 -0
  42. package/src/checkSite.ts +3 -2
  43. package/src/cmsNotice.d.mts +7 -0
  44. package/src/cmsNotice.mjs +11 -0
  45. package/src/cmsNotice.test.mjs +50 -0
  46. package/src/fields.tsx +38 -13
  47. package/src/headerPreview.d.mts +14 -4
  48. package/src/headerPreview.mjs +10 -8
  49. package/src/headerPreview.test.mjs +12 -4
  50. package/src/headerTheme.tsx +19 -26
  51. package/src/i18n/da-DK/common.ts +1 -0
  52. package/src/i18n/da-DK/forms.ts +12 -0
  53. package/src/i18n/en-US/common.ts +2 -0
  54. package/src/i18n/en-US/forms.ts +15 -0
  55. package/src/levelStep.d.mts +10 -0
  56. package/src/levelStep.mjs +66 -0
  57. package/src/levelStep.test.mjs +119 -0
  58. package/src/linkAddress.d.mts +21 -0
  59. package/src/linkAddress.mjs +50 -0
  60. package/src/linkAddress.test.mjs +57 -0
  61. package/src/moveItem.d.mts +9 -0
  62. package/src/moveItem.mjs +17 -0
  63. package/src/moveItem.test.mjs +16 -0
  64. package/src/movedRoutes.mjs +8 -3
  65. package/src/movedRoutes.test.mjs +11 -2
  66. package/src/schema.ts +11 -0
  67. package/src/site.ts +1 -0
  68. package/src/store.ts +5 -33
  69. package/src/storeSort.tsx +39 -0
  70. package/src/themeFilter.d.mts +17 -0
  71. package/src/themeFilter.mjs +16 -0
  72. package/src/themeFilter.test.mjs +24 -0
  73. package/src/themeUi.ts +1 -0
  74. package/src/ui.tsx +2 -1
  75. package/starter/.gitattributes +2 -0
  76. package/starter/package.json +1 -1
  77. package/starter/src/data/social.json +1 -7
  78. package/theme/blocks/buttons/Buttons.tsx +12 -2
  79. package/theme/components/Header.astro +21 -31
  80. package/theme/components/HeaderCta.tsx +21 -0
  81. package/theme/components/HeaderPhoneButton.tsx +28 -0
  82. package/theme/components/MobileMenu.tsx +56 -43
  83. package/theme/components/headerParts.ts +5 -4
  84. package/theme/styles/global.css +22 -5
  85. package/theme/styles/ui.css +110 -7
  86. package/theme/ui/NavigationDrawer.tsx +6 -1
  87. package/src/legacyMove.test.mjs +0 -137
  88. 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', 'appearance/logo'] },
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, and its themes, are Find apps', () => {
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: 'appearance', title: 'Appearance', ids: ['colors', 'logo'] },
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: '#appearance/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', 'global', 'apps', 'blocks', 'appearance', 'privacy', 'settings'],
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/themes', menu, menuKnown), { section: 'apps', item: '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 Global › Header', () => {
265
- assert.deepEqual(currentPlace('appearance/header', menu, menuKnown), { section: 'global', item: 'header' });
266
- assert.deepEqual(currentPlace('src/data/menu.json', menu, { ...menuKnown, open: 'menu' }), { section: 'global', item: 'header' });
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 Appearance › Footer is Global › Footer, a row without children', () => {
270
- assert.deepEqual(currentPlace('appearance/footer', menu, menuKnown), { section: 'global', item: 'footer' });
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'] }];
@@ -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;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pure validation logic for store apps and themes.
3
- * Exposes isText, isPath, ITEM_ID, HANDLE, LEGACY, STANDARD, RECORD, validApp, validTheme, strayStoreId.
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 ?? '') && (x.legacy === undefined || LEGACY.test(x.legacy));
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 keeps legacy, and drops a bad one', () => {
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-next-20261106213045",
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): a button in place of the phone icon, and the side of the
2
- // header the mobile menu button sits on; and the main menu's items as the header draws them. Exposes headerOf (pure;
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
  }
@@ -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 PROFILE_IDS)[number]} ProfileId */
1
+ /** @typedef {(typeof NETWORKS)[number]['id']} NetworkId */
2
2
  /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
3
- /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
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
- export const PROFILE_IDS: readonly ["facebook", "instagram", "linkedin", "x", "youtube", "tiktok", "pinterest"];
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 ProfileId = (typeof PROFILE_IDS)[number];
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, 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.
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
- export const PROFILE_IDS = /** @type {const} */ (['facebook', 'instagram', 'linkedin', 'x', 'youtube', 'tiktok', 'pinterest']);
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 PROFILE_IDS)[number]} ProfileId */
24
+ /** @typedef {(typeof NETWORKS)[number]['id']} NetworkId */
7
25
  /** @typedef {(typeof SHARE_IDS)[number]} ShareId */
8
- /** @typedef {{ profiles: { id: ProfileId, url: string }[], share: ShareId[] }} Social */
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 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
- }
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