@janga/norna 0.7.2 → 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 (145) hide show
  1. package/README.md +16 -16
  2. package/bin/norna-cli.mjs +12 -7
  3. package/docs/README.md +10 -11
  4. package/docs/commands.md +8 -8
  5. package/docs/configuration.md +13 -145
  6. package/docs/content.md +17 -15
  7. package/docs/design/command-organization.md +55 -55
  8. package/docs/design/site-examples-structure.md +2 -2
  9. package/docs/engine-development.md +5 -5
  10. package/docs/getting-started.md +19 -26
  11. package/docs/images-and-metadata.md +1 -1
  12. package/docs/local-development.md +2 -2
  13. package/docs/publishing.md +1 -1
  14. package/docs/routes.md +2 -2
  15. package/docs/site-structure.md +8 -7
  16. package/docs/theme.md +149 -7
  17. package/docs/typography.md +47 -28
  18. package/examples/dog-gallery/.astro/collections/site.schema.json +802 -0
  19. package/examples/dog-gallery/.astro/collections/theme.schema.json +714 -0
  20. package/examples/dog-gallery/.astro/content-assets.mjs +4 -0
  21. package/examples/dog-gallery/.astro/content-modules.mjs +1 -0
  22. package/examples/dog-gallery/.astro/content.d.ts +175 -0
  23. package/examples/dog-gallery/.astro/data-store.json +1 -0
  24. package/examples/dog-gallery/.astro/dev-local.json +8 -0
  25. package/examples/dog-gallery/.astro/dev.json +13 -0
  26. package/examples/dog-gallery/.astro/dev.log +49 -0
  27. package/examples/dog-gallery/.astro/settings.json +5 -0
  28. package/examples/dog-gallery/.astro/types.d.ts +2 -0
  29. package/examples/dog-gallery/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  30. package/examples/dog-gallery/dist/_astro/SitePage.BFHZm5UG.css +1 -0
  31. package/examples/dog-gallery/dist/dog-care/index.html +15 -0
  32. package/examples/dog-gallery/dist/favicon.svg +7 -0
  33. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1080.webp +0 -0
  34. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1440.webp +0 -0
  35. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1536.webp +0 -0
  36. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-480.webp +0 -0
  37. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-768.webp +0 -0
  38. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-480.webp +0 -0
  39. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-768.webp +0 -0
  40. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-960.webp +0 -0
  41. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1080.webp +0 -0
  42. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1440.webp +0 -0
  43. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1920.webp +0 -0
  44. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-3939.webp +0 -0
  45. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-480.webp +0 -0
  46. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-768.webp +0 -0
  47. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1080.webp +0 -0
  48. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1440.webp +0 -0
  49. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1920.webp +0 -0
  50. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-2400.webp +0 -0
  51. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-480.webp +0 -0
  52. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-768.webp +0 -0
  53. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1080.webp +0 -0
  54. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1440.webp +0 -0
  55. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1600.webp +0 -0
  56. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-480.webp +0 -0
  57. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-768.webp +0 -0
  58. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1080.webp +0 -0
  59. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1440.webp +0 -0
  60. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1920.webp +0 -0
  61. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-2576.webp +0 -0
  62. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-480.webp +0 -0
  63. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-768.webp +0 -0
  64. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1080.webp +0 -0
  65. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1277.webp +0 -0
  66. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-480.webp +0 -0
  67. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-768.webp +0 -0
  68. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1080.webp +0 -0
  69. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1440.webp +0 -0
  70. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1536.webp +0 -0
  71. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-480.webp +0 -0
  72. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-768.webp +0 -0
  73. package/examples/dog-gallery/dist/index.html +43 -0
  74. package/examples/dog-gallery/dist/robots.txt +2 -0
  75. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +74 -0
  76. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  77. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  78. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  79. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  80. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  81. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  82. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  83. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  84. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  85. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  86. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  87. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  88. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  89. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  90. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  91. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  92. package/examples/dog-gallery/node_modules/.vite/deps/package.json +3 -0
  93. package/examples/dog-gallery/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  94. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  95. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  96. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  97. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  98. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  99. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  100. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  101. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  102. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  103. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  104. package/examples/dog-gallery/site/config.mjs +12 -42
  105. package/examples/dog-gallery/site/content.md +27 -5
  106. package/examples/dog-gallery/site/routes/dog-care/route-content.md +17 -3
  107. package/examples/dog-gallery/site/theme.md +85 -26
  108. package/fixtures/basic/site/config.mjs +2 -22
  109. package/fixtures/basic/site/content.md +1 -1
  110. package/fixtures/basic/site/theme.md +7 -3
  111. package/package.json +3 -3
  112. package/scripts/check-config.mjs +3 -3
  113. package/scripts/deploy-site.mjs +3 -3
  114. package/scripts/init-site.mjs +10 -8
  115. package/scripts/lib/frontmatter-yaml.mjs +85 -0
  116. package/scripts/lib/presentation.mjs +10 -11
  117. package/scripts/lib/project-config.mjs +91 -45
  118. package/scripts/lib/site-content.mjs +20 -2
  119. package/scripts/lib/theme-config.mjs +34 -0
  120. package/scripts/lib/typography.mjs +100 -20
  121. package/scripts/show-typography.mjs +58 -154
  122. package/scripts/sync-content-sections.mjs +6 -6
  123. package/scripts/test-content-check.mjs +3 -4
  124. package/scripts/test-engine-commands.mjs +2 -2
  125. package/scripts/test-package-check.mjs +17 -16
  126. package/scripts/test-temporary-visibility.mjs +2 -3
  127. package/src/components/SitePage.astro +2 -3
  128. package/src/components/SiteSection.astro +39 -5
  129. package/src/content.config.ts +60 -2
  130. package/src/layouts/BaseLayout.astro +17 -0
  131. package/src/styles/global.css +54 -50
  132. package/starters/basic/README.md +10 -15
  133. package/starters/basic/package.json +2 -2
  134. package/starters/basic/site/config.mjs +3 -24
  135. package/starters/basic/site/content.md +40 -7
  136. package/starters/basic/site/theme.md +110 -17
  137. package/starters/project/.github/workflows/deploy.yml +65 -0
  138. package/starters/project/README.md +72 -0
  139. package/starters/project/package-lock.json +4296 -0
  140. package/starters/project/package.json +35 -0
  141. package/starters/project/site/config.mjs +43 -0
  142. package/starters/project/site/content.md +88 -0
  143. package/starters/project/site/public/robots.txt +2 -0
  144. package/starters/project/site/routes/guide/route-content.md +58 -0
  145. package/starters/project/site/theme.md +15 -0
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: Fixture Gallery
2
+ title: Fixture Site
3
3
  description: Minimal cli-gallery fixture site.
4
4
  sections:
5
5
  - id: intro
@@ -1,7 +1,11 @@
1
1
  ---
2
- presentation:
3
- typography:
4
- preset: quiet-gallery
2
+ layout:
3
+ pageWidth: 1180px
4
+ gallery:
5
+ width: 900px
6
+ typography:
7
+ fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif"
8
+ preset: quiet-gallery
5
9
  frame:
6
10
  colors: presentation
7
11
  ---
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@janga/norna",
3
3
  "description": "A command-line publishing workflow for simple static sites.",
4
4
  "type": "module",
5
- "version": "0.7.2",
5
+ "version": "0.7.3",
6
6
  "license": "GPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -51,8 +51,8 @@
51
51
  "config:check": "node bin/norna.mjs config:check",
52
52
  "content:check": "node bin/norna.mjs content:check",
53
53
  "content:sync": "node bin/norna.mjs content:sync",
54
- "typography:presets": "node bin/norna.mjs typography:presets",
55
- "typography:show": "node bin/norna.mjs typography:show",
54
+ "typography:presets": "node bin/norna.mjs typography presets",
55
+ "typography:show": "node bin/norna.mjs typography show",
56
56
  "site:public": "node bin/norna.mjs site:public",
57
57
  "images": "node bin/norna.mjs images",
58
58
  "build": "node bin/norna.mjs build",
@@ -23,9 +23,9 @@ try {
23
23
  console.log(`Base path: ${projectConfig.site.basePath}`);
24
24
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
25
25
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
26
- console.log(`Gallery width: ${projectConfig.gallery.width}`);
27
- console.log(`Gallery max available width: desktop ${projectConfig.gallery.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableWidthPercent.mobile}%`);
28
- console.log(`Gallery max available height: desktop ${projectConfig.gallery.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableHeightPercent.mobile}%`);
26
+ console.log(`Image area width: ${projectConfig.gallery.width}`);
27
+ console.log(`Image max available width: desktop ${projectConfig.gallery.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableWidthPercent.mobile}%`);
28
+ console.log(`Image max available height: desktop ${projectConfig.gallery.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableHeightPercent.mobile}%`);
29
29
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
30
30
  console.log(`Language: ${projectConfig.locale.lang}`);
31
31
  console.log(`GitHub repo: ${projectConfig.github.repo}`);
@@ -196,7 +196,7 @@ const assertMainBranch = async () => {
196
196
 
197
197
  const assertDeployableStatus = async (entries, expectedImagePaths) => {
198
198
  if (entries.length === 0) {
199
- fail('Refusing to deploy: no changes to commit after the gallery build.');
199
+ fail('Refusing to deploy: no changes to commit after the Norna build.');
200
200
  }
201
201
 
202
202
  const unexpectedUntracked = entries.filter((entry) => (
@@ -206,7 +206,7 @@ const assertDeployableStatus = async (entries, expectedImagePaths) => {
206
206
  if (unexpectedUntracked.length > 0) {
207
207
  fail([
208
208
  'Refusing to deploy: unexpected untracked files are present.',
209
- `Only new referenced gallery images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<route-folder>/images/<section-id>/ are staged automatically.`,
209
+ `Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<route-folder>/images/<section-id>/ are staged automatically.`,
210
210
  ...unexpectedUntracked.map((entry) => `- ${formatEntry(entry)}`),
211
211
  ].join('\n'));
212
212
  }
@@ -331,7 +331,7 @@ const deployCommittedMain = async () => {
331
331
  await assertPushableBranch();
332
332
  await runBuild();
333
333
  await printStatusShort();
334
- await assertCleanWorktree('Refusing to deploy: the gallery build produced uncommitted changes. Commit them before deploying.');
334
+ await assertCleanWorktree('Refusing to deploy: the Norna build produced uncommitted changes. Commit them before deploying.');
335
335
  await fetchRemoteMain();
336
336
 
337
337
  const relation = await assertPushableBranch();
@@ -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'),
@@ -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
 
@@ -229,7 +234,7 @@ const readLocale = (rawLocale) => {
229
234
  closeMenu: readString({ closeMenu: labels.closeMenu ?? 'Close menu' }, 'closeMenu', 'locale.labels'),
230
235
  skipToContent: readString({ skipToContent: labels.skipToContent ?? 'Skip to content' }, 'skipToContent', 'locale.labels'),
231
236
  sectionNavigation: readString({ sectionNavigation: labels.sectionNavigation ?? 'Sections' }, 'sectionNavigation', 'locale.labels'),
232
- gallery: readString({ gallery: labels.gallery ?? 'Gallery' }, 'gallery', 'locale.labels'),
237
+ gallery: readString({ gallery: labels.gallery ?? 'Images' }, 'gallery', 'locale.labels'),
233
238
  menu: readString({ menu: labels.menu ?? 'Menu' }, 'menu', 'locale.labels'),
234
239
  pageNavigation: readString({ pageNavigation: labels.pageNavigation ?? 'On this page' }, 'pageNavigation', 'locale.labels'),
235
240
  siteNavigation: readString({ siteNavigation: labels.siteNavigation ?? 'Pages' }, 'siteNavigation', 'locale.labels'),
@@ -280,10 +285,17 @@ const readBuildInfo = (footer) => {
280
285
  };
281
286
 
282
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);
283
294
  const rawSite = assertObject(rawConfig.site, 'site');
284
- const rawLayout = assertObject(rawConfig.layout ?? {}, 'layout');
285
- const rawGallery = assertObject(rawConfig.gallery ?? {}, 'gallery');
286
- 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);
287
299
  const rawNavigation = assertObject(rawConfig.navigation ?? {}, 'navigation');
288
300
  const rawLocale = rawConfig.locale ?? {};
289
301
  const rawFooter = assertObject(rawConfig.footer ?? {}, 'footer');
@@ -302,28 +314,62 @@ export const projectConfig = Object.freeze({
302
314
  gutter: readResponsiveCssLength(rawLayout, 'gutter', 'layout', Object.freeze({
303
315
  desktop: 'clamp(1.25rem, 4vw, 3rem)',
304
316
  mobile: '1rem',
305
- })),
306
- 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
+ }),
307
353
  }),
308
354
  gallery: Object.freeze({
309
355
  maxAvailableHeightPercent: readResponsivePercent(rawGallery, 'maxAvailableHeightPercent', 'gallery', Object.freeze({
310
356
  desktop: 74,
311
357
  mobile: 68,
312
- })),
358
+ }), siteThemeLabel),
313
359
  maxAvailableWidthPercent: readResponsivePercent(rawGallery, 'maxAvailableWidthPercent', 'gallery', Object.freeze({
314
360
  desktop: 100,
315
361
  mobile: 100,
316
- })),
317
- width: readCssLength(rawGallery, 'width', 'gallery', '900px'),
362
+ }), siteThemeLabel),
363
+ width: readCssLength(rawGallery, 'width', 'gallery', '900px', siteThemeLabel),
318
364
  }),
319
365
  typography: Object.freeze({
320
- fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily),
366
+ fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily, siteThemeLabel),
321
367
  }),
322
- navigation: Object.freeze({
323
- smoothScroll: readSmoothScroll(rawNavigation),
324
- }),
325
- locale: readLocale(rawLocale),
326
- footer: Object.freeze({
368
+ navigation: Object.freeze({
369
+ smoothScroll: readSmoothScroll(rawNavigation),
370
+ }),
371
+ locale: readLocale(rawLocale),
372
+ footer: Object.freeze({
327
373
  buildInfo: readBuildInfo(rawFooter),
328
374
  copyrightMessage: readOptionalString(rawFooter, 'copyrightMessage', 'footer'),
329
375
  }),
@@ -18,42 +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(['navigation', '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',
27
28
  'brand',
28
29
  'caption',
29
30
  'carousel',
30
31
  'color',
31
32
  'colors',
32
33
  'desktop',
34
+ 'fontFamily',
33
35
  'from',
36
+ 'finalSectionBottom',
34
37
  'gallery',
38
+ 'gutter',
39
+ 'h1',
40
+ 'h2',
41
+ 'h3',
42
+ 'h4',
35
43
  'heading',
44
+ 'headings',
36
45
  'id',
37
46
  'include',
38
47
  'image',
48
+ 'imageGap',
39
49
  'inlineStyles',
40
50
  'label',
41
51
  'lineHeight',
52
+ 'maxAvailableHeightPercent',
53
+ 'maxAvailableWidthPercent',
42
54
  'mobile',
43
55
  'navigation',
44
56
  'overrides',
45
57
  'order',
46
58
  'paragraphSpacing',
59
+ 'pageWidth',
47
60
  'preset',
48
61
  'presentation',
49
62
  'sections',
63
+ 'sectionGap',
64
+ 'sectionHeadingToBody',
50
65
  'size',
51
66
  'spacing',
67
+ 'subheadingRuleTop',
68
+ 'subheadingTop',
52
69
  'textColor',
53
70
  'theme',
54
71
  'typography',
55
72
  'until',
56
73
  'visible',
74
+ 'width',
57
75
  ]);
58
76
 
59
77
  export const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
@@ -224,7 +242,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
224
242
  if (knownTopLevelFrontmatterKeys.has(key)) continue;
225
243
 
226
244
  const fix = knownNestedFrontmatterKeys.has(key)
227
- ? `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.`
228
246
  : `Move "${key}:" under the correct parent key, or remove it if it is not part of the ${fileKind} schema.`;
229
247
 
230
248
  addIssue({
@@ -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
+ };