@janga/norna 0.7.13 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -0,0 +1,86 @@
1
+ import {
2
+ getThemePresetMetadata,
3
+ themePresetNames,
4
+ } from './theme-presets.mjs';
5
+
6
+ const option = (title, description) => Object.freeze({ title, description });
7
+ const definition = (values, options) => Object.freeze({
8
+ values: Object.freeze(values),
9
+ options: Object.freeze(options),
10
+ });
11
+
12
+ export const schemaValueDefinitions = Object.freeze([
13
+ definition(themePresetNames, Object.fromEntries(themePresetNames.map((name) => {
14
+ const metadata = getThemePresetMetadata(name);
15
+ return [name, option(metadata.title, metadata.description)];
16
+ }))),
17
+ definition(['instant', 'smooth'], {
18
+ instant: option('Instant', 'Jump directly to anchors without animated scrolling. This is the default.'),
19
+ smooth: option('Browser smooth', 'Use the browser\'s native smooth scrolling for anchor navigation.'),
20
+ }),
21
+ definition(['en', 'sv'], {
22
+ en: option('English', 'Use Norna\'s built-in English interface text.'),
23
+ sv: option('Swedish', 'Use Norna\'s built-in Swedish interface text.'),
24
+ }),
25
+ definition(['left', 'center', 'right'], {
26
+ left: option('Left', 'Align text with the left edge of its text area.'),
27
+ center: option('Center', 'Center text within its text area.'),
28
+ right: option('Right', 'Align text with the right edge of its text area.'),
29
+ }),
30
+ definition(['small', 'medium', 'large', 'xlarge'], {
31
+ small: option('Small', 'Use the small type size from the active typography system.'),
32
+ medium: option('Medium', 'Use the standard type size from the active typography system.'),
33
+ large: option('Large', 'Use a larger type size for stronger emphasis.'),
34
+ xlarge: option('Extra large', 'Use the largest available type size for exceptional emphasis.'),
35
+ }),
36
+ definition(['narrow', 'normal', 'wide'], {
37
+ narrow: option('Narrow', 'Use a shorter line length suited to reading-focused text.'),
38
+ normal: option('Normal', 'Use the balanced default line length.'),
39
+ wide: option('Wide', 'Allow body text to use more horizontal space.'),
40
+ }),
41
+ definition([400, 500, 600, 700], {
42
+ 400: option('Regular', 'Use regular font weight.'),
43
+ 500: option('Medium', 'Use medium font weight.'),
44
+ 600: option('Semibold', 'Use semibold font weight.'),
45
+ 700: option('Bold', 'Use bold font weight.'),
46
+ }),
47
+ definition(['restrained', 'dense', 'reading', 'statement'], {
48
+ restrained: option('Restrained', 'Quiet typography with regular-weight headings and balanced line lengths.'),
49
+ dense: option('Dense', 'Compact typography with wider text and tighter line height.'),
50
+ reading: option('Reading', 'Reading-focused typography with narrower text and relaxed line height.'),
51
+ statement: option('Statement', 'Stronger headings and tighter body rhythm for short, expressive pages.'),
52
+ }),
53
+ definition(['dark', 'light', 'paper'], {
54
+ dark: option('Dark', 'Black page and section surfaces with light text.'),
55
+ light: option('Light', 'White and cool light surfaces with dark text.'),
56
+ paper: option('Paper', 'Warm off-white surfaces with dark text.'),
57
+ }),
58
+ definition(['base', 'soft', 'emphasis'], {
59
+ base: option('Base', 'Use the palette\'s normal page surface.'),
60
+ soft: option('Soft', 'Use the palette\'s subtle contrasting surface.'),
61
+ emphasis: option('Emphasis', 'Use the palette\'s strongest section surface.'),
62
+ }),
63
+ definition(['compact', 'normal', 'airy'], {
64
+ compact: option('Compact', 'Reduce vertical spacing while preserving readable separation.'),
65
+ normal: option('Normal', 'Use balanced vertical spacing.'),
66
+ airy: option('Airy', 'Increase vertical spacing for a more expansive presentation.'),
67
+ }),
68
+ definition(['warning'], {
69
+ warning: option('Warning', 'Present the banner as an important warning notice.'),
70
+ }),
71
+ definition(['short', 'medium', 'long', 'full'], {
72
+ short: option('Short', 'Use a compact localized date or time representation.'),
73
+ medium: option('Medium', 'Use a moderately detailed localized representation.'),
74
+ long: option('Long', 'Use a detailed localized representation.'),
75
+ full: option('Full', 'Use the most detailed localized representation.'),
76
+ }),
77
+ ]);
78
+
79
+ const valuesEqual = (left, right) => (
80
+ left.length === right.length
81
+ && left.every((value, index) => value === right[index])
82
+ );
83
+
84
+ export const getSchemaValueDefinition = (values) => (
85
+ schemaValueDefinitions.find((candidate) => valuesEqual(candidate.values, values))
86
+ );
@@ -8,7 +8,6 @@ import {
8
8
  siteImagesLabel,
9
9
  siteRoutesDir,
10
10
  siteRoutesLabel,
11
- siteThemeLabel,
12
11
  } from './site-paths.mjs';
13
12
  import { parseRouteDirectory } from './route-model.mjs';
14
13
 
@@ -22,15 +21,14 @@ const deprecatedInlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}
22
21
  const frontmatterDelimiterRegex = /^---\s*$/;
23
22
  const knownConfigTopLevelFrontmatterKeys = new Set(['url', 'language', 'scrollBehavior']);
24
23
  const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'navigation', 'sections']);
25
- const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'gallery', 'typography', 'presentation']);
26
- const knownRouteThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'gallery', 'typography', 'presentation']);
24
+ const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'images', 'typography', 'palette', 'sectionSurfaces']);
25
+ const knownRouteThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'images', 'typography', 'palette', 'sectionSurfaces']);
27
26
  const knownSitewideTopLevelFrontmatterKeys = new Set(['navigation', 'banners', 'footer']);
28
27
  const knownNestedFrontmatterKeys = new Set([
29
28
  'align',
30
29
  'alt',
31
30
  'body',
32
31
  'blockGap',
33
- 'brand',
34
32
  'caption',
35
33
  'carousel',
36
34
  'density',
@@ -38,7 +36,6 @@ const knownNestedFrontmatterKeys = new Set([
38
36
  'fontFamily',
39
37
  'from',
40
38
  'finalSectionBottom',
41
- 'gallery',
42
39
  'gutter',
43
40
  'headingToBlock',
44
41
  'h1',
@@ -51,6 +48,7 @@ const knownNestedFrontmatterKeys = new Set([
51
48
  'include',
52
49
  'image',
53
50
  'imageGap',
51
+ 'images',
54
52
  'label',
55
53
  'logo',
56
54
  'lineHeight',
@@ -63,16 +61,14 @@ const knownNestedFrontmatterKeys = new Set([
63
61
  'pageWidth',
64
62
  'palette',
65
63
  'preset',
66
- 'presentation',
64
+ 'profile',
67
65
  'rhythm',
68
- 'sequence',
69
66
  'sections',
70
67
  'sectionGap',
71
68
  'sectionSurfaces',
72
69
  'size',
73
70
  'spacingAfter',
74
71
  'spacingBefore',
75
- 'surface',
76
72
  'theme',
77
73
  'typography',
78
74
  'until',
@@ -108,14 +104,14 @@ export const getContentFiles = async () => {
108
104
 
109
105
  const routeDirectory = entry.name;
110
106
  const routeDir = path.join(siteRoutesDir, routeDirectory);
111
- const routeContentPath = path.join(routeDir, 'route-content.md');
107
+ const routeContentPath = path.join(routeDir, 'content.md');
112
108
 
113
109
  if (!(await fileExists(routeContentPath))) continue;
114
110
 
115
111
  const { routeId, routeOrder } = parseRouteDirectory(routeDirectory, `${siteRoutesLabel}/${routeDirectory}`);
116
112
 
117
113
  contentFiles.push({
118
- contentLabel: `${siteRoutesLabel}/${routeDirectory}/route-content.md`,
114
+ contentLabel: `${siteRoutesLabel}/${routeDirectory}/content.md`,
119
115
  contentPath: routeContentPath,
120
116
  imagesDir: path.join(routeDir, 'images'),
121
117
  imagesLabel: `${siteRoutesLabel}/${routeDirectory}/images`,
@@ -253,9 +249,16 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
253
249
  const key = keyMatch[1];
254
250
  if (knownTopLevelFrontmatterKeys.has(key)) continue;
255
251
 
256
- const fix = knownNestedFrontmatterKeys.has(key)
257
- ? `Indent "${key}:" under the object it belongs to, or move image content into Norna Markdown blocks.`
258
- : `Move "${key}:" under the correct parent key, or remove it if it is not part of the ${fileKind} schema.`;
252
+ let fix;
253
+ if (fileKind === 'content' && key === 'images') {
254
+ fix = 'Put local image references in norna-image-stack or norna-image-carousel blocks in the Markdown body.';
255
+ } else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
256
+ fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
257
+ } else if (knownNestedFrontmatterKeys.has(key)) {
258
+ fix = `Indent "${key}:" under the object it belongs to.`;
259
+ } else {
260
+ fix = `Move "${key}:" under the correct parent key, or remove it if it is not part of the ${fileKind} schema.`;
261
+ }
259
262
 
260
263
  addIssue({
261
264
  severity: 'error',
@@ -271,31 +274,34 @@ export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
271
274
  fileKind: 'content',
272
275
  });
273
276
 
274
- export const validateConfigFrontmatterStructure = (frontmatter, addIssue) =>
277
+ export const validateConfigYamlStructure = (frontmatter, addIssue) =>
275
278
  validateFrontmatterStructure(frontmatter, addIssue, {
276
279
  knownTopLevelFrontmatterKeys: knownConfigTopLevelFrontmatterKeys,
277
280
  fileKind: 'config',
278
281
  });
279
282
 
280
- export const validateThemeFrontmatterStructure = (frontmatter, addIssue) =>
283
+ export const validateThemeYamlStructure = (frontmatter, addIssue) =>
281
284
  validateFrontmatterStructure(frontmatter, addIssue, {
282
285
  knownTopLevelFrontmatterKeys: knownThemeTopLevelFrontmatterKeys,
283
286
  fileKind: 'theme',
284
287
  });
285
288
 
286
- export const validateRouteThemeFrontmatterStructure = (frontmatter, addIssue) =>
289
+ export const validateRouteThemeYamlStructure = (frontmatter, addIssue) =>
287
290
  validateFrontmatterStructure(frontmatter, addIssue, {
288
291
  knownTopLevelFrontmatterKeys: knownRouteThemeTopLevelFrontmatterKeys,
289
292
  fileKind: 'route theme',
290
293
  });
291
294
 
292
- export const validateSitewideFrontmatterStructure = (frontmatter, addIssue) =>
295
+ export const validateSitewideYamlStructure = (frontmatter, addIssue) =>
293
296
  validateFrontmatterStructure(frontmatter, addIssue, {
294
297
  knownTopLevelFrontmatterKeys: knownSitewideTopLevelFrontmatterKeys,
295
298
  fileKind: 'sitewide content',
296
299
  });
297
300
 
298
- export const readThemeFile = async (sitePath) => readSiteFile(sitePath, siteThemeLabel);
301
+ export const readThemeFile = async (sitePath) => {
302
+ const source = await readFile(sitePath, 'utf8');
303
+ return { frontmatter: source, frontmatterBody: source, body: '' };
304
+ };
299
305
 
300
306
  export const getFrontmatterSections = (frontmatter) => {
301
307
  const sections = [];
@@ -22,10 +22,7 @@ export const invocationRoot = configuredInvocationRoot
22
22
 
23
23
  const hasSiteFilesInDirectory = (siteDir) => {
24
24
  return (
25
- (
26
- existsSync(path.join(siteDir, 'config.md'))
27
- || existsSync(path.join(siteDir, 'config.mjs'))
28
- )
25
+ existsSync(path.join(siteDir, 'config.yaml'))
29
26
  && existsSync(path.join(siteDir, 'content.md'))
30
27
  );
31
28
  };
@@ -117,11 +114,10 @@ const getPathLabel = (filePath) => {
117
114
  };
118
115
 
119
116
  export const siteDir = resolvedSitePaths.siteDir;
120
- export const siteConfigPath = path.join(siteDir, 'config.md');
121
- export const legacySiteConfigPath = path.join(siteDir, 'config.mjs');
122
- export const siteThemePath = path.join(siteDir, 'theme.md');
117
+ export const siteConfigPath = path.join(siteDir, 'config.yaml');
118
+ export const siteThemePath = path.join(siteDir, 'theme.yaml');
123
119
  export const siteContentPath = path.join(siteDir, 'content.md');
124
- export const sitewideContentPath = path.join(siteDir, 'sitewide-content.md');
120
+ export const sitewideContentPath = path.join(siteDir, 'sitewide-content.yaml');
125
121
  export const siteImagesDir = path.join(siteDir, 'images');
126
122
  export const siteRoutesDir = path.join(siteDir, 'routes');
127
123
  export const sitePublicDir = path.join(siteDir, 'public');
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { sitewideSchema } from './schema-definitions.mjs';
3
+ import {
4
+ sitewideContentLabel,
5
+ sitewideContentPath,
6
+ } from './site-paths.mjs';
7
+ import {
8
+ validateSitewideYamlStructure,
9
+ } from './site-content.mjs';
10
+ import { parseYamlConfig } from './yaml-config.mjs';
11
+
12
+ export const readSitewideContent = async () => {
13
+ const source = await readFile(sitewideContentPath, 'utf8').catch((error) => {
14
+ if (error?.code === 'ENOENT') return null;
15
+ throw error;
16
+ });
17
+
18
+ if (!source) return {};
19
+
20
+ return parseYamlConfig(source, sitewideContentLabel, {
21
+ schema: sitewideSchema,
22
+ validateStructure: validateSitewideYamlStructure,
23
+ });
24
+ };
@@ -1,11 +1,8 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { parseYamlMapping } from './frontmatter-yaml.mjs';
4
3
  import {
5
- splitSiteFile,
6
- validateFrontmatterIndentation,
7
- validateRouteThemeFrontmatterStructure,
8
- validateThemeFrontmatterStructure,
4
+ validateRouteThemeYamlStructure,
5
+ validateThemeYamlStructure,
9
6
  } from './site-content.mjs';
10
7
  import {
11
8
  siteRoutesDir,
@@ -14,7 +11,9 @@ import {
14
11
  siteThemePath,
15
12
  sitewideContentLabel,
16
13
  } from './site-paths.mjs';
14
+ import { resolveThemePresentation } from './presentation.mjs';
17
15
  import { resolveThemeConfig } from './theme-presets.mjs';
16
+ import { parseYamlConfig } from './yaml-config.mjs';
18
17
 
19
18
  export const readThemeConfig = async () => {
20
19
  const themeFile = await readFile(siteThemePath, 'utf8').catch((error) => {
@@ -24,24 +23,14 @@ export const readThemeConfig = async () => {
24
23
 
25
24
  throw error;
26
25
  });
27
- const { frontmatter, frontmatterBody } = splitSiteFile(themeFile, siteThemeLabel);
28
- const issues = [];
29
-
30
- validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
31
- validateThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
32
-
33
- if (issues.length > 0) {
34
- throw new Error([
35
- `${siteThemeLabel} has invalid frontmatter.`,
36
- ...issues.map((issue) => `- ${issue.message}`),
37
- ].join('\n'));
38
- }
39
-
40
- const config = parseYamlMapping(frontmatterBody);
26
+ const config = parseYamlConfig(themeFile, siteThemeLabel, {
27
+ validateStructure: validateThemeYamlStructure,
28
+ });
41
29
  if (Object.hasOwn(config, 'navigation')) {
42
- throw new Error(`${siteThemeLabel} may not define navigation. Brand and logo belong in ${sitewideContentLabel}.`);
30
+ throw new Error(`${siteThemeLabel} may not define navigation. The site label and logo settings belong in ${sitewideContentLabel}.`);
43
31
  }
44
32
  resolveThemeConfig(config, siteThemeLabel);
33
+ resolveThemePresentation(config, siteThemeLabel);
45
34
 
46
35
  return config;
47
36
  };
@@ -61,7 +50,7 @@ const getRouteThemeFiles = async (directory, relativeDirectory = '') => {
61
50
  continue;
62
51
  }
63
52
 
64
- if (entry.name === 'theme.md') {
53
+ if (entry.name === 'theme.yaml') {
65
54
  files.push({
66
55
  path: absolutePath,
67
56
  label: `${siteRoutesLabel}/${relativePath.split(path.sep).join('/')}`,
@@ -78,23 +67,14 @@ export const validateRouteThemeFiles = async () => {
78
67
 
79
68
  for (const file of files) {
80
69
  const source = await readFile(file.path, 'utf8');
81
- const { frontmatter, frontmatterBody } = splitSiteFile(source, file.label);
82
- const issues = [];
83
- validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
84
- validateRouteThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
85
-
86
- if (issues.length > 0) {
87
- throw new Error([
88
- `${file.label} has invalid frontmatter.`,
89
- ...issues.map((issue) => `- ${issue.message}`),
90
- ].join('\n'));
91
- }
92
-
93
- const config = parseYamlMapping(frontmatterBody);
70
+ const config = parseYamlConfig(source, file.label, {
71
+ validateStructure: validateRouteThemeYamlStructure,
72
+ });
94
73
  if (Object.hasOwn(config, 'navigation')) {
95
- throw new Error(`${file.label} may not define navigation. Brand and logo belong in ${sitewideContentLabel}.`);
74
+ throw new Error(`${file.label} may not define navigation. The site label and logo settings belong in ${sitewideContentLabel}.`);
96
75
  }
97
76
  resolveThemeConfig(config, file.label);
77
+ resolveThemePresentation(config, file.label);
98
78
 
99
79
  configs.push({ ...file, config });
100
80
  }
@@ -1,13 +1,6 @@
1
1
  import { mergeDeep } from './typography.mjs';
2
2
 
3
- export const themePresetNames = [
4
- 'portfolio',
5
- 'documentation',
6
- 'project',
7
- 'statement',
8
- ];
9
-
10
- export const themePresets = Object.freeze({
3
+ const themePresetConfigurations = Object.freeze({
11
4
  portfolio: Object.freeze({
12
5
  layout: {
13
6
  density: 'normal',
@@ -17,23 +10,18 @@ export const themePresets = Object.freeze({
17
10
  mobile: '1rem',
18
11
  },
19
12
  },
20
- gallery: {
13
+ images: {
21
14
  width: '1000px',
22
15
  maxAvailableWidthPercent: { desktop: 100, mobile: 100 },
23
16
  maxAvailableHeightPercent: { desktop: 78, mobile: 68 },
24
17
  },
25
18
  typography: {
26
19
  fontFamily: "'Helvetica Neue', Arial, sans-serif",
27
- preset: 'quiet-gallery',
20
+ profile: 'restrained',
28
21
  rhythm: 'normal',
29
22
  },
30
- presentation: {
31
- palette: 'dark',
32
- sectionSurfaces: {
33
- mode: 'none',
34
- sequence: ['base', 'soft', 'emphasis'],
35
- },
36
- },
23
+ palette: 'dark',
24
+ sectionSurfaces: ['base'],
37
25
  }),
38
26
  documentation: Object.freeze({
39
27
  layout: {
@@ -44,23 +32,18 @@ export const themePresets = Object.freeze({
44
32
  mobile: '1rem',
45
33
  },
46
34
  },
47
- gallery: {
35
+ images: {
48
36
  width: '920px',
49
37
  maxAvailableWidthPercent: { desktop: 100, mobile: 100 },
50
38
  maxAvailableHeightPercent: { desktop: 74, mobile: 68 },
51
39
  },
52
40
  typography: {
53
41
  fontFamily: "Georgia, 'Times New Roman', serif",
54
- preset: 'text-forward',
42
+ profile: 'reading',
55
43
  rhythm: 'compact',
56
44
  },
57
- presentation: {
58
- palette: 'paper',
59
- sectionSurfaces: {
60
- mode: 'cycle',
61
- sequence: ['base', 'soft'],
62
- },
63
- },
45
+ palette: 'paper',
46
+ sectionSurfaces: ['base', 'soft'],
64
47
  }),
65
48
  project: Object.freeze({
66
49
  layout: {
@@ -71,23 +54,18 @@ export const themePresets = Object.freeze({
71
54
  mobile: '1rem',
72
55
  },
73
56
  },
74
- gallery: {
57
+ images: {
75
58
  width: '840px',
76
59
  maxAvailableWidthPercent: { desktop: 100, mobile: 100 },
77
60
  maxAvailableHeightPercent: { desktop: 70, mobile: 62 },
78
61
  },
79
62
  typography: {
80
63
  fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
81
- preset: 'text-forward',
64
+ profile: 'reading',
82
65
  rhythm: 'compact',
83
66
  },
84
- presentation: {
85
- palette: 'light',
86
- sectionSurfaces: {
87
- mode: 'cycle',
88
- sequence: ['base', 'soft'],
89
- },
90
- },
67
+ palette: 'light',
68
+ sectionSurfaces: ['base', 'soft'],
91
69
  }),
92
70
  statement: Object.freeze({
93
71
  layout: {
@@ -98,27 +76,62 @@ export const themePresets = Object.freeze({
98
76
  mobile: '1rem',
99
77
  },
100
78
  },
101
- gallery: {
79
+ images: {
102
80
  width: '1080px',
103
81
  maxAvailableWidthPercent: { desktop: 100, mobile: 100 },
104
82
  maxAvailableHeightPercent: { desktop: 80, mobile: 70 },
105
83
  },
106
84
  typography: {
107
85
  fontFamily: "'Trebuchet MS', 'Helvetica Neue', Arial, sans-serif",
108
- preset: 'statement',
86
+ profile: 'statement',
109
87
  rhythm: 'airy',
110
88
  },
111
- presentation: {
112
- palette: 'paper',
113
- sectionSurfaces: {
114
- mode: 'cycle',
115
- sequence: ['base', 'emphasis'],
116
- },
117
- },
89
+ palette: 'paper',
90
+ sectionSurfaces: ['base', 'emphasis'],
91
+ }),
92
+ });
93
+
94
+ export const themePresetDefinitions = Object.freeze({
95
+ portfolio: Object.freeze({
96
+ title: 'Portfolio',
97
+ description: 'For portfolios and image-led sites, with restrained typography and generous space for images.',
98
+ theme: themePresetConfigurations.portfolio,
99
+ }),
100
+ documentation: Object.freeze({
101
+ title: 'Documentation',
102
+ description: 'For guides and reference material, with reading-focused typography and compact spacing.',
103
+ theme: themePresetConfigurations.documentation,
104
+ }),
105
+ project: Object.freeze({
106
+ title: 'Project',
107
+ description: 'For project and product sites that balance explanation, code, cards, and images.',
108
+ theme: themePresetConfigurations.project,
109
+ }),
110
+ statement: Object.freeze({
111
+ title: 'Statement',
112
+ description: 'For short, expressive sites, with larger typography, airy spacing, and stronger section emphasis.',
113
+ theme: themePresetConfigurations.statement,
118
114
  }),
119
115
  });
120
116
 
121
- export const getThemePreset = (presetName, sourceLabel = 'theme.md') => {
117
+ export const themePresetNames = Object.freeze(Object.keys(themePresetDefinitions));
118
+
119
+ export const themePresets = Object.freeze(Object.fromEntries(
120
+ themePresetNames.map((name) => [name, themePresetDefinitions[name].theme]),
121
+ ));
122
+
123
+ export const getThemePresetMetadata = (presetName) => {
124
+ const definition = themePresetDefinitions[presetName];
125
+ if (!definition) return undefined;
126
+
127
+ return {
128
+ name: presetName,
129
+ title: definition.title,
130
+ description: definition.description,
131
+ };
132
+ };
133
+
134
+ export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
122
135
  const preset = themePresets[presetName];
123
136
 
124
137
  if (!preset) {
@@ -128,7 +141,7 @@ export const getThemePreset = (presetName, sourceLabel = 'theme.md') => {
128
141
  return structuredClone(preset);
129
142
  };
130
143
 
131
- export const resolveThemeConfig = (theme = {}, sourceLabel = 'theme.md') => {
144
+ export const resolveThemeConfig = (theme = {}, sourceLabel = 'theme.yaml') => {
132
145
  const presetName = theme?.preset;
133
146
  if (presetName === undefined) return structuredClone(theme ?? {});
134
147
 
@@ -151,15 +164,16 @@ const responsiveValueLines = (label, value, indent = 2) => {
151
164
  ];
152
165
  };
153
166
 
154
- export const renderThemePresetReference = (presetName, sourceLabel = 'theme.md') => {
167
+ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
155
168
  const preset = getThemePreset(presetName, sourceLabel);
156
- const { layout, gallery, typography, presentation } = preset;
169
+ const metadata = getThemePresetMetadata(presetName);
170
+ const { layout, images, typography, palette, sectionSurfaces } = preset;
157
171
 
158
172
  return [
159
- '---',
160
173
  `# Original values for Norna's "${presetName}" theme preset.`,
161
- '# This is a reference file. Norna only loads theme.md.',
162
- '# Keep the preset in theme.md and copy only the values you want to override.',
174
+ `# ${metadata.description}`,
175
+ '# This is a reference file. Norna only loads theme.yaml.',
176
+ '# Keep the preset in theme.yaml and copy only the values you want to override.',
163
177
  `# Available theme presets: ${themePresetNames.join(', ')}.`,
164
178
  `preset: ${presetName}`,
165
179
  '',
@@ -174,18 +188,18 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.md')
174
188
  ' # Optional spacing overrides use blockGap, finalSectionBottom,',
175
189
  ' # firstSectionTop, headingToBlock, imageGap, and sectionGap.',
176
190
  '',
177
- 'gallery:',
191
+ 'images:',
178
192
  ' # width accepts a positive CSS length.',
179
- ` width: ${gallery.width}`,
193
+ ` width: ${images.width}`,
180
194
  ' # Percent values must be greater than 0 and at most 100.',
181
- ...responsiveValueLines('maxAvailableWidthPercent', gallery.maxAvailableWidthPercent),
182
- ...responsiveValueLines('maxAvailableHeightPercent', gallery.maxAvailableHeightPercent),
195
+ ...responsiveValueLines('maxAvailableWidthPercent', images.maxAvailableWidthPercent),
196
+ ...responsiveValueLines('maxAvailableHeightPercent', images.maxAvailableHeightPercent),
183
197
  '',
184
198
  'typography:',
185
199
  ' # Any valid CSS font-family stack without semicolons.',
186
200
  ` fontFamily: ${quote(typography.fontFamily)}`,
187
- ' # Alternatives: quiet-gallery, compact-gallery, text-forward, statement.',
188
- ` preset: ${typography.preset}`,
201
+ ' # Alternatives: restrained, dense, reading, statement.',
202
+ ` profile: ${typography.profile}`,
189
203
  ' # Alternatives: compact, normal, airy.',
190
204
  ` rhythm: ${typography.rhythm}`,
191
205
  ' # Fine-grained overrides can target headings.h1-h4, body, and caption.',
@@ -195,15 +209,11 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.md')
195
209
  ' # h2:',
196
210
  ' # size: large # small, medium, large, or xlarge',
197
211
  '',
198
- 'presentation:',
199
- ' # Alternatives: dark, light, paper.',
200
- ` palette: ${presentation.palette}`,
201
- ' sectionSurfaces:',
202
- ' # Alternatives: none, cycle.',
203
- ` mode: ${presentation.sectionSurfaces.mode}`,
204
- ' # Use one to three unique values from: base, soft, emphasis.',
205
- ` sequence: [${presentation.sectionSurfaces.sequence.join(', ')}]`,
206
- '---',
212
+ '# Alternatives: dark, light, paper.',
213
+ `palette: ${palette}`,
214
+ '# One value keeps every section on the same surface; multiple values cycle.',
215
+ '# Use one to three unique values from: base, soft, emphasis.',
216
+ `sectionSurfaces: [${sectionSurfaces.join(', ')}]`,
207
217
  '',
208
218
  ].join('\n');
209
219
  };