@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @morphemecms/cms
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 07db64b: A site can show one block in a column beside the text of its pages: a block says it can with `beside: true` in its `load.ts`, the owner picks it under Appearance › Side column (`src/data/side.json`), and a page can turn it off (`side: false`); each block now gives its own front-page lead in a `lead.ts`; `defineSite` takes `logo` for the structured data; and the icon packs have calendar-x, identification-card, money, receipt and wallet.
8
+
3
9
  ## 0.2.0
4
10
 
5
11
  ### Minor Changes
@@ -347,6 +347,17 @@ export const collections: Collection[] = [
347
347
  layout: true,
348
348
  hint: 'Siden fylder hele bredden uden spalten ved siden af teksten.',
349
349
  },
350
+ {
351
+ name: 'side',
352
+ label: 'Sidekolonne',
353
+ type: 'boolean',
354
+ toggle: ['Til', 'Fra'],
355
+ // Without the key the column is on; only "Fra" is written (side: false).
356
+ default: true,
357
+ missing: true,
358
+ layout: true,
359
+ hint: 'Blokken ved siden af teksten vises på siden. Den vælges under Udseende › Sidekolonne.',
360
+ },
350
361
  {
351
362
  name: 'review',
352
363
  label: 'Til gennemsyn',
@@ -39,6 +39,7 @@ import {
39
39
  PuzzlePiece,
40
40
  Shapes,
41
41
  ShieldCheck,
42
+ SidebarSimple,
42
43
  Signpost,
43
44
  Smiley,
44
45
  SlidersHorizontal,
@@ -133,6 +134,7 @@ export const icons: Record<string, Icon> = {
133
134
  logo: Sticker,
134
135
  ikonpakker: Smiley,
135
136
  bevaegelse: WaveSine,
137
+ sidekolonne: SidebarSimple,
136
138
  dele: Shapes,
137
139
  };
138
140
 
@@ -9,25 +9,11 @@
9
9
  // "Kontakt", the footer's "Find vej" and Google's contact details (the structured data) go to it. The privacy policy and
10
10
  // the cookie policy are shown at their own addresses too; the cookie banner's "Læs mere" goes to the cookie policy (its
11
11
  // heading about cookies, when it is the privacy policy: cookiesHref in packages/editor/site/standardPages.ts). What of a section of the front page is only text and pictures is
12
- // written as text above its card (its lead, packages/editor/src/sections.mjs): in the editor, when that page is open
12
+ // written as text above its card (its lead, lead.ts in the block's folder, frontLeads.ts; packages/editor/src/sections.mjs): in the editor, when that page is open
13
13
  // (EntryForm.tsx), and on the site, when it is read into sections. No React here, so the build can use it.
14
- import type { Lead, Template } from '../src/schema';
14
+ import type { Template } from '../src/schema';
15
15
 
16
- /** The text above a block's card that is the block's own on the front page: its heading, text and picture. The top is
17
- * the standard block "Banner", whose text up to its "Knapper" block (with its heading and picture) is the text at the
18
- * top; the block and the text after it are on its card. */
19
- export const frontLeads: Record<string, Lead> = {
20
- hero: { body: 'text', upTo: 'buttons' },
21
- accordion: { heading: 'heading', body: 'intro' },
22
- testimonials: { heading: 'heading' },
23
- presentation: { heading: 'heading', body: 'paragraphs', list: true },
24
- cards: { heading: 'heading' },
25
- book: { image: true },
26
- steps: { heading: 'heading' },
27
- prices: { heading: 'heading', body: 'notes', list: true },
28
- posts: { heading: 'heading' },
29
- address: { image: true, heading: 'heading', body: 'text' },
30
- };
16
+ export { frontLeads } from '../src/standard/frontLeads';
31
17
 
32
18
  /** A standard page: its field in the file (`key`), its name there and in a message ("Forsiden …") in the site's language,
33
19
  * where it is shown (null: nowhere at an address, the 404 page; left out: at its own address), the rules its page has
package/defaults/texts.ts CHANGED
@@ -26,6 +26,9 @@ export const texts: Record<string, Record<string, string>> = {
26
26
  'Skærmlæsere springer billedet over.': 'Screen readers skip the picture.',
27
27
  Til: 'On',
28
28
  Fra: 'Off',
29
+ Sidekolonne: 'Side column',
30
+ 'Blokken ved siden af teksten vises på siden. Den vælges under Udseende › Sidekolonne.':
31
+ 'The block beside the text is shown on the page. It is chosen under Appearance › Side column.',
29
32
  'Hele bredden': 'Full width',
30
33
  'Siden fylder hele bredden uden spalten ved siden af teksten.': 'The page fills the whole width without the column beside the text.',
31
34
  Status: 'Status',
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> <path d="M16 2v3" /> <rect x="3" y="3" width="18" height="18" rx="2" /> <path d="M3 9h18" /> <path d="m14 13-4 4" /> <path d="m10 13 4 4" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 19a4 4 0 00-8 0" /> <path d="M16 10h2" /> <path d="M16 14h2" /> <circle cx="9" cy="12" r="3" /> <rect x="2" y="5" width="20" height="14" rx="2" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2" /> <circle cx="12" cy="12" r="2" /> <path d="M6 12h.01M18 12h.01" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17V7" /> <path d="M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8" /> <path d="M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" /> <path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" /></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m381-218-43-43 100-99-100-99 43-43 99 100 99-100 43 43-100 99 100 99-43 43-99-100-99 100ZM180-80q-24 0-42-18t-18-42v-620q0-24 18-42t42-18h65v-60h65v60h340v-60h65v60h65q24 0 42 18t18 42v620q0 24-18 42t-42 18H180Zm0-60h600v-430H180v430Zm0-490h600v-130H180v130Zm0 0v-130 130Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M580-450h180v-60H580v60Zm0-120h180v-60H580v60ZM200-320h320v-19q0-42-42.5-68.5T360-434q-75 0-117.5 26.5T200-339v19Zm211.5-195.42q21.5-21.42 21.5-51.5t-21.42-51.58q-21.42-21.5-51.5-21.5t-51.58 21.42q-21.5 21.42-21.5 51.5t21.42 51.58q21.42 21.5 51.5 21.5t51.58-21.42ZM140-160q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h680q24 0 42 18t18 42v520q0 24-18 42t-42 18H140Zm0-60h680v-520H140v520Zm0 0v-520 520Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M540-420q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35ZM220-280q-24.75 0-42.37-17.63Q160-315.25 160-340v-400q0-24.75 17.63-42.38Q195.25-800 220-800h640q24.75 0 42.38 17.62Q920-764.75 920-740v400q0 24.75-17.62 42.37Q884.75-280 860-280H220Zm100-60h440q0-42 29-71t71-29v-200q-42 0-71-29t-29-71H320q0 42-29 71t-71 29v200q42 0 71 29t29 71Zm480 180H100q-24.75 0-42.37-17.63Q40-195.25 40-220v-460h60v460h700v60ZM220-340v-400 400Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-81v-798l60 60 60-60 60 60 60-60 60 60 60-60 60 60 60-60 60 60 60-60 60 60 60-60v798l-60-60-60 60-60-60-60 60-60-60-60 60-60-59.85L360-81l-60-59.85L240-81l-60-59.85L120-81Zm117-215h490v-60H237v60Zm0-154h490v-60H237v60Zm0-155h490v-60H237v60Zm-57 423h600v-596H180v596Zm0-596v596-596Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M240-160q-66 0-113-47T80-320v-320q0-66 47-113t113-47h480q66 0 113 47t47 113v320q0 66-47 113t-113 47H240Zm0-470h480q29 0 54.5 9t45.5 26v-45q0-42-29-71t-71-29H240q-42 0-71 29t-29 71v45q20-17 45.5-26t54.5-9Zm-97 136 477 115q7 2 14.5.5T647-385l160-134q-13-23-36-37t-51-14H240q-35 0-62 21.5T143-494Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM72,48v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80H48V48ZM208,208H48V96H208V208Zm-50.34-74.34L139.31,152l18.35,18.34a8,8,0,0,1-11.32,11.32L128,163.31l-18.34,18.35a8,8,0,0,1-11.32-11.32L116.69,152,98.34,133.66a8,8,0,0,1,11.32-11.32L128,140.69l18.34-18.35a8,8,0,0,1,11.32,11.32Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M200,112a8,8,0,0,1-8,8H152a8,8,0,0,1,0-16h40A8,8,0,0,1,200,112Zm-8,24H152a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm40-80V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Zm-80.26-34a8,8,0,1,1-15.5,4c-2.63-10.26-13.06-18-24.25-18s-21.61,7.74-24.25,18a8,8,0,1,1-15.5-4,39.84,39.84,0,0,1,17.19-23.34,32,32,0,1,1,45.12,0A39.76,39.76,0,0,1,135.75,166ZM96,136a16,16,0,1,0-16-16A16,16,0,0,0,96,136Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152ZM240,56H16a8,8,0,0,0-8,8V192a8,8,0,0,0,8,8H240a8,8,0,0,0,8-8V64A8,8,0,0,0,240,56ZM193.65,184H62.35A56.78,56.78,0,0,0,24,145.65v-35.3A56.78,56.78,0,0,0,62.35,72h131.3A56.78,56.78,0,0,0,232,110.35v35.3A56.78,56.78,0,0,0,193.65,184ZM232,93.37A40.81,40.81,0,0,1,210.63,72H232ZM45.37,72A40.81,40.81,0,0,1,24,93.37V72ZM24,162.63A40.81,40.81,0,0,1,45.37,184H24ZM210.63,184A40.81,40.81,0,0,1,232,162.63V184Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M72,104a8,8,0,0,1,8-8h96a8,8,0,0,1,0,16H80A8,8,0,0,1,72,104Zm8,40h96a8,8,0,0,0,0-16H80a8,8,0,0,0,0,16ZM232,56V208a8,8,0,0,1-11.58,7.15L192,200.94l-28.42,14.21a8,8,0,0,1-7.16,0L128,200.94,99.58,215.15a8,8,0,0,1-7.16,0L64,200.94,35.58,215.15A8,8,0,0,1,24,208V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Zm-16,0H40V195.06l20.42-10.22a8,8,0,0,1,7.16,0L96,199.06l28.42-14.22a8,8,0,0,1,7.16,0L160,199.06l28.42-14.22a8,8,0,0,1,7.16,0L216,195.06Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,64H56a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H56A24,24,0,0,0,32,56V184a24,24,0,0,0,24,24H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,128H56a8,8,0,0,1-8-8V78.63A23.84,23.84,0,0,0,56,80H216Zm-48-60a12,12,0,1,1,12,12A12,12,0,0,1,168,132Z"/></svg>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The block type to draw beside a page's text, or null.
3
+ * @param {{ wide?: boolean, side?: boolean }} page the page's front matter
4
+ * @param {{ block: string | null } | null} setting the contents of src/data/side.json, or null when the file is missing
5
+ * @param {(type: string) => boolean} beside whether that type's load.ts has beside: true
6
+ * @returns {string | null}
7
+ */
8
+ export function sideBlockOf(page: {
9
+ wide?: boolean;
10
+ side?: boolean;
11
+ }, setting: {
12
+ block: string | null;
13
+ } | null, beside: (type: string) => boolean): string | null;
14
+ /**
15
+ * Whether a block's load.ts source says `export const beside = true` (the editor never imports load.ts, so it reads the text).
16
+ * @param {string} source
17
+ * @returns {boolean}
18
+ */
19
+ export function besideInSource(source: string): boolean;
20
+ /**
21
+ * The installed block types that can be drawn beside a page's text, for the choice under "Udseende" › "Sidekolonne".
22
+ * @param {{ type: string, label: string, app?: string }[]} blocks every block the editor knows (siteBlocks)
23
+ * @param {Record<string, string>} sources the text of each block's load.ts, by type
24
+ * @param {string[]} installed the ids of the apps installed
25
+ * @returns {{ type: string, label: string }[]}
26
+ */
27
+ export function sideChoices(blocks: {
28
+ type: string;
29
+ label: string;
30
+ app?: string;
31
+ }[], sources: Record<string, string>, installed: string[]): {
32
+ type: string;
33
+ label: string;
34
+ }[];
35
+ /**
36
+ * Whether the page form shows the switch "Sidekolonne": src/data/side.json chooses a block and the page is not wide.
37
+ * @param {{ block?: unknown } | null | undefined} setting the contents of src/data/side.json
38
+ * @param {{ wide?: unknown }} page the page's values
39
+ * @returns {boolean}
40
+ */
41
+ export function sideSwitchShown(setting: {
42
+ block?: unknown;
43
+ } | null | undefined, page: {
44
+ wide?: unknown;
45
+ }): boolean;
@@ -0,0 +1,75 @@
1
+ // Determines the side block type to draw beside a page's text, and what the editor offers about it.
2
+ // Exposes sideBlockOf (the site), besideInSource, sideChoices and sideSwitchShown (the editor: Sidekolonne).
3
+ // No dependencies.
4
+
5
+ /**
6
+ * The block type to draw beside a page's text, or null.
7
+ * @param {{ wide?: boolean, side?: boolean }} page the page's front matter
8
+ * @param {{ block: string | null } | null} setting the contents of src/data/side.json, or null when the file is missing
9
+ * @param {(type: string) => boolean} beside whether that type's load.ts has beside: true
10
+ * @returns {string | null}
11
+ */
12
+ export function sideBlockOf(page, setting, beside) {
13
+ // Treat null or undefined page as empty object
14
+ const p = page || {};
15
+
16
+ // Rule 1: When setting is null or undefined, return null
17
+ if (setting === null || setting === undefined) {
18
+ return null;
19
+ }
20
+
21
+ // Rule 2: When setting.block is not a non-empty string, return null
22
+ if (typeof setting.block !== 'string' || setting.block.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ // Rule 3: When page.wide === true, return null
27
+ if (p.wide === true) {
28
+ return null;
29
+ }
30
+
31
+ // Rule 4: When page.side === false, return null
32
+ if (p.side === false) {
33
+ return null;
34
+ }
35
+
36
+ // Rule 5: When beside(setting.block) is not true, return null
37
+ if (beside(setting.block) !== true) {
38
+ return null;
39
+ }
40
+
41
+ // Rule 6: Return setting.block
42
+ return setting.block;
43
+ }
44
+
45
+ /**
46
+ * Whether a block's load.ts source says `export const beside = true` (the editor never imports load.ts, so it reads the text).
47
+ * @param {string} source
48
+ * @returns {boolean}
49
+ */
50
+ export function besideInSource(source) {
51
+ return /^export\s+const\s+beside\s*=\s*true\s*;?\s*$/m.test(source);
52
+ }
53
+
54
+ /**
55
+ * The installed block types that can be drawn beside a page's text, for the choice under "Udseende" › "Sidekolonne".
56
+ * @param {{ type: string, label: string, app?: string }[]} blocks every block the editor knows (siteBlocks)
57
+ * @param {Record<string, string>} sources the text of each block's load.ts, by type
58
+ * @param {string[]} installed the ids of the apps installed
59
+ * @returns {{ type: string, label: string }[]}
60
+ */
61
+ export function sideChoices(blocks, sources, installed) {
62
+ return blocks
63
+ .filter((b) => (!b.app || installed.includes(b.app)) && besideInSource(sources[b.type] ?? ''))
64
+ .map((b) => ({ type: b.type, label: b.label }));
65
+ }
66
+
67
+ /**
68
+ * Whether the page form shows the switch "Sidekolonne": src/data/side.json chooses a block and the page is not wide.
69
+ * @param {{ block?: unknown } | null | undefined} setting the contents of src/data/side.json
70
+ * @param {{ wide?: unknown }} page the page's values
71
+ * @returns {boolean}
72
+ */
73
+ export function sideSwitchShown(setting, page) {
74
+ return typeof setting?.block === 'string' && setting.block.length > 0 && page?.wide !== true;
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ // Tests for sideBlockOf (the site) and besideInSource, sideChoices and sideSwitchShown (the editor's Sidekolonne).
2
+ // Validates side block selection logic.
3
+ // Depends on lib/side-block.mjs.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { besideInSource, sideBlockOf, sideChoices, sideSwitchShown } from './side-block.mjs';
7
+
8
+ const yes = () => true;
9
+ const no = () => false;
10
+
11
+ test('returns null when setting is null', () => {
12
+ assert.equal(sideBlockOf({}, null, yes), null);
13
+ });
14
+
15
+ test('returns null when setting.block is null', () => {
16
+ assert.equal(sideBlockOf({}, { block: null }, yes), null);
17
+ });
18
+
19
+ test('returns block type when valid', () => {
20
+ assert.equal(sideBlockOf({}, { block: 'side-card' }, yes), 'side-card');
21
+ });
22
+
23
+ test('returns null when page.wide is true', () => {
24
+ assert.equal(sideBlockOf({ wide: true }, { block: 'side-card' }, yes), null);
25
+ });
26
+
27
+ test('returns null when page.side is false', () => {
28
+ assert.equal(sideBlockOf({ side: false }, { block: 'side-card' }, yes), null);
29
+ });
30
+
31
+ test('returns block type when page.side is true', () => {
32
+ assert.equal(sideBlockOf({ side: true }, { block: 'side-card' }, yes), 'side-card');
33
+ });
34
+
35
+ test('returns null when beside returns false', () => {
36
+ assert.equal(sideBlockOf({}, { block: 'side-card' }, no), null);
37
+ });
38
+
39
+ test('beside is called with the chosen type', () => {
40
+ const seen = [];
41
+ sideBlockOf({}, { block: 'side-card' }, (t) => {
42
+ seen.push(t);
43
+ return true;
44
+ });
45
+ assert.deepEqual(seen, ['side-card']);
46
+ });
47
+
48
+ test('treats undefined page as empty object', () => {
49
+ assert.equal(sideBlockOf(undefined, { block: 'side-card' }, yes), 'side-card');
50
+ });
51
+
52
+ test('besideInSource reads export const beside = true', () => {
53
+ assert.equal(besideInSource('import x from "y";\nexport const beside = true;\nexport const load = 1;'), true);
54
+ assert.equal(besideInSource('export const beside = false;'), false);
55
+ assert.equal(besideInSource('// export const beside = true;'), false);
56
+ assert.equal(besideInSource('export const load = 1;'), false);
57
+ });
58
+
59
+ test('sideChoices lists installed blocks with beside, by label', () => {
60
+ const blocks = [
61
+ { type: 'hero', label: 'Hero' },
62
+ { type: 'toc', label: 'Contents' },
63
+ { type: 'chat', label: 'Chat', app: 'chat' },
64
+ { type: 'gone', label: 'Gone', app: 'gone' },
65
+ ];
66
+ const beside = 'export const beside = true;';
67
+ const sources = { toc: beside, chat: beside, gone: beside, hero: 'export const load = 1;' };
68
+ assert.deepEqual(sideChoices(blocks, sources, ['chat']), [
69
+ { type: 'toc', label: 'Contents' },
70
+ { type: 'chat', label: 'Chat' },
71
+ ]);
72
+ });
73
+
74
+ test('sideSwitchShown needs a chosen block and a page that is not wide', () => {
75
+ assert.equal(sideSwitchShown({ block: 'toc' }, {}), true);
76
+ assert.equal(sideSwitchShown({ block: 'toc' }, { wide: true }), false);
77
+ assert.equal(sideSwitchShown({ block: null }, {}), false);
78
+ assert.equal(sideSwitchShown(null, {}), false);
79
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.2.0",
3
+ "version": "0.3.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,56 @@
1
+ // Checks the column beside a page's text (src/data/side.json, `beside` in a block's load.ts, PageLayout.astro) with the
2
+ // theme now in src/data/theme.json: puts a TEST-ONLY block and three pages in the site (scripts/fixtures/side/), builds it
3
+ // in a folder of its own and looks in the HTML. Exposes checkSide(root, theme); used by check-themes.mjs. Removes what it
4
+ // put in the site, and the side.json it wrote, however it ends.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { binOf } from './bin.mjs';
11
+
12
+ const fixtures = fileURLToPath(new URL('fixtures/side/', import.meta.url));
13
+ const BLOCK = 'src/apps/side-fixture/blocks/side-test';
14
+ const PAGES = ['side-plain', 'side-wide', 'side-off'];
15
+
16
+ /** What the fixture's pages must show: [page, whether the column is on it]. */
17
+ export const expected = [
18
+ ['side-plain', true],
19
+ ['side-wide', false],
20
+ ['side-off', false],
21
+ ];
22
+
23
+ /** Whether the built page has the block beside its text, and its text's length (data-side-length). */
24
+ export const columnOf = (html) => ({
25
+ column: /<aside class="page-side">[\s\S]*?data-side-test="\/side-plain\/"[\s\S]*?<\/aside>/.test(html),
26
+ grid: html.includes('class="page-with-side"'),
27
+ anywhere: html.includes('data-side-test'),
28
+ });
29
+
30
+ /** Builds the site with the fixture and checks the three cases for the theme in use; throws with what is wrong. */
31
+ export function checkSide(root) {
32
+ const put = [BLOCK, 'src/data/side.json', ...PAGES.map((p) => `src/content/pages/${p}.md`)].map((f) => join(root, f));
33
+ if (put.some(existsSync)) throw new Error('Remove the side-fixture files from src/ first (scripts/check-side.mjs puts them there).');
34
+ const out = mkdtempSync(join(tmpdir(), 'side-'));
35
+ try {
36
+ mkdirSync(join(root, BLOCK), { recursive: true });
37
+ cpSync(join(fixtures, 'block'), join(root, BLOCK), { recursive: true });
38
+ cpSync(join(fixtures, 'side.json'), join(root, 'src/data/side.json'));
39
+ for (const p of PAGES) cpSync(join(fixtures, `${p}.md`), join(root, `src/content/pages/${p}.md`));
40
+ execFileSync(process.execPath, [binOf('astro'), 'build', '--outDir', out], { cwd: root, stdio: 'inherit' });
41
+ const problems = [];
42
+ for (const [page, on] of expected) {
43
+ const html = readFileSync(join(out, page, 'index.html'), 'utf8');
44
+ const found = columnOf(html);
45
+ if (on && !(found.column && found.grid)) problems.push(`/${page}/ has no column beside its text`);
46
+ if (!on && (found.anywhere || found.grid)) problems.push(`/${page}/ has the column, and should not`);
47
+ }
48
+ const plain = columnOf(readFileSync(join(out, 'side-plain/index.html'), 'utf8'));
49
+ if (!plain.anywhere) problems.push('the block was not drawn at all');
50
+ if (problems.length) throw new Error(problems.join('\n'));
51
+ } finally {
52
+ rmSync(out, { recursive: true, force: true });
53
+ rmSync(join(root, 'src/apps/side-fixture'), { recursive: true, force: true });
54
+ for (const f of put.slice(1)) rmSync(f, { force: true });
55
+ }
56
+ }
@@ -2,7 +2,8 @@
2
2
  // others than the one in use (src/data/theme.json), the editor's check of the content (check-content.mjs), the site
3
3
  // built (in a folder of its own, so dist/ stays the theme in use's) and every screen of the editor opened
4
4
  // (editor-screens.mjs). The theme in use is checked by the steps before (npm run build, npm run check:screens).
5
- // src/data/theme.json is the theme being checked while it runs, and as it was after.
5
+ // src/data/theme.json is the theme being checked while it runs, and as it was after. The theme in use too builds once with
6
+ // a test block beside the text of the pages (check-side.mjs), as each of the others does after its screens.
6
7
  import { execFileSync } from 'node:child_process';
7
8
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
9
  import { tmpdir } from 'node:os';
@@ -10,6 +11,7 @@ import { join } from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { activeTheme, installedThemes } from '@morphemecms/cms/site/theme.mjs';
12
13
  import { binOf } from './bin.mjs';
14
+ import { checkSide } from './check-side.mjs';
13
15
 
14
16
  const ROOT = `${process.cwd()}/`;
15
17
  const file = join(ROOT, 'src/data/theme.json');
@@ -18,7 +20,18 @@ const others = installedThemes().filter((t) => t !== activeTheme());
18
20
  const run = (cmd, args) => execFileSync(cmd, args, { cwd: ROOT, stdio: 'inherit' });
19
21
 
20
22
  const failed = [];
23
+ const side = (theme) => {
24
+ try {
25
+ console.log(`
26
+ Kolonnen ved siden af teksten med temaet ${theme}:`);
27
+ checkSide(ROOT);
28
+ } catch (error) {
29
+ console.error(error.message);
30
+ failed.push(`${theme} (kolonnen)`);
31
+ }
32
+ };
21
33
  try {
34
+ side(activeTheme());
22
35
  for (const theme of others) {
23
36
  console.log(`\nTemaet ${theme}:`);
24
37
  writeFileSync(file, `${JSON.stringify({ theme }, null, 2)}\n`);
@@ -27,6 +40,7 @@ try {
27
40
  run(process.execPath, [fileURLToPath(new URL('check-content.mjs', import.meta.url))]);
28
41
  run(process.execPath, [binOf('astro'), 'build', '--outDir', out]);
29
42
  run(process.execPath, [fileURLToPath(new URL('editor-screens.mjs', import.meta.url)), out]);
43
+ checkSide(ROOT);
30
44
  } catch {
31
45
  failed.push(theme);
32
46
  } finally {
@@ -5,7 +5,7 @@
5
5
  // sections as blocks (packages/editor/src/sections.mjs) and read back: the sections must be the same, a section
6
6
  // of text with the same heading and the same HTML. Every page is also read into sections as the front page (as the
7
7
  // site reads the page that is the front page without the editor, partsOfMarkdown in sections.mjs, packages/editor/site/home.ts):
8
- // they must be those the editor reads. Run: npm run check:editor [files...]
8
+ // they must be those the editor reads. A page with the switch "Sidekolonne" off and on again has the same front matter. Run: npm run check:editor [files...]
9
9
  // Milkdown needs a DOM; jsdom stands in for the browser.
10
10
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
11
  import { join } from 'node:path';
@@ -45,7 +45,7 @@ const textOf = Object.fromEntries(
45
45
  .flatMap((c) => c.files)
46
46
  .map((f) => [f.file, f.fields.find((x) => x.type === 'blocks')]),
47
47
  );
48
- const { splitFrontmatter, writeFields } = await import('@morphemecms/cms/src/frontmatter.mjs');
48
+ const { booleanWrite, splitFrontmatter, writeFields } = await import('@morphemecms/cms/src/frontmatter.mjs');
49
49
 
50
50
  const walk = (dir) =>
51
51
  (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []).flatMap((e) =>
@@ -103,6 +103,17 @@ for (const file of files) {
103
103
  console.log(`\n✗ ${file}\n front matter skrives ikke tilbage uændret`);
104
104
  continue;
105
105
  }
106
+ // The switch "Sidekolonne" (side): off writes `side: false`; on again removes the key, and the front matter is as before.
107
+ if (file.includes('src/content/pages/')) {
108
+ const off = writeFields(yaml, { side: booleanWrite(false, true) });
109
+ if (!/^side: false$/m.test(off) || writeFields(off, { side: booleanWrite(true, true) }) !== writeFields(yaml, { side: '' })) {
110
+ failed++;
111
+ console.log(`
112
+ ✗ ${file}
113
+ Sidekolonne slået fra og til giver ikke det samme front matter`);
114
+ continue;
115
+ }
116
+ }
106
117
  const root = document.createElement('div');
107
118
  // As in the editor: with the free lines between the blocks, written without them.
108
119
  const editor = await makeEditor(root, body).use(spacing).create();
@@ -0,0 +1,9 @@
1
+ // TEST-ONLY drawing of the block in load.ts (scripts/check-side.mjs): a paragraph the check looks for in the built page.
2
+ // Exposes a React component given the block's view.
3
+ export default function SideTest({ view }: { view: { path: string; length: number } }) {
4
+ return (
5
+ <p data-side-test={view.path} data-side-length={view.length}>
6
+ Side test block
7
+ </p>
8
+ );
9
+ }
@@ -0,0 +1,8 @@
1
+ // TEST-ONLY block for check:themes (scripts/check-side.mjs): a block that can be drawn beside a page's text (`beside`).
2
+ // Its view is the page's address and the length of the text it was given, so the check sees that both reach the block.
3
+ import type { Load } from '@morphemecms/cms/src/standard/view';
4
+
5
+ export const beside = true;
6
+
7
+ export const load: Load<{ path: string; length: number }> = async (props, at) =>
8
+ Object.keys(props).length === 0 && at.html ? { path: at.path ?? '', length: at.html.length } : null;
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side off"
3
+ side: false
4
+ ---
5
+
6
+ # Side off
7
+
8
+ A page with side false: no column.
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: "Side plain"
3
+ ---
4
+
5
+ # Side plain
6
+
7
+ A plain page: the column is beside this text.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: "Side wide"
3
+ wide: true
4
+ ---
5
+
6
+ # Side wide
7
+
8
+ A wide page: no column.
@@ -0,0 +1,3 @@
1
+ {
2
+ "block": "side-test"
3
+ }
@@ -19,7 +19,7 @@ import { execFileSync } from 'node:child_process';
19
19
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
20
  import { ICON_PACK_FILE, ICON_PACK_LICENSE, SYSTEM_PACKS } from '@morphemecms/cms/site/iconPacks.mjs';
21
21
  import { packFolder } from '@morphemecms/cms/site/icons.mjs';
22
- import { MATERIAL_DESIGN_ICONS, PACK_MAPS, PACK_SOURCES, phosphorFile, usedIcons } from '@morphemecms/cms/site/icons.mjs';
22
+ import { MATERIAL_DESIGN_ICONS, PACK_MAPS, PACK_SOURCES, phosphorFile, phosphorNames, usedIcons } from '@morphemecms/cms/site/icons.mjs';
23
23
 
24
24
  const MDI = 'material-design-icons';
25
25
  const version = (spec) => spec.replace(/^.*@/, '');
@@ -78,7 +78,7 @@ const lines = (fill) => (inner) =>
78
78
 
79
79
  // What each pack's files are, by Phosphor's name: its set's, and those from Material Design Icons.
80
80
  function sourcesOf(id) {
81
- if (id === 'phosphor') return used.map((name) => ({ name, text: () => phosphorFile(name) }));
81
+ if (id === 'phosphor') return phosphorNames(used).map((name) => ({ name, text: () => phosphorFile(name) }));
82
82
  const map = PACK_MAPS[id];
83
83
  const mdi = MATERIAL_DESIGN_ICONS[id] ?? {};
84
84
  return [
@@ -104,7 +104,7 @@ for (const id of SYSTEM_PACKS) {
104
104
  );
105
105
  for (const { name, icon, file } of sources)
106
106
  if (file && !existsSync(file)) problems.push(`${PACK_SOURCES[id].name} har intet "${icon}" (for ${name}).`);
107
- if (id === 'phosphor') for (const name of used) if (!phosphorFile(name)) problems.push(`Phosphor har intet "${name}".`);
107
+ if (id === 'phosphor') for (const name of phosphorNames(used)) if (!phosphorFile(name)) problems.push(`Phosphor har intet "${name}".`);
108
108
  }
109
109
  if (problems.length) {
110
110
  for (const p of problems) console.error(p);
@@ -1,3 +1,5 @@
1
+ // The site's content collections (blog posts, pages) and their front matter. Exposes `collections` for Astro.
2
+ // Depends on astro:content.
1
3
  import { defineCollection, z } from 'astro:content';
2
4
  import { glob } from 'astro/loaders';
3
5
 
@@ -74,6 +76,8 @@ const pages = defineCollection({
74
76
  noindex: z.boolean().optional().default(false),
75
77
  // Across the whole page, without the owner's card beside the text (PageLayout.astro).
76
78
  wide: z.boolean().optional().default(false),
79
+ // `false`: without the block beside the text, which src/data/side.json chooses (PageLayout.astro).
80
+ side: z.boolean().optional(),
77
81
  // A new version of a page (/ny/…): what the owner should look at or answer before it replaces the
78
82
  // current page. Shown at the top of the new version and on /ny/.
79
83
  review: z.string().optional(),
@@ -45,7 +45,7 @@ export type SiteOwn = {
45
45
  * What a site can say of itself, else the standard: the owner's picture (beside a blog post, and shown when a page with
46
46
  * none of its own is shared), the booking page and the pages a booking ends on, the page about the owner, the block
47
47
  * drawn after every blog post, the word a page's title can end in, pictures that are the same though their names say
48
- * otherwise, and the file of its redirects from old addresses.
48
+ * otherwise, the file of its redirects from old addresses, and the logo in the structured data.
49
49
  */
50
50
  export type SiteChoices = {
51
51
  /**
@@ -91,6 +91,11 @@ export type SiteChoices = {
91
91
  rules: string;
92
92
  map: string;
93
93
  };
94
+ /**
95
+ * The address of the site's logo in the structured data (the business's `logo`,
96
+ * site/schema.ts), a path on the site ("/logo.svg").
97
+ */
98
+ logo: string;
94
99
  };
95
100
  export type SiteConfig = SiteOwn & SiteChoices & typeof STANDARD;
96
101
  declare namespace STANDARD {
@@ -113,6 +118,7 @@ declare namespace STANDARD {
113
118
  let theme: string;
114
119
  let icons: string;
115
120
  let motion: string;
121
+ let side: string;
116
122
  let themes: string;
117
123
  let csp: string;
118
124
  let colors: string;