@janga/norna 0.7.14 → 0.7.16

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 (143) 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/README.md +1 -1
  31. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  32. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  33. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  35. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  36. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  37. package/examples/feature-demos/sitewide-content/README.md +8 -0
  38. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  39. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  40. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  41. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  43. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  44. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  45. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  46. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  48. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  50. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  51. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  52. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  53. package/fixtures/basic/site/content.md +3 -3
  54. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  55. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  56. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  57. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  58. package/package.json +10 -3
  59. package/schemas/config.schema.json +77 -0
  60. package/schemas/content-frontmatter.schema.json +81 -0
  61. package/schemas/manifest.json +9 -0
  62. package/schemas/sitewide-content.schema.json +215 -0
  63. package/schemas/theme.schema.json +1208 -0
  64. package/scripts/check-config.mjs +18 -11
  65. package/scripts/export-theme-preset.mjs +1 -1
  66. package/scripts/generate-schemas.mjs +283 -0
  67. package/scripts/init-site.mjs +22 -27
  68. package/scripts/lib/documentation-links.mjs +8 -0
  69. package/scripts/lib/editor-language-service.mjs +609 -0
  70. package/scripts/lib/example-sites.mjs +2 -2
  71. package/scripts/lib/git-status.mjs +7 -0
  72. package/scripts/lib/logo-assets.mjs +15 -10
  73. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  74. package/scripts/lib/presentation.mjs +23 -12
  75. package/scripts/lib/project-config.mjs +17 -41
  76. package/scripts/lib/public-asset-conventions.mjs +112 -0
  77. package/scripts/lib/schema-definitions.mjs +213 -0
  78. package/scripts/lib/schema-value-definitions.mjs +86 -0
  79. package/scripts/lib/site-content.mjs +24 -18
  80. package/scripts/lib/site-paths.mjs +4 -8
  81. package/scripts/lib/sitewide-content.mjs +24 -0
  82. package/scripts/lib/theme-config.mjs +15 -35
  83. package/scripts/lib/theme-presets.mjs +76 -66
  84. package/scripts/lib/typography.mjs +15 -15
  85. package/scripts/lib/yaml-config.mjs +50 -0
  86. package/scripts/list-theme-presets.mjs +15 -0
  87. package/scripts/release.mjs +84 -8
  88. package/scripts/show-typography.mjs +22 -46
  89. package/scripts/sync-content-sections.mjs +2 -2
  90. package/scripts/test-client-javascript.mjs +13 -22
  91. package/scripts/test-content-check.mjs +21 -26
  92. package/scripts/test-content-model-v2.mjs +72 -40
  93. package/scripts/test-documentation.mjs +70 -4
  94. package/scripts/test-editor-language-service.mjs +158 -0
  95. package/scripts/test-engine-commands.mjs +65 -34
  96. package/scripts/test-package-check.mjs +37 -30
  97. package/scripts/test-project-config.mjs +15 -20
  98. package/scripts/test-schemas.mjs +102 -0
  99. package/scripts/test-temporary-visibility.mjs +5 -13
  100. package/scripts/test-theme-presets.mjs +64 -37
  101. package/src/components/CardList.astro +2 -5
  102. package/src/components/ImageCarousel.astro +21 -21
  103. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  104. package/src/components/SiteNavigation.astro +3 -3
  105. package/src/components/SitePage.astro +8 -8
  106. package/src/components/SiteSection.astro +16 -19
  107. package/src/content.config.ts +17 -209
  108. package/src/layouts/BaseLayout.astro +9 -9
  109. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  110. package/src/lib/routeThemes.ts +9 -29
  111. package/src/lib/sectionContent.ts +3 -3
  112. package/src/lib/sitePublicAssets.ts +5 -21
  113. package/src/styles/global.css +73 -73
  114. package/starters/basic/.github/workflows/deploy.yml +1 -1
  115. package/starters/basic/README.md +16 -13
  116. package/starters/basic/package.json +3 -2
  117. package/starters/basic/site/{config.md → config.yaml} +0 -2
  118. package/starters/basic/site/content.md +1 -1
  119. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  120. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  121. package/starters/project/.github/workflows/deploy.yml +1 -1
  122. package/starters/project/README.md +22 -15
  123. package/starters/project/package.json +3 -2
  124. package/starters/project/site/{config.md → config.yaml} +0 -2
  125. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  126. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  127. package/docs/site-structure.md +0 -98
  128. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  129. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  130. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  131. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  132. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  133. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  134. package/fixtures/content-model-v2/site/theme.md +0 -7
  135. package/scripts/lib/sitewide-config.mjs +0 -34
  136. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  137. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  138. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  139. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  140. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  142. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  143. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -1,7 +1,7 @@
1
- export const typographyPresetNames = [
2
- 'quiet-gallery',
3
- 'compact-gallery',
4
- 'text-forward',
1
+ export const typographyProfileNames = [
2
+ 'restrained',
3
+ 'dense',
4
+ 'reading',
5
5
  'statement',
6
6
  ];
7
7
 
@@ -11,8 +11,8 @@ export const typographyRhythmNames = [
11
11
  'airy',
12
12
  ];
13
13
 
14
- export const typographyPresets = {
15
- 'quiet-gallery': {
14
+ export const typographyProfiles = {
15
+ restrained: {
16
16
  headings: {
17
17
  h1: {
18
18
  align: { desktop: 'left', mobile: 'left' },
@@ -51,7 +51,7 @@ export const typographyPresets = {
51
51
  lineHeight: 1.35,
52
52
  },
53
53
  },
54
- 'compact-gallery': {
54
+ dense: {
55
55
  headings: {
56
56
  h1: {
57
57
  align: { desktop: 'left', mobile: 'left' },
@@ -90,7 +90,7 @@ export const typographyPresets = {
90
90
  lineHeight: 1.25,
91
91
  },
92
92
  },
93
- 'text-forward': {
93
+ reading: {
94
94
  headings: {
95
95
  h1: {
96
96
  align: { desktop: 'left', mobile: 'left' },
@@ -216,7 +216,7 @@ export const typographyRhythms = {
216
216
  };
217
217
 
218
218
  export const defaultTypography = {
219
- preset: 'quiet-gallery',
219
+ profile: 'restrained',
220
220
  rhythm: 'normal',
221
221
  };
222
222
 
@@ -243,13 +243,13 @@ export const mergeDeep = (base, override) => {
243
243
  };
244
244
 
245
245
  export const resolveTypographyConfig = (typography = defaultTypography) => {
246
- const presetName = typography?.preset ?? defaultTypography.preset;
246
+ const profileName = typography?.profile ?? defaultTypography.profile;
247
247
  const rhythmName = typography?.rhythm ?? defaultTypography.rhythm;
248
- const preset = typographyPresets[presetName];
248
+ const profile = typographyProfiles[profileName];
249
249
  const rhythm = typographyRhythms[rhythmName];
250
250
 
251
- if (!preset) {
252
- throw new Error(`Unknown typography preset: ${presetName}`);
251
+ if (!profile) {
252
+ throw new Error(`Unknown typography profile: ${profileName}`);
253
253
  }
254
254
 
255
255
  if (!rhythm) {
@@ -257,9 +257,9 @@ export const resolveTypographyConfig = (typography = defaultTypography) => {
257
257
  }
258
258
 
259
259
  return {
260
- preset: presetName,
260
+ profile: profileName,
261
261
  rhythm: rhythmName,
262
- values: mergeDeep(mergeDeep(preset, rhythm), typography?.overrides),
262
+ values: mergeDeep(mergeDeep(profile, rhythm), typography?.overrides),
263
263
  };
264
264
  };
265
265
 
@@ -0,0 +1,50 @@
1
+ import { load } from 'js-yaml';
2
+ import {
3
+ validateFrontmatterIndentation,
4
+ } from './site-content.mjs';
5
+
6
+ const formatSchemaIssues = (issues) => issues.map((issue) => {
7
+ const location = issue.path.join('.') || '(root)';
8
+ return `- ${location}: ${issue.message}`;
9
+ });
10
+
11
+ export const parseYamlConfig = (source, label, {
12
+ schema,
13
+ validateStructure,
14
+ } = {}) => {
15
+ const issues = [];
16
+
17
+ validateFrontmatterIndentation(source, (issue) => issues.push(issue));
18
+ validateStructure?.(source, (issue) => issues.push(issue));
19
+
20
+ if (issues.length > 0) {
21
+ throw new Error([
22
+ `${label} has invalid YAML structure.`,
23
+ ...issues.map((issue) => `- ${issue.message}`),
24
+ ].join('\n'));
25
+ }
26
+
27
+ let data;
28
+ try {
29
+ data = load(source) ?? {};
30
+ } catch (error) {
31
+ const detail = error instanceof Error ? error.message : String(error);
32
+ throw new Error(`${label} contains invalid YAML.\n${detail}`);
33
+ }
34
+
35
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
36
+ throw new Error(`${label} must contain a YAML mapping at the top level.`);
37
+ }
38
+
39
+ if (!schema) return data;
40
+
41
+ const parsed = schema.safeParse(data);
42
+ if (!parsed.success) {
43
+ throw new Error([
44
+ `${label} has invalid values.`,
45
+ ...formatSchemaIssues(parsed.error.issues),
46
+ ].join('\n'));
47
+ }
48
+
49
+ return parsed.data;
50
+ };
@@ -0,0 +1,15 @@
1
+ import {
2
+ getThemePresetMetadata,
3
+ themePresetNames,
4
+ } from './lib/theme-presets.mjs';
5
+
6
+ const args = process.argv.slice(2);
7
+ if (args.length > 0) {
8
+ throw new Error('Usage: norna theme:presets');
9
+ }
10
+
11
+ console.log('Theme presets\n');
12
+ for (const name of themePresetNames) {
13
+ const preset = getThemePresetMetadata(name);
14
+ console.log(`${name}\n ${preset.description}`);
15
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { promisify } from 'node:util';
5
5
  import { execFile } from 'node:child_process';
6
+ import { getShortGitStatusPath, parseShortGitStatus } from './lib/git-status.mjs';
6
7
  import { runInherit } from './lib/run-command.mjs';
7
8
 
8
9
  const execFileAsync = promisify(execFile);
@@ -17,7 +18,7 @@ const showHelp = releaseArguments.includes('--help') || releaseArguments.include
17
18
 
18
19
  const printUsage = () => {
19
20
  console.log('Usage: node scripts/release.mjs <patch|minor|major>');
20
- console.log('Checks npm authentication, runs npm test, creates the version commit and tag, publishes to npm, then pushes the commit and tag.');
21
+ console.log('Checks npm authentication, prepares the version and schemas, runs npm test, creates the release commit and tag, publishes to npm, then pushes.');
21
22
  };
22
23
 
23
24
  const run = (command, args) => runInherit(command, args, { cwd: repoRoot });
@@ -27,6 +28,48 @@ const readPackageVersion = async () => {
27
28
  return packageJson.version;
28
29
  };
29
30
 
31
+ const releasePaths = ['package.json', 'package-lock.json', 'schemas'];
32
+
33
+ const getWorktreeChanges = async () => {
34
+ const { stdout } = await execFileAsync('git', ['status', '--short'], { cwd: repoRoot });
35
+ return parseShortGitStatus(stdout);
36
+ };
37
+
38
+ const isReleasePath = (filePath) => (
39
+ filePath === 'package.json'
40
+ || filePath === 'package-lock.json'
41
+ || filePath === 'schemas'
42
+ || filePath.startsWith('schemas/')
43
+ );
44
+
45
+ const assertOnlyReleaseChanges = async () => {
46
+ const changes = await getWorktreeChanges();
47
+ const unexpected = changes.filter((line) => !isReleasePath(getShortGitStatusPath(line)));
48
+ if (unexpected.length > 0) {
49
+ throw new Error(`Release checks changed unexpected files:\n${unexpected.join('\n')}`);
50
+ }
51
+ for (const requiredPath of ['package.json', 'package-lock.json']) {
52
+ if (!changes.some((line) => getShortGitStatusPath(line) === requiredPath)) {
53
+ throw new Error(`Release preparation did not update ${requiredPath}.`);
54
+ }
55
+ }
56
+ };
57
+
58
+ const assertTagDoesNotExist = async (tag) => {
59
+ try {
60
+ await execFileAsync('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tag}`], { cwd: repoRoot });
61
+ } catch (error) {
62
+ if (error?.code === 1) return;
63
+ throw error;
64
+ }
65
+ throw new Error(`Git tag ${tag} already exists.`);
66
+ };
67
+
68
+ const rollbackPreparedRelease = async () => {
69
+ await execFileAsync('git', ['restore', '--staged', '--worktree', '--', ...releasePaths], { cwd: repoRoot });
70
+ console.log('Release preparation failed before commit. Restored package version and generated schemas.');
71
+ };
72
+
30
73
  const assertCleanWorktree = async (stage) => {
31
74
  const { stdout } = await execFileAsync('git', ['status', '--short'], { cwd: repoRoot });
32
75
 
@@ -61,17 +104,50 @@ if (showHelp || !releaseType) {
61
104
  throw new Error(`Unsupported release type: ${releaseType}`);
62
105
  } else {
63
106
  const previousVersion = await readPackageVersion();
107
+ let preparationStarted = false;
108
+ let releaseCommitCreated = false;
109
+ let releaseTagCreated = false;
110
+ let publishedVersion = null;
64
111
 
65
112
  await assertCleanWorktree('before the release checks');
66
113
  await assertNpmAuthenticated();
67
114
  console.log(`Preparing a ${releaseType} release from ${previousVersion}.`);
68
- await run(npmBin, ['test']);
69
- await assertCleanWorktree('after the release checks');
70
- await run(npmBin, ['version', releaseType, '--message', 'Release %s']);
71
115
 
72
- const publishedVersion = await readPackageVersion();
73
- await run(npmBin, ['run', 'release:publish']);
74
- await run('git', ['push', '--follow-tags']);
116
+ try {
117
+ preparationStarted = true;
118
+ await run(npmBin, ['version', releaseType, '--no-git-tag-version']);
119
+ publishedVersion = await readPackageVersion();
120
+ const releaseTag = `v${publishedVersion}`;
121
+ if (publishedVersion === previousVersion) {
122
+ throw new Error(`Version did not change from ${previousVersion}.`);
123
+ }
124
+
125
+ await assertTagDoesNotExist(releaseTag);
126
+ await run(npmBin, ['run', 'schemas:generate']);
127
+ await run(npmBin, ['test']);
128
+ await assertOnlyReleaseChanges();
129
+ await run('git', ['add', ...releasePaths]);
130
+ await run('git', ['commit', '-m', `Release ${releaseTag}`]);
131
+ releaseCommitCreated = true;
132
+ await run('git', ['tag', releaseTag]);
133
+ releaseTagCreated = true;
134
+ await run(npmBin, ['run', 'release:publish']);
135
+ await run('git', ['push', '--follow-tags']);
75
136
 
76
- console.log(`Released @janga/norna@${publishedVersion}.`);
137
+ console.log(`Released @janga/norna@${publishedVersion}.`);
138
+ } catch (error) {
139
+ if (preparationStarted && !releaseCommitCreated) {
140
+ try {
141
+ await rollbackPreparedRelease();
142
+ } catch (rollbackError) {
143
+ throw new AggregateError([error, rollbackError], 'Release failed and automatic rollback also failed.');
144
+ }
145
+ } else if (releaseCommitCreated) {
146
+ const state = releaseTagCreated
147
+ ? `Release commit and tag v${publishedVersion} remain locally.`
148
+ : `Release commit for v${publishedVersion} remains locally, but its tag was not created.`;
149
+ console.error(state);
150
+ }
151
+ throw error;
152
+ }
77
153
  }
@@ -1,22 +1,19 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { parseYamlMapping } from './lib/frontmatter-yaml.mjs';
4
3
  import {
5
4
  defaultTypography,
6
5
  resolveTypographyConfig,
7
6
  toYamlLines,
8
- typographyPresets,
7
+ typographyProfiles,
9
8
  typographyRhythms,
10
9
  } from './lib/typography.mjs';
11
10
  import {
12
11
  getBodySections,
13
12
  getContentFiles,
14
13
  readSiteFile,
15
- splitSiteFile,
16
14
  validateContentFrontmatterStructure,
17
15
  validateFrontmatterIndentation,
18
- validateRouteThemeFrontmatterStructure,
19
- validateThemeFrontmatterStructure,
16
+ validateRouteThemeYamlStructure,
20
17
  } from './lib/site-content.mjs';
21
18
  import {
22
19
  siteContentLabel,
@@ -25,7 +22,9 @@ import {
25
22
  siteThemeLabel,
26
23
  siteThemePath,
27
24
  } from './lib/site-paths.mjs';
25
+ import { readThemeConfig } from './lib/theme-config.mjs';
28
26
  import { resolveThemeConfig } from './lib/theme-presets.mjs';
27
+ import { parseYamlConfig } from './lib/yaml-config.mjs';
29
28
 
30
29
  const mode = process.argv[2] ?? 'show';
31
30
 
@@ -130,7 +129,7 @@ const annotateResolvedValues = (resolved, sources) => {
130
129
  return annotated;
131
130
  };
132
131
 
133
- const defaultSources = (presetName, rhythmName) => {
132
+ const defaultSources = (profileName, rhythmName) => {
134
133
  const sources = {};
135
134
 
136
135
  for (const path of typographyValuePaths) {
@@ -138,7 +137,7 @@ const defaultSources = (presetName, rhythmName) => {
138
137
  setPath(sources, path, {
139
138
  source: rhythmValuePaths.has(pathKey)
140
139
  ? `rhythm:${rhythmName}`
141
- : `preset:${presetName}`,
140
+ : `profile:${profileName}`,
142
141
  inherited: false,
143
142
  });
144
143
  }
@@ -165,16 +164,16 @@ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
165
164
  const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
166
165
  const resolved = resolveTypographyConfig(typographyConfig ?? defaultTypography);
167
166
  const sources = applyOverrideSources(
168
- defaultSources(resolved.preset, resolved.rhythm),
167
+ defaultSources(resolved.profile, resolved.rhythm),
169
168
  typographyConfig,
170
169
  sourceLabel,
171
170
  );
172
171
 
173
172
  return {
174
- preset: {
175
- value: resolved.preset,
176
- source: typographyConfig?.preset ? sourceLabel : 'engine default',
177
- ...(typographyConfig?.preset ? {} : { inherited: true }),
173
+ profile: {
174
+ value: resolved.profile,
175
+ source: typographyConfig?.profile ? sourceLabel : 'engine default',
176
+ ...(typographyConfig?.profile ? {} : { inherited: true }),
178
177
  },
179
178
  rhythm: {
180
179
  value: resolved.rhythm,
@@ -187,25 +186,13 @@ const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
187
186
  };
188
187
 
189
188
  const formatAnnotatedTypography = (annotated) => ({
190
- preset: annotated.preset,
189
+ profile: annotated.profile,
191
190
  rhythm: annotated.rhythm,
192
191
  resolved: annotateResolvedValues(annotated.resolved, annotated.sources),
193
192
  });
194
193
 
195
194
  const readThemeTypography = async () => {
196
- const themeFile = await readFile(siteThemePath, 'utf8');
197
- const { frontmatter: themeFrontmatter, frontmatterBody: themeFrontmatterBody } = splitSiteFile(themeFile, siteThemeLabel);
198
- const indentationIssues = [];
199
- validateFrontmatterIndentation(themeFrontmatter, (issue) => indentationIssues.push(issue));
200
- validateThemeFrontmatterStructure(themeFrontmatter, (issue) => indentationIssues.push(issue));
201
- if (indentationIssues.length > 0) {
202
- throw new Error([
203
- `Cannot inspect typography because ${siteThemeLabel} has invalid frontmatter.`,
204
- ...indentationIssues.map((issue) => `- ${issue.message}`),
205
- ].join('\n'));
206
- }
207
-
208
- const themeConfig = resolveThemeConfig(parseYamlMapping(themeFrontmatterBody), siteThemeLabel);
195
+ const themeConfig = resolveThemeConfig(await readThemeConfig(), siteThemeLabel);
209
196
  const themeTypographyConfig = themeConfig.typography;
210
197
  return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
211
198
  };
@@ -213,26 +200,17 @@ const readThemeTypography = async () => {
213
200
  const readRouteThemeTypography = async (contentFile, themeTypography) => {
214
201
  if (contentFile.isHome) return themeTypography;
215
202
 
216
- const routeThemePath = path.join(siteRoutesDir, contentFile.routeDirectory, 'theme.md');
217
- const routeThemeLabel = `${contentFile.contentLabel.replace(/\/route-content\.md$/, '')}/theme.md`;
203
+ const routeThemePath = path.join(siteRoutesDir, contentFile.routeDirectory, 'theme.yaml');
218
204
  const routeThemeFile = await readFile(routeThemePath, 'utf8').catch((error) => {
219
205
  if (error?.code === 'ENOENT') return null;
220
206
  throw error;
221
207
  });
222
208
  if (!routeThemeFile) return themeTypography;
209
+ const routeThemeLabel = `${contentFile.contentLabel.replace(/\/content\.md$/, '')}/theme.yaml`;
223
210
 
224
- const { frontmatter, frontmatterBody } = splitSiteFile(routeThemeFile, routeThemeLabel);
225
- const indentationIssues = [];
226
- validateFrontmatterIndentation(frontmatter, (issue) => indentationIssues.push(issue));
227
- validateRouteThemeFrontmatterStructure(frontmatter, (issue) => indentationIssues.push(issue));
228
- if (indentationIssues.length > 0) {
229
- throw new Error([
230
- `Cannot inspect typography because ${routeThemeLabel} has invalid frontmatter.`,
231
- ...indentationIssues.map((issue) => `- ${issue.message}`),
232
- ].join('\n'));
233
- }
234
-
235
- const routeThemeConfig = resolveThemeConfig(parseYamlMapping(frontmatterBody), routeThemeLabel);
211
+ const routeThemeConfig = resolveThemeConfig(parseYamlConfig(routeThemeFile, routeThemeLabel, {
212
+ validateStructure: validateRouteThemeYamlStructure,
213
+ }), routeThemeLabel);
236
214
  const typographyConfig = routeThemeConfig.typography;
237
215
  return resolveAnnotatedTypographyConfig(typographyConfig ?? defaultTypography, routeThemeLabel);
238
216
  };
@@ -267,9 +245,9 @@ const readPageTypography = async (contentFile, siteThemeTypography) => {
267
245
  };
268
246
  };
269
247
 
270
- if (mode === 'presets') {
248
+ if (mode === 'profiles') {
271
249
  console.log(toYamlLines({
272
- presets: typographyPresets,
250
+ profiles: typographyProfiles,
273
251
  rhythms: typographyRhythms,
274
252
  }).join('\n'));
275
253
  } else if (mode === 'show') {
@@ -278,9 +256,7 @@ if (mode === 'presets') {
278
256
  const output = {
279
257
  theme: {
280
258
  source: siteThemeLabel,
281
- presentation: {
282
- typography: formatAnnotatedTypography(themeTypography),
283
- },
259
+ typography: formatAnnotatedTypography(themeTypography),
284
260
  },
285
261
  pages: Object.fromEntries(pages.map((page) => [
286
262
  page.route,
@@ -299,5 +275,5 @@ if (mode === 'presets') {
299
275
 
300
276
  console.log(toYamlLines(output).join('\n'));
301
277
  } else {
302
- throw new Error('Usage: norna typography presets|show');
278
+ throw new Error('Usage: norna typography profiles|show');
303
279
  }
@@ -21,7 +21,7 @@ import {
21
21
  toPosixPath,
22
22
  validateContentFrontmatterStructure,
23
23
  validateFrontmatterIndentation,
24
- validateThemeFrontmatterStructure,
24
+ validateThemeYamlStructure,
25
25
  } from './lib/site-content.mjs';
26
26
  import { readImageDimensions } from './lib/image-dimensions.mjs';
27
27
  import {
@@ -393,7 +393,7 @@ let themeFrontmatter = null;
393
393
  try {
394
394
  themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
395
395
  validateFrontmatterIndentation(themeFrontmatter, addIssue);
396
- validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
396
+ validateThemeYamlStructure(themeFrontmatter, addIssue);
397
397
  } catch (error) {
398
398
  if (error?.code !== 'ENOENT') {
399
399
  throw error;
@@ -11,7 +11,7 @@ await mkdir(tempParent, { recursive: true });
11
11
  const tempRoot = await mkdtemp(path.join(tempParent, 'norna-client-javascript-'));
12
12
  const siteDir = path.join(tempRoot, 'site');
13
13
  const routeDir = path.join(siteDir, 'routes', '010-details');
14
- const configPath = path.join(siteDir, 'config.md');
14
+ const configPath = path.join(siteDir, 'config.yaml');
15
15
 
16
16
  const runBuild = () => {
17
17
  const result = spawnSync(process.execPath, [nornaBin, 'build'], {
@@ -44,10 +44,8 @@ const assertScrollBehavior = (html, behavior, label) => {
44
44
  `${label} should render scroll-behavior: ${behavior}.`,
45
45
  );
46
46
  };
47
- const writeConfig = (scrollBehavior) => writeFile(configPath, `---
48
- url: https://example.com/
49
- ${scrollBehavior ? `scrollBehavior: ${scrollBehavior}\n` : ''}---
50
- `);
47
+ const writeConfig = (scrollBehavior) => writeFile(configPath, `url: https://example.com/
48
+ ${scrollBehavior ? `scrollBehavior: ${scrollBehavior}\n` : ''}`);
51
49
  const writeSvg = (filePath, color) => writeFile(filePath, [
52
50
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 90">',
53
51
  ` <rect width="160" height="90" fill="${color}"/>`,
@@ -58,17 +56,12 @@ const writeSvg = (filePath, color) => writeFile(filePath, [
58
56
  try {
59
57
  await mkdir(path.join(siteDir, 'public'), { recursive: true });
60
58
  await writeConfig();
61
- await writeFile(path.join(siteDir, 'theme.md'), `---
62
- typography:
63
- preset: text-forward
64
- presentation:
65
- palette: paper
66
- ---
59
+ await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
60
+ profile: reading
61
+ palette: paper
67
62
  `);
68
- await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
69
- navigation:
70
- brand: Test site
71
- ---
63
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
64
+ label: Test site
72
65
  `);
73
66
  await writeFile(path.join(siteDir, 'content.md'), `---
74
67
  title: Section navigation
@@ -99,7 +92,7 @@ The navigation enhancement keeps native anchors clear of the sticky header.
99
92
  assert.match(instantHtml, /href="#intro"/);
100
93
  assert.match(instantHtml, /href="#more"/);
101
94
 
102
- await writeConfig('browser-smooth');
95
+ await writeConfig('smooth');
103
96
  runBuild();
104
97
  const browserSmoothHtml = await readPage();
105
98
  assert.equal(
@@ -124,7 +117,7 @@ sections:
124
117
 
125
118
  Ordinary Markdown needs no client-side JavaScript.
126
119
  `);
127
- await writeFile(path.join(routeDir, 'route-content.md'), `---
120
+ await writeFile(path.join(routeDir, 'content.md'), `---
128
121
  title: Details
129
122
  description: Static managed images.
130
123
  sections:
@@ -166,7 +159,7 @@ This sentence has additional context.{note-ref}
166
159
 
167
160
  {note: The note remains readable without JavaScript, while alignment is enhanced when JavaScript runs.}
168
161
  `);
169
- await writeFile(path.join(routeDir, 'route-content.md'), `---
162
+ await writeFile(path.join(routeDir, 'content.md'), `---
170
163
  title: Carousel
171
164
  description: An interactive image carousel.
172
165
  sections:
@@ -201,7 +194,7 @@ sections:
201
194
 
202
195
  The only interactive feature is the banner.
203
196
  `);
204
- await writeFile(path.join(routeDir, 'route-content.md'), `---
197
+ await writeFile(path.join(routeDir, 'content.md'), `---
205
198
  title: Details
206
199
  description: A second plain page.
207
200
  sections:
@@ -211,12 +204,10 @@ sections:
211
204
 
212
205
  Plain route content.
213
206
  `);
214
- await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
215
- banners:
207
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `banners:
216
208
  - id: test-banner
217
209
  title: Test banner
218
210
  text: This banner can be dismissed.
219
- ---
220
211
  `);
221
212
 
222
213
  runBuild();
@@ -42,12 +42,9 @@ const makePngHeader = ({ width, height }) => {
42
42
  return buffer;
43
43
  };
44
44
 
45
- const defaultTheme = `---
46
- typography:
47
- preset: quiet-gallery
48
- presentation:
49
- palette: dark
50
- ---
45
+ const defaultTheme = `typography:
46
+ profile: restrained
47
+ palette: dark
51
48
  `;
52
49
 
53
50
  const withTempProject = async ({ site, theme = defaultTheme, files, siteDirectory = 'site' }, run) => {
@@ -55,7 +52,7 @@ const withTempProject = async ({ site, theme = defaultTheme, files, siteDirector
55
52
 
56
53
  try {
57
54
  await writeFixtureFile(root, `${siteDirectory}/content.md`, site);
58
- await writeFixtureFile(root, `${siteDirectory}/theme.md`, theme);
55
+ await writeFixtureFile(root, `${siteDirectory}/theme.yaml`, theme);
59
56
 
60
57
  for (const file of files) {
61
58
  if (typeof file === 'string') {
@@ -370,12 +367,11 @@ This paragraph has {note-ref} two references {note-ref}.
370
367
  });
371
368
 
372
369
  const badWhitespaceSite = `---
373
- presentation:
374
- typography:
375
- preset: quiet-gallery
376
- \u00a0\u00a0\u00a0\u00a0overrides:
377
- body:
378
- paragraphSpacing: 0.3em
370
+ typography:
371
+ profile: restrained
372
+ \u00a0\u00a0overrides:
373
+ body:
374
+ paragraphSpacing: 0.3em
379
375
  sections:
380
376
  intro: {}
381
377
 
@@ -394,18 +390,17 @@ test('content:check explains invalid frontmatter indentation whitespace', async
394
390
 
395
391
  assert.equal(result.status, 1, output);
396
392
  assert.match(output, /^Content check failed\./m);
397
- assert.match(output, /Frontmatter line 5 uses tabs, non-breaking spaces, or invalid whitespace for indentation\./);
393
+ assert.match(output, /Frontmatter line 4 uses tabs, non-breaking spaces, or invalid whitespace for indentation\./);
398
394
  assert.match(output, /Replace the indentation on that line with ordinary spaces\./);
399
395
  });
400
396
  });
401
397
 
402
398
  const badNestedValueSite = `---
403
- presentation:
404
- typography:
405
- preset: quiet-gallery
406
- overrides:
407
- body:
408
- paragraphSpacing: 0.3em
399
+ typography:
400
+ profile: restrained
401
+ overrides:
402
+ body:
403
+ paragraphSpacing: 0.3em
409
404
  sections:
410
405
  intro: {}
411
406
 
@@ -424,17 +419,17 @@ test('content:check explains frontmatter indentation below scalar values', async
424
419
 
425
420
  assert.equal(result.status, 1, output);
426
421
  assert.match(output, /^Content check failed\./m);
427
- assert.match(output, /Frontmatter line 5 is indented under line 4, but line 4 already has a value\./);
422
+ assert.match(output, /Frontmatter line 4 is indented under line 3, but line 3 already has a value\./);
428
423
  assert.match(output, /Move the later line to the same indentation level as its sibling/);
429
424
  });
430
425
  });
431
426
 
432
- const topLevelGallerySite = `---
427
+ const topLevelImagesSite = `---
433
428
  title: Example
434
429
  description: Example site.
435
430
  sections:
436
431
  intro: {}
437
- gallery:
432
+ images:
438
433
  - image: intro.jpg
439
434
 
440
435
  ---
@@ -444,7 +439,7 @@ Text.
444
439
 
445
440
  test('content:check explains likely misindented frontmatter keys', async () => {
446
441
  await withTempProject({
447
- site: topLevelGallerySite,
442
+ site: topLevelImagesSite,
448
443
  files: ['site/images/intro/intro.jpg'],
449
444
  }, async (root) => {
450
445
  const result = runContentScript(root, ['--check']);
@@ -452,8 +447,8 @@ test('content:check explains likely misindented frontmatter keys', async () => {
452
447
 
453
448
  assert.equal(result.status, 1, output);
454
449
  assert.match(output, /^Content check failed\./m);
455
- assert.match(output, /Frontmatter line 6 defines "gallery" at the top level, but it is not a valid top-level content field\./);
456
- assert.match(output, /Indent "gallery:" under the object it belongs to, or move image content into Norna Markdown blocks\./);
450
+ assert.match(output, /Frontmatter line 6 defines "images" at the top level, but it is not a valid top-level content field\./);
451
+ assert.match(output, /Put local image references in norna-image-stack or norna-image-carousel blocks in the Markdown body\./);
457
452
  });
458
453
  });
459
454