@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
@@ -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,46 +56,50 @@ 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
- title: Static single page
75
- description: A single page without interactive features.
67
+ title: Section navigation
68
+ description: A single page with sticky section navigation.
76
69
  sections:
77
70
  intro: {}
78
71
  more: {}
79
72
  ---
80
73
  ## Intro {#intro}
81
74
 
82
- Ordinary Markdown needs no client-side JavaScript.
75
+ The section heading is a native anchor target.
83
76
 
84
77
  ## More {#more}
85
78
 
86
- Additional sections do not make the page interactive.
79
+ The navigation enhancement keeps native anchors clear of the sticky header.
87
80
  `);
88
81
 
89
82
  runBuild();
90
83
  const instantHtml = await readPage();
91
- assertNoClientJavaScript(instantHtml, 'The default instant scroll mode');
84
+ assert.equal(
85
+ getScripts(instantHtml).length,
86
+ 1,
87
+ 'Sticky section navigation should load only its anchor-offset enhancement.',
88
+ );
89
+ assert.match(getScripts(instantHtml)[0], /site-top-anchor-offset/);
92
90
  assertScrollBehavior(instantHtml, 'auto', 'The default instant scroll mode');
93
91
  assert.match(instantHtml, /<nav class="page-nav"/);
94
92
  assert.match(instantHtml, /href="#intro"/);
95
93
  assert.match(instantHtml, /href="#more"/);
96
94
 
97
- await writeConfig('browser-smooth');
95
+ await writeConfig('smooth');
98
96
  runBuild();
99
97
  const browserSmoothHtml = await readPage();
100
- assertNoClientJavaScript(browserSmoothHtml, 'The browser smooth scroll mode');
98
+ assert.equal(
99
+ getScripts(browserSmoothHtml).length,
100
+ 1,
101
+ 'Browser smooth scrolling should use the same anchor-offset enhancement.',
102
+ );
101
103
  assertScrollBehavior(browserSmoothHtml, 'smooth', 'The browser smooth scroll mode');
102
104
 
103
105
  await writeConfig();
@@ -115,7 +117,7 @@ sections:
115
117
 
116
118
  Ordinary Markdown needs no client-side JavaScript.
117
119
  `);
118
- await writeFile(path.join(routeDir, 'route-content.md'), `---
120
+ await writeFile(path.join(routeDir, 'content.md'), `---
119
121
  title: Details
120
122
  description: Static managed images.
121
123
  sections:
@@ -157,7 +159,7 @@ This sentence has additional context.{note-ref}
157
159
 
158
160
  {note: The note remains readable without JavaScript, while alignment is enhanced when JavaScript runs.}
159
161
  `);
160
- await writeFile(path.join(routeDir, 'route-content.md'), `---
162
+ await writeFile(path.join(routeDir, 'content.md'), `---
161
163
  title: Carousel
162
164
  description: An interactive image carousel.
163
165
  sections:
@@ -192,7 +194,7 @@ sections:
192
194
 
193
195
  The only interactive feature is the banner.
194
196
  `);
195
- await writeFile(path.join(routeDir, 'route-content.md'), `---
197
+ await writeFile(path.join(routeDir, 'content.md'), `---
196
198
  title: Details
197
199
  description: A second plain page.
198
200
  sections:
@@ -202,12 +204,10 @@ sections:
202
204
 
203
205
  Plain route content.
204
206
  `);
205
- await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
206
- banners:
207
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `banners:
207
208
  - id: test-banner
208
209
  title: Test banner
209
210
  text: This banner can be dismissed.
210
- ---
211
211
  `);
212
212
 
213
213
  runBuild();