@janga/norna 0.7.13 → 0.7.15

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 (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -1,6 +1,84 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { documentationLink } from './documentation-links.mjs';
3
+
4
+ const field = (description, options = {}) => Object.freeze({ description, ...options });
5
+ const value = (title, description) => Object.freeze({ title, description });
6
+ const nornaBlocksDocumentation = documentationLink('Norna blocks reference', 'content.md', 'norna-blocks');
7
+
8
+ export const nornaMarkdownBlockDefinitions = Object.freeze({
9
+ 'norna-image-stack': Object.freeze({
10
+ description: 'Display one or more managed images in a vertical stack.',
11
+ documentation: nornaBlocksDocumentation,
12
+ item: Object.freeze({
13
+ start: field('Start another managed image.', { key: 'image', prefix: '- ' }),
14
+ fields: Object.freeze({
15
+ alt: field('Optional alternative text for the image.'),
16
+ caption: field('Optional visible caption below the image.'),
17
+ }),
18
+ }),
19
+ }),
20
+ 'norna-image-carousel': Object.freeze({
21
+ description: 'Display two or more managed images in an interactive carousel.',
22
+ documentation: nornaBlocksDocumentation,
23
+ item: Object.freeze({
24
+ start: field('Start another managed carousel image.', { key: 'image', prefix: '- ' }),
25
+ fields: Object.freeze({
26
+ alt: field('Optional alternative text for the image.'),
27
+ caption: field('Optional visible caption for the image.'),
28
+ }),
29
+ }),
30
+ }),
31
+ 'norna-card-list': Object.freeze({
32
+ description: 'Display a structured list of cards.',
33
+ documentation: nornaBlocksDocumentation,
34
+ options: Object.freeze({
35
+ layout: field('Place each card image above, left, or right of its text. Defaults to image-top.', {
36
+ default: 'image-top',
37
+ values: Object.freeze({
38
+ 'image-top': value('Image above', 'Place the image above the card text.'),
39
+ 'image-left': value('Image left', 'Place the image to the left of the card text.'),
40
+ 'image-right': value('Image right', 'Place the image to the right of the card text.'),
41
+ }),
42
+ }),
43
+ flow: field('Arrange cards as a responsive grid or a vertical stack. Defaults to grid.', {
44
+ default: 'grid',
45
+ values: Object.freeze({
46
+ grid: value('Grid', 'Arrange cards in a responsive grid.'),
47
+ stack: value('Stack', 'Arrange cards in one vertical column.'),
48
+ }),
49
+ }),
50
+ size: field('Set the coordinated card and image size. Defaults to m.', {
51
+ default: 'm',
52
+ values: Object.freeze({
53
+ s: value('Small', 'Use compact cards and images.'),
54
+ m: value('Medium', 'Use the balanced default card size.'),
55
+ l: value('Large', 'Use larger cards and images.'),
56
+ xl: value('Extra large', 'Use the largest card and image size.'),
57
+ }),
58
+ }),
59
+ width: field('Limit the width of the complete card list. Defaults to normal.', {
60
+ default: 'normal',
61
+ values: Object.freeze({
62
+ text: value('Text', 'Match the active body-text width.'),
63
+ narrow: value('Narrow', 'Use a narrow card-list width.'),
64
+ normal: value('Normal', 'Use the balanced default card-list width.'),
65
+ wide: value('Wide', 'Allow the card list to use more horizontal space.'),
66
+ }),
67
+ }),
68
+ }),
69
+ item: Object.freeze({
70
+ start: field('Start another card. Every card requires a title.', { key: 'title', prefix: '- ' }),
71
+ fields: Object.freeze({
72
+ text: field('Optional card text.'),
73
+ image: field('Optional managed image filename.'),
74
+ link: field('Optional URL opened when the card is activated.'),
75
+ 'badge-text': field('Optional short badge displayed on the card.'),
76
+ }),
77
+ }),
78
+ }),
79
+ });
2
80
 
3
- export const nornaBlockTypes = new Set(['norna-image-stack', 'norna-image-carousel', 'norna-card-list']);
81
+ export const nornaBlockTypes = new Set(Object.keys(nornaMarkdownBlockDefinitions));
4
82
 
5
83
  const blockTypeLabels = {
6
84
  'norna-image-stack': 'norna-image-stack',
@@ -10,7 +88,7 @@ const blockTypeLabels = {
10
88
 
11
89
  const knownBlockTypeList = Array.from(nornaBlockTypes).join(', ');
12
90
  const imageNameRegex = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
13
- const markerTagName = 'norna-media-block';
91
+ const markerTagName = 'norna-block';
14
92
  const imageStackExample = [
15
93
  '```norna-image-stack',
16
94
  '- image: filename.jpg',
@@ -30,10 +108,11 @@ const cardListExample = [
30
108
  ' badge-text: Recommended',
31
109
  '```',
32
110
  ].join('\n');
33
- const cardListLayouts = new Set(['image-top', 'image-left', 'image-right']);
34
- const cardListFlows = new Set(['grid', 'stack']);
35
- const cardListSizes = new Set(['s', 'm', 'l', 'xl']);
36
- const cardListWidths = new Set(['text', 'narrow', 'normal', 'wide']);
111
+ const cardListDefinition = nornaMarkdownBlockDefinitions['norna-card-list'];
112
+ const cardListLayouts = new Set(Object.keys(cardListDefinition.options.layout.values));
113
+ const cardListFlows = new Set(Object.keys(cardListDefinition.options.flow.values));
114
+ const cardListSizes = new Set(Object.keys(cardListDefinition.options.size.values));
115
+ const cardListWidths = new Set(Object.keys(cardListDefinition.options.width.values));
37
116
 
38
117
  const formatLocation = ({ label, line } = {}) => [
39
118
  label,
@@ -294,10 +373,10 @@ const parseImageListBlock = (source, options = {}) => {
294
373
  const parseCardListBlock = (source, options = {}) => {
295
374
  const cards = [];
296
375
  const allowedKeys = new Set(['text', 'image', 'link', 'badge-text']);
297
- let layout = 'image-top';
298
- let flow = 'grid';
299
- let size = 'm';
300
- let width = 'normal';
376
+ let layout = cardListDefinition.options.layout.default;
377
+ let flow = cardListDefinition.options.flow.default;
378
+ let size = cardListDefinition.options.size.default;
379
+ let width = cardListDefinition.options.width.default;
301
380
  let current = null;
302
381
 
303
382
  for (const [index, line] of normalizeLines(source).entries()) {
@@ -551,7 +630,7 @@ export const createNornaMarkdownBlockMarker = (type, source) => {
551
630
 
552
631
  export const splitNornaMarkdownBlockMarkers = (html, options = {}) => {
553
632
  const blocks = [];
554
- const markerRegex = /<norna-media-block\s+data-type="([^"]+)"\s+data-source="([^"]*)"\s*><\/norna-media-block>|<pre\b([^>]*)>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g;
633
+ const markerRegex = /<norna-block\s+data-type="([^"]+)"\s+data-source="([^"]*)"\s*><\/norna-block>|<pre\b([^>]*)>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g;
555
634
  let cursor = 0;
556
635
 
557
636
  for (const match of html.matchAll(markerRegex)) {
@@ -5,6 +5,7 @@ import {
5
5
  import { resolveThemeConfig } from './theme-presets.mjs';
6
6
 
7
7
  export const presentationPaletteNames = ['dark', 'light', 'paper'];
8
+ const sectionSurfaceNames = ['base', 'soft', 'emphasis'];
8
9
 
9
10
  const presentationPalettes = Object.freeze({
10
11
  dark: {
@@ -66,8 +67,6 @@ const presentationPalettes = Object.freeze({
66
67
  },
67
68
  });
68
69
 
69
- const normalizePresentation = (presentation) => presentation ?? {};
70
-
71
70
  export const getPresentationPalette = (paletteName = 'dark') => {
72
71
  const palette = presentationPalettes[paletteName];
73
72
  if (!palette) {
@@ -93,19 +92,33 @@ export const getPresentationCssVariables = (presentation) => {
93
92
  };
94
93
  };
95
94
 
96
- export const resolveThemePresentation = (theme, sourceLabel = 'theme.md') => {
95
+ const getSectionSurfaces = (value = ['base'], sourceLabel = 'theme.yaml') => {
96
+ if (!Array.isArray(value) || value.length < 1 || value.length > 3) {
97
+ throw new Error(`sectionSurfaces must contain one to three values in ${sourceLabel}.`);
98
+ }
99
+
100
+ for (const surface of value) {
101
+ if (!sectionSurfaceNames.includes(surface)) {
102
+ throw new Error(`Unknown section surface "${surface}" in ${sourceLabel}. Use one of: ${sectionSurfaceNames.join(', ')}.`);
103
+ }
104
+ }
105
+
106
+ if (new Set(value).size !== value.length) {
107
+ throw new Error(`Each section surface may appear only once in ${sourceLabel}.`);
108
+ }
109
+
110
+ return value;
111
+ };
112
+
113
+ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
97
114
  const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
98
- const normalizedThemePresentation = normalizePresentation(normalizedTheme.presentation);
99
- const paletteName = normalizedThemePresentation.palette ?? 'dark';
115
+ const paletteName = normalizedTheme.palette ?? 'dark';
100
116
  const palette = getPresentationPalette(paletteName);
101
117
 
102
118
  return {
103
119
  paletteName,
104
120
  palette,
105
- sectionSurfaces: {
106
- mode: normalizedThemePresentation.sectionSurfaces?.mode ?? 'none',
107
- sequence: normalizedThemePresentation.sectionSurfaces?.sequence ?? ['base', 'soft', 'emphasis'],
108
- },
121
+ sectionSurfaces: getSectionSurfaces(normalizedTheme.sectionSurfaces, sourceLabel),
109
122
  typography: resolveTypographyConfig(normalizedTheme.typography ?? defaultTypography),
110
123
  };
111
124
  };
@@ -117,9 +130,7 @@ export const resolvePagePresentation = (theme, sourceLabel) => {
117
130
  };
118
131
 
119
132
  export const resolveSectionSurface = (pagePresentation, sectionIndex) => {
120
- const surfaceName = pagePresentation.sectionSurfaces.mode === 'cycle'
121
- ? pagePresentation.sectionSurfaces.sequence[sectionIndex % pagePresentation.sectionSurfaces.sequence.length]
122
- : 'base';
133
+ const surfaceName = pagePresentation.sectionSurfaces[sectionIndex % pagePresentation.sectionSurfaces.length];
123
134
 
124
135
  return {
125
136
  name: surfaceName,
@@ -1,52 +1,28 @@
1
- import { access, readFile } from 'node:fs/promises';
2
- import { parseYamlMapping } from './frontmatter-yaml.mjs';
1
+ import { readFile } from 'node:fs/promises';
3
2
  import {
4
- splitSiteFile,
5
- validateConfigFrontmatterStructure,
6
- validateFrontmatterIndentation,
3
+ validateConfigYamlStructure,
7
4
  } from './site-content.mjs';
8
5
  import {
9
- legacySiteConfigPath,
10
6
  siteConfigLabel,
11
7
  siteConfigPath,
12
8
  siteThemeLabel,
13
9
  } from './site-paths.mjs';
14
10
  import { readThemeConfig } from './theme-config.mjs';
15
11
  import { resolveThemeConfig } from './theme-presets.mjs';
12
+ import { parseYamlConfig } from './yaml-config.mjs';
16
13
 
17
- const fileExists = (filePath) => access(filePath).then(() => true, () => false);
18
14
  const readSiteConfig = async () => {
19
- const source = await readFile(siteConfigPath, 'utf8').catch(async (error) => {
20
- if (error?.code === 'ENOENT' && await fileExists(legacySiteConfigPath)) {
21
- throw new Error(`${siteConfigLabel} is required. Replace the obsolete config.mjs with a frontmatter-only config.md file.`);
22
- }
23
-
15
+ const source = await readFile(siteConfigPath, 'utf8').catch((error) => {
24
16
  if (error?.code === 'ENOENT') {
25
17
  throw new Error(`${siteConfigLabel} is required. Create it before running Norna.`);
26
18
  }
27
19
 
28
20
  throw error;
29
21
  });
30
- const { body, frontmatter, frontmatterBody } = splitSiteFile(source, siteConfigLabel);
31
- const issues = [];
32
-
33
- validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
34
- validateConfigFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
35
22
 
36
- if (body.trim()) {
37
- issues.push({
38
- message: `${siteConfigLabel} may contain YAML frontmatter only. Remove the Markdown body.`,
39
- });
40
- }
41
-
42
- if (issues.length > 0) {
43
- throw new Error([
44
- `${siteConfigLabel} has invalid frontmatter.`,
45
- ...issues.map((issue) => `- ${issue.message}`),
46
- ].join('\n'));
47
- }
48
-
49
- return parseYamlMapping(frontmatterBody);
23
+ return parseYamlConfig(source, siteConfigLabel, {
24
+ validateStructure: validateConfigYamlStructure,
25
+ });
50
26
  };
51
27
 
52
28
  const siteConfig = await readSiteConfig();
@@ -207,7 +183,7 @@ const readSiteUrl = (config) => {
207
183
  const localeLabels = Object.freeze({
208
184
  en: Object.freeze({
209
185
  dismissBanner: 'Dismiss notice',
210
- gallery: 'Images',
186
+ images: 'Images',
211
187
  note: 'Note',
212
188
  pageNavigation: 'On this page',
213
189
  siteBanners: 'Site notices',
@@ -216,7 +192,7 @@ const localeLabels = Object.freeze({
216
192
  }),
217
193
  sv: Object.freeze({
218
194
  dismissBanner: 'Stäng meddelande',
219
- gallery: 'Bilder',
195
+ images: 'Bilder',
220
196
  note: 'Not',
221
197
  pageNavigation: 'På den här sidan',
222
198
  siteBanners: 'Meddelanden',
@@ -246,10 +222,10 @@ const readLocale = (config) => {
246
222
  });
247
223
  };
248
224
 
249
- const rawConfig = assertObject(siteConfig, 'config frontmatter');
250
- const rawTheme = assertObject(themeConfig, 'theme frontmatter', siteThemeLabel);
225
+ const rawConfig = assertObject(siteConfig, 'config YAML');
226
+ const rawTheme = assertObject(themeConfig, 'theme YAML', siteThemeLabel);
251
227
  const siteUrl = readSiteUrl(rawConfig);
252
- const scrollBehaviorNames = ['instant', 'browser-smooth'];
228
+ const scrollBehaviorNames = ['instant', 'smooth'];
253
229
 
254
230
  const defaultFontFamily = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
255
231
  const layoutDensityNames = ['compact', 'normal', 'airy'];
@@ -337,7 +313,7 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
337
313
  const rawThemeConfig = assertObject(resolveThemeConfig(theme, sourceLabel), 'theme frontmatter', sourceLabel);
338
314
  const rawLayoutConfig = assertObject(rawThemeConfig.layout ?? {}, 'layout', sourceLabel);
339
315
  const rawLayoutSpacingConfig = assertObject(rawLayoutConfig.spacing ?? {}, 'layout.spacing', sourceLabel);
340
- const rawGalleryConfig = assertObject(rawThemeConfig.gallery ?? {}, 'gallery', sourceLabel);
316
+ const rawImagesConfig = assertObject(rawThemeConfig.images ?? {}, 'images', sourceLabel);
341
317
  const rawTypographyConfig = assertObject(rawThemeConfig.typography ?? {}, 'typography', sourceLabel);
342
318
  const resolvedLayoutDensity = readEnum(rawLayoutConfig, 'density', 'layout', layoutDensityNames, 'normal', sourceLabel);
343
319
  const resolvedLayoutSpacingDefaults = layoutDensityProfiles[resolvedLayoutDensity];
@@ -359,16 +335,16 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
359
335
  sectionGap: readResponsiveCssLength(rawLayoutSpacingConfig, 'sectionGap', 'layout.spacing', resolvedLayoutSpacingDefaults.sectionGap, sourceLabel),
360
336
  }),
361
337
  }),
362
- gallery: Object.freeze({
363
- maxAvailableHeightPercent: readResponsivePercent(rawGalleryConfig, 'maxAvailableHeightPercent', 'gallery', Object.freeze({
338
+ images: Object.freeze({
339
+ maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
364
340
  desktop: 74,
365
341
  mobile: 68,
366
342
  }), sourceLabel),
367
- maxAvailableWidthPercent: readResponsivePercent(rawGalleryConfig, 'maxAvailableWidthPercent', 'gallery', Object.freeze({
343
+ maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
368
344
  desktop: 100,
369
345
  mobile: 100,
370
346
  }), sourceLabel),
371
- width: readCssLength(rawGalleryConfig, 'width', 'gallery', '900px', sourceLabel),
347
+ width: readCssLength(rawImagesConfig, 'width', 'images', '900px', sourceLabel),
372
348
  }),
373
349
  typography: Object.freeze({
374
350
  fontFamily: readFontFamily(rawTypographyConfig, 'fontFamily', 'typography', defaultFontFamily, sourceLabel),
@@ -0,0 +1,112 @@
1
+ import path from 'node:path';
2
+
3
+ export const logoAssetFilenames = Object.freeze([
4
+ 'logo.svg',
5
+ 'logo.png',
6
+ 'logo.jpg',
7
+ 'logo.jpeg',
8
+ ]);
9
+
10
+ export const browserIconAssetDefinitions = Object.freeze([
11
+ Object.freeze({ filename: 'favicon.svg', rel: 'icon', type: 'image/svg+xml' }),
12
+ Object.freeze({ filename: 'favicon.ico', rel: 'icon', sizes: 'any' }),
13
+ Object.freeze({ filename: 'favicon.png', rel: 'icon', type: 'image/png' }),
14
+ Object.freeze({ filename: 'apple-touch-icon.png', rel: 'apple-touch-icon' }),
15
+ ]);
16
+
17
+ export const browserIconAssetFilenames = Object.freeze(
18
+ browserIconAssetDefinitions.map(({ filename }) => filename),
19
+ );
20
+
21
+ const logoExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg']);
22
+ const faviconExtensions = new Set(['.svg', '.png', '.ico']);
23
+ const exactLogoNames = new Set(logoAssetFilenames);
24
+ const exactBrowserIconNames = new Set(browserIconAssetFilenames);
25
+
26
+ export const isLogoAssetFilename = (filename) => exactLogoNames.has(filename);
27
+
28
+ const editDistance = (left, right) => {
29
+ const rows = Array.from({ length: left.length + 1 }, (_, index) => [index]);
30
+ for (let column = 1; column <= right.length; column += 1) rows[0][column] = column;
31
+
32
+ for (let row = 1; row <= left.length; row += 1) {
33
+ for (let column = 1; column <= right.length; column += 1) {
34
+ rows[row][column] = Math.min(
35
+ rows[row - 1][column] + 1,
36
+ rows[row][column - 1] + 1,
37
+ rows[row - 1][column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1),
38
+ );
39
+ }
40
+ }
41
+
42
+ return rows[left.length][right.length];
43
+ };
44
+
45
+ const getSuspiciousAsset = (filename) => {
46
+ const lowerFilename = filename.toLowerCase();
47
+ const parsed = path.parse(lowerFilename);
48
+
49
+ if (exactLogoNames.has(lowerFilename) && filename !== lowerFilename) {
50
+ return {
51
+ code: 'public-asset-case',
52
+ message: `Norna does not recognize "${filename}" as the navigation logo on case-sensitive file systems. Rename it to "${lowerFilename}".`,
53
+ };
54
+ }
55
+
56
+ if (exactBrowserIconNames.has(lowerFilename) && filename !== lowerFilename) {
57
+ return {
58
+ code: 'public-asset-case',
59
+ message: `Norna does not recognize "${filename}" as a browser icon on case-sensitive file systems. Rename it to "${lowerFilename}".`,
60
+ };
61
+ }
62
+
63
+ if (parsed.name === 'logo' && !logoExtensions.has(parsed.ext)) {
64
+ return {
65
+ code: 'unsupported-logo-file',
66
+ message: `Norna does not recognize "${filename}" as the navigation logo. Use exactly one of ${logoAssetFilenames.join(', ')}.`,
67
+ };
68
+ }
69
+
70
+ if (parsed.name === 'favicon' && !faviconExtensions.has(parsed.ext)) {
71
+ return {
72
+ code: 'unsupported-favicon-file',
73
+ message: `Norna does not link "${filename}" as a browser icon. Use favicon.svg, favicon.ico, or favicon.png.`,
74
+ };
75
+ }
76
+
77
+ if (/^favicon[-_.]/.test(lowerFilename) && !exactBrowserIconNames.has(lowerFilename)) {
78
+ return {
79
+ code: 'unrecognized-favicon-file',
80
+ message: `Norna does not link "${filename}" automatically. Use favicon.svg, favicon.ico, favicon.png, or apple-touch-icon.png for convention-based browser icons.`,
81
+ };
82
+ }
83
+
84
+ if (logoExtensions.has(parsed.ext) && editDistance(parsed.name, 'logo') === 1) {
85
+ return {
86
+ code: 'possible-logo-typo',
87
+ message: `"${filename}" looks like a misspelled navigation logo filename. Norna recognizes ${logoAssetFilenames.join(', ')}.`,
88
+ };
89
+ }
90
+
91
+ if (faviconExtensions.has(parsed.ext) && editDistance(parsed.name, 'favicon') <= 2) {
92
+ return {
93
+ code: 'possible-favicon-typo',
94
+ message: `"${filename}" looks like a misspelled browser icon filename. Norna recognizes favicon.svg, favicon.ico, and favicon.png.`,
95
+ };
96
+ }
97
+
98
+ return null;
99
+ };
100
+
101
+ export const inspectPublicAssetFilenames = (filenames) => {
102
+ const files = [...filenames].sort((left, right) => left.localeCompare(right, 'en'));
103
+ const logos = files.filter(isLogoAssetFilename);
104
+ const browserIcons = files.filter((filename) => exactBrowserIconNames.has(filename));
105
+ const suspicious = files.flatMap((filename) => {
106
+ if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename)) return [];
107
+ const issue = getSuspiciousAsset(filename);
108
+ return issue ? [{ filename, ...issue }] : [];
109
+ });
110
+
111
+ return { browserIcons, files, logos, suspicious };
112
+ };
@@ -0,0 +1,213 @@
1
+ import { z } from 'astro/zod';
2
+ import { themePresetNames } from './theme-presets.mjs';
3
+
4
+ const isDateOnly = (value) => {
5
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
6
+
7
+ const date = new Date(`${value}T00:00:00.000Z`);
8
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
9
+ };
10
+
11
+ const textAlign = z.enum(['left', 'center', 'right']).describe('Text alignment. Omit an override to keep the active typography profile.');
12
+ const textSize = z.enum(['small', 'medium', 'large', 'xlarge']).describe('Text size from the active typography system. Omit an override to keep the profile value.');
13
+ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width of body text. Omit an override to keep the profile value.');
14
+ const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
15
+ const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
16
+ const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
17
+ const presentationPalette = z.enum(['dark', 'light', 'paper']).describe('Coordinated site color palette. Omit this to use the selected preset.');
18
+ const sectionSurface = z.enum(['base', 'soft', 'emphasis']).describe('Semantic section surface from the active palette.');
19
+ const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinated spacing density. Omit this to use the selected preset.');
20
+ const lineHeight = z.number()
21
+ .min(1, 'Use a unitless line height of at least 1.')
22
+ .max(3, 'Use a unitless line height of at most 3.')
23
+ .describe('Unitless line height between 1 and 3.');
24
+ const cssLength = z.string().regex(
25
+ /^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|lh))$/,
26
+ 'Use a CSS length such as "0", "0.8em", "1rem", or "12px".',
27
+ ).describe('CSS length using px, rem, em, ch or lh.');
28
+ const visualCssLengthUnit = String.raw`(?:px|rem|em|vw|vh|vmin|vmax|ch|%)`;
29
+ const visualCssLengthValue = String.raw`(?:\d+(?:\.\d+)?|\.\d+)${visualCssLengthUnit}`;
30
+ const visualCssLength = z.string().regex(
31
+ new RegExp(`^(?:0|${visualCssLengthValue}|clamp\\(\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*\\))$`),
32
+ 'Use a CSS length such as "0", "900px", "56rem", "90%", or "clamp(1rem, 4vw, 3rem)".',
33
+ ).describe('Responsive CSS length or clamp() expression.');
34
+ const dateOnly = z.string()
35
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format.')
36
+ .refine(isDateOnly, 'Use a real calendar date.')
37
+ .describe('Calendar date using YYYY-MM-DD.');
38
+ const visibilityWindow = z.object({
39
+ from: dateOnly.optional().describe('First date on which the content is visible.'),
40
+ until: dateOnly.optional().describe('First date on which the content is no longer visible.'),
41
+ }).strict().refine(
42
+ (value) => value.from !== undefined || value.until !== undefined,
43
+ 'Specify from, until, or both.',
44
+ ).refine(
45
+ (value) => value.from === undefined || value.until === undefined || value.from < value.until,
46
+ 'visible.until must be later than visible.from.',
47
+ ).describe('Optional visibility interval.');
48
+ const overrideResponsiveTextAlign = z.object({
49
+ desktop: textAlign.optional().describe('Text alignment on wider screens.'),
50
+ mobile: textAlign.optional().describe('Text alignment on narrow screens.'),
51
+ }).strict().refine(
52
+ (value) => value.desktop !== undefined || value.mobile !== undefined,
53
+ 'Specify desktop, mobile, or both.',
54
+ );
55
+ const commonTextPresentationOverride = {
56
+ align: overrideResponsiveTextAlign.optional().describe('Responsive text alignment override.'),
57
+ size: textSize.optional(),
58
+ lineHeight: lineHeight.optional(),
59
+ };
60
+ const headingPresentationOverride = z.object({
61
+ ...commonTextPresentationOverride,
62
+ weight: headingWeight.optional().describe('Heading font weight. Omit this to keep the profile value.'),
63
+ spacingBefore: cssLength.optional().describe('Vertical space before the heading.'),
64
+ spacingAfter: cssLength.optional().describe('Vertical space after the heading.'),
65
+ }).strict().describe('Focused overrides for one heading level.');
66
+ const headingLevelsPresentationOverride = z.object({
67
+ h1: headingPresentationOverride.optional().describe('Overrides for Markdown level 1 headings.'),
68
+ h2: headingPresentationOverride.optional().describe('Overrides for Markdown level 2 section headings.'),
69
+ h3: headingPresentationOverride.optional().describe('Overrides for Markdown level 3 headings.'),
70
+ h4: headingPresentationOverride.optional().describe('Overrides for Markdown level 4 headings.'),
71
+ }).strict().describe('Heading overrides by Markdown level.');
72
+ const bodyPresentationOverride = z.object({
73
+ ...commonTextPresentationOverride,
74
+ width: textWidth.optional().describe('Maximum body-text line length.'),
75
+ paragraphSpacing: cssLength.optional().describe('Vertical space between body paragraphs.'),
76
+ }).strict().describe('Focused overrides for body text.');
77
+ const captionPresentationOverride = z.object({
78
+ ...commonTextPresentationOverride,
79
+ spacingBefore: cssLength.optional().describe('Vertical space between an image and its caption.'),
80
+ }).strict().describe('Focused overrides for image captions.');
81
+ const typographyOverrides = z.object({
82
+ headings: headingLevelsPresentationOverride.optional().describe('Overrides for heading levels.'),
83
+ body: bodyPresentationOverride.optional().describe('Overrides for body text.'),
84
+ caption: captionPresentationOverride.optional().describe('Overrides for image captions.'),
85
+ }).strict().describe('Fine-grained typography overrides applied after profile and rhythm.');
86
+ const themeTypography = z.object({
87
+ fontFamily: z.string()
88
+ .min(1)
89
+ .refine((value) => !/[\n\r;{}]/.test(value), 'Do not use semicolons, braces, or line breaks.')
90
+ .optional()
91
+ .describe('CSS font-family stack.'),
92
+ profile: typographyProfile.optional().describe('Typography profile. Omit this to keep the selected preset.'),
93
+ rhythm: spacingDensity.optional().describe('Typography spacing rhythm. Omit this to keep the selected preset.'),
94
+ overrides: typographyOverrides.optional().describe('Focused typography overrides applied after the profile.'),
95
+ }).strict().refine(
96
+ (value) => value.fontFamily !== undefined || value.profile !== undefined || value.rhythm !== undefined || value.overrides !== undefined,
97
+ 'Specify fontFamily, profile, rhythm, overrides, or a combination of them.',
98
+ ).describe('Typography profile, rhythm, font family, and focused overrides.');
99
+ const responsiveCssLength = z.union([
100
+ visualCssLength,
101
+ z.object({
102
+ desktop: visualCssLength,
103
+ mobile: visualCssLength,
104
+ }).strict(),
105
+ ]);
106
+ const responsivePercent = z.union([
107
+ z.number().positive().max(100),
108
+ z.object({
109
+ desktop: z.number().positive().max(100).describe('Percentage used on wider screens.'),
110
+ mobile: z.number().positive().max(100).describe('Percentage used on narrow screens.'),
111
+ }).strict(),
112
+ ]);
113
+ const themeLayoutSpacing = z.object({
114
+ blockGap: responsiveCssLength.optional().describe('Default vertical gap between structured content blocks.'),
115
+ finalSectionBottom: responsiveCssLength.optional().describe('Bottom space after the final section.'),
116
+ firstSectionTop: responsiveCssLength.optional().describe('Top space before the first section.'),
117
+ headingToBlock: responsiveCssLength.optional().describe('Gap from a section heading to a following structured block.'),
118
+ imageGap: responsiveCssLength.optional().describe('Gap between images in an image stack.'),
119
+ sectionGap: responsiveCssLength.optional().describe('Vertical separation between page sections.'),
120
+ }).strict().describe('Fine-grained spacing overrides applied after layout density.');
121
+ const themeLayout = z.object({
122
+ density: spacingDensity.optional().describe('Overall spacing density. Omit this to keep the selected preset.'),
123
+ pageWidth: visualCssLength.optional().describe('Maximum width of the site layout. Omit this to keep the selected preset.'),
124
+ gutter: responsiveCssLength.optional().describe('Horizontal page gutter as one value or separate desktop and mobile values.'),
125
+ spacing: themeLayoutSpacing.optional().describe('Fine-grained spacing overrides.'),
126
+ }).strict().describe('Optional layout overrides applied after the preset.');
127
+ const themeImages = z.object({
128
+ width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
129
+ maxAvailableWidthPercent: responsivePercent.optional().describe('Maximum percentage of available horizontal space used by managed images.'),
130
+ maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by managed images.'),
131
+ }).strict().describe('Optional defaults for managed image presentation.');
132
+ const sectionSurfaces = z.array(sectionSurface).min(1).max(3).refine(
133
+ (value) => new Set(value).size === value.length,
134
+ 'Each section surface may appear only once.',
135
+ ).describe('Surface sequence cycled across page sections.');
136
+ const pageNavigation = z.object({
137
+ include: z.boolean().optional().describe('Include this page in site navigation. Pages are included when omitted.'),
138
+ label: z.string().optional().describe('Navigation label when it should differ from the page title.'),
139
+ }).strict();
140
+ const sitewideNavigation = z.object({
141
+ label: z.string().min(1).optional().describe('Accessible site label used for navigation text and logo alternative text.'),
142
+ logo: z.object({
143
+ height: visualCssLength.optional().describe('Displayed logo height. Width follows the intrinsic aspect ratio.'),
144
+ }).strict().optional().describe('Optional display settings for a convention-based navigation logo.'),
145
+ }).strict().describe('Site-wide identity shown in navigation.');
146
+
147
+ const sectionMetadata = z.object({
148
+ visible: visibilityWindow.optional().describe('Optional date window for this section.'),
149
+ }).strict().describe('Metadata keyed by the explicit Markdown section id. Markdown order remains authoritative.');
150
+ const banner = z.object({
151
+ id: z.string().regex(/^[a-z0-9-]+$/).describe('Stable banner identifier used for dismissal state.'),
152
+ tone: z.enum(['warning']).default('warning').describe('Semantic banner tone.'),
153
+ visible: visibilityWindow.optional().describe('Optional date window for the banner.'),
154
+ title: z.string().min(1).describe('Short banner heading.'),
155
+ text: z.string().min(1).describe('Concise banner message.'),
156
+ }).strict().describe('Dismissible, site-wide one-line notice.');
157
+ const banners = z.array(banner).optional().default([]).refine(
158
+ (values) => new Set(values.map((value) => value.id)).size === values.length,
159
+ 'Banner ids must be unique.',
160
+ );
161
+ const dateTimeFormat = z.object({
162
+ locale: z.string().min(1).describe('Intl locale such as en-GB or sv-SE.'),
163
+ timeZone: z.string().min(1).describe('IANA time zone such as Europe/Stockholm.'),
164
+ dateStyle: z.enum(['short', 'medium', 'long', 'full']).describe('Intl dateStyle for the build timestamp.'),
165
+ timeStyle: z.enum(['short', 'medium', 'long', 'full']).describe('Intl timeStyle for the build timestamp.'),
166
+ }).strict().refine((value) => {
167
+ try {
168
+ new Intl.DateTimeFormat(value.locale, {
169
+ dateStyle: value.dateStyle,
170
+ timeStyle: value.timeStyle,
171
+ timeZone: value.timeZone,
172
+ });
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }, 'Use a valid Intl.DateTimeFormat configuration.');
178
+ const sitewideFooter = z.object({
179
+ copyrightMessage: z.string().min(1).optional().describe('Copyright or ownership text shown in the site footer.'),
180
+ buildInfo: z.object({
181
+ enabled: z.boolean().default(true).describe('Show generated build information in the footer.'),
182
+ text: z.string().min(1).describe('Label shown before the generated build timestamp.'),
183
+ dateTimeFormat: dateTimeFormat.describe('Formatting used for the generated build timestamp.'),
184
+ }).strict().optional().describe('Optional generated build timestamp.'),
185
+ }).strict().describe('Site-wide footer content.');
186
+
187
+ export const configSchema = z.object({
188
+ url: z.string().url().describe('Absolute public URL for the built site.'),
189
+ language: z.string().regex(/^(?:en|sv)(?:-[a-zA-Z0-9]+)*$/).optional().describe('Site language tag using Norna\'s English or Swedish interface text; the default is en.'),
190
+ scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
191
+ }).strict().describe('Technical settings for one Norna site. Routes cannot provide technical configuration.');
192
+
193
+ export const siteSchema = z.object({
194
+ title: z.string().describe('Page title used in metadata and route navigation.'),
195
+ description: z.string().describe('Page description used in metadata.'),
196
+ navigation: pageNavigation.optional().describe('Optional navigation metadata for this page.'),
197
+ sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}).describe('Optional metadata keyed by Markdown section id. Do not duplicate section order here.'),
198
+ }).strict().describe('Frontmatter for a homepage or route content.md file.');
199
+
200
+ export const themeVisualSchema = z.object({
201
+ preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
202
+ layout: themeLayout.optional(),
203
+ images: themeImages.optional(),
204
+ typography: themeTypography.optional(),
205
+ palette: presentationPalette.optional(),
206
+ sectionSurfaces: sectionSurfaces.optional().describe('Surface sequence cycled through page sections. Omit this to keep the selected preset.'),
207
+ }).strict().describe('Visual settings for a site or route. A route theme replaces the inherited visual theme; site identity remains site-wide.');
208
+
209
+ export const sitewideSchema = z.object({
210
+ navigation: sitewideNavigation.optional().describe('Site-wide navigation identity.'),
211
+ banners: banners.describe('Site-wide dismissible notices.'),
212
+ footer: sitewideFooter.optional().describe('Site-wide footer content.'),
213
+ }).strict().describe('Editorial content and identity shared by every page.');