@janga/norna 0.7.2 → 0.7.4

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 (88) hide show
  1. package/README.md +16 -16
  2. package/bin/norna-cli.mjs +12 -7
  3. package/docs/README.md +11 -12
  4. package/docs/commands.md +9 -9
  5. package/docs/configuration.md +14 -145
  6. package/docs/content.md +17 -15
  7. package/docs/design/command-organization.md +58 -57
  8. package/docs/design/site-examples-structure.md +6 -5
  9. package/docs/engine-development.md +5 -5
  10. package/docs/getting-started.md +20 -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 +133 -7
  17. package/docs/typography.md +78 -43
  18. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +74 -0
  19. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  20. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  21. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  22. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  23. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  24. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  25. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  26. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  27. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  28. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  29. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  30. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  31. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  32. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  33. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  34. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  35. package/examples/dog-gallery/node_modules/.vite/deps/package.json +3 -0
  36. package/examples/dog-gallery/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  37. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  38. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  39. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  40. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  41. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  42. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  43. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  44. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  45. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  46. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  47. package/examples/dog-gallery/site/config.mjs +12 -42
  48. package/examples/dog-gallery/site/content.md +28 -6
  49. package/examples/dog-gallery/site/routes/dog-care/route-content.md +17 -3
  50. package/examples/dog-gallery/site/theme.md +83 -26
  51. package/fixtures/basic/site/config.mjs +2 -22
  52. package/fixtures/basic/site/content.md +1 -1
  53. package/fixtures/basic/site/theme.md +7 -3
  54. package/package.json +3 -3
  55. package/scripts/check-config.mjs +4 -3
  56. package/scripts/deploy-site.mjs +3 -3
  57. package/scripts/init-site.mjs +10 -8
  58. package/scripts/lib/frontmatter-yaml.mjs +85 -0
  59. package/scripts/lib/presentation.mjs +10 -11
  60. package/scripts/lib/project-config.mjs +146 -45
  61. package/scripts/lib/site-content.mjs +21 -3
  62. package/scripts/lib/theme-config.mjs +34 -0
  63. package/scripts/lib/typography.mjs +155 -35
  64. package/scripts/show-typography.mjs +127 -159
  65. package/scripts/sync-content-sections.mjs +6 -6
  66. package/scripts/test-content-check.mjs +3 -4
  67. package/scripts/test-engine-commands.mjs +21 -3
  68. package/scripts/test-package-check.mjs +34 -17
  69. package/scripts/test-temporary-visibility.mjs +2 -3
  70. package/src/components/SitePage.astro +2 -3
  71. package/src/components/SiteSection.astro +115 -19
  72. package/src/content.config.ts +66 -6
  73. package/src/layouts/BaseLayout.astro +11 -0
  74. package/src/styles/global.css +83 -52
  75. package/starters/basic/README.md +11 -15
  76. package/starters/basic/package.json +2 -2
  77. package/starters/basic/site/config.mjs +3 -24
  78. package/starters/basic/site/content.md +40 -7
  79. package/starters/basic/site/theme.md +84 -18
  80. package/starters/project/.github/workflows/deploy.yml +65 -0
  81. package/starters/project/README.md +72 -0
  82. package/starters/project/package-lock.json +4296 -0
  83. package/starters/project/package.json +35 -0
  84. package/starters/project/site/config.mjs +43 -0
  85. package/starters/project/site/content.md +128 -0
  86. package/starters/project/site/public/robots.txt +2 -0
  87. package/starters/project/site/routes/guide/route-content.md +85 -0
  88. package/starters/project/site/theme.md +43 -0
@@ -3,26 +3,6 @@ export default {
3
3
  url: 'https://example.com/',
4
4
  basePath: '/',
5
5
  },
6
- layout: {
7
- pageWidth: '1180px',
8
- gutter: {
9
- desktop: 'clamp(1.25rem, 4vw, 3rem)',
10
- mobile: '1rem',
11
- },
12
- },
13
- gallery: {
14
- maxAvailableWidthPercent: {
15
- desktop: 100,
16
- mobile: 100,
17
- },
18
- maxAvailableHeightPercent: {
19
- desktop: 74,
20
- mobile: 68,
21
- },
22
- },
23
- typography: {
24
- fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
25
- },
26
6
  navigation: {
27
7
  smoothScroll: {
28
8
  enabled: true,
@@ -36,7 +16,7 @@ export default {
36
16
  labels: {
37
17
  skipToContent: 'Skip to content',
38
18
  sectionNavigation: 'Sections',
39
- gallery: 'Gallery',
19
+ gallery: 'Images',
40
20
  },
41
21
  },
42
22
  footer: {
@@ -53,7 +33,7 @@ export default {
53
33
  },
54
34
  },
55
35
  github: {
56
- repo: 'owner/fixture-gallery',
36
+ repo: 'owner/fixture-site',
57
37
  branch: 'main',
58
38
  pagesWorkflow: 'Deploy to GitHub Pages',
59
39
  },
@@ -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.4",
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,10 @@ 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(`Layout density: ${projectConfig.layout.density}`);
27
+ console.log(`Image area width: ${projectConfig.gallery.width}`);
28
+ console.log(`Image max available width: desktop ${projectConfig.gallery.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableWidthPercent.mobile}%`);
29
+ console.log(`Image max available height: desktop ${projectConfig.gallery.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableHeightPercent.mobile}%`);
29
30
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
30
31
  console.log(`Language: ${projectConfig.locale.lang}`);
31
32
  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,43 @@
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 readEnum = (object, key, path, allowedValues, fallback, sourceLabel = siteConfigLabel) => {
21
+ const value = object[key] ?? fallback;
22
+
23
+ if (!allowedValues.includes(value)) {
24
+ throw new Error(`${path}.${key} must be one of ${allowedValues.join(', ')} in ${sourceLabel}.`);
25
+ }
26
+
27
+ return value;
28
+ };
29
+
30
+ const readString = (object, key, path, sourceLabel = siteConfigLabel) => {
19
31
  const value = object[key];
20
32
 
21
33
  if (typeof value !== 'string' || value.trim() === '') {
22
- throw new Error(`${path}.${key} must be a non-empty string in ${siteConfigLabel}.`);
34
+ throw new Error(`${path}.${key} must be a non-empty string in ${sourceLabel}.`);
23
35
  }
24
36
 
25
37
  return value.trim();
26
38
  };
27
39
 
28
- const readOptionalString = (object, key, path) => {
40
+ const readOptionalString = (object, key, path, sourceLabel = siteConfigLabel) => {
29
41
  const value = object[key];
30
42
 
31
43
  if (value === undefined || value === null || value === '') {
@@ -33,66 +45,69 @@ const readOptionalString = (object, key, path) => {
33
45
  }
34
46
 
35
47
  if (typeof value !== 'string' || value.trim() === '') {
36
- throw new Error(`${path}.${key} must be a non-empty string when set in ${siteConfigLabel}.`);
48
+ throw new Error(`${path}.${key} must be a non-empty string when set in ${sourceLabel}.`);
37
49
  }
38
50
 
39
51
  return value.trim();
40
52
  };
41
53
 
42
- const readFontFamily = (object, key, path, fallback) => {
54
+ const readFontFamily = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
43
55
  const value = object[key] ?? fallback;
44
56
 
45
57
  if (typeof value !== 'string' || value.trim() === '') {
46
- throw new Error(`${path}.${key} must be a non-empty CSS font-family value in ${siteConfigLabel}.`);
58
+ throw new Error(`${path}.${key} must be a non-empty CSS font-family value in ${sourceLabel}.`);
47
59
  }
48
60
 
49
61
  const normalizedValue = value.trim();
50
62
 
51
63
  if (/[\n\r;{}]/.test(normalizedValue)) {
52
- throw new Error(`${path}.${key} must not contain semicolons, braces, or line breaks in ${siteConfigLabel}.`);
64
+ throw new Error(`${path}.${key} must not contain semicolons, braces, or line breaks in ${sourceLabel}.`);
53
65
  }
54
66
 
55
67
  return normalizedValue;
56
68
  };
57
69
 
58
- const readCssLength = (object, key, path, fallback) => {
70
+ const readCssLength = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
59
71
  const value = object[key] ?? fallback;
60
72
 
61
73
  if (typeof value !== 'string' || value.trim() === '') {
62
- throw new Error(`${path}.${key} must be a non-empty CSS length in ${siteConfigLabel}.`);
74
+ throw new Error(`${path}.${key} must be a non-empty CSS length in ${sourceLabel}.`);
63
75
  }
64
76
 
65
77
  const normalizedValue = value.trim();
66
78
 
67
79
  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}.`);
80
+ throw new Error(`${path}.${key} must be a CSS length such as "900px", "56rem", or "90%" in ${sourceLabel}.`);
69
81
  }
70
82
 
71
83
  return normalizedValue;
72
84
  };
73
85
 
74
- const readCssLengthValue = (value, path) => {
86
+ const readCssLengthValue = (value, path, sourceLabel = siteConfigLabel) => {
75
87
  if (typeof value !== 'string' || value.trim() === '') {
76
- throw new Error(`${path} must be a non-empty CSS length in ${siteConfigLabel}.`);
88
+ throw new Error(`${path} must be a non-empty CSS length in ${sourceLabel}.`);
77
89
  }
78
90
 
79
91
  const normalizedValue = value.trim();
80
92
 
81
93
  if (
82
- (!simpleCssLengthPattern.test(normalizedValue) && !clampCssLengthPattern.test(normalizedValue))
83
- || parseFloat(normalizedValue) <= 0
94
+ normalizedValue !== '0'
95
+ && (
96
+ (!simpleCssLengthPattern.test(normalizedValue) && !clampCssLengthPattern.test(normalizedValue))
97
+ || parseFloat(normalizedValue) < 0
98
+ )
84
99
  ) {
85
- throw new Error(`${path} must be a CSS length such as "48px", "3rem", "4vw", or a clamp() of those lengths in ${siteConfigLabel}.`);
100
+ throw new Error(`${path} must be a CSS length such as "0", "48px", "3rem", "4vw", or a clamp() of those lengths in ${sourceLabel}.`);
86
101
  }
87
102
 
88
103
  return normalizedValue;
89
104
  };
90
105
 
91
- const readResponsiveCssLength = (object, key, path, fallback) => {
106
+ const readResponsiveCssLength = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
92
107
  const value = object[key] ?? fallback;
93
108
 
94
109
  if (typeof value === 'string') {
95
- const length = readCssLengthValue(value, `${path}.${key}`);
110
+ const length = readCssLengthValue(value, `${path}.${key}`, sourceLabel);
96
111
 
97
112
  return Object.freeze({
98
113
  desktop: length,
@@ -100,27 +115,27 @@ const readResponsiveCssLength = (object, key, path, fallback) => {
100
115
  });
101
116
  }
102
117
 
103
- const responsiveValue = assertObject(value, `${path}.${key}`);
118
+ const responsiveValue = assertObject(value, `${path}.${key}`, sourceLabel);
104
119
 
105
120
  return Object.freeze({
106
- desktop: readCssLengthValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`),
107
- mobile: readCssLengthValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`),
121
+ desktop: readCssLengthValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`, sourceLabel),
122
+ mobile: readCssLengthValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`, sourceLabel),
108
123
  });
109
124
  };
110
125
 
111
- const readPercentValue = (value, path) => {
126
+ const readPercentValue = (value, path, sourceLabel = siteConfigLabel) => {
112
127
  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}.`);
128
+ throw new Error(`${path} must be a number greater than 0 and less than or equal to 100 in ${sourceLabel}.`);
114
129
  }
115
130
 
116
131
  return value;
117
132
  };
118
133
 
119
- const readResponsivePercent = (object, key, path, fallback) => {
134
+ const readResponsivePercent = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
120
135
  const value = object[key] ?? fallback;
121
136
 
122
137
  if (typeof value === 'number') {
123
- const percent = readPercentValue(value, `${path}.${key}`);
138
+ const percent = readPercentValue(value, `${path}.${key}`, sourceLabel);
124
139
 
125
140
  return Object.freeze({
126
141
  desktop: percent,
@@ -128,11 +143,11 @@ const readResponsivePercent = (object, key, path, fallback) => {
128
143
  });
129
144
  }
130
145
 
131
- const responsiveValue = assertObject(value, `${path}.${key}`);
146
+ const responsiveValue = assertObject(value, `${path}.${key}`, sourceLabel);
132
147
 
133
148
  return Object.freeze({
134
- desktop: readPercentValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`),
135
- mobile: readPercentValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`),
149
+ desktop: readPercentValue(responsiveValue.desktop ?? fallback.desktop, `${path}.${key}.desktop`, sourceLabel),
150
+ mobile: readPercentValue(responsiveValue.mobile ?? fallback.mobile, `${path}.${key}.mobile`, sourceLabel),
136
151
  });
137
152
  };
138
153
 
@@ -229,7 +244,7 @@ const readLocale = (rawLocale) => {
229
244
  closeMenu: readString({ closeMenu: labels.closeMenu ?? 'Close menu' }, 'closeMenu', 'locale.labels'),
230
245
  skipToContent: readString({ skipToContent: labels.skipToContent ?? 'Skip to content' }, 'skipToContent', 'locale.labels'),
231
246
  sectionNavigation: readString({ sectionNavigation: labels.sectionNavigation ?? 'Sections' }, 'sectionNavigation', 'locale.labels'),
232
- gallery: readString({ gallery: labels.gallery ?? 'Gallery' }, 'gallery', 'locale.labels'),
247
+ gallery: readString({ gallery: labels.gallery ?? 'Images' }, 'gallery', 'locale.labels'),
233
248
  menu: readString({ menu: labels.menu ?? 'Menu' }, 'menu', 'locale.labels'),
234
249
  pageNavigation: readString({ pageNavigation: labels.pageNavigation ?? 'On this page' }, 'pageNavigation', 'locale.labels'),
235
250
  siteNavigation: readString({ siteNavigation: labels.siteNavigation ?? 'Pages' }, 'siteNavigation', 'locale.labels'),
@@ -280,10 +295,17 @@ const readBuildInfo = (footer) => {
280
295
  };
281
296
 
282
297
  const rawConfig = assertObject(siteConfig, 'default export');
298
+ const misplacedThemeKeys = ['layout', 'gallery', 'typography'].filter((key) => key in rawConfig);
299
+ if (misplacedThemeKeys.length > 0) {
300
+ throw new Error(`${misplacedThemeKeys.join(', ')} belong in ${siteThemeLabel}, not ${siteConfigLabel}.`);
301
+ }
302
+
303
+ const rawTheme = assertObject(themeConfig, 'theme frontmatter', siteThemeLabel);
283
304
  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');
305
+ const rawLayout = assertObject(rawTheme.layout ?? {}, 'layout', siteThemeLabel);
306
+ const rawLayoutSpacing = assertObject(rawLayout.spacing ?? {}, 'layout.spacing', siteThemeLabel);
307
+ const rawGallery = assertObject(rawTheme.gallery ?? {}, 'gallery', siteThemeLabel);
308
+ const rawTypography = assertObject(rawTheme.typography ?? {}, 'typography', siteThemeLabel);
287
309
  const rawNavigation = assertObject(rawConfig.navigation ?? {}, 'navigation');
288
310
  const rawLocale = rawConfig.locale ?? {};
289
311
  const rawFooter = assertObject(rawConfig.footer ?? {}, 'footer');
@@ -292,6 +314,77 @@ const rawDeploy = assertObject(rawConfig.deploy ?? {}, 'deploy');
292
314
  const rawDeployWatch = assertObject(rawDeploy.watch ?? {}, 'deploy.watch');
293
315
 
294
316
  const defaultFontFamily = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
317
+ const layoutDensityNames = ['compact', 'normal', 'airy'];
318
+ const layoutDensityProfiles = Object.freeze({
319
+ compact: Object.freeze({
320
+ bodyToImages: Object.freeze({
321
+ desktop: '1rem',
322
+ mobile: '0.9rem',
323
+ }),
324
+ finalSectionBottom: Object.freeze({
325
+ desktop: 'clamp(1.2rem, 2.4vw, 2.25rem)',
326
+ mobile: '1.25rem',
327
+ }),
328
+ firstSectionTop: Object.freeze({
329
+ desktop: 'clamp(1rem, 2vw, 1.75rem)',
330
+ mobile: '1rem',
331
+ }),
332
+ imageGap: Object.freeze({
333
+ desktop: 'clamp(1rem, 2vw, 1.5rem)',
334
+ mobile: '1.25rem',
335
+ }),
336
+ sectionGap: Object.freeze({
337
+ desktop: 'clamp(1.2rem, 2.4vw, 2.25rem)',
338
+ mobile: '1.25rem',
339
+ }),
340
+ }),
341
+ normal: Object.freeze({
342
+ bodyToImages: Object.freeze({
343
+ desktop: '1.25rem',
344
+ mobile: '1rem',
345
+ }),
346
+ finalSectionBottom: Object.freeze({
347
+ desktop: 'clamp(1.4rem, 3vw, 2.75rem)',
348
+ mobile: '1.5rem',
349
+ }),
350
+ firstSectionTop: Object.freeze({
351
+ desktop: 'clamp(1.25rem, 3vw, 2.5rem)',
352
+ mobile: '1.25rem',
353
+ }),
354
+ imageGap: Object.freeze({
355
+ desktop: 'clamp(1.25rem, 2.8vw, 2rem)',
356
+ mobile: '1.5rem',
357
+ }),
358
+ sectionGap: Object.freeze({
359
+ desktop: 'clamp(1.4rem, 3vw, 2.75rem)',
360
+ mobile: '1.5rem',
361
+ }),
362
+ }),
363
+ airy: Object.freeze({
364
+ bodyToImages: Object.freeze({
365
+ desktop: '1.5rem',
366
+ mobile: '1.25rem',
367
+ }),
368
+ finalSectionBottom: Object.freeze({
369
+ desktop: 'clamp(2.25rem, 5vw, 4.5rem)',
370
+ mobile: '2rem',
371
+ }),
372
+ firstSectionTop: Object.freeze({
373
+ desktop: 'clamp(2rem, 5vw, 4rem)',
374
+ mobile: '1.5rem',
375
+ }),
376
+ imageGap: Object.freeze({
377
+ desktop: 'clamp(1.5rem, 3.5vw, 2.75rem)',
378
+ mobile: '2rem',
379
+ }),
380
+ sectionGap: Object.freeze({
381
+ desktop: 'clamp(2.25rem, 5vw, 4.5rem)',
382
+ mobile: '2rem',
383
+ }),
384
+ }),
385
+ });
386
+ const layoutDensity = readEnum(rawLayout, 'density', 'layout', layoutDensityNames, 'normal', siteThemeLabel);
387
+ const layoutSpacingDefaults = layoutDensityProfiles[layoutDensity];
295
388
 
296
389
  export const projectConfig = Object.freeze({
297
390
  site: Object.freeze({
@@ -299,31 +392,39 @@ export const projectConfig = Object.freeze({
299
392
  url: readUrl(rawSite, 'url', 'site'),
300
393
  }),
301
394
  layout: Object.freeze({
395
+ density: layoutDensity,
302
396
  gutter: readResponsiveCssLength(rawLayout, 'gutter', 'layout', Object.freeze({
303
397
  desktop: 'clamp(1.25rem, 4vw, 3rem)',
304
398
  mobile: '1rem',
305
- })),
306
- pageWidth: readCssLength(rawLayout, 'pageWidth', 'layout', '1180px'),
399
+ }), siteThemeLabel),
400
+ pageWidth: readCssLength(rawLayout, 'pageWidth', 'layout', '1180px', siteThemeLabel),
401
+ spacing: Object.freeze({
402
+ bodyToImages: readResponsiveCssLength(rawLayoutSpacing, 'bodyToImages', 'layout.spacing', layoutSpacingDefaults.bodyToImages, siteThemeLabel),
403
+ finalSectionBottom: readResponsiveCssLength(rawLayoutSpacing, 'finalSectionBottom', 'layout.spacing', layoutSpacingDefaults.finalSectionBottom, siteThemeLabel),
404
+ firstSectionTop: readResponsiveCssLength(rawLayoutSpacing, 'firstSectionTop', 'layout.spacing', layoutSpacingDefaults.firstSectionTop, siteThemeLabel),
405
+ imageGap: readResponsiveCssLength(rawLayoutSpacing, 'imageGap', 'layout.spacing', layoutSpacingDefaults.imageGap, siteThemeLabel),
406
+ sectionGap: readResponsiveCssLength(rawLayoutSpacing, 'sectionGap', 'layout.spacing', layoutSpacingDefaults.sectionGap, siteThemeLabel),
407
+ }),
307
408
  }),
308
409
  gallery: Object.freeze({
309
410
  maxAvailableHeightPercent: readResponsivePercent(rawGallery, 'maxAvailableHeightPercent', 'gallery', Object.freeze({
310
411
  desktop: 74,
311
412
  mobile: 68,
312
- })),
413
+ }), siteThemeLabel),
313
414
  maxAvailableWidthPercent: readResponsivePercent(rawGallery, 'maxAvailableWidthPercent', 'gallery', Object.freeze({
314
415
  desktop: 100,
315
416
  mobile: 100,
316
- })),
317
- width: readCssLength(rawGallery, 'width', 'gallery', '900px'),
417
+ }), siteThemeLabel),
418
+ width: readCssLength(rawGallery, 'width', 'gallery', '900px', siteThemeLabel),
318
419
  }),
319
420
  typography: Object.freeze({
320
- fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily),
421
+ fontFamily: readFontFamily(rawTypography, 'fontFamily', 'typography', defaultFontFamily, siteThemeLabel),
321
422
  }),
322
- navigation: Object.freeze({
323
- smoothScroll: readSmoothScroll(rawNavigation),
324
- }),
325
- locale: readLocale(rawLocale),
326
- footer: Object.freeze({
423
+ navigation: Object.freeze({
424
+ smoothScroll: readSmoothScroll(rawNavigation),
425
+ }),
426
+ locale: readLocale(rawLocale),
427
+ footer: Object.freeze({
327
428
  buildInfo: readBuildInfo(rawFooter),
328
429
  copyrightMessage: readOptionalString(rawFooter, 'copyrightMessage', 'footer'),
329
430
  }),