@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
@@ -109,89 +109,191 @@ test('content model v2 fixture checks and builds', async () => {
109
109
  await runNorna(['--site-dir', siteDir, 'build']);
110
110
 
111
111
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
112
- assert.ok(manifest['images/intro/duplicate.jpg']);
113
- assert.ok(manifest['routes/010-guide/images/intro/duplicate.jpg']);
112
+ assert.ok(manifest['images/duplicate.jpg']);
113
+ assert.ok(manifest['pages/010-guide/images/duplicate.jpg']);
114
+
115
+ const homepageHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
116
+ assert.match(homepageHtml, /site-section-page-title/);
117
+ assert.match(homepageHtml, /site-section-page-title-empty/);
118
+ assert.match(homepageHtml, /fetchpriority="high"/);
119
+ } finally {
120
+ await rm(root, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('the removed routes directory is rejected with a page migration hint', async () => {
125
+ const { root, siteDir } = await createTempSite();
126
+ try {
127
+ await writeFile(path.join(siteDir, 'content.md'), `# Home
128
+ Home content.
129
+ `);
130
+ await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
131
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), 'Old page content.\n');
132
+
133
+ await assert.rejects(
134
+ runNorna(['--site-dir', siteDir, 'content:check']),
135
+ (error) => {
136
+ assert.match(error.output, /site\/routes is no longer supported\. Rename it to site\/pages and use NNN-page-id directory names\./);
137
+ return true;
138
+ },
139
+ );
114
140
  } finally {
115
141
  await rm(root, { recursive: true, force: true });
116
142
  }
117
143
  });
118
144
 
119
- test('route theme replaces visual theme while site identity stays at the root', async () => {
145
+ test('page theme replaces visual theme without changing site navigation', async () => {
120
146
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
121
147
  try {
122
148
  await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
123
149
  profile: reading
124
150
  `);
125
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
126
- label: Root Brand
127
- `);
128
- await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
151
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
129
152
  await writeFile(path.join(siteDir, 'content.md'), `---
130
- title: Root
131
- description: Root page
153
+ page:
154
+ description: Root page
132
155
  ---
133
156
 
157
+ # Root
158
+
134
159
  ## Home {#home}
135
160
 
136
161
  Root content.
137
162
  `);
138
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
139
- title: Guide
140
- description: Guide page
141
- navigation:
142
- label: Guide
163
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
164
+ page:
165
+ description: Guide page
143
166
  ---
144
167
 
145
- ## Guide {#guide}
146
-
147
- Route content.
168
+ # Guide
169
+ Page content.
148
170
  `);
149
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `typography:
171
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'theme.yaml'), `typography:
150
172
  profile: statement
151
173
  palette: light
152
174
  `);
153
175
 
154
176
  await runNorna(['--site-dir', siteDir, 'build']);
155
177
  const rootHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
156
- const routeHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'guide', 'index.html'), 'utf8');
178
+ const pageHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'guide', 'index.html'), 'utf8');
157
179
  assert.match(rootHtml, /--color-page: #000000/);
158
- assert.match(routeHtml, /--color-page: #ffffff/);
159
- assert.match(routeHtml, /<a class="site-brand" href="\/">Root Brand<\/a>/);
180
+ assert.match(pageHtml, /--color-page: #ffffff/);
181
+ assert.match(pageHtml, /<a href="\/">\s*Root\s*<\/a>/);
160
182
  } finally {
161
183
  await rm(root, { recursive: true, force: true });
162
184
  }
163
185
  });
164
186
 
165
- test('route theme cannot define site identity', async () => {
187
+ test('page metadata, navigation logo, and page listing have separate roles', async () => {
166
188
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
167
189
  try {
168
- await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
190
+ await mkdir(path.join(siteDir, 'public'), { recursive: true });
191
+ await writeFile(path.join(siteDir, 'public', 'logo.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>');
192
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `logo:
193
+ height: 2rem
194
+ `);
195
+ await writeFile(path.join(siteDir, 'content.md'), `# Welcome
196
+
197
+ ## Intro {#intro}
198
+
199
+ Homepage content.
200
+ `);
201
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
202
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
203
+ page:
204
+ description: The visible guide page.
205
+ ---
206
+
207
+ # Guide
208
+ Guide content.
209
+ `);
210
+ await mkdir(path.join(siteDir, 'pages', '020-private'), { recursive: true });
211
+ await writeFile(path.join(siteDir, 'pages', '020-private', 'content.md'), `---
212
+ navigation:
213
+ listed: false
214
+ ---
215
+
216
+ # Unlisted
217
+ Public but unlisted content.
218
+ `);
219
+
220
+ await runNorna(['--site-dir', siteDir, 'build']);
221
+ const distDir = path.join(path.dirname(siteDir), 'dist');
222
+ const homeHtml = await readFile(path.join(distDir, 'index.html'), 'utf8');
223
+ const guideHtml = await readFile(path.join(distDir, 'guide', 'index.html'), 'utf8');
224
+ const unlistedHtml = await readFile(path.join(distDir, 'private', 'index.html'), 'utf8');
225
+
226
+ assert.match(homeHtml, /<title>Welcome<\/title>/);
227
+ assert.doesNotMatch(homeHtml, /<meta name="description"/);
228
+ assert.match(homeHtml, /<img class="site-brand-logo"[^>]+alt="Welcome"/);
229
+ assert.match(homeHtml, /<a href="\/" aria-current="page">\s*Welcome\s*<\/a>/);
230
+ assert.match(homeHtml, /<a href="\/guide\/">\s*Guide\s*<\/a>/);
231
+ assert.doesNotMatch(homeHtml, />\s*Unlisted\s*<\/a>/);
232
+ assert.match(guideHtml, /<meta name="description" content="The visible guide page\."/);
233
+ assert.match(unlistedHtml, /Public but unlisted content/);
234
+ } finally {
235
+ await rm(root, { recursive: true, force: true });
236
+ }
237
+ });
238
+
239
+ test('footer build information uses the site language and fixed formatting', async () => {
240
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
241
+ try {
242
+ await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\nlanguage: sv-SE\n');
243
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `footer:
244
+ buildInfo: true
245
+ `);
169
246
  await writeFile(path.join(siteDir, 'content.md'), `---
170
- title: Root
171
- description: Root page
247
+ page:
248
+ description: Footer build information.
172
249
  ---
173
250
 
251
+ # Footer
252
+
174
253
  ## Home {#home}
175
254
 
176
- Root content.
255
+ Content.
177
256
  `);
178
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
179
- title: Guide
180
- description: Guide page
257
+
258
+ await runNorna(['--site-dir', siteDir, 'build']);
259
+ const html = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
260
+ assert.match(html, /<span>Byggd [^<]+<\/span>/);
261
+ } finally {
262
+ await rm(root, { recursive: true, force: true });
263
+ }
264
+ });
265
+
266
+ test('page theme cannot define navigation logo settings', async () => {
267
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
268
+ try {
269
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
270
+ await writeFile(path.join(siteDir, 'content.md'), `---
271
+ page:
272
+ description: Root page
181
273
  ---
182
274
 
183
- ## Guide {#guide}
275
+ # Root
276
+
277
+ ## Home {#home}
278
+
279
+ Root content.
280
+ `);
281
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
282
+ page:
283
+ description: Guide page
284
+ ---
184
285
 
185
- Route content.
286
+ # Guide
287
+ Page content.
186
288
  `);
187
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `navigation:
188
- label: Route Brand
289
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'theme.yaml'), `logo:
290
+ height: 3rem
189
291
  `);
190
292
 
191
293
  await assert.rejects(
192
294
  runNorna(['--site-dir', siteDir, 'config:check']),
193
295
  (error) => {
194
- assert.match(error.output, /may not define navigation\. The site label and logo settings belong in site\/sitewide-content\.yaml/);
296
+ assert.match(error.output, /may not define navigation logo settings\. Optional logo display settings belong under "logo:" in site\/sitewide-content\.yaml/);
195
297
  return true;
196
298
  },
197
299
  );
@@ -203,16 +305,14 @@ Route content.
203
305
  test('navigation logo settings require one conventional logo file', async () => {
204
306
  const { root, siteDir } = await createTempSite();
205
307
  try {
206
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
207
- label: Missing logo
208
- logo:
209
- height: 2rem
308
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `logo:
309
+ height: 2rem
210
310
  `);
211
311
 
212
312
  await assert.rejects(
213
313
  runNorna(['--site-dir', siteDir, 'config:check']),
214
314
  (error) => {
215
- assert.match(error.output, /navigation\.logo is configured, but no logo file was found/);
315
+ assert.match(error.output, /Site-wide logo is configured, but no logo file was found/);
216
316
  assert.match(error.output, /site\/public\/logo\.svg/);
217
317
  return true;
218
318
  },
@@ -225,9 +325,6 @@ test('navigation logo settings require one conventional logo file', async () =>
225
325
  test('multiple conventional navigation logo files stop config validation', async () => {
226
326
  const { root, siteDir } = await createTempSite();
227
327
  try {
228
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
229
- label: Conflicting logos
230
- `);
231
328
  await mkdir(path.join(siteDir, 'public'), { recursive: true });
232
329
  await writeFile(path.join(siteDir, 'public', 'logo.svg'), '<svg/>');
233
330
  await writeFile(path.join(siteDir, 'public', 'logo.png'), 'png');
@@ -250,10 +347,12 @@ test('content:check warns for local Markdown images but not external Markdown im
250
347
  const { root, siteDir } = await createTempSite();
251
348
  try {
252
349
  await writeFile(path.join(siteDir, 'content.md'), `---
253
- title: Markdown Image Warning
254
- description: Fixture
350
+ page:
351
+ description: Fixture
255
352
  ---
256
353
 
354
+ # Markdown Image Warning
355
+
257
356
  ## Intro {#intro}
258
357
 
259
358
  ![Local](portrait.jpg)
@@ -276,10 +375,12 @@ test('managed SVG images are copied as static image output', async () => {
276
375
  const { root, siteDir } = await createTempSite();
277
376
  try {
278
377
  await writeFile(path.join(siteDir, 'content.md'), `---
279
- title: SVG Fixture
280
- description: Fixture
378
+ page:
379
+ description: Fixture
281
380
  ---
282
381
 
382
+ # SVG Fixture
383
+
283
384
  ## Intro {#intro}
284
385
 
285
386
  \`\`\`norna-image-stack
@@ -287,18 +388,18 @@ description: Fixture
287
388
  alt: Diagram
288
389
  \`\`\`
289
390
  `);
290
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
291
- await writeFile(path.join(siteDir, 'images', 'intro', 'diagram.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620"/></svg>\n');
391
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
392
+ await writeFile(path.join(siteDir, 'images', 'diagram.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620"/></svg>\n');
292
393
 
293
394
  await runContentScript(siteDir, ['--check']);
294
395
  await runNorna(['--site-dir', siteDir, 'images']);
295
396
 
296
397
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
297
- const entry = manifest['images/intro/diagram.svg'];
398
+ const entry = manifest['images/diagram.svg'];
298
399
  assert.equal(entry.kind, 'static');
299
400
  assert.equal(entry.width, 900);
300
401
  assert.equal(entry.height, 620);
301
- assert.match(entry.src, /^\/images\/original\/images\/intro\/diagram-[a-f0-9]{8}\.svg$/);
402
+ assert.match(entry.src, /^\/images\/original\/images\/diagram-[a-f0-9]{8}\.svg$/);
302
403
  assert.equal(entry.variants, undefined);
303
404
  assert.equal(await fileExists(path.join(siteDir, '.norna', 'public', entry.src.replace(/^\//, ''))), true);
304
405
  } finally {
@@ -310,10 +411,12 @@ test('inline notes render as linked numbered margin notes', async () => {
310
411
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
311
412
  try {
312
413
  await writeFile(path.join(siteDir, 'content.md'), `---
313
- title: Inline notes
314
- description: Fixture
414
+ page:
415
+ description: Fixture
315
416
  ---
316
417
 
418
+ # Inline notes
419
+
317
420
  ## Intro {#intro}
318
421
 
319
422
  The first paragraph points to an explanation.{note-ref}
@@ -348,10 +451,12 @@ test('updated managed SVG images get updated static output', async () => {
348
451
  const { root, siteDir } = await createTempSite();
349
452
  try {
350
453
  await writeFile(path.join(siteDir, 'content.md'), `---
351
- title: SVG Update Fixture
352
- description: Fixture
454
+ page:
455
+ description: Fixture
353
456
  ---
354
457
 
458
+ # SVG Update Fixture
459
+
355
460
  ## Intro {#intro}
356
461
 
357
462
  \`\`\`norna-image-stack
@@ -359,22 +464,22 @@ description: Fixture
359
464
  alt: Diagram
360
465
  \`\`\`
361
466
  `);
362
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
363
- const sourcePath = path.join(siteDir, 'images', 'intro', 'diagram.svg');
467
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
468
+ const sourcePath = path.join(siteDir, 'images', 'diagram.svg');
364
469
  await writeFile(sourcePath, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620" fill="red"/></svg>\n');
365
470
 
366
471
  await runContentScript(siteDir, ['--check']);
367
472
  await runNorna(['--site-dir', siteDir, 'images']);
368
473
 
369
474
  const firstManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
370
- const firstEntry = firstManifest['images/intro/diagram.svg'];
475
+ const firstEntry = firstManifest['images/diagram.svg'];
371
476
  const firstOutputPath = path.join(siteDir, '.norna', 'public', firstEntry.src.replace(/^\//, ''));
372
477
 
373
478
  await writeFile(sourcePath, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620" fill="blue"/></svg>\n');
374
479
  await runNorna(['--site-dir', siteDir, 'images']);
375
480
 
376
481
  const secondManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
377
- const secondEntry = secondManifest['images/intro/diagram.svg'];
482
+ const secondEntry = secondManifest['images/diagram.svg'];
378
483
  const secondOutputPath = path.join(siteDir, '.norna', 'public', secondEntry.src.replace(/^\//, ''));
379
484
 
380
485
  assert.notEqual(secondEntry.sourceHash, firstEntry.sourceHash);
@@ -390,10 +495,12 @@ test('content:check warns when carousel SVG images have no intrinsic aspect rati
390
495
  const { root, siteDir } = await createTempSite();
391
496
  try {
392
497
  await writeFile(path.join(siteDir, 'content.md'), `---
393
- title: SVG Carousel Fixture
394
- description: Fixture
498
+ page:
499
+ description: Fixture
395
500
  ---
396
501
 
502
+ # SVG Carousel Fixture
503
+
397
504
  ## Intro {#intro}
398
505
 
399
506
  \`\`\`norna-image-carousel
@@ -403,9 +510,9 @@ description: Fixture
403
510
  alt: Second
404
511
  \`\`\`
405
512
  `);
406
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
407
- await writeFile(path.join(siteDir, 'images', 'intro', 'first.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>\n');
408
- await writeFile(path.join(siteDir, 'images', 'intro', 'second.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100"/></svg>\n');
513
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
514
+ await writeFile(path.join(siteDir, 'images', 'first.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>\n');
515
+ await writeFile(path.join(siteDir, 'images', 'second.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100"/></svg>\n');
409
516
 
410
517
  const { stdout } = await runContentScript(siteDir, ['--check']);
411
518
  assert.match(stdout, /Content check completed with warnings\./);
@@ -419,10 +526,12 @@ test('norna-card-list images are managed image references', async () => {
419
526
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
420
527
  try {
421
528
  await writeFile(path.join(siteDir, 'content.md'), `---
422
- title: Card List Fixture
423
- description: Fixture
529
+ page:
530
+ description: Fixture
424
531
  ---
425
532
 
533
+ # Card List Fixture
534
+
426
535
  ## Help {#help}
427
536
 
428
537
  \`\`\`norna-card-list
@@ -440,15 +549,15 @@ width: narrow
440
549
  text: Support the shelter.
441
550
  \`\`\`
442
551
  `);
443
- await mkdir(path.join(siteDir, 'images', 'help'), { recursive: true });
444
- await writeFile(path.join(siteDir, 'images', 'help', 'adopt.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"><rect width="160" height="100"/></svg>\n');
552
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
553
+ await writeFile(path.join(siteDir, 'images', 'adopt.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"><rect width="160" height="100"/></svg>\n');
445
554
 
446
555
  const { stdout } = await runContentScript(siteDir, ['--check']);
447
556
  assert.match(stdout, /Content check passed\./);
448
557
 
449
558
  await runNorna(['--site-dir', siteDir, 'images']);
450
559
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
451
- assert.equal(manifest['images/help/adopt.svg'].kind, 'static');
560
+ assert.equal(manifest['images/adopt.svg'].kind, 'static');
452
561
 
453
562
  await runNorna(['--site-dir', siteDir, 'build']);
454
563
  const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
@@ -460,7 +569,7 @@ width: narrow
460
569
  assert.match(html, /class="card-list-badge">Recommended<\/p>/);
461
570
  assert.match(html, /Give a dog a new home\./);
462
571
  assert.match(html, /href="\/adopt\/"/);
463
- assert.match(html, /src="\/images\/original\/images\/help\/adopt-[a-f0-9]{8}\.svg"/);
572
+ assert.match(html, /src="\/images\/original\/images\/adopt-[a-f0-9]{8}\.svg"/);
464
573
  } finally {
465
574
  await rm(root, { recursive: true, force: true });
466
575
  }
@@ -470,10 +579,12 @@ test('content:check fails when a norna-card-list image file is missing', async (
470
579
  const { root, siteDir } = await createTempSite();
471
580
  try {
472
581
  await writeFile(path.join(siteDir, 'content.md'), `---
473
- title: Missing Card Image
474
- description: Fixture
582
+ page:
583
+ description: Fixture
475
584
  ---
476
585
 
586
+ # Missing Card Image
587
+
477
588
  ## Help {#help}
478
589
 
479
590
  \`\`\`norna-card-list
@@ -485,7 +596,7 @@ description: Fixture
485
596
  await assert.rejects(
486
597
  () => runContentScript(siteDir, ['--check']),
487
598
  (error) => {
488
- assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/images\/help\/missing\.svg or anywhere under any page or route image root\./);
599
+ assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/images\/missing\.svg or anywhere under any page image root\./);
489
600
  return true;
490
601
  },
491
602
  );
@@ -498,10 +609,12 @@ test('content:check fails for malformed norna-card-list blocks', async () => {
498
609
  const { root, siteDir } = await createTempSite();
499
610
  try {
500
611
  await writeFile(path.join(siteDir, 'content.md'), `---
501
- title: Malformed Card List
502
- description: Fixture
612
+ page:
613
+ description: Fixture
503
614
  ---
504
615
 
616
+ # Malformed Card List
617
+
505
618
  ## Help {#help}
506
619
 
507
620
  \`\`\`norna-card-list
@@ -525,10 +638,12 @@ test('content:check fails for invalid norna-card-list options', async () => {
525
638
  const { root, siteDir } = await createTempSite();
526
639
  try {
527
640
  await writeFile(path.join(siteDir, 'content.md'), `---
528
- title: Invalid Card List Options
529
- description: Fixture
641
+ page:
642
+ description: Fixture
530
643
  ---
531
644
 
645
+ # Invalid Card List Options
646
+
532
647
  ## Help {#help}
533
648
 
534
649
  \`\`\`norna-card-list
@@ -558,10 +673,12 @@ test('content:check fails for invalid norna-card-list width', async () => {
558
673
  const { root, siteDir } = await createTempSite();
559
674
  try {
560
675
  await writeFile(path.join(siteDir, 'content.md'), `---
561
- title: Invalid Card List Width
562
- description: Fixture
676
+ page:
677
+ description: Fixture
563
678
  ---
564
679
 
680
+ # Invalid Card List Width
681
+
565
682
  ## Help {#help}
566
683
 
567
684
  \`\`\`norna-card-list
@@ -588,10 +705,12 @@ test('content:check fails for invalid norna-card-list size', async () => {
588
705
  const { root, siteDir } = await createTempSite();
589
706
  try {
590
707
  await writeFile(path.join(siteDir, 'content.md'), `---
591
- title: Invalid Card List Size
592
- description: Fixture
708
+ page:
709
+ description: Fixture
593
710
  ---
594
711
 
712
+ # Invalid Card List Size
713
+
595
714
  ## Help {#help}
596
715
 
597
716
  \`\`\`norna-card-list
@@ -618,10 +737,12 @@ test('content:sync moves Norna-managed images inside the same page image root',
618
737
  const { root, siteDir } = await createTempSite();
619
738
  try {
620
739
  await writeFile(path.join(siteDir, 'content.md'), `---
621
- title: Sync Fixture
622
- description: Fixture
740
+ page:
741
+ description: Fixture
623
742
  ---
624
743
 
744
+ # Sync Fixture
745
+
625
746
  ## Work {#work}
626
747
 
627
748
  \`\`\`norna-image-stack
@@ -634,7 +755,7 @@ description: Fixture
634
755
 
635
756
  await runContentScript(siteDir, ['--write', '--yes']);
636
757
 
637
- const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.jpg'), 'utf8');
758
+ const moved = await readFile(path.join(siteDir, 'images', 'moved.jpg'), 'utf8');
638
759
  assert.equal(moved, 'fixture image');
639
760
  } finally {
640
761
  await rm(root, { recursive: true, force: true });
@@ -645,10 +766,12 @@ test('content:sync moves managed SVG images inside the same page image root', as
645
766
  const { root, siteDir } = await createTempSite();
646
767
  try {
647
768
  await writeFile(path.join(siteDir, 'content.md'), `---
648
- title: SVG Sync Fixture
649
- description: Fixture
769
+ page:
770
+ description: Fixture
650
771
  ---
651
772
 
773
+ # SVG Sync Fixture
774
+
652
775
  ## Work {#work}
653
776
 
654
777
  \`\`\`norna-image-stack
@@ -661,161 +784,179 @@ description: Fixture
661
784
 
662
785
  await runContentScript(siteDir, ['--write', '--yes']);
663
786
 
664
- const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.svg'), 'utf8');
787
+ const moved = await readFile(path.join(siteDir, 'images', 'moved.svg'), 'utf8');
665
788
  assert.match(moved, /viewBox="0 0 10 10"/);
666
789
  } finally {
667
790
  await rm(root, { recursive: true, force: true });
668
791
  }
669
792
  });
670
793
 
671
- test('content:sync moves Norna-managed images across route image roots when git is clean', async () => {
794
+ test('content:sync moves Norna-managed images across page image roots when git is clean', async () => {
672
795
  const { root, siteDir } = await createTempSite();
673
796
  try {
674
- const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
675
- const targetRouteDir = path.join(siteDir, 'routes', '020-target');
676
- await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
677
- await mkdir(targetRouteDir, { recursive: true });
797
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
798
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
799
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
800
+ await mkdir(targetPageDir, { recursive: true });
678
801
  await writeFile(path.join(siteDir, 'content.md'), `---
679
- title: Cross Route Sync
680
- description: Fixture
802
+ page:
803
+ description: Fixture
681
804
  ---
682
805
 
806
+ # Cross Page Sync
807
+
683
808
  ## Home {#home}
684
809
 
685
810
  Text.
686
811
  `);
687
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
688
- title: Source
689
- description: Fixture
812
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
813
+ page:
814
+ description: Fixture
690
815
  ---
691
816
 
817
+ # Source
818
+
692
819
  ## Old {#old}
693
820
 
694
821
  Text.
695
822
  `);
696
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
697
- title: Target
698
- description: Fixture
823
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
824
+ page:
825
+ description: Fixture
699
826
  ---
700
827
 
828
+ # Target
829
+
701
830
  ## Work {#work}
702
831
 
703
832
  \`\`\`norna-image-stack
704
833
  - image: moved.jpg
705
834
  \`\`\`
706
835
  `);
707
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
836
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
708
837
  await initCleanGitWorktree(root);
709
838
 
710
839
  await runContentScript(siteDir, ['--write', '--yes']);
711
840
 
712
- assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'moved.jpg')), true);
713
- assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg')), false);
841
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'moved.jpg')), true);
842
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'moved.jpg')), false);
714
843
  } finally {
715
844
  await rm(root, { recursive: true, force: true });
716
845
  }
717
846
  });
718
847
 
719
- test('content:sync refuses cross-route image moves when git status is dirty', async () => {
848
+ test('content:sync refuses cross-page image moves when git status is dirty', async () => {
720
849
  const { root, siteDir } = await createTempSite();
721
850
  try {
722
- const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
723
- const targetRouteDir = path.join(siteDir, 'routes', '020-target');
724
- await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
725
- await mkdir(targetRouteDir, { recursive: true });
851
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
852
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
853
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
854
+ await mkdir(targetPageDir, { recursive: true });
726
855
  await writeFile(path.join(siteDir, 'content.md'), `---
727
- title: Dirty Cross Route Sync
728
- description: Fixture
856
+ page:
857
+ description: Fixture
729
858
  ---
730
859
 
860
+ # Dirty Cross Page Sync
861
+
731
862
  ## Home {#home}
732
863
 
733
864
  Text.
734
865
  `);
735
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
736
- title: Source
737
- description: Fixture
866
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
867
+ page:
868
+ description: Fixture
738
869
  ---
739
870
 
871
+ # Source
872
+
740
873
  ## Old {#old}
741
874
 
742
875
  Text.
743
876
  `);
744
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
745
- title: Target
746
- description: Fixture
877
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
878
+ page:
879
+ description: Fixture
747
880
  ---
748
881
 
882
+ # Target
883
+
749
884
  ## Work {#work}
750
885
 
751
886
  \`\`\`norna-image-stack
752
887
  - image: moved.jpg
753
888
  \`\`\`
754
889
  `);
755
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
890
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
756
891
  await initCleanGitWorktree(root);
757
892
  await writeFile(path.join(root, 'dirty.txt'), 'dirty');
758
893
 
759
894
  await assert.rejects(
760
895
  () => runContentScript(siteDir, ['--write', '--yes']),
761
896
  (error) => {
762
- assert.match(error.output, /Cross-route content sync requires a clean git working tree before moving files between page or route image roots\./);
897
+ assert.match(error.output, /Cross-page content sync requires a clean git working tree before moving files between page image roots\./);
763
898
  assert.match(error.output, /dirty\.txt/);
764
899
  return true;
765
900
  },
766
901
  );
767
902
 
768
- assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'moved.jpg')), false);
769
- assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg')), true);
903
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'moved.jpg')), false);
904
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'moved.jpg')), true);
770
905
  } finally {
771
906
  await rm(root, { recursive: true, force: true });
772
907
  }
773
908
  });
774
909
 
775
- test('content:check reports cross-route image moves without requiring clean git status', async () => {
910
+ test('content:check reports cross-page image moves without requiring clean git status', async () => {
776
911
  const { root, siteDir } = await createTempSite();
777
912
  try {
778
- const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
779
- const targetRouteDir = path.join(siteDir, 'routes', '020-target');
780
- await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
781
- await mkdir(targetRouteDir, { recursive: true });
913
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
914
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
915
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
916
+ await mkdir(targetPageDir, { recursive: true });
782
917
  await writeFile(path.join(siteDir, 'content.md'), `---
783
- title: Cross Route Check
784
- description: Fixture
918
+ page:
919
+ description: Fixture
785
920
  ---
786
921
 
922
+ # Cross Page Check
923
+
787
924
  ## Home {#home}
788
925
 
789
926
  Text.
790
927
  `);
791
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
792
- title: Source
793
- description: Fixture
928
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
929
+ page:
930
+ description: Fixture
794
931
  ---
795
932
 
933
+ # Source
934
+
796
935
  ## Old {#old}
797
936
 
798
937
  Text.
799
938
  `);
800
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
801
- title: Target
802
- description: Fixture
939
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
940
+ page:
941
+ description: Fixture
803
942
  ---
804
943
 
944
+ # Target
945
+
805
946
  ## Work {#work}
806
947
 
807
948
  \`\`\`norna-image-stack
808
949
  - image: moved.jpg
809
950
  \`\`\`
810
951
  `);
811
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
952
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
812
953
  await writeFile(path.join(root, 'dirty.txt'), 'dirty');
813
954
 
814
955
  await assert.rejects(
815
956
  () => runContentScript(siteDir, ['--check']),
816
957
  (error) => {
817
- assert.match(error.output, /Image "moved\.jpg" is used here but is located in .*site\/routes\/010-source\/images\/old\/moved\.jpg\./);
818
- assert.match(error.output, /Run norna content:sync to move it from .*site\/routes\/010-source to .*site\/routes\/020-target\./);
958
+ assert.match(error.output, /Image "moved\.jpg" is used here but is located in .*site\/pages\/010-source\/images\/moved\.jpg\./);
959
+ assert.match(error.output, /Run norna content:sync to move it from .*site\/pages\/010-source to .*site\/pages\/020-target\./);
819
960
  assert.doesNotMatch(error.output, /requires a clean git working tree before moving files/);
820
961
  return true;
821
962
  },
@@ -825,56 +966,63 @@ description: Fixture
825
966
  }
826
967
  });
827
968
 
828
- test('content:sync refuses to move a cross-route image still referenced by its current section', async () => {
969
+ test('content:sync refuses to move a cross-page image still referenced by its current section', async () => {
829
970
  const { root, siteDir } = await createTempSite();
830
971
  try {
831
- const sourceRouteDir = path.join(siteDir, 'routes', '010-source');
832
- const targetRouteDir = path.join(siteDir, 'routes', '020-target');
833
- await mkdir(path.join(sourceRouteDir, 'images', 'old'), { recursive: true });
834
- await mkdir(targetRouteDir, { recursive: true });
972
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
973
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
974
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
975
+ await mkdir(targetPageDir, { recursive: true });
835
976
  await writeFile(path.join(siteDir, 'content.md'), `---
836
- title: Cross Route Copy
837
- description: Fixture
977
+ page:
978
+ description: Fixture
838
979
  ---
839
980
 
981
+ # Cross Page Copy
982
+
840
983
  ## Home {#home}
841
984
 
842
985
  Text.
843
986
  `);
844
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
845
- title: Source
846
- description: Fixture
987
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
988
+ page:
989
+ description: Fixture
847
990
  ---
848
991
 
992
+ # Source
993
+
849
994
  ## Old {#old}
850
995
 
851
996
  \`\`\`norna-image-stack
852
997
  - image: shared.jpg
853
998
  \`\`\`
854
999
  `);
855
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
856
- title: Target
857
- description: Fixture
1000
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
1001
+ page:
1002
+ description: Fixture
858
1003
  ---
859
1004
 
1005
+ # Target
1006
+
860
1007
  ## Work {#work}
861
1008
 
862
1009
  \`\`\`norna-image-stack
863
1010
  - image: shared.jpg
864
1011
  \`\`\`
865
1012
  `);
866
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'shared.jpg'), 'route image');
1013
+ await writeFile(path.join(sourcePageDir, 'images', 'shared.jpg'), 'page image');
1014
+ await initCleanGitWorktree(root);
867
1015
 
868
1016
  await assert.rejects(
869
1017
  () => runContentScript(siteDir, ['--write', '--yes']),
870
1018
  (error) => {
871
- assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/routes\/010-source\/images\/old\/shared\.jpg because it is still referenced from .*site\/routes\/010-source\/content\.md \[old\]\./);
1019
+ assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/pages\/010-source\/images\/shared\.jpg because it is still referenced from .*site\/pages\/010-source\/content\.md \[old\]\./);
872
1020
  return true;
873
1021
  },
874
1022
  );
875
1023
 
876
- assert.equal(await fileExists(path.join(targetRouteDir, 'images', 'work', 'shared.jpg')), false);
877
- assert.equal(await fileExists(path.join(sourceRouteDir, 'images', 'old', 'shared.jpg')), true);
1024
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'shared.jpg')), false);
1025
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'shared.jpg')), true);
878
1026
  } finally {
879
1027
  await rm(root, { recursive: true, force: true });
880
1028
  }
@@ -884,10 +1032,12 @@ test('CLI content:sync refreshes generated images after moving a carousel image'
884
1032
  const { root, siteDir } = await createTempSite();
885
1033
  try {
886
1034
  await writeFile(path.join(siteDir, 'content.md'), `---
887
- title: Sync Fixture
888
- description: Fixture
1035
+ page:
1036
+ description: Fixture
889
1037
  ---
890
1038
 
1039
+ # Sync Fixture
1040
+
891
1041
  ## Work {#work}
892
1042
 
893
1043
  \`\`\`norna-image-carousel
@@ -898,41 +1048,48 @@ description: Fixture
898
1048
  \`\`\`
899
1049
  `);
900
1050
  await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
901
- await mkdir(path.join(siteDir, 'images', 'work'), { recursive: true });
902
- await cp(path.join(fixtureSiteDir, 'images', 'intro', 'hero.jpg'), path.join(siteDir, 'images', 'old', 'slide-one.jpg'));
903
- await cp(path.join(fixtureSiteDir, 'images', 'intro', 'detail.jpg'), path.join(siteDir, 'images', 'work', 'slide-two.jpg'), { force: true });
1051
+ await cp(path.join(fixtureSiteDir, 'images', 'hero.jpg'), path.join(siteDir, 'images', 'old', 'slide-one.jpg'));
1052
+ await cp(path.join(fixtureSiteDir, 'images', 'detail.jpg'), path.join(siteDir, 'images', 'slide-two.jpg'), { force: true });
904
1053
 
905
1054
  await runNorna(['--site-dir', siteDir, 'content:sync', '--yes']);
906
1055
 
907
1056
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
908
- assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'slide-one.jpg')), true);
909
- assert.ok(manifest['images/work/slide-one.jpg']);
910
- assert.ok(manifest['images/work/slide-two.jpg']);
1057
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'slide-one.jpg')), true);
1058
+ assert.ok(manifest['images/slide-one.jpg']);
1059
+ assert.ok(manifest['images/slide-two.jpg']);
911
1060
  } finally {
912
1061
  await rm(root, { recursive: true, force: true });
913
1062
  }
914
1063
  });
915
1064
 
916
- test('content:check fails when a level 2 section is missing an explicit id', async () => {
917
- const { root, siteDir } = await createTempSite();
1065
+ test('automatic H2 and H3 ids render while explicit ids remain stable', async () => {
1066
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
918
1067
  try {
919
1068
  await writeFile(path.join(siteDir, 'content.md'), `---
920
- title: Missing Id
921
- description: Fixture
1069
+ page:
1070
+ description: Fixture
922
1071
  ---
923
1072
 
924
- ## Intro
1073
+ # Heading IDs
1074
+
1075
+ ## Café and **code**
1076
+
1077
+ ### More details
1078
+
1079
+ Automatic identifiers.
925
1080
 
926
- This section has no explicit id.
1081
+ ## Renamed heading {#stable-id}
1082
+
1083
+ Explicit identifier.
927
1084
  `);
928
1085
 
929
- await assert.rejects(
930
- () => runContentScript(siteDir, ['--check']),
931
- (error) => {
932
- assert.match(error.output, /Section heading "Intro" is missing an explicit id\./);
933
- return true;
934
- },
935
- );
1086
+ await runContentScript(siteDir, ['--check']);
1087
+ await runNorna(['--site-dir', siteDir, 'build']);
1088
+
1089
+ const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
1090
+ assert.match(html, /<h2 id="cafe-and-code">Café and <strong>code<\/strong><\/h2>/);
1091
+ assert.match(html, /<h3 id="more-details">More details<\/h3>/);
1092
+ assert.match(html, /<h2 id="stable-id">Renamed heading<\/h2>/);
936
1093
  } finally {
937
1094
  await rm(root, { recursive: true, force: true });
938
1095
  }
@@ -942,10 +1099,12 @@ test('content:check fails for malformed Norna image blocks', async () => {
942
1099
  const { root, siteDir } = await createTempSite();
943
1100
  try {
944
1101
  await writeFile(path.join(siteDir, 'content.md'), `---
945
- title: Malformed Block
946
- description: Fixture
1102
+ page:
1103
+ description: Fixture
947
1104
  ---
948
1105
 
1106
+ # Malformed Block
1107
+
949
1108
  ## Intro {#intro}
950
1109
 
951
1110
  \`\`\`norna-image-stack
@@ -972,10 +1131,12 @@ test('content:check fails for unknown Norna block names', async () => {
972
1131
  await mkdir(path.join(siteDir, 'images', 'plain'), { recursive: true });
973
1132
  await writeFile(path.join(siteDir, 'images', 'plain', 'image.jpg'), 'fixture image');
974
1133
  await writeFile(path.join(siteDir, 'content.md'), `---
975
- title: Unknown Block
976
- description: Fixture
1134
+ page:
1135
+ description: Fixture
977
1136
  ---
978
1137
 
1138
+ # Unknown Block
1139
+
979
1140
  ## Plain {#plain}
980
1141
 
981
1142
  \`\`\`norna-gallery-stack
@@ -1001,10 +1162,12 @@ test('content:check rejects the removed norna-note block', async () => {
1001
1162
  const { root, siteDir } = await createTempSite();
1002
1163
  try {
1003
1164
  await writeFile(path.join(siteDir, 'content.md'), `---
1004
- title: Removed note block
1005
- description: Fixture
1165
+ page:
1166
+ description: Fixture
1006
1167
  ---
1007
1168
 
1169
+ # Removed note block
1170
+
1008
1171
  ## Intro {#intro}
1009
1172
 
1010
1173
  \`\`\`norna-note
@@ -1027,13 +1190,15 @@ This block is no longer supported.
1027
1190
  test('content:check supports tilde fenced Norna blocks', async () => {
1028
1191
  const { root, siteDir } = await createTempSite();
1029
1192
  try {
1030
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1031
- await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
1193
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
1194
+ await writeFile(path.join(siteDir, 'images', 'hero.jpg'), 'fixture image');
1032
1195
  await writeFile(path.join(siteDir, 'content.md'), `---
1033
- title: Tilde Fence
1034
- description: Fixture
1196
+ page:
1197
+ description: Fixture
1035
1198
  ---
1036
1199
 
1200
+ # Tilde Fence
1201
+
1037
1202
  ## Intro {#intro}
1038
1203
 
1039
1204
  ~~~norna-image-stack
@@ -1052,10 +1217,12 @@ test('content:check ignores Norna blocks shown inside longer code fences', async
1052
1217
  const { root, siteDir } = await createTempSite();
1053
1218
  try {
1054
1219
  await writeFile(path.join(siteDir, 'content.md'), `---
1055
- title: Code Example
1056
- description: Fixture
1220
+ page:
1221
+ description: Fixture
1057
1222
  ---
1058
1223
 
1224
+ # Code Example
1225
+
1059
1226
  ## Intro {#intro}
1060
1227
 
1061
1228
  \`\`\`\`md
@@ -1076,10 +1243,12 @@ test('content:check fails for likely Norna block names outside fences', async ()
1076
1243
  const { root, siteDir } = await createTempSite();
1077
1244
  try {
1078
1245
  await writeFile(path.join(siteDir, 'content.md'), `---
1079
- title: Missing Fence
1080
- description: Fixture
1246
+ page:
1247
+ description: Fixture
1081
1248
  ---
1082
1249
 
1250
+ # Missing Fence
1251
+
1083
1252
  ## Intro {#intro}
1084
1253
 
1085
1254
  norna-image-stack
@@ -1103,10 +1272,12 @@ test('content:check fails for short Norna block fences', async () => {
1103
1272
  const { root, siteDir } = await createTempSite();
1104
1273
  try {
1105
1274
  await writeFile(path.join(siteDir, 'content.md'), `---
1106
- title: Short Fence
1107
- description: Fixture
1275
+ page:
1276
+ description: Fixture
1108
1277
  ---
1109
1278
 
1279
+ # Short Fence
1280
+
1110
1281
  ## Intro {#intro}
1111
1282
 
1112
1283
  \`\`norna-image-stack
@@ -1131,10 +1302,12 @@ test('content:check fails for unclosed Norna blocks', async () => {
1131
1302
  const { root, siteDir } = await createTempSite();
1132
1303
  try {
1133
1304
  await writeFile(path.join(siteDir, 'content.md'), `---
1134
- title: Unclosed Fence
1135
- description: Fixture
1305
+ page:
1306
+ description: Fixture
1136
1307
  ---
1137
1308
 
1309
+ # Unclosed Fence
1310
+
1138
1311
  ## Intro {#intro}
1139
1312
 
1140
1313
  \`\`\`norna-image-stack
@@ -1159,10 +1332,12 @@ test('content:check continues after malformed Norna blocks', async () => {
1159
1332
  const { root, siteDir } = await createTempSite();
1160
1333
  try {
1161
1334
  await writeFile(path.join(siteDir, 'content.md'), `---
1162
- title: Multiple Issues
1163
- description: Fixture
1335
+ page:
1336
+ description: Fixture
1164
1337
  ---
1165
1338
 
1339
+ # Multiple Issues
1340
+
1166
1341
  ## Intro {#intro}
1167
1342
 
1168
1343
  \`\`\`norna-image-stack
@@ -1187,8 +1362,8 @@ image broken.jpg
1187
1362
  () => runContentScript(siteDir, ['--check']),
1188
1363
  (error) => {
1189
1364
  assert.match(error.output, /Invalid norna-image-stack entry "image broken\.jpg"\. Start each image with "- image: filename\.jpg"\./);
1190
- assert.match(error.output, /Image "missing-intro\.jpg" does not exist at .*site\/images\/intro\/missing-intro\.jpg or anywhere under any page or route image root\./);
1191
- assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/images\/more\/missing-more\.jpg or anywhere under any page or route image root\./);
1365
+ assert.match(error.output, /Image "missing-intro\.jpg" does not exist at .*site\/images\/missing-intro\.jpg or anywhere under any page image root\./);
1366
+ assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/images\/missing-more\.jpg or anywhere under any page image root\./);
1192
1367
  return true;
1193
1368
  },
1194
1369
  );
@@ -1200,13 +1375,15 @@ image broken.jpg
1200
1375
  test('content:check allows Norna images without alt text', async () => {
1201
1376
  const { root, siteDir } = await createTempSite();
1202
1377
  try {
1203
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1204
- await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
1378
+ await mkdir(path.join(siteDir, 'images'), { recursive: true });
1379
+ await writeFile(path.join(siteDir, 'images', 'hero.jpg'), 'fixture image');
1205
1380
  await writeFile(path.join(siteDir, 'content.md'), `---
1206
- title: Missing Alt
1207
- description: Fixture
1381
+ page:
1382
+ description: Fixture
1208
1383
  ---
1209
1384
 
1385
+ # Missing Alt
1386
+
1210
1387
  ## Intro {#intro}
1211
1388
 
1212
1389
  \`\`\`norna-image-stack
@@ -1225,10 +1402,12 @@ test('content:check reports single-image carousel and missing file together', as
1225
1402
  const { root, siteDir } = await createTempSite();
1226
1403
  try {
1227
1404
  await writeFile(path.join(siteDir, 'content.md'), `---
1228
- title: Beginner Errors
1229
- description: Fixture
1405
+ page:
1406
+ description: Fixture
1230
1407
  ---
1231
1408
 
1409
+ # Beginner Errors
1410
+
1232
1411
  ## Plain {#plain}
1233
1412
 
1234
1413
  \`\`\`norna-image-carousel
@@ -1240,7 +1419,7 @@ description: Fixture
1240
1419
  () => runContentScript(siteDir, ['--check']),
1241
1420
  (error) => {
1242
1421
  assert.match(error.output, /norna-image-carousel on line \d+ contains 1 image\. A carousel needs at least two images\./);
1243
- assert.match(error.output, /Image "foo\.jpg" does not exist at .*site\/images\/plain\/foo\.jpg or anywhere under any page or route image root\./);
1422
+ assert.match(error.output, /Image "foo\.jpg" does not exist at .*site\/images\/foo\.jpg or anywhere under any page image root\./);
1244
1423
  return true;
1245
1424
  },
1246
1425
  );
@@ -1253,10 +1432,12 @@ test('content:check fails when a Norna-managed image file is missing', async ()
1253
1432
  const { root, siteDir } = await createTempSite();
1254
1433
  try {
1255
1434
  await writeFile(path.join(siteDir, 'content.md'), `---
1256
- title: Missing Image
1257
- description: Fixture
1435
+ page:
1436
+ description: Fixture
1258
1437
  ---
1259
1438
 
1439
+ # Missing Image
1440
+
1260
1441
  ## Intro {#intro}
1261
1442
 
1262
1443
  \`\`\`norna-image-stack
@@ -1268,7 +1449,7 @@ description: Fixture
1268
1449
  await assert.rejects(
1269
1450
  () => runContentScript(siteDir, ['--check']),
1270
1451
  (error) => {
1271
- assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/images\/intro\/missing\.jpg or anywhere under any page or route image root\./);
1452
+ assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/images\/missing\.jpg or anywhere under any page image root\./);
1272
1453
  return true;
1273
1454
  },
1274
1455
  );
@@ -1281,10 +1462,12 @@ test('content:check fails when a page repeats a section id', async () => {
1281
1462
  const { root, siteDir } = await createTempSite();
1282
1463
  try {
1283
1464
  await writeFile(path.join(siteDir, 'content.md'), `---
1284
- title: Duplicate Section
1285
- description: Fixture
1465
+ page:
1466
+ description: Fixture
1286
1467
  ---
1287
1468
 
1469
+ # Duplicate Section
1470
+
1288
1471
  ## Intro {#intro}
1289
1472
 
1290
1473
  First.
@@ -1297,7 +1480,7 @@ Second.
1297
1480
  await assert.rejects(
1298
1481
  () => runContentScript(siteDir, ['--check']),
1299
1482
  (error) => {
1300
- assert.match(error.output, /Duplicate Markdown section heading id "intro"\./);
1483
+ assert.match(error.output, /Two headings resolve to id "intro"\./);
1301
1484
  return true;
1302
1485
  },
1303
1486
  );
@@ -1306,16 +1489,51 @@ Second.
1306
1489
  }
1307
1490
  });
1308
1491
 
1309
- test('content:check fails when section metadata has no Markdown section', async () => {
1492
+ test('content:check reports collisions between automatic H2 and H3 ids', async () => {
1310
1493
  const { root, siteDir } = await createTempSite();
1311
1494
  try {
1312
1495
  await writeFile(path.join(siteDir, 'content.md'), `---
1313
- title: Orphaned Metadata
1314
- description: Fixture
1496
+ page:
1497
+ description: Fixture
1498
+ ---
1499
+
1500
+ # Automatic collision
1501
+
1502
+ ## Förstå
1503
+
1504
+ Section text.
1505
+
1506
+ ### Forsta
1507
+
1508
+ Topic text.
1509
+ `);
1510
+
1511
+ await assert.rejects(
1512
+ () => runContentScript(siteDir, ['--check']),
1513
+ (error) => {
1514
+ assert.match(error.output, /Two headings resolve to id "forsta"\./);
1515
+ assert.match(error.output, /The other heading is on line \d+\./);
1516
+ assert.match(error.output, /Add a unique explicit id to at least one heading/);
1517
+ return true;
1518
+ },
1519
+ );
1520
+ } finally {
1521
+ await rm(root, { recursive: true, force: true });
1522
+ }
1523
+ });
1524
+
1525
+ test('content:check rejects removed sections frontmatter', async () => {
1526
+ const { root, siteDir } = await createTempSite();
1527
+ try {
1528
+ await writeFile(path.join(siteDir, 'content.md'), `---
1529
+ page:
1530
+ description: Fixture
1315
1531
  sections:
1316
- missing: {}
1532
+ intro: {}
1317
1533
  ---
1318
1534
 
1535
+ # Removed Section Metadata
1536
+
1319
1537
  ## Intro {#intro}
1320
1538
 
1321
1539
  Text.
@@ -1324,7 +1542,7 @@ Text.
1324
1542
  await assert.rejects(
1325
1543
  () => runContentScript(siteDir, ['--check']),
1326
1544
  (error) => {
1327
- assert.match(error.output, /Section metadata "missing" does not match any Markdown section\./);
1545
+ assert.match(error.output, /defines "sections" at the top level, but it is not a valid top-level content field\./);
1328
1546
  return true;
1329
1547
  },
1330
1548
  );
@@ -1337,10 +1555,12 @@ test('content:sync refuses ambiguous image relocation', async () => {
1337
1555
  const { root, siteDir } = await createTempSite();
1338
1556
  try {
1339
1557
  await writeFile(path.join(siteDir, 'content.md'), `---
1340
- title: Ambiguous Move
1341
- description: Fixture
1558
+ page:
1559
+ description: Fixture
1342
1560
  ---
1343
1561
 
1562
+ # Ambiguous Move
1563
+
1344
1564
  ## Work {#work}
1345
1565
 
1346
1566
  \`\`\`norna-image-stack
@@ -1363,7 +1583,7 @@ description: Fixture
1363
1583
  },
1364
1584
  );
1365
1585
 
1366
- assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'shared.jpg')), false);
1586
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'shared.jpg')), false);
1367
1587
  assert.equal(await fileExists(path.join(siteDir, 'images', 'old-a', 'shared.jpg')), true);
1368
1588
  assert.equal(await fileExists(path.join(siteDir, 'images', 'old-b', 'shared.jpg')), true);
1369
1589
  } finally {
@@ -1371,14 +1591,16 @@ description: Fixture
1371
1591
  }
1372
1592
  });
1373
1593
 
1374
- test('content:sync refuses to move an image still referenced by its current section', async () => {
1594
+ test('content:sync moves one page-local image once when multiple sections reference it', async () => {
1375
1595
  const { root, siteDir } = await createTempSite();
1376
1596
  try {
1377
1597
  await writeFile(path.join(siteDir, 'content.md'), `---
1378
- title: Copied Image Reference
1379
- description: Fixture
1598
+ page:
1599
+ description: Fixture
1380
1600
  ---
1381
1601
 
1602
+ # Copied Image Reference
1603
+
1382
1604
  ## Intro {#intro}
1383
1605
 
1384
1606
  \`\`\`norna-image-stack
@@ -1396,16 +1618,11 @@ description: Fixture
1396
1618
  await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1397
1619
  await writeFile(path.join(siteDir, 'images', 'intro', 'shared.jpg'), 'shared image');
1398
1620
 
1399
- await assert.rejects(
1400
- () => runContentScript(siteDir, ['--write', '--yes']),
1401
- (error) => {
1402
- assert.match(error.output, /Cannot relocate "shared\.jpg" from .*site\/images\/intro\/shared\.jpg because it is still referenced from .*site\/content\.md \[intro\]\./);
1403
- return true;
1404
- },
1405
- );
1621
+ await runContentScript(siteDir, ['--write', '--yes']);
1406
1622
 
1407
- assert.equal(await fileExists(path.join(siteDir, 'images', 'intro', 'shared.jpg')), true);
1408
- assert.equal(await fileExists(path.join(siteDir, 'images', 'plain', 'shared.jpg')), false);
1623
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'intro', 'shared.jpg')), false);
1624
+ assert.equal(await fileExists(path.join(siteDir, 'images', 'shared.jpg')), true);
1625
+ await runContentScript(siteDir, ['--check']);
1409
1626
  } finally {
1410
1627
  await rm(root, { recursive: true, force: true });
1411
1628
  }