@morphemecms/cms 0.2.0 → 0.3.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 (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/defaults/collections.ts +11 -0
  3. package/defaults/editor.ts +2 -0
  4. package/defaults/standardPages.ts +3 -17
  5. package/defaults/texts.ts +3 -0
  6. package/icons/lucide/calendar-x.svg +1 -0
  7. package/icons/lucide/identification-card.svg +1 -0
  8. package/icons/lucide/money.svg +1 -0
  9. package/icons/lucide/receipt.svg +1 -0
  10. package/icons/lucide/wallet.svg +1 -0
  11. package/icons/material-symbols/calendar-x.svg +1 -0
  12. package/icons/material-symbols/identification-card.svg +1 -0
  13. package/icons/material-symbols/money.svg +1 -0
  14. package/icons/material-symbols/receipt.svg +1 -0
  15. package/icons/material-symbols/wallet.svg +1 -0
  16. package/icons/phosphor/calendar-x.svg +1 -0
  17. package/icons/phosphor/identification-card.svg +1 -0
  18. package/icons/phosphor/money.svg +1 -0
  19. package/icons/phosphor/receipt.svg +1 -0
  20. package/icons/phosphor/wallet.svg +1 -0
  21. package/lib/side-block.d.mts +45 -0
  22. package/lib/side-block.mjs +75 -0
  23. package/lib/side-block.test.d.mts +1 -0
  24. package/lib/side-block.test.mjs +79 -0
  25. package/package.json +1 -1
  26. package/scripts/check-side.mjs +56 -0
  27. package/scripts/check-themes.mjs +15 -1
  28. package/scripts/editor-roundtrip.mjs +13 -2
  29. package/scripts/fixtures/side/block/View.tsx +9 -0
  30. package/scripts/fixtures/side/block/load.ts +8 -0
  31. package/scripts/fixtures/side/side-off.md +8 -0
  32. package/scripts/fixtures/side/side-plain.md +7 -0
  33. package/scripts/fixtures/side/side-wide.md +8 -0
  34. package/scripts/fixtures/side/side.json +3 -0
  35. package/scripts/icon-packs.mjs +3 -3
  36. package/site/content.config.ts +4 -0
  37. package/site/defineSite.d.mts +7 -1
  38. package/site/defineSite.mjs +7 -1
  39. package/site/icons.d.mts +21 -0
  40. package/site/icons.mjs +16 -1
  41. package/site/schema.ts +1 -1
  42. package/site/side.ts +10 -0
  43. package/site/site.ts +2 -0
  44. package/src/App.tsx +3 -1
  45. package/src/EntryForm.tsx +17 -6
  46. package/src/Library.tsx +9 -2
  47. package/src/SideColumn.tsx +83 -0
  48. package/src/frontmatter.d.mts +2 -0
  49. package/src/frontmatter.mjs +6 -0
  50. package/src/frontmatter.test.d.mts +1 -0
  51. package/src/frontmatter.test.mjs +27 -0
  52. package/src/i18n/da-DK/app.ts +1 -0
  53. package/src/i18n/da-DK/common.ts +1 -0
  54. package/src/i18n/da-DK/library.ts +9 -0
  55. package/src/i18n/en-US/app.ts +1 -0
  56. package/src/i18n/en-US/common.ts +2 -0
  57. package/src/i18n/en-US/library.ts +12 -0
  58. package/src/schema.ts +2 -0
  59. package/src/standard/Block.astro +7 -2
  60. package/src/standard/BlockSite.astro +2 -2
  61. package/src/standard/accordion/lead.ts +4 -0
  62. package/src/standard/cards/lead.ts +4 -0
  63. package/src/standard/frontLeads.ts +12 -0
  64. package/src/standard/hero/index.tsx +1 -1
  65. package/src/standard/hero/lead.ts +4 -0
  66. package/src/standard/leads.d.mts +8 -0
  67. package/src/standard/leads.mjs +14 -0
  68. package/src/standard/leads.test.d.mts +1 -0
  69. package/src/standard/leads.test.mjs +27 -0
  70. package/src/standard/posts/lead.ts +4 -0
  71. package/src/standard/steps/lead.ts +4 -0
  72. package/src/standard/testimonials/lead.ts +4 -0
  73. package/src/standard/view.tsx +5 -2
  74. package/src/standard/views.ts +8 -0
  75. package/theme/layouts/PageLayout.astro +19 -5
  76. package/theme/styles/components.css +23 -0
@@ -21,7 +21,7 @@
21
21
  * What a site can say of itself, else the standard: the owner's picture (beside a blog post, and shown when a page with
22
22
  * none of its own is shared), the booking page and the pages a booking ends on, the page about the owner, the block
23
23
  * drawn after every blog post, the word a page's title can end in, pictures that are the same though their names say
24
- * otherwise, and the file of its redirects from old addresses.
24
+ * otherwise, the file of its redirects from old addresses, and the logo in the structured data.
25
25
  * @typedef {object} SiteChoices
26
26
  * @property {{ src: string; alt: string } | null} picture The owner's picture, or none.
27
27
  * @property {{ path: string; thanks: string[] }} booking The booking page, where "Book en tid" goes when no booking sheet
@@ -36,6 +36,8 @@
36
36
  * @property {{ rules: string; map: string }} siteRedirects The site's other redirects: rules ("<from> <to> 301", one to a
37
37
  * line) and the Worker's map (`{ ids, paths }`, worker/index.js), shown and changed with the editor's own under
38
38
  * Videresendelser (src/redirectData.ts).
39
+ * @property {string} logo The address of the site's logo in the structured data (the business's `logo`,
40
+ * site/schema.ts), a path on the site ("/logo.svg").
39
41
  */
40
42
 
41
43
  /** @type {SiteChoices} */
@@ -47,6 +49,7 @@ const CHOICES = {
47
49
  titleEnd: '',
48
50
  samePictures: {},
49
51
  siteRedirects: { rules: 'public/_redirects', map: 'src/data/old-addresses.json' },
52
+ logo: '/logo.svg',
50
53
  };
51
54
 
52
55
  /** The same on every site. */
@@ -81,6 +84,9 @@ const STANDARD = {
81
84
  /** How the site moves, `{ "scheme": "expressive" | "standard" | "calm" }`, under "Udseende" › "Bevægelse" (Motion in
82
85
  * src/Motion.tsx; lib/motion.ts, site/motion.ts). Without it, the theme's ("motion" in its theme.json). */
83
86
  motion: 'src/data/motion.json',
87
+ /** The block drawn in a column beside the text of ordinary pages, `{ "block": "<type>" | null }`, under "Udseende" ›
88
+ * "Sidekolonne" (SideColumn in src/SideColumn.tsx; site/side.ts). Without it, no page has a column. */
89
+ side: 'src/data/side.json',
84
90
  /** The folder of the themes installed, one for each (<folder>/) (site/theme.mjs); one from the store (src/store.ts) is
85
91
  * copied into it and deleted from it. */
86
92
  themes: 'src/themes',
package/site/icons.d.mts CHANGED
@@ -105,6 +105,7 @@ export const MATERIAL_SYMBOLS: {
105
105
  bus: string;
106
106
  'calendar-blank': string;
107
107
  'calendar-check': string;
108
+ 'calendar-x': string;
108
109
  car: string;
109
110
  cards: string;
110
111
  'caret-down': string;
@@ -152,6 +153,7 @@ export const MATERIAL_SYMBOLS: {
152
153
  handshake: string;
153
154
  heart: string;
154
155
  house: string;
156
+ 'identification-card': string;
155
157
  image: string;
156
158
  'image-square': string;
157
159
  info: string;
@@ -173,6 +175,7 @@ export const MATERIAL_SYMBOLS: {
173
175
  medal: string;
174
176
  megaphone: string;
175
177
  minus: string;
178
+ money: string;
176
179
  moon: string;
177
180
  newspaper: string;
178
181
  'paint-brush-broad': string;
@@ -186,6 +189,7 @@ export const MATERIAL_SYMBOLS: {
186
189
  'puzzle-piece': string;
187
190
  question: string;
188
191
  quotes: string;
192
+ receipt: string;
189
193
  rows: string;
190
194
  ruler: string;
191
195
  shapes: string;
@@ -222,6 +226,7 @@ export const MATERIAL_SYMBOLS: {
222
226
  users: string;
223
227
  'users-three': string;
224
228
  'video-camera': string;
229
+ wallet: string;
225
230
  warning: string;
226
231
  'warning-circle': string;
227
232
  'wave-sine': string;
@@ -258,6 +263,7 @@ export const LUCIDE: {
258
263
  bus: string;
259
264
  'calendar-blank': string;
260
265
  'calendar-check': string;
266
+ 'calendar-x': string;
261
267
  car: string;
262
268
  cards: string;
263
269
  'caret-down': string;
@@ -306,6 +312,7 @@ export const LUCIDE: {
306
312
  'hard-drives': string;
307
313
  heart: string;
308
314
  house: string;
315
+ 'identification-card': string;
309
316
  image: string;
310
317
  'image-square': string;
311
318
  info: string;
@@ -327,6 +334,7 @@ export const LUCIDE: {
327
334
  medal: string;
328
335
  megaphone: string;
329
336
  minus: string;
337
+ money: string;
330
338
  moon: string;
331
339
  newspaper: string;
332
340
  'paint-brush-broad': string;
@@ -340,6 +348,7 @@ export const LUCIDE: {
340
348
  'puzzle-piece': string;
341
349
  question: string;
342
350
  quotes: string;
351
+ receipt: string;
343
352
  rows: string;
344
353
  ruler: string;
345
354
  scan: string;
@@ -378,6 +387,7 @@ export const LUCIDE: {
378
387
  users: string;
379
388
  'users-three': string;
380
389
  'video-camera': string;
390
+ wallet: string;
381
391
  warning: string;
382
392
  'warning-circle': string;
383
393
  'wave-sine': string;
@@ -411,6 +421,7 @@ export const PACK_MAPS: {
411
421
  bus: string;
412
422
  'calendar-blank': string;
413
423
  'calendar-check': string;
424
+ 'calendar-x': string;
414
425
  car: string;
415
426
  cards: string;
416
427
  'caret-down': string;
@@ -458,6 +469,7 @@ export const PACK_MAPS: {
458
469
  handshake: string;
459
470
  heart: string;
460
471
  house: string;
472
+ 'identification-card': string;
461
473
  image: string;
462
474
  'image-square': string;
463
475
  info: string;
@@ -479,6 +491,7 @@ export const PACK_MAPS: {
479
491
  medal: string;
480
492
  megaphone: string;
481
493
  minus: string;
494
+ money: string;
482
495
  moon: string;
483
496
  newspaper: string;
484
497
  'paint-brush-broad': string;
@@ -492,6 +505,7 @@ export const PACK_MAPS: {
492
505
  'puzzle-piece': string;
493
506
  question: string;
494
507
  quotes: string;
508
+ receipt: string;
495
509
  rows: string;
496
510
  ruler: string;
497
511
  shapes: string;
@@ -528,6 +542,7 @@ export const PACK_MAPS: {
528
542
  users: string;
529
543
  'users-three': string;
530
544
  'video-camera': string;
545
+ wallet: string;
531
546
  warning: string;
532
547
  'warning-circle': string;
533
548
  'wave-sine': string;
@@ -562,6 +577,7 @@ export const PACK_MAPS: {
562
577
  bus: string;
563
578
  'calendar-blank': string;
564
579
  'calendar-check': string;
580
+ 'calendar-x': string;
565
581
  car: string;
566
582
  cards: string;
567
583
  'caret-down': string;
@@ -610,6 +626,7 @@ export const PACK_MAPS: {
610
626
  'hard-drives': string;
611
627
  heart: string;
612
628
  house: string;
629
+ 'identification-card': string;
613
630
  image: string;
614
631
  'image-square': string;
615
632
  info: string;
@@ -631,6 +648,7 @@ export const PACK_MAPS: {
631
648
  medal: string;
632
649
  megaphone: string;
633
650
  minus: string;
651
+ money: string;
634
652
  moon: string;
635
653
  newspaper: string;
636
654
  'paint-brush-broad': string;
@@ -644,6 +662,7 @@ export const PACK_MAPS: {
644
662
  'puzzle-piece': string;
645
663
  question: string;
646
664
  quotes: string;
665
+ receipt: string;
647
666
  rows: string;
648
667
  ruler: string;
649
668
  scan: string;
@@ -682,6 +701,7 @@ export const PACK_MAPS: {
682
701
  users: string;
683
702
  'users-three': string;
684
703
  'video-camera': string;
704
+ wallet: string;
685
705
  warning: string;
686
706
  'warning-circle': string;
687
707
  'wave-sine': string;
@@ -690,4 +710,5 @@ export const PACK_MAPS: {
690
710
  x: string;
691
711
  };
692
712
  };
713
+ export function phosphorNames(used: any): any[];
693
714
  export function isIconName(name: any): any;
package/site/icons.mjs CHANGED
@@ -118,6 +118,7 @@ export const MATERIAL_SYMBOLS = {
118
118
  bus: 'directions_bus',
119
119
  'calendar-blank': 'calendar_today',
120
120
  'calendar-check': 'event_available',
121
+ 'calendar-x': 'event_busy',
121
122
  car: 'directions_car',
122
123
  cards: 'cards_stack',
123
124
  'caret-down': 'keyboard_arrow_down',
@@ -165,6 +166,7 @@ export const MATERIAL_SYMBOLS = {
165
166
  handshake: 'handshake',
166
167
  heart: 'favorite',
167
168
  house: 'home',
169
+ 'identification-card': 'id_card',
168
170
  image: 'image',
169
171
  'image-square': 'image',
170
172
  info: 'info',
@@ -186,6 +188,7 @@ export const MATERIAL_SYMBOLS = {
186
188
  medal: 'military_tech',
187
189
  megaphone: 'campaign',
188
190
  minus: 'remove',
191
+ money: 'payments',
189
192
  moon: 'dark_mode',
190
193
  newspaper: 'newspaper',
191
194
  'paint-brush-broad': 'brush',
@@ -199,6 +202,7 @@ export const MATERIAL_SYMBOLS = {
199
202
  'puzzle-piece': 'extension',
200
203
  question: 'help',
201
204
  quotes: 'format_quote',
205
+ receipt: 'receipt',
202
206
  rows: 'view_agenda',
203
207
  ruler: 'straighten',
204
208
  shapes: 'shapes',
@@ -235,6 +239,7 @@ export const MATERIAL_SYMBOLS = {
235
239
  users: 'group',
236
240
  'users-three': 'groups',
237
241
  'video-camera': 'videocam',
242
+ wallet: 'wallet',
238
243
  warning: 'warning',
239
244
  'warning-circle': 'error',
240
245
  'wave-sine': 'animation',
@@ -272,6 +277,7 @@ export const LUCIDE = {
272
277
  bus: 'bus',
273
278
  'calendar-blank': 'calendar',
274
279
  'calendar-check': 'calendar-check',
280
+ 'calendar-x': 'calendar-x',
275
281
  car: 'car',
276
282
  cards: 'gallery-vertical-end',
277
283
  'caret-down': 'chevron-down',
@@ -320,6 +326,7 @@ export const LUCIDE = {
320
326
  'hard-drives': 'server',
321
327
  heart: 'heart',
322
328
  house: 'house',
329
+ 'identification-card': 'id-card',
323
330
  image: 'image',
324
331
  'image-square': 'image',
325
332
  info: 'info',
@@ -341,6 +348,7 @@ export const LUCIDE = {
341
348
  medal: 'medal',
342
349
  megaphone: 'megaphone',
343
350
  minus: 'minus',
351
+ money: 'banknote',
344
352
  moon: 'moon',
345
353
  newspaper: 'newspaper',
346
354
  'paint-brush-broad': 'paintbrush',
@@ -354,6 +362,7 @@ export const LUCIDE = {
354
362
  'puzzle-piece': 'puzzle',
355
363
  question: 'circle-question-mark',
356
364
  quotes: 'quote',
365
+ receipt: 'receipt',
357
366
  rows: 'rows-2',
358
367
  ruler: 'ruler',
359
368
  scan: 'scan',
@@ -392,6 +401,7 @@ export const LUCIDE = {
392
401
  users: 'users',
393
402
  'users-three': 'users-round',
394
403
  'video-camera': 'video',
404
+ wallet: 'wallet',
395
405
  warning: 'triangle-alert',
396
406
  'warning-circle': 'circle-alert',
397
407
  'wave-sine': 'spline',
@@ -403,6 +413,11 @@ export const LUCIDE = {
403
413
  /** Each system pack's map: Phosphor's name → its set's icon (Phosphor's: its own names, no map). */
404
414
  export const PACK_MAPS = { 'material-symbols': MATERIAL_SYMBOLS, lucide: LUCIDE };
405
415
 
416
+ /** The names of Phosphor's pack: the icons the site and the editor draw (`used`), and every name the other system
417
+ * packs' maps name (an app's icon, which the CMS itself may not draw), sorted. */
418
+ export const phosphorNames = (used) =>
419
+ [...new Set([...used, ...Object.values(PACK_MAPS).flatMap(Object.keys), ...Object.values(MATERIAL_DESIGN_ICONS).flatMap(Object.keys)])].sort();
420
+
406
421
  /** The names the system's pack has an icon by in its maps (its set's and Material Design Icons'); all for Phosphor's. */
407
422
  const mapped = (pack) => (PACK_MAPS[pack] ? [...Object.keys(PACK_MAPS[pack]), ...Object.keys(MATERIAL_DESIGN_ICONS[pack] ?? {})] : null);
408
423
 
@@ -553,7 +568,7 @@ function systemPackProblems(id, used) {
553
568
  );
554
569
  }
555
570
  const written = Object.keys(packIcons(id));
556
- const wanted = names ?? [...used];
571
+ const wanted = names ?? phosphorNames(used);
557
572
  const unwritten = wanted.filter((name) => !written.includes(name)).sort();
558
573
  const stray = written.filter((name) => !wanted.includes(name)).sort();
559
574
  if (unwritten.length) problems.push(`${dir}/ mangler ${unwritten.join(', ')}: kør npm run icons.`);
package/site/schema.ts CHANGED
@@ -45,7 +45,7 @@ export const business = {
45
45
  description: site.description,
46
46
  url: abs('/'),
47
47
  ...(site.portrait ? { image: abs(site.portrait) } : {}),
48
- logo: abs('/logo.svg'),
48
+ logo: abs(site.logo),
49
49
  telephone: site.phoneIntl,
50
50
  email: site.email,
51
51
  ...(site.cvr ? { taxID: site.cvr } : {}),
package/site/side.ts ADDED
@@ -0,0 +1,10 @@
1
+ // The site's choice of the block drawn beside the text of its pages: src/data/side.json, `{ "block": "<type>" | null }`.
2
+ // Optional: a site without the file has no column. Exposes sideSetting; read by sideBlock in src/standard/views.ts.
3
+ const found = import.meta.glob<{ block?: unknown }>('/src/data/side.json', { eager: true, import: 'default' });
4
+
5
+ /** The contents of src/data/side.json, or null when the site has none. */
6
+ export const sideSetting: { block: string | null } | null = (() => {
7
+ const file = found['/src/data/side.json'];
8
+ if (!file) return null;
9
+ return { block: typeof file.block === 'string' && file.block ? file.block : null };
10
+ })();
package/site/site.ts CHANGED
@@ -52,6 +52,8 @@ export const site = {
52
52
  /** The owner's picture (config.ts), or '' when the site has none. */
53
53
  portrait: config.picture?.src ?? '',
54
54
  portraitAlt: config.picture?.alt ?? '',
55
+ /** The logo in the structured data (config.ts), '/logo.svg' unless the site says otherwise. */
56
+ logo: config.logo || '/logo.svg',
55
57
  };
56
58
 
57
59
  // Primary navigation (menu.json, "Hovedmenu"). Labels, URLs and sub-items started out as the old site's menu.
package/src/App.tsx CHANGED
@@ -21,7 +21,7 @@
21
21
  // post (SeoScores.tsx); and seo/<part> the site's own (seoParts)
22
22
  // udseende how the site looks (Library.tsx Theme): udseende/temaer the themes installed (Themes) and udseende/temaer/<theme>
23
23
  // a theme's page, with the button that swaps to it (ThemePage), udseende/ikonpakker the icon packs (IconPacks),
24
- // udseende/bevaegelse how it moves (MotionSchemes),
24
+ // udseende/bevaegelse how it moves (MotionSchemes), udseende/sidekolonne the block beside the pages' text (SideColumn),
25
25
  // udseende/farver the colours (Colors.tsx;
26
26
  // udseende/farver/reservefarver the site's own while an app changes the pages'), udseende/logo the logo
27
27
  // and icon (BrandForm.tsx), udseende/dele the parts (Library.tsx PartsHome): udseende/dele/editor the editor's, every
@@ -126,6 +126,7 @@ import {
126
126
  } from './store';
127
127
  import Colors, { colorsOf, FALLBACK_ROUTE } from './Colors';
128
128
  import { applyColors, applyMotion } from './theme';
129
+ import SideColumn from './SideColumn';
129
130
  import { apps, notOffered } from './registry';
130
131
  import {
131
132
  Account,
@@ -829,6 +830,7 @@ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
829
830
  if (route === 'udseende/temaer') return <Themes env={env} files={files} />;
830
831
  if (route === 'udseende/ikonpakker') return <IconPacks env={env} files={files} />;
831
832
  if (route === 'udseende/bevaegelse') return <MotionSchemes env={env} files={files} />;
833
+ if (route === 'udseende/sidekolonne') return <SideColumn env={env} files={files} />;
832
834
  // A theme installed has its page; one from the store that is not (or not yet built), its page in the store.
833
835
  const themeShown = route.startsWith('udseende/temaer/') && themes.find((x) => x.id === route.slice(16));
834
836
  if (themeShown) return <ThemePage key={themeShown.id} of={themeShown} env={env} files={files} />;
package/src/EntryForm.tsx CHANGED
@@ -14,7 +14,9 @@ import { addressOf, moveRedirects, REDIRECTS, retarget } from './addresses';
14
14
  import { changesOf, flatten, readAll, REDIRECT_FILES, setRedirect } from './redirectData';
15
15
  import { TAB_ROUTES } from './Redirects';
16
16
  import { choose } from './ask';
17
- import { readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
17
+ import { sideSettingOf } from './SideColumn';
18
+ import { sideSwitchShown } from '../lib/side-block.mjs';
19
+ import { booleanRead, booleanWrite, readFields, splitFrontmatter, writeFields } from './frontmatter.mjs';
18
20
  import MarkdownEditor, { type MarkdownHandle } from './MarkdownEditor';
19
21
  import { useBlockDialog } from './BlockDialog';
20
22
  import { blockHtml } from '../lib/blocks.mjs';
@@ -115,7 +117,7 @@ function valuesOf(collection: MarkdownCollection, file: RepoFile): Record<string
115
117
  collection.fields.map((f) => {
116
118
  const v = data[f.name];
117
119
  if (f.name === 'path') return [f.name, addressNow(collection, file, data)];
118
- if (f.type === 'boolean') return [f.name, v === true];
120
+ if (f.type === 'boolean') return [f.name, booleanRead(v, f.missing)];
119
121
  if (f.multiple) return [f.name, Array.isArray(v) ? v.map(String) : []];
120
122
  return [f.name, v == null ? '' : String(v)];
121
123
  }),
@@ -128,9 +130,12 @@ const scrolling = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: re
128
130
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
129
131
 
130
132
  // The values as they are saved: a field only in use with another's value (Field.only: "Tidspunkt" with
131
- // "Planlagt") is empty without it, so it leaves the file.
133
+ // "Planlagt") is empty without it, so it leaves the file; a switch that is on when its key is missing (Field.missing,
134
+ // "Sidekolonne") leaves the file when it is on (booleanWrite).
132
135
  const inForm = (fields: Field[], values: Record<string, unknown>) =>
133
- Object.fromEntries(fields.map((f) => [f.name, inUse(f, fields, values) ? values[f.name] : '']));
136
+ Object.fromEntries(
137
+ fields.map((f) => [f.name, inUse(f, fields, values) ? (f.type === 'boolean' ? booleanWrite(values[f.name], f.missing) : values[f.name]) : '']),
138
+ );
134
139
 
135
140
  type Props = { collection: MarkdownCollection; file: RepoFile; env: Env };
136
141
 
@@ -593,7 +598,11 @@ function Form({
593
598
  const layout = collection.fields.filter((f) => f.layout);
594
599
  const relaid = !isNew && changed.some((f) => f.layout);
595
600
  const sameLayout = (data: Record<string, unknown>) =>
596
- layout.every((f) => String(data[f.name] || '') === String(values[f.name] || ''));
601
+ layout.every((f) =>
602
+ f.type === 'boolean'
603
+ ? booleanRead(data[f.name], f.missing) === (values[f.name] === true)
604
+ : String(data[f.name] || '') === String(values[f.name] || ''),
605
+ );
597
606
  const changes = (...names: string[]) => isNew || relaid || changed.some((f) => names.includes(f.name));
598
607
  const others =
599
608
  isNew || relaid
@@ -630,8 +639,10 @@ function Form({
630
639
  set('date', String(v).slice(0, 10));
631
640
  };
632
641
  // The fields in use (Field.only).
642
+ // "Sidekolonne" only while src/data/side.json chooses a block and the page is not "Hele bredden" (sideSwitchShown).
643
+ const sideShown = sideSwitchShown({ block: sideSettingOf(env.files) }, values);
633
644
  const field = (f: Field) =>
634
- inUse(f, collection.fields, values) && <FieldInput key={f.name} field={f} value={values[f.name]} onChange={(v) => onChange(f, v)} />;
645
+ inUse(f, collection.fields, values) && (f.name !== 'side' || sideShown) && <FieldInput key={f.name} field={f} value={values[f.name]} onChange={(v) => onChange(f, v)} />;
635
646
 
636
647
  return (
637
648
  <Fields.Provider value={fields}>
package/src/Library.tsx CHANGED
@@ -113,6 +113,7 @@ import { colorsOf } from './Colors';
113
113
  import { OwnerLicenses } from './Licenses';
114
114
  import { apps } from './site';
115
115
  import { offBlocks } from './registry';
116
+ import { sideChoicesOf, sideSettingOf } from './SideColumn';
116
117
 
117
118
  // Where a part opens: its list, or the file itself when it is one file (the menu).
118
119
  const hrefOf = (c: Collection) => (c.kind === 'files' && c.files.length === 1 ? `#${c.files[0].file}` : `#liste/${c.id}`);
@@ -208,8 +209,8 @@ export function Company({ part, files }: { part?: Part; files: RepoFile[] }) {
208
209
  }
209
210
 
210
211
  // "Udseende": first "Temaer" (Themes), with the theme the site is built with at the end of its row (or, after a swap,
211
- // until the site is built with the new one, that one), "Ikonpakker" (IconPacks), with the pack chosen, and "Bevægelse"
212
- // (MotionSchemes), with the scheme chosen; then how the
212
+ // until the site is built with the new one, that one), "Ikonpakker" (IconPacks), with the pack chosen, "Bevægelse"
213
+ // (MotionSchemes), with the scheme chosen, and "Sidekolonne" (SideColumn.tsx), with the block chosen; then how the
213
214
  // site looks, the colours (Colors.tsx), the logo and
214
215
  // icon (BrandForm.tsx) and the site's own parts there (appearance in packages/editor/defaults/editor.ts: "Standardsider", a screen of its
215
216
  // own, CompanyForm.tsx, going back here), and how the editor and the blocks look, their parts ("Dele", PartsHome), each on a
@@ -226,6 +227,12 @@ export function Theme({ files }: { files: RepoFile[] }) {
226
227
  detail: iconPacks.find((x) => x.id === chosenPack(files))?.name,
227
228
  },
228
229
  { id: 'bevaegelse', label: t.common.parts.motion, icon: icons.bevaegelse, detail: t.library.motionSchemes[motionOf(files)] },
230
+ {
231
+ id: 'sidekolonne',
232
+ label: t.common.parts.sideColumn,
233
+ icon: icons.sidekolonne,
234
+ detail: sideChoicesOf(files).find((x) => x.type === sideSettingOf(files))?.label ?? t.library.sideNone,
235
+ },
229
236
  { id: 'farver', label: t.common.parts.colors, icon: icons.farver, trailing: <ThemeColors files={files} /> },
230
237
  { id: 'logo', label: t.common.parts.brand, icon: icons.logo },
231
238
  ...appearance,
@@ -0,0 +1,83 @@
1
+ // "Udseende" › "Sidekolonne" (#udseende/sidekolonne): the block drawn in a column beside the text of the pages, chosen
2
+ // among "Ingen" and the installed blocks that can be (`beside` in their load.ts), saved in the site's file (config.side)
3
+ // in its own commit. Exposes SideColumn, sideSettingOf and sideChoicesOf (also used by the page form and by Library.tsx).
4
+ // Uses ui.tsx (ChoiceRow, Group), registry.tsx (siteBlocks) and lib/side-block.mjs.
5
+ import { useState } from 'react';
6
+ import { config } from '@site/editor';
7
+ import { ConflictError, type RepoFile } from './github';
8
+ import Screen from './Screen';
9
+ import { ChoiceRow, EmptyNote, Group } from './ui';
10
+ import type { Env } from './App';
11
+ import { kindAndName } from './deploy';
12
+ import { ask } from './ask';
13
+ import { installedApps } from './apps';
14
+ import { siteBlocks } from './registry';
15
+ import { locale, siteT, t } from './i18n';
16
+ import { sideChoices } from '../lib/side-block.mjs';
17
+
18
+ // The text of each block's load.ts (the editor never imports it: it runs on the site), by the block's type.
19
+ const sources: Record<string, string> = Object.fromEntries(
20
+ Object.entries({
21
+ ...import.meta.glob<string>('./standard/*/load.ts', { eager: true, query: '?raw', import: 'default' }),
22
+ ...import.meta.glob<string>('/src/apps/*/blocks/*/load.ts', { eager: true, query: '?raw', import: 'default' }),
23
+ }).map(([path, text]) => [path.split('/').at(-2)!, text]),
24
+ );
25
+
26
+ const capital = (s: string) => s.charAt(0).toLocaleUpperCase(locale) + s.slice(1);
27
+ const back = { href: '#udseende', label: t.common.parts.appearance };
28
+
29
+ /** The block type the site's file (config.side) chooses, or null. */
30
+ export function sideSettingOf(files: { path: string; text: string }[]): string | null {
31
+ try {
32
+ const block = JSON.parse(files.find((f) => f.path === config.side)?.text ?? '{}').block;
33
+ return typeof block === 'string' && block ? block : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** The installed blocks that can be drawn beside a page's text, with their names in the editor's language. */
40
+ export const sideChoicesOf = (files: { path: string; text: string }[]) =>
41
+ sideChoices(siteBlocks, sources, installedApps(files)) as { type: string; label: string }[];
42
+
43
+ /** "Sidekolonne": "Ingen" and each block that can be drawn beside the text, the one chosen ticked. A tap on another asks
44
+ * first and then saves it (`{ "block": "<type>" }`, or `{ "block": null }`) at once, in its own commit, as an icon pack is. */
45
+ export default function SideColumn({ env, files }: { env: Env; files: RepoFile[] }) {
46
+ const [saving, setSaving] = useState(false);
47
+ const chosen = sideSettingOf(files);
48
+ const choices = sideChoicesOf(files);
49
+
50
+ async function choose(type: string | null, name: string) {
51
+ if (saving || type === chosen) return;
52
+ if (!(await ask(type ? t.library.changeSideAsk(name) : t.library.changeSideNoneAsk, t.library.changeSide))) return;
53
+ const path = config.side;
54
+ const file = files.find((f) => f.path === path);
55
+ const text = `${JSON.stringify({ block: type }, null, 2)}\n`;
56
+ setSaving(true);
57
+ env.notify({ text: t.common.saving });
58
+ try {
59
+ const change = t.app.changes.chosen(kindAndName(t.app.changes.sideColumn, name));
60
+ const siteName = choices.find((x) => x.type === type)?.label ?? name;
61
+ const message = type ? siteT.library.sideCommit(siteName) : siteT.library.sideCommitNone;
62
+ const oids = await env.commit(message, [{ path, content: text, oid: file?.oid || null }], change);
63
+ env.onSaved({ path, oid: oids[path], text });
64
+ env.notify({ text: t.library.sideSaved(capital(change)), kind: 'saved' });
65
+ } catch (e) {
66
+ env.notify({ text: e instanceof ConflictError ? e.message : t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
67
+ } finally {
68
+ setSaving(false);
69
+ }
70
+ }
71
+
72
+ return (
73
+ <Screen title={t.common.parts.sideColumn} wide back={back}>
74
+ <Group>
75
+ <ChoiceRow selected={!chosen} onClick={() => choose(null, t.library.sideNone)} headline={t.library.sideNone} supporting={t.library.sideNoneHint} />
76
+ {choices.map((x) => (
77
+ <ChoiceRow key={x.type} selected={x.type === chosen} onClick={() => choose(x.type, x.label)} headline={x.label} />
78
+ ))}
79
+ </Group>
80
+ {!choices.length && <EmptyNote>{t.library.sideEmpty}</EmptyNote>}
81
+ </Screen>
82
+ );
83
+ }
@@ -4,3 +4,5 @@ export function splitFrontmatter(text: any): {
4
4
  };
5
5
  export function readFields(yaml: any): any;
6
6
  export function writeFields(yaml: any, changes: any): string;
7
+ export function booleanRead(value: any, missing: any): boolean;
8
+ export function booleanWrite(value: any, missing: any): any;
@@ -40,3 +40,9 @@ function toNode(doc, key, value) {
40
40
  }
41
41
  return node;
42
42
  }
43
+
44
+ // A true/false field that is true when it is missing from the file (Field.missing, "Sidekolonne": `side`): booleanRead
45
+ // gives the form's value for what is in the file; booleanWrite what is written for the form's value (nothing, '', when
46
+ // it is the missing one, so the key leaves the file).
47
+ export const booleanRead = (value, missing) => (value === undefined ? missing === true : value === true);
48
+ export const booleanWrite = (value, missing) => (missing !== undefined && value === missing ? '' : value);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ // Tests booleanRead and booleanWrite (frontmatter.mjs): the page switch "Sidekolonne" is `side: false` when off and the
2
+ // key is missing when on. Run: node --test src/frontmatter.test.mjs
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import { booleanRead, booleanWrite, writeFields } from './frontmatter.mjs';
6
+
7
+ const yaml = 'title: "Om os"\nwide: false';
8
+
9
+ test('a missing key reads as on, side: false as off', () => {
10
+ assert.equal(booleanRead(undefined, true), true);
11
+ assert.equal(booleanRead(false, true), false);
12
+ assert.equal(booleanRead(true, true), true);
13
+ assert.equal(booleanRead(undefined, undefined), false);
14
+ assert.equal(booleanRead(true, undefined), true);
15
+ });
16
+
17
+ test('off writes side: false, on removes the key and leaves the rest as it was', () => {
18
+ const off = writeFields(yaml, { side: booleanWrite(false, true) });
19
+ assert.equal(off, `${yaml}\nside: false`);
20
+ assert.equal(writeFields(off, { side: booleanWrite(true, true) }), yaml);
21
+ assert.equal(writeFields(yaml, { side: booleanWrite(true, true) }), yaml);
22
+ });
23
+
24
+ test('a switch without a missing value is written as it is', () => {
25
+ assert.equal(booleanWrite(true, undefined), true);
26
+ assert.equal(booleanWrite(false, undefined), false);
27
+ });
@@ -30,6 +30,7 @@ export const app: Messages['app'] = {
30
30
  theme: 'tema',
31
31
  iconPack: 'ikonpakke',
32
32
  motion: 'bevægelse',
33
+ sideColumn: 'sidekolonne',
33
34
  colors: 'farver',
34
35
  redirect: (from) => `videresendelse fra ${from}`,
35
36
  logo: 'logo',
@@ -97,6 +97,7 @@ export const common: Messages['common'] = {
97
97
  themes: 'Temaer',
98
98
  iconPacks: 'Ikonpakker',
99
99
  motion: 'Bevægelse',
100
+ sideColumn: 'Sidekolonne',
100
101
  colors: 'Farver',
101
102
  brand: 'Logo og ikon',
102
103
  blockSettings: 'Blokindstillinger',
@@ -64,6 +64,15 @@ export const library: Messages['library'] = {
64
64
  changeMotion: 'Skift bevægelse',
65
65
  motionSaved: (change) => `${change}. Hjemmesiden får den nye bevægelse, når den er bygget.`,
66
66
  motionCommit: (name) => `Editor: skift bevægelse til ${name}`,
67
+ sideNone: 'Ingen',
68
+ sideNoneHint: 'Siderne har ingen kolonne ved siden af teksten',
69
+ sideEmpty: 'Ingen installeret blok kan tegnes ved siden af en sides tekst.',
70
+ changeSideAsk: (name) => `Siderne får kolonnen ${name}, når hjemmesiden er bygget om et par minutter.`,
71
+ changeSideNoneAsk: 'Siderne mister kolonnen ved siden af teksten, når hjemmesiden er bygget om et par minutter.',
72
+ changeSide: 'Skift sidekolonne',
73
+ sideSaved: (change) => `${change}. Hjemmesiden får ændringen, når den er bygget.`,
74
+ sideCommit: (name) => `Editor: skift sidekolonne til ${name}`,
75
+ sideCommitNone: 'Editor: slå sidekolonnen fra',
67
76
  themeIconPackStuck: 'Temaets ikonpakke er i brug, så temaet kan ikke afinstalleres. Vælg en anden ikonpakke først.',
68
77
  themeDropsIconPack: (name, pack) =>
69
78
  `Den nye version af ${name} har ikke ikonpakken ${pack}, som er i brug. Vælg en anden ikonpakke først.`,
@@ -35,6 +35,7 @@ export const app = {
35
35
  theme: 'theme',
36
36
  iconPack: 'icon pack',
37
37
  motion: 'motion',
38
+ sideColumn: 'side column',
38
39
  colors: 'colors',
39
40
  redirect: (from: string) => `redirect from ${from}`,
40
41
  logo: 'logo',
@@ -115,6 +115,8 @@ export const common = {
115
115
  iconPacks: 'Icon packs',
116
116
  /** How the site moves, Material 3's motion schemes (#udseende/bevaegelse, MotionSchemes in Library.tsx). */
117
117
  motion: 'Motion',
118
+ /** The block drawn in a column beside the text of the pages (#udseende/sidekolonne, SideColumn.tsx). */
119
+ sideColumn: 'Side column',
118
120
  colors: 'Colors',
119
121
  brand: 'Logo and icon',
120
122
  blockSettings: 'Block settings',
@@ -94,6 +94,18 @@ export const library = {
94
94
  changeMotion: 'Change motion',
95
95
  motionSaved: (change: string) => `${change}. The website gets the new motion once it’s built.`,
96
96
  motionCommit: (name: string) => `Editor: change motion to ${name}`,
97
+ /** Under "Sidekolonne" (SideColumn.tsx): the choice of no block and what is under it, what is said when no block can be
98
+ * drawn there, asked before one is chosen, the question's button, what is said after it is saved, and the commit, in
99
+ * the site's language (siteT). */
100
+ sideNone: 'None',
101
+ sideNoneHint: 'The pages have no column beside their text',
102
+ sideEmpty: 'No installed block can be drawn beside the text of a page.',
103
+ changeSideAsk: (name: string) => `The pages get the column ${name} once the website is built in a few minutes.`,
104
+ changeSideNoneAsk: 'The pages lose the column beside their text once the website is built in a few minutes.',
105
+ changeSide: 'Change side column',
106
+ sideSaved: (change: string) => `${change}. The website gets the change once it’s built.`,
107
+ sideCommit: (name: string) => `Editor: change side column to ${name}`,
108
+ sideCommitNone: 'Editor: turn off the side column',
97
109
  /** A theme whose icon pack is chosen cannot be uninstalled, nor updated to a version without it (ThemeStore.tsx). */
98
110
  themeIconPackStuck: "The theme's icon pack is in use, so the theme can’t be uninstalled. Choose another icon pack first.",
99
111
  themeDropsIconPack: (name: string, pack: string) =>
package/src/schema.ts CHANGED
@@ -414,4 +414,6 @@ export type SiteProps<P = Record<string, any>> = {
414
414
  path?: string;
415
415
  preview?: boolean;
416
416
  texts?: Record<string, any>;
417
+ /** The page's text, for a block drawn beside it (`beside` in its load.ts). */
418
+ html?: string;
417
419
  };