@janga/norna 0.7.16 → 0.7.17

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 (200) hide show
  1. package/README.md +6 -6
  2. package/astro.config.mjs +35 -3
  3. package/docs/README.md +10 -10
  4. package/docs/commands.md +2 -2
  5. package/docs/content.md +82 -102
  6. package/docs/design/navigation-and-theme-plan.md +115 -0
  7. package/docs/design/norna-diagram-design.md +9 -9
  8. package/docs/design/site-examples-structure.md +9 -9
  9. package/docs/engine-development.md +9 -9
  10. package/docs/getting-started.md +20 -15
  11. package/docs/images-and-metadata.md +10 -9
  12. package/docs/pages.md +134 -0
  13. package/docs/public-files.md +49 -36
  14. package/docs/publishing.md +19 -3
  15. package/docs/site-files.md +29 -28
  16. package/docs/sitewide-content.md +20 -36
  17. package/docs/theme.md +20 -20
  18. package/docs/typography.md +10 -10
  19. package/examples/README.md +2 -2
  20. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/site.schema.json +17 -41
  21. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/sitewide.schema.json +26 -67
  22. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/theme.schema.json +185 -88
  23. package/examples/complete-sites/dog-shelter-multi-page/.astro/data-store.json +1 -1
  24. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev-local.json +4 -4
  25. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.json +5 -5
  26. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.log +33 -23
  27. package/examples/complete-sites/dog-shelter-multi-page/README.md +1 -1
  28. package/examples/complete-sites/dog-shelter-multi-page/node_modules/.vite/deps/_metadata.json +8 -8
  29. package/examples/complete-sites/dog-shelter-multi-page/site/.norna/generated-images.json +20 -20
  30. package/examples/complete-sites/dog-shelter-multi-page/site/content.md +3 -4
  31. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/010-dogs/content.md +3 -4
  32. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/020-adopt/content.md +3 -4
  33. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +2 -4
  34. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/site.schema.json +17 -41
  35. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/sitewide.schema.json +26 -67
  36. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/theme.schema.json +185 -88
  37. package/examples/complete-sites/dog-shelter-single-page/.astro/data-store.json +1 -1
  38. package/examples/complete-sites/dog-shelter-single-page/.astro/dev-local.json +8 -0
  39. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.json +5 -5
  40. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.log +16 -12
  41. package/examples/complete-sites/dog-shelter-single-page/README.md +1 -1
  42. package/examples/complete-sites/dog-shelter-single-page/node_modules/.vite/deps/_metadata.json +8 -8
  43. package/examples/complete-sites/dog-shelter-single-page/site/.norna/generated-images.json +12 -12
  44. package/examples/complete-sites/dog-shelter-single-page/site/content.md +5 -2
  45. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +2 -4
  46. package/examples/feature-demos/media-and-surfaces/.astro/collections/site.schema.json +17 -41
  47. package/examples/feature-demos/media-and-surfaces/.astro/collections/sitewide.schema.json +26 -67
  48. package/examples/feature-demos/media-and-surfaces/.astro/collections/theme.schema.json +185 -88
  49. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  50. package/examples/feature-demos/media-and-surfaces/.astro/dev.log +4 -14
  51. package/examples/feature-demos/media-and-surfaces/README.md +2 -2
  52. package/examples/feature-demos/media-and-surfaces/node_modules/.vite/deps/_metadata.json +9 -9
  53. package/examples/feature-demos/media-and-surfaces/site/.norna/generated-images.json +16 -16
  54. package/examples/feature-demos/media-and-surfaces/site/content.md +4 -7
  55. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/010-media/content.md +6 -5
  56. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/content.md +7 -6
  57. package/examples/feature-demos/media-and-surfaces/site/sitewide-content.yaml +0 -2
  58. package/examples/feature-demos/sitewide-content/README.md +3 -3
  59. package/examples/feature-demos/sitewide-content/site/content.md +8 -7
  60. package/examples/feature-demos/sitewide-content/site/pages/010-logo-and-navigation/content.md +17 -0
  61. package/examples/feature-demos/sitewide-content/site/{routes → pages}/020-notices/content.md +7 -6
  62. package/examples/feature-demos/sitewide-content/site/{routes → pages}/030-footer/content.md +6 -5
  63. package/examples/feature-demos/sitewide-content/site/sitewide-content.yaml +4 -13
  64. package/examples/feature-demos/theme-presets/README.md +1 -1
  65. package/examples/feature-demos/theme-presets/site/.norna/generated-images.json +60 -60
  66. package/examples/feature-demos/theme-presets/site/content.md +4 -7
  67. package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/content.md +4 -7
  68. package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/content.md +4 -7
  69. package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/content.md +4 -7
  70. package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/content.md +4 -7
  71. package/examples/feature-demos/theme-presets/site/sitewide-content.yaml +1 -10
  72. package/fixtures/basic/site/content.md +5 -6
  73. package/fixtures/basic/site/sitewide-content.yaml +1 -8
  74. package/fixtures/content-model-v2/site/content.md +11 -13
  75. package/fixtures/content-model-v2/site/pages/010-guide/content.md +18 -0
  76. package/fixtures/nested-pages/site/.norna/generated-images.json +10 -0
  77. package/fixtures/nested-pages/site/config.yaml +1 -0
  78. package/fixtures/nested-pages/site/content.md +15 -0
  79. package/fixtures/nested-pages/site/pages/010-guides/content.md +15 -0
  80. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/content.md +21 -0
  81. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/images/diagram.svg +21 -0
  82. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/pages/010-macos/content.md +14 -0
  83. package/fixtures/nested-pages/site/pages/010-guides/pages/020-workflows/content.md +11 -0
  84. package/fixtures/nested-pages/site/pages/010-guides/theme.yaml +3 -0
  85. package/fixtures/nested-pages/site/pages/020-reference/content.md +10 -0
  86. package/fixtures/nested-pages/site/pages/020-reference/pages/010-installation/content.md +11 -0
  87. package/fixtures/nested-pages/site/theme.yaml +3 -0
  88. package/package.json +7 -4
  89. package/schemas/config.schema.json +5 -5
  90. package/schemas/content-frontmatter.schema.json +28 -63
  91. package/schemas/manifest.json +1 -0
  92. package/schemas/sitewide-content.schema.json +58 -132
  93. package/schemas/theme.schema.json +396 -86
  94. package/scripts/check-config.mjs +6 -5
  95. package/scripts/deploy-site.mjs +6 -8
  96. package/scripts/doctor.mjs +2 -2
  97. package/scripts/generate-images.mjs +11 -18
  98. package/scripts/generate-schemas.mjs +3 -217
  99. package/scripts/lib/base-path-redirect.mjs +9 -0
  100. package/scripts/lib/editor-language-service.mjs +126 -66
  101. package/scripts/lib/heading-ids.mjs +161 -0
  102. package/scripts/lib/navigation-model.mjs +51 -0
  103. package/scripts/lib/norna-markdown-blocks.mjs +3 -4
  104. package/scripts/lib/page-model.mjs +83 -0
  105. package/scripts/lib/project-config.mjs +28 -0
  106. package/scripts/lib/schema-definitions.mjs +41 -46
  107. package/scripts/lib/schema-editor-metadata.mjs +475 -0
  108. package/scripts/lib/schema-value-definitions.mjs +6 -6
  109. package/scripts/lib/site-content.mjs +124 -87
  110. package/scripts/lib/site-paths.mjs +2 -2
  111. package/scripts/lib/theme-config.mjs +9 -16
  112. package/scripts/lib/theme-presets.mjs +9 -1
  113. package/scripts/release.mjs +1 -1
  114. package/scripts/show-typography.mjs +26 -20
  115. package/scripts/sync-content-sections.mjs +106 -65
  116. package/scripts/{test-temporary-visibility.mjs → test-banner-visibility.mjs} +7 -24
  117. package/scripts/test-client-javascript.mjs +39 -42
  118. package/scripts/test-content-check.mjs +138 -54
  119. package/scripts/test-content-model-v2.mjs +445 -228
  120. package/scripts/test-documentation.mjs +6 -1
  121. package/scripts/test-editor-language-service.mjs +168 -24
  122. package/scripts/test-engine-commands.mjs +2 -1
  123. package/scripts/test-heading-ids.mjs +68 -0
  124. package/scripts/test-navigation-model.mjs +63 -0
  125. package/scripts/test-nested-pages.mjs +110 -0
  126. package/scripts/test-package-check.mjs +31 -40
  127. package/scripts/test-page-model.mjs +80 -0
  128. package/scripts/test-project-config.mjs +25 -1
  129. package/scripts/test-schemas.mjs +66 -14
  130. package/scripts/test-theme-presets.mjs +33 -24
  131. package/src/components/NavigationPageTree.astro +68 -0
  132. package/src/components/SectionNavigationScript.astro +110 -5
  133. package/src/components/SiteBreadcrumbs.astro +29 -0
  134. package/src/components/SiteNavigation.astro +65 -69
  135. package/src/components/SitePage.astro +70 -46
  136. package/src/components/SiteSection.astro +27 -20
  137. package/src/content.config.ts +9 -5
  138. package/src/layouts/BaseLayout.astro +11 -20
  139. package/src/lib/pageThemes.ts +42 -0
  140. package/src/lib/sectionContent.ts +109 -106
  141. package/src/lib/siteNavigation.ts +48 -0
  142. package/src/lib/sitePages.ts +85 -38
  143. package/src/pages/{[slug].astro → [...slug].astro} +2 -2
  144. package/src/styles/global.css +373 -55
  145. package/starters/basic/README.md +5 -7
  146. package/starters/basic/site/content.md +7 -4
  147. package/starters/basic/site/sitewide-content.yaml +1 -10
  148. package/starters/project/README.md +8 -8
  149. package/starters/project/site/content.md +5 -4
  150. package/starters/project/site/{routes → pages}/010-guide/content.md +5 -4
  151. package/starters/project/site/sitewide-content.yaml +1 -10
  152. package/docs/routes.md +0 -121
  153. package/examples/feature-demos/sitewide-content/site/routes/010-identity/content.md +0 -20
  154. package/fixtures/content-model-v2/site/routes/010-guide/content.md +0 -17
  155. package/scripts/lib/route-model.mjs +0 -17
  156. package/scripts/test-route-model.mjs +0 -32
  157. package/src/lib/routeThemes.ts +0 -41
  158. /package/examples/complete-sites/dog-shelter-multi-page/site/images/{intro/shelter-dog.jpg → shelter-dog.jpg} +0 -0
  159. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rex.jpg +0 -0
  160. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rover.jpg +0 -0
  161. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/020-adopt/images/adopt → pages/020-adopt/images}/adoption-dog.jpg +0 -0
  162. /package/examples/complete-sites/dog-shelter-single-page/site/images/{you-can-help/foster-dog.jpg → foster-dog.jpg} +0 -0
  163. /package/examples/complete-sites/dog-shelter-single-page/site/images/{what-we-do/rescue-dog.jpg → rescue-dog.jpg} +0 -0
  164. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-carousel.svg +0 -0
  165. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-stack.svg +0 -0
  166. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-surfaces.svg +0 -0
  167. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-one.svg +0 -0
  168. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-three.svg +0 -0
  169. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-two.svg +0 -0
  170. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-one.svg +0 -0
  171. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-two.svg +0 -0
  172. /package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/theme.yaml +0 -0
  173. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/puppy-portrait.jpg +0 -0
  174. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/shepherd-portrait.jpg +0 -0
  175. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/white-puppy.jpg +0 -0
  176. /package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/theme.yaml +0 -0
  177. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/black-schnauzer.jpg +0 -0
  178. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/retriever-portrait.jpg +0 -0
  179. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/schnauzer-portrait.jpg +0 -0
  180. /package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/theme.yaml +0 -0
  181. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/boxer-portrait.jpg +0 -0
  182. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grass-puppy.jpg +0 -0
  183. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grey-street-dog.jpg +0 -0
  184. /package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/theme.yaml +0 -0
  185. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/black-pedigree.jpg +0 -0
  186. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-grass.jpg +0 -0
  187. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-wide.jpg +0 -0
  188. /package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/theme.yaml +0 -0
  189. /package/fixtures/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
  190. /package/fixtures/content-model-v2/site/images/{card-section/adopt.svg → adopt.svg} +0 -0
  191. /package/fixtures/content-model-v2/site/images/{intro/detail.jpg → detail.jpg} +0 -0
  192. /package/fixtures/content-model-v2/site/images/{intro/duplicate.jpg → duplicate.jpg} +0 -0
  193. /package/fixtures/content-model-v2/site/images/{card-section/foster.svg → foster.svg} +0 -0
  194. /package/fixtures/content-model-v2/site/images/{intro/hero.jpg → hero.jpg} +0 -0
  195. /package/fixtures/content-model-v2/site/images/{plain/image.jpg → image.jpg} +0 -0
  196. /package/fixtures/content-model-v2/site/images/{timed/slide-one.jpg → slide-one.jpg} +0 -0
  197. /package/fixtures/content-model-v2/site/images/{timed/slide-three.jpg → slide-three.jpg} +0 -0
  198. /package/fixtures/content-model-v2/site/images/{timed/slide-two.jpg → slide-two.jpg} +0 -0
  199. /package/fixtures/content-model-v2/site/{routes/010-guide/images/intro → pages/010-guide/images}/duplicate.jpg +0 -0
  200. /package/starters/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
@@ -13,7 +13,6 @@ import {
13
13
  import {
14
14
  getBodySections,
15
15
  getContentFiles,
16
- getFrontmatterSections,
17
16
  getImageCandidatesByName,
18
17
  getDeprecatedInlineStyleReferences,
19
18
  readSiteFile,
@@ -23,6 +22,7 @@ import {
23
22
  validateFrontmatterIndentation,
24
23
  validateThemeYamlStructure,
25
24
  } from './lib/site-content.mjs';
25
+ import { getHeadingIdentifierIssues } from './lib/heading-ids.mjs';
26
26
  import { readImageDimensions } from './lib/image-dimensions.mjs';
27
27
  import {
28
28
  siteThemeLabel,
@@ -65,7 +65,7 @@ const promptForWrite = async () => {
65
65
  if (!process.stdin.isTTY) return false;
66
66
 
67
67
  const rl = createInterface({ input, output });
68
- const answer = await rl.question('This will move image files into the page or route image roots where Norna references them. Continue? [y/N] ');
68
+ const answer = await rl.question('This will move image files into the page image roots where Norna references them. Continue? [y/N] ');
69
69
  rl.close();
70
70
 
71
71
  return answer.trim().toLowerCase() === 'y';
@@ -136,7 +136,7 @@ const getReportLines = (title, unreferencedImages) => {
136
136
  lines.push(
137
137
  '',
138
138
  'Unreferenced Images',
139
- 'These files are kept under page or route image roots but are not referenced by Norna-managed image references:',
139
+ 'These files are kept under page image roots but are not referenced by Norna-managed image references:',
140
140
  );
141
141
 
142
142
  for (const imagePath of unreferencedImages) {
@@ -171,16 +171,16 @@ const getAspectRatioLabel = ({ width, height }) => {
171
171
  return `${width / divisor}:${height / divisor}`;
172
172
  };
173
173
 
174
- const getExpectedImagePath = (contentFile, sectionId, imageName) =>
175
- path.join(contentFile.imagesDir, sectionId, imageName);
174
+ const getExpectedImagePath = (contentFile, imageName) =>
175
+ path.join(contentFile.imagesDir, imageName);
176
176
 
177
- const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
178
- `${contentFile.imagesLabel}/${sectionId}/${imageName}`;
177
+ const getExpectedImageLabel = (contentFile, imageName) =>
178
+ `${contentFile.imagesLabel}/${imageName}`;
179
179
 
180
180
  const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
181
181
  (globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
182
182
 
183
- const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
183
+ const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
184
184
 
185
185
  const getRootLabel = (contentFile) => contentFile.isHome
186
186
  ? contentFile.imagesLabel
@@ -217,8 +217,8 @@ const assertCleanGitForCrossRootSync = async (moves) => {
217
217
  } catch (error) {
218
218
  addIssue({
219
219
  severity: 'error',
220
- message: 'Cross-route content sync requires a clean git working tree, but git status could not be checked.',
221
- fix: 'Run content:sync inside a Git worktree, or move cross-route image files manually.',
220
+ message: 'Cross-page content sync requires a clean git working tree, but git status could not be checked.',
221
+ fix: 'Run content:sync inside a Git worktree, or move cross-page image files manually.',
222
222
  });
223
223
  return;
224
224
  }
@@ -227,7 +227,7 @@ const assertCleanGitForCrossRootSync = async (moves) => {
227
227
 
228
228
  addIssue({
229
229
  severity: 'error',
230
- message: 'Cross-route content sync requires a clean git working tree before moving files between page or route image roots.',
230
+ message: 'Cross-page content sync requires a clean git working tree before moving files between page image roots.',
231
231
  fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
232
232
  });
233
233
  };
@@ -256,9 +256,9 @@ const addConflictingMoveIssues = () => {
256
256
  if (destinations.size <= 1) continue;
257
257
 
258
258
  const firstMove = moves[0];
259
- addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
259
+ addSectionIssue(firstMove.contentFile, firstMove.section, {
260
260
  severity: 'error',
261
- message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.sectionId, move.imageName)).join(', ')}.`,
261
+ message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
262
262
  fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
263
263
  });
264
264
  }
@@ -268,9 +268,9 @@ const addConflictingMoveIssues = () => {
268
268
  if (sources.size <= 1) continue;
269
269
 
270
270
  const firstMove = moves[0];
271
- addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
271
+ addSectionIssue(firstMove.contentFile, firstMove.section, {
272
272
  severity: 'error',
273
- message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.sectionId, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
273
+ message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
274
274
  fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
275
275
  });
276
276
  }
@@ -284,8 +284,8 @@ const validateImageReference = async (
284
284
  expectedReferencesByPath,
285
285
  ) => {
286
286
  const imageName = reference.image;
287
- const expectedPath = getExpectedImagePath(contentFile, section.id, imageName);
288
- const expectedLabel = getExpectedImageLabel(contentFile, section.id, imageName);
287
+ const expectedPath = getExpectedImagePath(contentFile, imageName);
288
+ const expectedLabel = getExpectedImageLabel(contentFile, imageName);
289
289
  const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
290
290
 
291
291
  if (expectedExists) {
@@ -297,8 +297,8 @@ const validateImageReference = async (
297
297
  if (candidates.length === 0) {
298
298
  addSectionIssue(contentFile, section, {
299
299
  severity: 'error',
300
- message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page or route image root.`,
301
- fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
300
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
301
+ fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
302
302
  });
303
303
  return null;
304
304
  }
@@ -315,7 +315,7 @@ const validateImageReference = async (
315
315
  const sourceCandidate = candidates[0];
316
316
  const sourcePath = sourceCandidate.imagePath;
317
317
  const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
318
- .filter((expectedReference) => expectedReference.contentFile !== contentFile || expectedReference.section.id !== section.id);
318
+ .filter((expectedReference) => expectedReference.contentFile !== contentFile);
319
319
 
320
320
  if (referencesAtCurrentLocation.length > 0) {
321
321
  addSectionIssue(contentFile, section, {
@@ -326,15 +326,17 @@ const validateImageReference = async (
326
326
  return null;
327
327
  }
328
328
 
329
- imageMoves.push({
330
- imageName,
331
- from: sourcePath,
332
- to: expectedPath,
333
- contentFile,
334
- sourceContentFile: sourceCandidate.contentFile,
335
- sectionId: section.id,
336
- crossRoot: sourceCandidate.contentFile !== contentFile,
337
- });
329
+ if (!imageMoves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
330
+ imageMoves.push({
331
+ imageName,
332
+ from: sourcePath,
333
+ to: expectedPath,
334
+ contentFile,
335
+ sourceContentFile: sourceCandidate.contentFile,
336
+ section,
337
+ crossRoot: sourceCandidate.contentFile !== contentFile,
338
+ });
339
+ }
338
340
  referencedImagePaths.add(sourcePath);
339
341
 
340
342
  if (!shouldWrite) {
@@ -342,8 +344,8 @@ const validateImageReference = async (
342
344
  severity: 'error',
343
345
  message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
344
346
  fix: sourceCandidate.contentFile === contentFile
345
- ? 'Run norna content:sync to move it inside the current page or route image root.'
346
- : `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-route sync requires a clean git working tree when files are moved.`,
347
+ ? 'Run norna content:sync to move it directly into the current page image root.'
348
+ : `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-page sync requires a clean git working tree when files are moved.`,
347
349
  });
348
350
  }
349
351
 
@@ -412,11 +414,27 @@ for (const contentFile of contentFiles) {
412
414
  validateFrontmatterIndentation(frontmatter, addIssue);
413
415
  validateContentFrontmatterStructure(frontmatter, addIssue);
414
416
 
415
- const frontmatterSections = getFrontmatterSections(frontmatter);
416
- const frontmatterIds = new Set(frontmatterSections.map((section) => section.id));
417
- const { sections } = getBodySections(body);
417
+ const { headings, pageHeadings, prelude, sections } = await getBodySections(body);
418
+ const headingIdentifierIssues = getHeadingIdentifierIssues(headings);
419
+ const invalidHeadingLines = new Set();
420
+ for (const issue of headingIdentifierIssues) {
421
+ invalidHeadingLines.add(issue.heading.line);
422
+ if (issue.otherHeading) invalidHeadingLines.add(issue.otherHeading.line);
423
+ const line = bodyLineOffset + issue.heading.line;
424
+ const relatedLine = issue.otherHeading
425
+ ? ` The other heading is on line ${bodyLineOffset + issue.otherHeading.line}.`
426
+ : '';
427
+ addIssue({
428
+ severity: 'error',
429
+ message: `${contentFile.contentLabel} line ${line}: ${issue.message}${relatedLine}`,
430
+ fix: issue.fix,
431
+ sectionId: `${contentFile.contentLabel}:heading:${issue.heading.line}`,
432
+ sectionLabel: `${contentFile.contentLabel} line ${line}`,
433
+ });
434
+ }
418
435
  const sectionsById = new Map();
419
- const blockResultsBySectionId = new Map();
436
+ const blockResultsBySection = new Map();
437
+ const validSections = new Set();
420
438
  const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
421
439
 
422
440
  for (const [imageName, candidates] of imageCandidatesByName) {
@@ -427,36 +445,71 @@ for (const contentFile of contentFiles) {
427
445
  }
428
446
  }
429
447
 
448
+ if (pageHeadings.length === 0) {
449
+ addContentIssue(contentFile, {
450
+ severity: 'error',
451
+ message: 'The page is missing its Markdown H1 title.',
452
+ fix: 'Add exactly one page title before any sections, for example "# About".',
453
+ });
454
+ } else if (pageHeadings.length > 1) {
455
+ addContentIssue(contentFile, {
456
+ severity: 'error',
457
+ message: `The page contains ${pageHeadings.length} Markdown H1 headings.`,
458
+ fix: 'Keep exactly one H1 page title. Use level 2 headings with explicit ids for sections.',
459
+ });
460
+ }
461
+
462
+ if (sections[0] && !sections[0].isPageTitle) {
463
+ addContentIssue(contentFile, {
464
+ severity: 'error',
465
+ message: `The first content heading is "${sections[0].heading}", but a Norna page must start with its H1 title.`,
466
+ fix: 'Add the page title first, for example "# About".',
467
+ });
468
+ }
469
+
470
+ if (prelude.trim()) {
471
+ addContentIssue(contentFile, {
472
+ severity: 'error',
473
+ message: 'The page contains content before its first heading.',
474
+ fix: 'Make the Markdown H1 page title the first content after optional frontmatter.',
475
+ });
476
+ }
477
+
430
478
  for (const section of sections) {
431
- if (!section.id) {
432
- addSectionIssue(contentFile, section, {
433
- severity: 'error',
434
- message: `Section heading "${section.heading.replace(/^##\s+/, '')}" is missing an explicit id.`,
435
- fix: `Write it as: ${section.heading} {#section-id}`,
436
- });
479
+ if (section.isPageTitle) {
480
+ if (
481
+ pageHeadings.length === 1
482
+ && sections[0] === section
483
+ && !invalidHeadingLines.has(section.line)
484
+ ) {
485
+ validSections.add(section);
486
+ }
437
487
  continue;
438
488
  }
439
489
 
490
+ if (invalidHeadingLines.has(section.line) || !section.id) continue;
491
+
440
492
  if (sectionsById.has(section.id)) {
441
493
  addSectionIssue(contentFile, section, {
442
494
  severity: 'error',
443
- message: `Duplicate Markdown section heading id "${section.id}".`,
444
- fix: 'Each Markdown level 2 section heading must use a unique explicit id within the page.',
495
+ message: `Duplicate Markdown heading id "${section.id}".`,
496
+ fix: 'Add a unique explicit id to at least one heading.',
445
497
  });
446
498
  continue;
447
499
  }
448
500
 
449
501
  sectionsById.set(section.id, section);
502
+ validSections.add(section);
450
503
  }
451
504
 
452
505
  for (const section of sections) {
453
- if (!section.id || !sectionsById.has(section.id)) continue;
506
+ if (!validSections.has(section)) continue;
454
507
 
455
508
  const blockResults = extractNornaMarkdownBlockDiagnostics(section.text, {
456
509
  label: contentFile.contentLabel,
457
510
  lineOffset: bodyLineOffset + section.line - 1,
458
511
  });
459
- blockResultsBySectionId.set(section.id, blockResults);
512
+ blockResultsBySection.set(section, blockResults);
460
513
 
461
514
  const inlineNoteResults = extractInlineNoteDiagnostics(section.text, {
462
515
  label: contentFile.contentLabel,
@@ -470,17 +523,16 @@ for (const contentFile of contentFiles) {
470
523
  }
471
524
 
472
525
  for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
473
- const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
526
+ const expectedPath = getExpectedImagePath(contentFile, reference.image);
474
527
  addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
475
528
  }
476
529
  }
477
530
 
478
531
  contentFileContexts.push({
479
532
  contentFile,
480
- frontmatterIds,
481
533
  sections,
482
- sectionsById,
483
- blockResultsBySectionId,
534
+ validSections,
535
+ blockResultsBySection,
484
536
  body,
485
537
  });
486
538
  }
@@ -488,23 +540,12 @@ for (const contentFile of contentFiles) {
488
540
  for (const context of contentFileContexts) {
489
541
  const {
490
542
  contentFile,
491
- frontmatterIds,
492
543
  sections,
493
- sectionsById,
494
- blockResultsBySectionId,
544
+ validSections,
545
+ blockResultsBySection,
495
546
  body,
496
547
  } = context;
497
548
 
498
- for (const id of frontmatterIds) {
499
- if (!sectionsById.has(id)) {
500
- addContentIssue(contentFile, {
501
- severity: 'error',
502
- message: `Section metadata "${id}" does not match any Markdown section.`,
503
- fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}", or remove sections.${id}.`,
504
- });
505
- }
506
- }
507
-
508
549
  for (const styleName of new Set(getDeprecatedInlineStyleReferences(body))) {
509
550
  addContentIssue(contentFile, {
510
551
  severity: 'error',
@@ -514,7 +555,7 @@ for (const context of contentFileContexts) {
514
555
  }
515
556
 
516
557
  for (const section of sections) {
517
- if (!section.id || !sectionsById.has(section.id)) continue;
558
+ if (!validSections.has(section)) continue;
518
559
 
519
560
  const markdownImages = extractMarkdownImageReferences(section.text);
520
561
  for (const reference of markdownImages) {
@@ -525,7 +566,7 @@ for (const context of contentFileContexts) {
525
566
  });
526
567
  }
527
568
 
528
- const { blocks, errors: blockErrors } = blockResultsBySectionId.get(section.id) ?? { blocks: [], errors: [] };
569
+ const { blocks, errors: blockErrors } = blockResultsBySection.get(section) ?? { blocks: [], errors: [] };
529
570
  for (const error of blockErrors) {
530
571
  addSectionIssue(contentFile, section, {
531
572
  severity: 'error',
@@ -601,5 +642,5 @@ if (!canWrite) {
601
642
  for (const move of imageMoves) {
602
643
  await mkdir(path.dirname(move.to), { recursive: true });
603
644
  await rename(move.from, move.to);
604
- console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/${move.sectionId}/.`);
645
+ console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
605
646
  }
@@ -8,7 +8,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'
8
8
  const nornaBin = path.join(repoRoot, 'bin', 'norna.mjs');
9
9
  const tempParent = path.join(repoRoot, 'node_modules', '.cache');
10
10
  await mkdir(tempParent, { recursive: true });
11
- const tempRoot = await mkdtemp(path.join(tempParent, 'norna-temporary-visibility-'));
11
+ const tempRoot = await mkdtemp(path.join(tempParent, 'norna-banner-visibility-'));
12
12
  const siteDir = path.join(tempRoot, 'site');
13
13
 
14
14
  const runCli = (args, env = {}) => {
@@ -34,7 +34,7 @@ const runCli = (args, env = {}) => {
34
34
  };
35
35
 
36
36
  try {
37
- await mkdir(path.join(siteDir, 'images', 'work'), { recursive: true });
37
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
38
38
  await mkdir(path.join(siteDir, 'public'), { recursive: true });
39
39
  await writeFile(path.join(siteDir, 'public', 'robots.txt'), 'User-agent: *\nAllow: /\n');
40
40
  await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\n');
@@ -56,28 +56,13 @@ palette: dark
56
56
  text: Active banner text.
57
57
  `);
58
58
  await writeFile(path.join(siteDir, 'content.md'), `---
59
- title: Temporary Visibility Test
60
- description: Test site for temporary sections.
61
- sections:
62
- expired:
63
- visible:
64
- until: "2026-01-01"
65
- active:
66
- visible:
67
- from: "2026-01-01"
68
- until: "2026-12-31"
69
- always: {}
70
-
59
+ page:
60
+ description: Test site for temporary banners.
71
61
  ---
72
- ## Expired {#expired}
73
-
74
- Expired section text.
75
-
76
- ## Active {#active}
77
62
 
78
- Active section text.
63
+ # Temporary Visibility Test
79
64
 
80
- ## Always {#always}
65
+ ## Content {#content}
81
66
 
82
67
  Always visible section text.
83
68
  `);
@@ -85,13 +70,11 @@ Always visible section text.
85
70
  runCli(['build'], { NORNA_TODAY: '2026-06-15' });
86
71
 
87
72
  const html = await readFile(path.join(tempRoot, 'dist', 'index.html'), 'utf8');
88
- assert.match(html, /Active section text/);
89
73
  assert.match(html, /Always visible section text/);
90
- assert.doesNotMatch(html, /Expired section text/);
91
74
  assert.match(html, /Active banner text/);
92
75
  assert.doesNotMatch(html, /Expired banner text/);
93
76
 
94
- console.log('Temporary visibility test passed.');
77
+ console.log('Temporary banner visibility test passed.');
95
78
  } finally {
96
79
  await rm(tempRoot, { force: true, recursive: true });
97
80
  }
@@ -10,7 +10,7 @@ const tempParent = path.join(repoRoot, 'node_modules', '.cache');
10
10
  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
- const routeDir = path.join(siteDir, 'routes', '010-details');
13
+ const pageDir = path.join(siteDir, 'pages', '010-details');
14
14
  const configPath = path.join(siteDir, 'config.yaml');
15
15
 
16
16
  const runBuild = () => {
@@ -59,17 +59,14 @@ try {
59
59
  await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
60
60
  profile: reading
61
61
  palette: paper
62
- `);
63
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
64
- label: Test site
65
62
  `);
66
63
  await writeFile(path.join(siteDir, 'content.md'), `---
67
- title: Section navigation
68
- description: A single page with sticky section navigation.
69
- sections:
70
- intro: {}
71
- more: {}
64
+ page:
65
+ description: A single page with sticky section navigation.
72
66
  ---
67
+
68
+ # Section navigation
69
+
73
70
  ## Intro {#intro}
74
71
 
75
72
  The section heading is a native anchor target.
@@ -104,27 +101,26 @@ The navigation enhancement keeps native anchors clear of the sticky header.
104
101
 
105
102
  await writeConfig();
106
103
 
107
- await mkdir(path.join(routeDir, 'images', 'details'), { recursive: true });
108
- await writeSvg(path.join(routeDir, 'images', 'details', 'first.svg'), '#7b8f78');
109
- await writeSvg(path.join(routeDir, 'images', 'details', 'second.svg'), '#a7664b');
104
+ await mkdir(path.join(pageDir, 'images'), { recursive: true });
105
+ await writeSvg(path.join(pageDir, 'images', 'first.svg'), '#7b8f78');
106
+ await writeSvg(path.join(pageDir, 'images', 'second.svg'), '#a7664b');
110
107
  await writeFile(path.join(siteDir, 'content.md'), `---
111
- title: Static baseline
112
- description: A page without interactive features.
113
- sections:
114
- intro: {}
108
+ page:
109
+ description: A page without interactive features.
115
110
  ---
111
+
112
+ # Static baseline
113
+
116
114
  ## Intro {#intro}
117
115
 
118
116
  Ordinary Markdown needs no client-side JavaScript.
119
117
  `);
120
- await writeFile(path.join(routeDir, 'content.md'), `---
121
- title: Details
122
- description: Static managed images.
123
- sections:
124
- details: {}
118
+ await writeFile(path.join(pageDir, 'content.md'), `---
119
+ page:
120
+ description: Static managed images.
125
121
  ---
126
- ## Details {#details}
127
122
 
123
+ # Details
128
124
  Static image stacks remain static.
129
125
 
130
126
  \`\`\`norna-image-stack
@@ -148,23 +144,25 @@ Static image stacks remain static.
148
144
  );
149
145
 
150
146
  await writeFile(path.join(siteDir, 'content.md'), `---
151
- title: Notes
152
- description: A page with an enhanced sidenote.
153
- sections:
154
- intro: {}
147
+ page:
148
+ description: A page with an enhanced sidenote.
155
149
  ---
150
+
151
+ # Notes
152
+
156
153
  ## Intro {#intro}
157
154
 
158
155
  This sentence has additional context.{note-ref}
159
156
 
160
157
  {note: The note remains readable without JavaScript, while alignment is enhanced when JavaScript runs.}
161
158
  `);
162
- await writeFile(path.join(routeDir, 'content.md'), `---
163
- title: Carousel
164
- description: An interactive image carousel.
165
- sections:
166
- details: {}
159
+ await writeFile(path.join(pageDir, 'content.md'), `---
160
+ page:
161
+ description: An interactive image carousel.
167
162
  ---
163
+
164
+ # Carousel
165
+
168
166
  ## Details {#details}
169
167
 
170
168
  \`\`\`norna-image-carousel
@@ -185,24 +183,23 @@ sections:
185
183
  assert.match(getScripts(carouselHtml)[0], /\ssrc=/, 'Carousel JavaScript should be emitted as a module asset.');
186
184
 
187
185
  await writeFile(path.join(siteDir, 'content.md'), `---
188
- title: Banner
189
- description: A page with a dismissible banner.
190
- sections:
191
- intro: {}
186
+ page:
187
+ description: A page with a dismissible banner.
192
188
  ---
189
+
190
+ # Banner
191
+
193
192
  ## Intro {#intro}
194
193
 
195
194
  The only interactive feature is the banner.
196
195
  `);
197
- await writeFile(path.join(routeDir, 'content.md'), `---
198
- title: Details
199
- description: A second plain page.
200
- sections:
201
- details: {}
196
+ await writeFile(path.join(pageDir, 'content.md'), `---
197
+ page:
198
+ description: A second plain page.
202
199
  ---
203
- ## Details {#details}
204
200
 
205
- Plain route content.
201
+ # Details
202
+ Plain page content.
206
203
  `);
207
204
  await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `banners:
208
205
  - id: test-banner