@janga/norna 0.7.1 → 0.7.3

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 (168) hide show
  1. package/README.md +32 -18
  2. package/astro.config.mjs +2 -0
  3. package/bin/norna-cli.mjs +175 -0
  4. package/bin/norna.mjs +149 -150
  5. package/docs/README.md +41 -22
  6. package/docs/commands.md +25 -12
  7. package/docs/configuration.md +38 -135
  8. package/docs/content.md +108 -265
  9. package/docs/{command-organization.md → design/command-organization.md} +64 -60
  10. package/docs/{site-examples-structure-note.md → design/site-examples-structure.md} +19 -24
  11. package/docs/engine-development.md +35 -7
  12. package/docs/getting-started.md +55 -26
  13. package/docs/images-and-metadata.md +1 -1
  14. package/docs/local-development.md +14 -1
  15. package/docs/publishing.md +24 -1
  16. package/docs/routes.md +90 -0
  17. package/docs/site-structure.md +18 -10
  18. package/docs/theme.md +292 -0
  19. package/docs/typography.md +144 -0
  20. package/examples/dog-gallery/.astro/collections/site.schema.json +802 -0
  21. package/examples/dog-gallery/.astro/collections/theme.schema.json +714 -0
  22. package/examples/dog-gallery/.astro/content-assets.mjs +4 -0
  23. package/examples/dog-gallery/.astro/content-modules.mjs +1 -0
  24. package/examples/dog-gallery/.astro/content.d.ts +175 -0
  25. package/examples/dog-gallery/.astro/data-store.json +1 -0
  26. package/examples/dog-gallery/.astro/dev-local.json +8 -0
  27. package/examples/dog-gallery/.astro/dev.json +13 -0
  28. package/examples/dog-gallery/.astro/dev.log +49 -0
  29. package/examples/dog-gallery/.astro/settings.json +5 -0
  30. package/examples/dog-gallery/.astro/types.d.ts +2 -0
  31. package/examples/dog-gallery/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  32. package/examples/dog-gallery/dist/_astro/SitePage.BFHZm5UG.css +1 -0
  33. package/examples/dog-gallery/dist/dog-care/index.html +15 -0
  34. package/examples/dog-gallery/dist/favicon.svg +7 -0
  35. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1080.webp +0 -0
  36. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1440.webp +0 -0
  37. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1536.webp +0 -0
  38. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-480.webp +0 -0
  39. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-768.webp +0 -0
  40. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-480.webp +0 -0
  41. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-768.webp +0 -0
  42. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-960.webp +0 -0
  43. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1080.webp +0 -0
  44. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1440.webp +0 -0
  45. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1920.webp +0 -0
  46. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-3939.webp +0 -0
  47. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-480.webp +0 -0
  48. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-768.webp +0 -0
  49. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1080.webp +0 -0
  50. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1440.webp +0 -0
  51. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1920.webp +0 -0
  52. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-2400.webp +0 -0
  53. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-480.webp +0 -0
  54. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-768.webp +0 -0
  55. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1080.webp +0 -0
  56. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1440.webp +0 -0
  57. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1600.webp +0 -0
  58. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-480.webp +0 -0
  59. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-768.webp +0 -0
  60. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1080.webp +0 -0
  61. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1440.webp +0 -0
  62. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1920.webp +0 -0
  63. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-2576.webp +0 -0
  64. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-480.webp +0 -0
  65. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-768.webp +0 -0
  66. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1080.webp +0 -0
  67. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1277.webp +0 -0
  68. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-480.webp +0 -0
  69. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-768.webp +0 -0
  70. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1080.webp +0 -0
  71. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1440.webp +0 -0
  72. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1536.webp +0 -0
  73. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-480.webp +0 -0
  74. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-768.webp +0 -0
  75. package/examples/dog-gallery/dist/index.html +43 -0
  76. package/examples/dog-gallery/dist/robots.txt +2 -0
  77. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +74 -0
  78. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  79. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  80. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  81. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  82. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  83. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  84. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  85. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  86. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  87. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  88. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  89. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  90. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  91. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  92. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  93. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  94. package/examples/dog-gallery/node_modules/.vite/deps/package.json +3 -0
  95. package/examples/dog-gallery/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  96. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  97. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  98. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  99. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  100. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  101. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  102. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  103. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  104. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  105. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  106. package/examples/dog-gallery/site/.norna/generated-images.json +226 -0
  107. package/examples/dog-gallery/site/config.mjs +67 -0
  108. package/examples/dog-gallery/site/content.md +168 -0
  109. package/examples/dog-gallery/site/images/black-dogs/black-puppy-meadow.png +0 -0
  110. package/examples/dog-gallery/site/images/black-dogs/photo-of-a-black-dog.jpg +0 -0
  111. package/examples/dog-gallery/site/images/brown-dogs/brown-dog.jpg +0 -0
  112. package/examples/dog-gallery/site/images/brown-dogs/dog-accompanies-master.jpg +0 -0
  113. package/examples/dog-gallery/site/images/golden-dogs/golden-retriever.jpg +0 -0
  114. package/examples/dog-gallery/site/images/golden-dogs/toller-puppy.jpg +0 -0
  115. package/examples/dog-gallery/site/images/white-dogs/white-cute-dog.jpg +0 -0
  116. package/examples/dog-gallery/site/images/white-dogs/white-puppy-garden.png +0 -0
  117. package/examples/dog-gallery/site/public/favicon.svg +7 -0
  118. package/examples/dog-gallery/site/public/robots.txt +2 -0
  119. package/examples/dog-gallery/site/routes/dog-care/route-content.md +49 -0
  120. package/examples/dog-gallery/site/theme.md +114 -0
  121. package/fixtures/basic/site/config.mjs +3 -22
  122. package/fixtures/basic/site/content.md +1 -1
  123. package/fixtures/basic/site/theme.md +7 -3
  124. package/package.json +10 -9
  125. package/scripts/check-config.mjs +4 -3
  126. package/scripts/deploy-site.mjs +3 -3
  127. package/scripts/dev-local.mjs +64 -15
  128. package/scripts/init-site.mjs +11 -9
  129. package/scripts/lib/frontmatter-yaml.mjs +85 -0
  130. package/scripts/lib/presentation.mjs +10 -11
  131. package/scripts/lib/project-config.mjs +113 -45
  132. package/scripts/lib/site-content.mjs +21 -2
  133. package/scripts/lib/site-paths.mjs +18 -3
  134. package/scripts/lib/theme-config.mjs +34 -0
  135. package/scripts/lib/typography.mjs +103 -23
  136. package/scripts/show-typography.mjs +202 -75
  137. package/scripts/sync-content-sections.mjs +6 -6
  138. package/scripts/test-cli-discovery.mjs +124 -0
  139. package/scripts/test-content-check.mjs +3 -4
  140. package/scripts/test-engine-commands.mjs +20 -6
  141. package/scripts/test-navigation.mjs +10 -6
  142. package/scripts/test-package-check.mjs +40 -11
  143. package/scripts/test-temporary-visibility.mjs +2 -3
  144. package/src/components/SiteNavigation.astro +7 -5
  145. package/src/components/SitePage.astro +5 -3
  146. package/src/components/SiteSection.astro +63 -29
  147. package/src/content.config.ts +64 -2
  148. package/src/layouts/BaseLayout.astro +19 -1
  149. package/src/lib/basePath.ts +21 -0
  150. package/src/lib/generatedImages.ts +8 -4
  151. package/src/lib/sectionContent.ts +6 -1
  152. package/src/lib/sitePublicAssets.ts +8 -1
  153. package/src/styles/global.css +59 -55
  154. package/starters/basic/.github/workflows/deploy.yml +3 -3
  155. package/starters/basic/README.md +30 -14
  156. package/starters/basic/package.json +3 -3
  157. package/starters/basic/site/config.mjs +4 -24
  158. package/starters/basic/site/content.md +44 -9
  159. package/starters/basic/site/theme.md +113 -16
  160. package/starters/project/.github/workflows/deploy.yml +65 -0
  161. package/starters/project/README.md +72 -0
  162. package/starters/project/package-lock.json +4296 -0
  163. package/starters/project/package.json +35 -0
  164. package/starters/project/site/config.mjs +43 -0
  165. package/starters/project/site/content.md +88 -0
  166. package/starters/project/site/public/robots.txt +2 -0
  167. package/starters/project/site/routes/guide/route-content.md +58 -0
  168. package/starters/project/site/theme.md +15 -0
@@ -13,12 +13,12 @@ const cliExecutableName = 'norna';
13
13
  const usage = `
14
14
  Usage: norna init <target-dir> [--type pure|embedded] [--site-dir <path>]
15
15
 
16
- Creates a new site project or adds a gallery source directory to an existing
16
+ Creates a new site project or adds a Norna site directory to an existing
17
17
  project.
18
18
 
19
19
  Examples:
20
- norna init my-gallery
21
- norna init my-gallery --type pure
20
+ norna init my-site
21
+ norna init my-site --type pure
22
22
  norna init . --type embedded --site-dir presentation
23
23
  `.trim();
24
24
 
@@ -133,8 +133,8 @@ const galleryScripts = {
133
133
  'norna:config:check': cliCommand('config:check'),
134
134
  'norna:content:check': cliCommand('content:check'),
135
135
  'norna:sync': cliCommand('content:sync'),
136
- 'norna:typography:presets': cliCommand('typography:presets'),
137
- 'norna:typography:show': cliCommand('typography:show'),
136
+ 'norna:typography:presets': cliCommand('typography presets'),
137
+ 'norna:typography:show': cliCommand('typography show'),
138
138
  'norna:public': cliCommand('site:public'),
139
139
  'norna:images': cliCommand('images'),
140
140
  'norna:build': cliCommand('build'),
@@ -156,7 +156,7 @@ const addGalleryDependency = (packageJson, version) => {
156
156
  const addGalleryScripts = (packageJson, scripts, { includePureAliases }) => {
157
157
  packageJson.scripts ??= {};
158
158
  const wantedScripts = {
159
- ...(includePureAliases ? { dev: 'npm run norna:dev' } : {}),
159
+ ...(includePureAliases ? { dev: 'npm run norna:dev --' } : {}),
160
160
  ...scripts,
161
161
  ...(includePureAliases ? { build: 'npm run norna:build' } : {}),
162
162
  };
@@ -180,7 +180,7 @@ const existingEntries = await readDirectoryEntries(targetRoot);
180
180
  if (type === 'pure') {
181
181
  if (existingEntries && existingEntries.length > 0) {
182
182
  const existingPackageJson = existingEntries.includes('package.json')
183
- ? ' The target already contains package.json; use --type embedded to add a gallery to an existing project.'
183
+ ? ' The target already contains package.json; use --type embedded to add a Norna site to an existing project.'
184
184
  : '';
185
185
  throw new Error(`Target directory must be empty for --type pure: ${targetRoot}.${existingPackageJson}`);
186
186
  }
@@ -218,7 +218,7 @@ if (type === 'pure') {
218
218
  const existingSiteEntries = await readDirectoryEntries(targetSiteDir);
219
219
 
220
220
  if (existingSiteEntries && existingSiteEntries.length > 0) {
221
- throw new Error(`Gallery site directory must be empty for --type embedded: ${targetSiteDir}`);
221
+ throw new Error(`Norna site directory must be empty for --type embedded: ${targetSiteDir}`);
222
222
  }
223
223
 
224
224
  addGalleryDependency(targetPackageJson, enginePackageJson.version);
@@ -246,4 +246,6 @@ console.log(` cd ${targetRoot}`);
246
246
  console.log(type === 'embedded'
247
247
  ? ' npm install'
248
248
  : ' npm install');
249
- console.log(' npm run norna:dev');
249
+ console.log(type === 'embedded'
250
+ ? ' npm run norna:dev'
251
+ : ' npm run dev');
@@ -0,0 +1,85 @@
1
+ const countIndent = (line) => line.match(/^\s*/)?.[0].length ?? 0;
2
+
3
+ const stripInlineComment = (value) => {
4
+ let quote = null;
5
+
6
+ for (let index = 0; index < value.length; index += 1) {
7
+ const character = value[index];
8
+ const previous = value[index - 1];
9
+
10
+ if ((character === '"' || character === "'") && previous !== '\\') {
11
+ quote = quote === character ? null : quote ?? character;
12
+ continue;
13
+ }
14
+
15
+ if (character === '#' && quote === null && /\s/.test(previous ?? '')) {
16
+ return value.slice(0, index).trimEnd();
17
+ }
18
+ }
19
+
20
+ return value;
21
+ };
22
+
23
+ const parseScalar = (rawValue) => {
24
+ const value = stripInlineComment(rawValue.trim());
25
+
26
+ if (value === 'true') return true;
27
+ if (value === 'false') return false;
28
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
29
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
30
+ return value.slice(1, -1);
31
+ }
32
+
33
+ return value;
34
+ };
35
+
36
+ export const parseMapping = (lines, startIndex = 0, baseIndent = -1) => {
37
+ const value = {};
38
+ let index = startIndex;
39
+
40
+ while (index < lines.length) {
41
+ const line = lines[index];
42
+ if (!line.trim() || line.trim().startsWith('#')) {
43
+ index += 1;
44
+ continue;
45
+ }
46
+
47
+ const indent = countIndent(line);
48
+ if (indent <= baseIndent) break;
49
+ if (line.trim().startsWith('- ')) break;
50
+
51
+ const match = line.trim().match(/^([a-zA-Z][a-zA-Z0-9-]*):(?:\s+(.*))?$/);
52
+ if (!match) break;
53
+
54
+ const [, key, rawValue] = match;
55
+ if (rawValue === undefined) {
56
+ const parsed = parseMapping(lines, index + 1, indent);
57
+ value[key] = parsed.value;
58
+ index = parsed.nextIndex;
59
+ } else {
60
+ value[key] = parseScalar(rawValue);
61
+ index += 1;
62
+ }
63
+ }
64
+
65
+ return { value, nextIndex: index };
66
+ };
67
+
68
+ export const parseYamlMapping = (source) => parseMapping(source.split(/\r?\n/)).value;
69
+
70
+ export const findMap = (lines, label, parentStart = 0, parentEnd = lines.length, requiredIndent = null) => {
71
+ for (let index = parentStart; index < parentEnd; index += 1) {
72
+ const line = lines[index];
73
+ const match = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9-]*):\s*$/);
74
+ if (!match || match[2] !== label) continue;
75
+ if (requiredIndent !== null && match[1].length !== requiredIndent) continue;
76
+
77
+ return {
78
+ index,
79
+ indent: match[1].length,
80
+ ...parseMapping(lines, index + 1, match[1].length),
81
+ };
82
+ }
83
+
84
+ return null;
85
+ };
@@ -11,19 +11,20 @@ export const fallbackPresentationColors = Object.freeze({
11
11
 
12
12
  const normalizePresentation = (presentation) => presentation ?? {};
13
13
 
14
- export const resolveThemePresentation = (themePresentation) => {
15
- const normalizedThemePresentation = normalizePresentation(themePresentation);
14
+ export const resolveThemePresentation = (theme) => {
15
+ const normalizedTheme = theme ?? {};
16
+ const normalizedThemePresentation = normalizePresentation(normalizedTheme.presentation);
16
17
 
17
18
  return {
18
19
  backgroundColor: normalizedThemePresentation.backgroundColor ?? fallbackPresentationColors.backgroundColor,
19
20
  textColor: normalizedThemePresentation.textColor ?? fallbackPresentationColors.textColor,
20
21
  inlineStyles: normalizedThemePresentation.inlineStyles ?? {},
21
- typography: resolveTypographyConfig(normalizedThemePresentation.typography ?? defaultTypography),
22
+ typography: resolveTypographyConfig(normalizedTheme.typography ?? defaultTypography),
22
23
  };
23
24
  };
24
25
 
25
- export const resolvePagePresentation = (themePresentation, pagePresentation) => {
26
- const resolvedThemePresentation = resolveThemePresentation(themePresentation);
26
+ export const resolvePagePresentation = (theme, pagePresentation) => {
27
+ const resolvedThemePresentation = resolveThemePresentation(theme);
27
28
  const normalizedPagePresentation = normalizePresentation(pagePresentation);
28
29
 
29
30
  return {
@@ -42,7 +43,7 @@ const getColorsFromPresentation = (presentation) => ({
42
43
  textColor: presentation.textColor,
43
44
  });
44
45
 
45
- const resolveColorSource = ({ colors, themePresentation, themeFrameColors, pagePresentation }) => {
46
+ const resolveColorSource = ({ colors, themeFrameColors, pagePresentation }) => {
46
47
  if (!colors || colors === 'theme') {
47
48
  return themeFrameColors;
48
49
  }
@@ -54,18 +55,16 @@ const resolveColorSource = ({ colors, themePresentation, themeFrameColors, pageP
54
55
  return colors;
55
56
  };
56
57
 
57
- export const resolveFrameColors = ({ themePresentation, themeFrame, pagePresentation, pageFrame }) => {
58
- const resolvedThemePresentation = resolveThemePresentation(themePresentation);
58
+ export const resolveFrameColors = ({ theme, pagePresentation, pageFrame }) => {
59
+ const resolvedThemePresentation = resolveThemePresentation(theme);
59
60
  const themeFrameColors = resolveColorSource({
60
- colors: themeFrame?.colors ?? 'presentation',
61
- themePresentation: resolvedThemePresentation,
61
+ colors: theme?.frame?.colors ?? 'presentation',
62
62
  themeFrameColors: getColorsFromPresentation(resolvedThemePresentation),
63
63
  pagePresentation: resolvedThemePresentation,
64
64
  });
65
65
 
66
66
  return resolveColorSource({
67
67
  colors: pageFrame?.colors ?? 'theme',
68
- themePresentation: resolvedThemePresentation,
69
68
  themeFrameColors,
70
69
  pagePresentation,
71
70
  });
@@ -1,31 +1,33 @@
1
1
  import { pathToFileURL } from 'node:url';
2
- import { siteConfigLabel, siteConfigPath } from './site-paths.mjs';
2
+ import { siteConfigLabel, siteConfigPath, siteThemeLabel } from './site-paths.mjs';
3
+ import { readThemeConfig } from './theme-config.mjs';
3
4
 
4
5
  const { default: siteConfig } = await import(/* @vite-ignore */ pathToFileURL(siteConfigPath).href);
6
+ const themeConfig = await readThemeConfig();
5
7
 
6
8
  const cssLengthPattern = String.raw`(?:\d+|\d*\.\d+)(?:px|rem|em|vw|vh|vmin|vmax|ch|%)`;
7
9
  const simpleCssLengthPattern = new RegExp(`^${cssLengthPattern}$`);
8
10
  const clampCssLengthPattern = new RegExp(`^clamp\\(\\s*${cssLengthPattern}\\s*,\\s*${cssLengthPattern}\\s*,\\s*${cssLengthPattern}\\s*\\)$`);
9
11
 
10
- const assertObject = (value, path) => {
12
+ const assertObject = (value, path, sourceLabel = siteConfigLabel) => {
11
13
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
12
- throw new Error(`${path} must be an object in ${siteConfigLabel}.`);
14
+ throw new Error(`${path} must be an object in ${sourceLabel}.`);
13
15
  }
14
16
 
15
17
  return value;
16
18
  };
17
19
 
18
- const readString = (object, key, path) => {
20
+ const readString = (object, key, path, sourceLabel = siteConfigLabel) => {
19
21
  const value = object[key];
20
22
 
21
23
  if (typeof value !== 'string' || value.trim() === '') {
22
- throw new Error(`${path}.${key} must be a non-empty string in ${siteConfigLabel}.`);
24
+ throw new Error(`${path}.${key} must be a non-empty string in ${sourceLabel}.`);
23
25
  }
24
26
 
25
27
  return value.trim();
26
28
  };
27
29
 
28
- const readOptionalString = (object, key, path) => {
30
+ const readOptionalString = (object, key, path, sourceLabel = siteConfigLabel) => {
29
31
  const value = object[key];
30
32
 
31
33
  if (value === undefined || value === null || value === '') {
@@ -33,66 +35,69 @@ const readOptionalString = (object, key, path) => {
33
35
  }
34
36
 
35
37
  if (typeof value !== 'string' || value.trim() === '') {
36
- throw new Error(`${path}.${key} must be a non-empty string when set in ${siteConfigLabel}.`);
38
+ throw new Error(`${path}.${key} must be a non-empty string when set in ${sourceLabel}.`);
37
39
  }
38
40
 
39
41
  return value.trim();
40
42
  };
41
43
 
42
- const readFontFamily = (object, key, path, fallback) => {
44
+ const readFontFamily = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
43
45
  const value = object[key] ?? fallback;
44
46
 
45
47
  if (typeof value !== 'string' || value.trim() === '') {
46
- throw new Error(`${path}.${key} must be a non-empty CSS font-family value in ${siteConfigLabel}.`);
48
+ throw new Error(`${path}.${key} must be a non-empty CSS font-family value in ${sourceLabel}.`);
47
49
  }
48
50
 
49
51
  const normalizedValue = value.trim();
50
52
 
51
53
  if (/[\n\r;{}]/.test(normalizedValue)) {
52
- throw new Error(`${path}.${key} must not contain semicolons, braces, or line breaks in ${siteConfigLabel}.`);
54
+ throw new Error(`${path}.${key} must not contain semicolons, braces, or line breaks in ${sourceLabel}.`);
53
55
  }
54
56
 
55
57
  return normalizedValue;
56
58
  };
57
59
 
58
- const readCssLength = (object, key, path, fallback) => {
60
+ const readCssLength = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
59
61
  const value = object[key] ?? fallback;
60
62
 
61
63
  if (typeof value !== 'string' || value.trim() === '') {
62
- throw new Error(`${path}.${key} must be a non-empty CSS length in ${siteConfigLabel}.`);
64
+ throw new Error(`${path}.${key} must be a non-empty CSS length in ${sourceLabel}.`);
63
65
  }
64
66
 
65
67
  const normalizedValue = value.trim();
66
68
 
67
69
  if (!simpleCssLengthPattern.test(normalizedValue) || parseFloat(normalizedValue) <= 0) {
68
- throw new Error(`${path}.${key} must be a CSS length such as "900px", "56rem", or "90%" in ${siteConfigLabel}.`);
70
+ throw new Error(`${path}.${key} must be a CSS length such as "900px", "56rem", or "90%" in ${sourceLabel}.`);
69
71
  }
70
72
 
71
73
  return normalizedValue;
72
74
  };
73
75
 
74
- const readCssLengthValue = (value, path) => {
76
+ const readCssLengthValue = (value, path, sourceLabel = siteConfigLabel) => {
75
77
  if (typeof value !== 'string' || value.trim() === '') {
76
- throw new Error(`${path} must be a non-empty CSS length in ${siteConfigLabel}.`);
78
+ throw new Error(`${path} must be a non-empty CSS length in ${sourceLabel}.`);
77
79
  }
78
80
 
79
81
  const normalizedValue = value.trim();
80
82
 
81
83
  if (
82
- (!simpleCssLengthPattern.test(normalizedValue) && !clampCssLengthPattern.test(normalizedValue))
83
- || parseFloat(normalizedValue) <= 0
84
+ normalizedValue !== '0'
85
+ && (
86
+ (!simpleCssLengthPattern.test(normalizedValue) && !clampCssLengthPattern.test(normalizedValue))
87
+ || parseFloat(normalizedValue) < 0
88
+ )
84
89
  ) {
85
- throw new Error(`${path} must be a CSS length such as "48px", "3rem", "4vw", or a clamp() of those lengths in ${siteConfigLabel}.`);
90
+ throw new Error(`${path} must be a CSS length such as "0", "48px", "3rem", "4vw", or a clamp() of those lengths in ${sourceLabel}.`);
86
91
  }
87
92
 
88
93
  return normalizedValue;
89
94
  };
90
95
 
91
- const readResponsiveCssLength = (object, key, path, fallback) => {
96
+ const readResponsiveCssLength = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
92
97
  const value = object[key] ?? fallback;
93
98
 
94
99
  if (typeof value === 'string') {
95
- const length = readCssLengthValue(value, `${path}.${key}`);
100
+ const length = readCssLengthValue(value, `${path}.${key}`, sourceLabel);
96
101
 
97
102
  return Object.freeze({
98
103
  desktop: length,
@@ -100,27 +105,27 @@ const readResponsiveCssLength = (object, key, path, fallback) => {
100
105
  });
101
106
  }
102
107
 
103
- const responsiveValue = assertObject(value, `${path}.${key}`);
108
+ const responsiveValue = assertObject(value, `${path}.${key}`, sourceLabel);
104
109
 
105
110
  return Object.freeze({
106
- desktop: readCssLengthValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`),
107
- mobile: readCssLengthValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`),
111
+ desktop: readCssLengthValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`, sourceLabel),
112
+ mobile: readCssLengthValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`, sourceLabel),
108
113
  });
109
114
  };
110
115
 
111
- const readPercentValue = (value, path) => {
116
+ const readPercentValue = (value, path, sourceLabel = siteConfigLabel) => {
112
117
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 100) {
113
- throw new Error(`${path} must be a number greater than 0 and less than or equal to 100 in ${siteConfigLabel}.`);
118
+ throw new Error(`${path} must be a number greater than 0 and less than or equal to 100 in ${sourceLabel}.`);
114
119
  }
115
120
 
116
121
  return value;
117
122
  };
118
123
 
119
- const readResponsivePercent = (object, key, path, fallback) => {
124
+ const readResponsivePercent = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
120
125
  const value = object[key] ?? fallback;
121
126
 
122
127
  if (typeof value === 'number') {
123
- const percent = readPercentValue(value, `${path}.${key}`);
128
+ const percent = readPercentValue(value, `${path}.${key}`, sourceLabel);
124
129
 
125
130
  return Object.freeze({
126
131
  desktop: percent,
@@ -128,11 +133,11 @@ const readResponsivePercent = (object, key, path, fallback) => {
128
133
  });
129
134
  }
130
135
 
131
- const responsiveValue = assertObject(value, `${path}.${key}`);
136
+ const responsiveValue = assertObject(value, `${path}.${key}`, sourceLabel);
132
137
 
133
138
  return Object.freeze({
134
- desktop: readPercentValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`),
135
- mobile: readPercentValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`),
139
+ desktop: readPercentValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`, sourceLabel),
140
+ mobile: readPercentValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`, sourceLabel),
136
141
  });
137
142
  };
138
143
 
@@ -176,6 +181,27 @@ const readUrl = (object, key, path) => {
176
181
  }
177
182
  };
178
183
 
184
+ const readBasePath = (object, key, path) => {
185
+ const value = object[key] ?? '/';
186
+
187
+ if (typeof value !== 'string' || value.trim() === '') {
188
+ throw new Error(`${path}.${key} must be a non-empty URL path in ${siteConfigLabel}.`);
189
+ }
190
+
191
+ const normalizedValue = value.trim();
192
+
193
+ if (
194
+ !normalizedValue.startsWith('/')
195
+ || !normalizedValue.endsWith('/')
196
+ || normalizedValue.includes('//')
197
+ || /[\s?#]/.test(normalizedValue)
198
+ ) {
199
+ throw new Error(`${path}.${key} must start and end with "/" and must not contain whitespace, "?", "#", or "//" in ${siteConfigLabel}.`);
200
+ }
201
+
202
+ return normalizedValue;
203
+ };
204
+
179
205
  const readSmoothScroll = (navigation) => {
180
206
  const rawSmoothScroll = assertObject(navigation.smoothScroll ?? {}, 'navigation.smoothScroll');
181
207
  const minimumDurationMs = readPositiveInteger(rawSmoothScroll, 'minimumDurationMs', 'navigation.smoothScroll', 2_000);
@@ -208,7 +234,7 @@ const readLocale = (rawLocale) => {
208
234
  closeMenu: readString({ closeMenu: labels.closeMenu ?? 'Close menu' }, 'closeMenu', 'locale.labels'),
209
235
  skipToContent: readString({ skipToContent: labels.skipToContent ?? 'Skip to content' }, 'skipToContent', 'locale.labels'),
210
236
  sectionNavigation: readString({ sectionNavigation: labels.sectionNavigation ?? 'Sections' }, 'sectionNavigation', 'locale.labels'),
211
- gallery: readString({ gallery: labels.gallery ?? 'Gallery' }, 'gallery', 'locale.labels'),
237
+ gallery: readString({ gallery: labels.gallery ?? 'Images' }, 'gallery', 'locale.labels'),
212
238
  menu: readString({ menu: labels.menu ?? 'Menu' }, 'menu', 'locale.labels'),
213
239
  pageNavigation: readString({ pageNavigation: labels.pageNavigation ?? 'On this page' }, 'pageNavigation', 'locale.labels'),
214
240
  siteNavigation: readString({ siteNavigation: labels.siteNavigation ?? 'Pages' }, 'siteNavigation', 'locale.labels'),
@@ -259,10 +285,17 @@ const readBuildInfo = (footer) => {
259
285
  };
260
286
 
261
287
  const rawConfig = assertObject(siteConfig, 'default export');
288
+ const misplacedThemeKeys = ['layout', 'gallery', 'typography'].filter((key) => key in rawConfig);
289
+ if (misplacedThemeKeys.length > 0) {
290
+ throw new Error(`${misplacedThemeKeys.join(', ')} belong in ${siteThemeLabel}, not ${siteConfigLabel}.`);
291
+ }
292
+
293
+ const rawTheme = assertObject(themeConfig, 'theme frontmatter', siteThemeLabel);
262
294
  const rawSite = assertObject(rawConfig.site, 'site');
263
- const rawLayout = assertObject(rawConfig.layout ?? {}, 'layout');
264
- const rawGallery = assertObject(rawConfig.gallery ?? {}, 'gallery');
265
- const rawTypography = assertObject(rawConfig.typography ?? {}, 'typography');
295
+ const rawLayout = assertObject(rawTheme.layout ?? {}, 'layout', siteThemeLabel);
296
+ const rawLayoutSpacing = assertObject(rawLayout.spacing ?? {}, 'layout.spacing', siteThemeLabel);
297
+ const rawGallery = assertObject(rawTheme.gallery ?? {}, 'gallery', siteThemeLabel);
298
+ const rawTypography = assertObject(rawTheme.typography ?? {}, 'typography', siteThemeLabel);
266
299
  const rawNavigation = assertObject(rawConfig.navigation ?? {}, 'navigation');
267
300
  const rawLocale = rawConfig.locale ?? {};
268
301
  const rawFooter = assertObject(rawConfig.footer ?? {}, 'footer');
@@ -274,34 +307,69 @@ const defaultFontFamily = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
274
307
 
275
308
  export const projectConfig = Object.freeze({
276
309
  site: Object.freeze({
310
+ basePath: readBasePath(rawSite, 'basePath', 'site'),
277
311
  url: readUrl(rawSite, 'url', 'site'),
278
312
  }),
279
313
  layout: Object.freeze({
280
314
  gutter: readResponsiveCssLength(rawLayout, 'gutter', 'layout', Object.freeze({
281
315
  desktop: 'clamp(1.25rem, 4vw, 3rem)',
282
316
  mobile: '1rem',
283
- })),
284
- pageWidth: readCssLength(rawLayout, 'pageWidth', 'layout', '1180px'),
317
+ }), siteThemeLabel),
318
+ pageWidth: readCssLength(rawLayout, 'pageWidth', 'layout', '1180px', siteThemeLabel),
319
+ spacing: Object.freeze({
320
+ bodyToImages: readResponsiveCssLength(rawLayoutSpacing, 'bodyToImages', 'layout.spacing', Object.freeze({
321
+ desktop: 'clamp(1.25rem, 2.5vw, 2rem)',
322
+ mobile: '1.25rem',
323
+ }), siteThemeLabel),
324
+ finalSectionBottom: readResponsiveCssLength(rawLayoutSpacing, 'finalSectionBottom', 'layout.spacing', Object.freeze({
325
+ desktop: 'clamp(2.55rem, 4.2vw, 3.9rem)',
326
+ mobile: '2.25rem',
327
+ }), siteThemeLabel),
328
+ firstSectionTop: readResponsiveCssLength(rawLayoutSpacing, 'firstSectionTop', 'layout.spacing', Object.freeze({
329
+ desktop: 'clamp(1.875rem, 3vw, 2.75rem)',
330
+ mobile: '1.375rem',
331
+ }), siteThemeLabel),
332
+ imageGap: readResponsiveCssLength(rawLayoutSpacing, 'imageGap', 'layout.spacing', Object.freeze({
333
+ desktop: 'clamp(1.5rem, 3.5vw, 2.75rem)',
334
+ mobile: '2.75rem',
335
+ }), siteThemeLabel),
336
+ sectionGap: readResponsiveCssLength(rawLayoutSpacing, 'sectionGap', 'layout.spacing', Object.freeze({
337
+ desktop: 'clamp(2.55rem, 4.2vw, 3.9rem)',
338
+ mobile: '2.25rem',
339
+ }), siteThemeLabel),
340
+ sectionHeadingToBody: readResponsiveCssLength(rawLayoutSpacing, 'sectionHeadingToBody', 'layout.spacing', Object.freeze({
341
+ desktop: 'clamp(0.5625rem, 1.25vw, 0.75rem)',
342
+ mobile: '0.4375rem',
343
+ }), siteThemeLabel),
344
+ subheadingRuleTop: readResponsiveCssLength(rawLayoutSpacing, 'subheadingRuleTop', 'layout.spacing', Object.freeze({
345
+ desktop: '1rem',
346
+ mobile: '0.8rem',
347
+ }), siteThemeLabel),
348
+ subheadingTop: readResponsiveCssLength(rawLayoutSpacing, 'subheadingTop', 'layout.spacing', Object.freeze({
349
+ desktop: '2rem',
350
+ mobile: '1.6rem',
351
+ }), siteThemeLabel),
352
+ }),
285
353
  }),
286
354
  gallery: Object.freeze({
287
355
  maxAvailableHeightPercent: readResponsivePercent(rawGallery, 'maxAvailableHeightPercent', 'gallery', Object.freeze({
288
356
  desktop: 74,
289
357
  mobile: 68,
290
- })),
358
+ }), siteThemeLabel),
291
359
  maxAvailableWidthPercent: readResponsivePercent(rawGallery, 'maxAvailableWidthPercent', 'gallery', Object.freeze({
292
360
  desktop: 100,
293
361
  mobile: 100,
294
- })),
295
- width: readCssLength(rawGallery, 'width', 'gallery', '900px'),
362
+ }), siteThemeLabel),
363
+ width: readCssLength(rawGallery, 'width', 'gallery', '900px', siteThemeLabel),
296
364
  }),
297
365
  typography: Object.freeze({
298
- fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily),
366
+ fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily, siteThemeLabel),
299
367
  }),
300
- navigation: Object.freeze({
301
- smoothScroll: readSmoothScroll(rawNavigation),
302
- }),
303
- locale: readLocale(rawLocale),
304
- footer: Object.freeze({
368
+ navigation: Object.freeze({
369
+ smoothScroll: readSmoothScroll(rawNavigation),
370
+ }),
371
+ locale: readLocale(rawLocale),
372
+ footer: Object.freeze({
305
373
  buildInfo: readBuildInfo(rawFooter),
306
374
  copyrightMessage: readOptionalString(rawFooter, 'copyrightMessage', 'footer'),
307
375
  }),
@@ -18,41 +18,60 @@ const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
18
18
  const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
19
19
  const frontmatterDelimiterRegex = /^---\s*$/;
20
20
  const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'slug', 'navigation', 'presentation', 'frame', 'sections']);
21
- const knownThemeTopLevelFrontmatterKeys = new Set(['presentation', 'frame']);
21
+ const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'layout', 'gallery', 'typography', 'presentation', 'frame']);
22
22
  const knownNestedFrontmatterKeys = new Set([
23
23
  'align',
24
24
  'alt',
25
25
  'backgroundColor',
26
26
  'body',
27
+ 'bodyToImages',
28
+ 'brand',
27
29
  'caption',
28
30
  'carousel',
29
31
  'color',
30
32
  'colors',
31
33
  'desktop',
34
+ 'fontFamily',
32
35
  'from',
36
+ 'finalSectionBottom',
33
37
  'gallery',
38
+ 'gutter',
39
+ 'h1',
40
+ 'h2',
41
+ 'h3',
42
+ 'h4',
34
43
  'heading',
44
+ 'headings',
35
45
  'id',
36
46
  'include',
37
47
  'image',
48
+ 'imageGap',
38
49
  'inlineStyles',
39
50
  'label',
40
51
  'lineHeight',
52
+ 'maxAvailableHeightPercent',
53
+ 'maxAvailableWidthPercent',
41
54
  'mobile',
42
55
  'navigation',
43
56
  'overrides',
44
57
  'order',
45
58
  'paragraphSpacing',
59
+ 'pageWidth',
46
60
  'preset',
47
61
  'presentation',
48
62
  'sections',
63
+ 'sectionGap',
64
+ 'sectionHeadingToBody',
49
65
  'size',
50
66
  'spacing',
67
+ 'subheadingRuleTop',
68
+ 'subheadingTop',
51
69
  'textColor',
52
70
  'theme',
53
71
  'typography',
54
72
  'until',
55
73
  'visible',
74
+ 'width',
56
75
  ]);
57
76
 
58
77
  export const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
@@ -223,7 +242,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
223
242
  if (knownTopLevelFrontmatterKeys.has(key)) continue;
224
243
 
225
244
  const fix = knownNestedFrontmatterKeys.has(key)
226
- ? `Indent "${key}:" under the section or object it belongs to. For gallery rows, "gallery:" normally belongs under a "sections" item.`
245
+ ? `Indent "${key}:" under the section or object it belongs to. For image rows, "gallery:" normally belongs under a "sections" item.`
227
246
  : `Move "${key}:" under the correct parent key, or remove it if it is not part of the ${fileKind} schema.`;
228
247
 
229
248
  addIssue({
@@ -20,15 +20,19 @@ export const invocationRoot = configuredInvocationRoot
20
20
  ? path.resolve(configuredInvocationRoot)
21
21
  : process.cwd();
22
22
 
23
- const hasSiteFiles = (projectRoot, siteDirectory) => {
24
- const siteDir = path.resolve(projectRoot, siteDirectory);
25
-
23
+ const hasSiteFilesInDirectory = (siteDir) => {
26
24
  return (
27
25
  existsSync(path.join(siteDir, 'config.mjs'))
28
26
  && existsSync(path.join(siteDir, 'content.md'))
29
27
  );
30
28
  };
31
29
 
30
+ const hasSiteFiles = (projectRoot, siteDirectory) => {
31
+ const siteDir = path.resolve(projectRoot, siteDirectory);
32
+
33
+ return hasSiteFilesInDirectory(siteDir);
34
+ };
35
+
32
36
  const findSiteProjectRoot = (startDirectory, siteDirectory) => {
33
37
  let current = path.resolve(startDirectory);
34
38
 
@@ -67,6 +71,17 @@ const resolveSitePaths = () => {
67
71
  };
68
72
  }
69
73
 
74
+ if (!hasConfiguredSiteDirectory && hasSiteFilesInDirectory(invocationRoot)) {
75
+ const siteDir = invocationRoot;
76
+ const siteProjectRoot = path.dirname(siteDir);
77
+
78
+ return {
79
+ siteDirectory: path.relative(siteProjectRoot, siteDir) || path.basename(siteDir),
80
+ siteDir,
81
+ siteProjectRoot,
82
+ };
83
+ }
84
+
70
85
  const discoveredRoot = findSiteProjectRoot(invocationRoot, fallbackSiteDirectory);
71
86
  const siteProjectRoot = discoveredRoot ?? invocationRoot;
72
87
 
@@ -0,0 +1,34 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseYamlMapping } from './frontmatter-yaml.mjs';
3
+ import {
4
+ splitSiteFile,
5
+ validateFrontmatterIndentation,
6
+ validateThemeFrontmatterStructure,
7
+ } from './site-content.mjs';
8
+ import { siteThemeLabel, siteThemePath } from './site-paths.mjs';
9
+
10
+ const emptyThemeFile = '---\n---\n';
11
+
12
+ export const readThemeConfig = async () => {
13
+ const themeFile = await readFile(siteThemePath, 'utf8').catch((error) => {
14
+ if (error?.code === 'ENOENT') {
15
+ return emptyThemeFile;
16
+ }
17
+
18
+ throw error;
19
+ });
20
+ const { frontmatter, frontmatterBody } = splitSiteFile(themeFile, siteThemeLabel);
21
+ const issues = [];
22
+
23
+ validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
24
+ validateThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
25
+
26
+ if (issues.length > 0) {
27
+ throw new Error([
28
+ `${siteThemeLabel} has invalid frontmatter.`,
29
+ ...issues.map((issue) => `- ${issue.message}`),
30
+ ].join('\n'));
31
+ }
32
+
33
+ return parseYamlMapping(frontmatterBody);
34
+ };