@janga/norna 0.7.16 → 0.7.18

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 (342) hide show
  1. package/README.md +18 -9
  2. package/astro.config.mjs +35 -3
  3. package/bin/norna-cli.mjs +1 -1
  4. package/docs/README.md +16 -14
  5. package/docs/commands.md +2 -2
  6. package/docs/configuration.md +25 -2
  7. package/docs/content.md +92 -103
  8. package/docs/design/navigation-and-theme-plan.md +186 -0
  9. package/docs/design/norna-diagram-design.md +9 -9
  10. package/docs/design/site-examples-structure.md +15 -11
  11. package/docs/engine-development.md +19 -22
  12. package/docs/getting-started.md +21 -16
  13. package/docs/images-and-metadata.md +12 -9
  14. package/docs/pages.md +190 -0
  15. package/docs/public-files.md +49 -36
  16. package/docs/publishing.md +19 -3
  17. package/docs/site-files.md +46 -38
  18. package/docs/sitewide-content.md +20 -36
  19. package/docs/theme.md +109 -143
  20. package/docs/typography.md +19 -31
  21. package/examples/README.md +10 -4
  22. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/site.schema.json +17 -41
  23. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/sitewide.schema.json +26 -67
  24. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/theme.schema.json +185 -88
  25. package/examples/complete-sites/dog-shelter-multi-page/.astro/data-store.json +1 -1
  26. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev-local.json +4 -4
  27. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.json +5 -5
  28. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.log +33 -23
  29. package/examples/complete-sites/dog-shelter-multi-page/README.md +1 -1
  30. package/examples/complete-sites/dog-shelter-multi-page/node_modules/.vite/deps/_metadata.json +8 -8
  31. package/examples/complete-sites/dog-shelter-multi-page/site/.norna/generated-images.json +20 -20
  32. package/examples/complete-sites/dog-shelter-multi-page/site/{content.md → pages/000-home/content.md} +3 -4
  33. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/010-dogs/content.md +3 -4
  34. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/020-adopt/content.md +3 -4
  35. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +2 -4
  36. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/site.schema.json +17 -41
  37. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/sitewide.schema.json +26 -67
  38. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/theme.schema.json +185 -88
  39. package/examples/complete-sites/dog-shelter-single-page/.astro/data-store.json +1 -1
  40. package/examples/complete-sites/dog-shelter-single-page/.astro/dev-local.json +8 -0
  41. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.json +5 -5
  42. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.log +16 -12
  43. package/examples/complete-sites/dog-shelter-single-page/README.md +1 -1
  44. package/examples/complete-sites/dog-shelter-single-page/node_modules/.vite/deps/_metadata.json +8 -8
  45. package/examples/complete-sites/dog-shelter-single-page/site/.norna/generated-images.json +12 -12
  46. package/examples/complete-sites/dog-shelter-single-page/site/{content.md → pages/000-home/content.md} +5 -2
  47. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +2 -4
  48. package/examples/feature-demos/media-and-surfaces/.astro/collections/site.schema.json +17 -41
  49. package/examples/feature-demos/media-and-surfaces/.astro/collections/sitewide.schema.json +26 -67
  50. package/examples/feature-demos/media-and-surfaces/.astro/collections/theme.schema.json +180 -91
  51. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  52. package/examples/feature-demos/media-and-surfaces/.astro/dev.log +4 -14
  53. package/examples/feature-demos/media-and-surfaces/README.md +2 -2
  54. package/examples/feature-demos/media-and-surfaces/node_modules/.vite/deps/_metadata.json +9 -9
  55. package/examples/feature-demos/media-and-surfaces/site/.norna/generated-images.json +16 -16
  56. package/examples/feature-demos/media-and-surfaces/site/{content.md → pages/000-home/content.md} +4 -7
  57. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/010-media/content.md +6 -5
  58. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/content.md +9 -7
  59. package/examples/feature-demos/media-and-surfaces/site/pages/020-surfaces/theme.yaml +2 -0
  60. package/examples/feature-demos/media-and-surfaces/site/sitewide-content.yaml +0 -2
  61. package/examples/feature-demos/sitewide-content/README.md +3 -3
  62. package/examples/feature-demos/sitewide-content/site/pages/000-home/content.md +17 -0
  63. package/examples/feature-demos/sitewide-content/site/pages/010-logo-and-navigation/content.md +17 -0
  64. package/examples/feature-demos/sitewide-content/site/{routes → pages}/020-notices/content.md +7 -6
  65. package/examples/feature-demos/sitewide-content/site/{routes → pages}/030-footer/content.md +6 -5
  66. package/examples/feature-demos/sitewide-content/site/sitewide-content.yaml +4 -13
  67. package/examples/feature-demos/theme-preset-documentation/README.md +16 -0
  68. package/examples/feature-demos/theme-preset-documentation/site/.norna/generated-images.json +74 -0
  69. package/examples/feature-demos/{theme-presets/site/routes/020-documentation → theme-preset-documentation/site/pages/000-home}/content.md +4 -7
  70. package/examples/feature-demos/theme-preset-documentation/site/sitewide-content.yaml +3 -0
  71. package/examples/feature-demos/theme-preset-portfolio/README.md +16 -0
  72. package/examples/feature-demos/theme-preset-portfolio/site/.norna/generated-images.json +74 -0
  73. package/examples/feature-demos/theme-preset-portfolio/site/config.yaml +1 -0
  74. package/examples/feature-demos/{theme-presets/site/routes/010-portfolio → theme-preset-portfolio/site/pages/000-home}/content.md +4 -7
  75. package/examples/feature-demos/theme-preset-portfolio/site/public/robots.txt +2 -0
  76. package/examples/feature-demos/theme-preset-portfolio/site/sitewide-content.yaml +3 -0
  77. package/examples/feature-demos/theme-preset-project/README.md +16 -0
  78. package/examples/feature-demos/theme-preset-project/site/.norna/generated-images.json +74 -0
  79. package/examples/feature-demos/theme-preset-project/site/config.yaml +1 -0
  80. package/examples/feature-demos/{theme-presets/site/routes/030-project → theme-preset-project/site/pages/000-home}/content.md +4 -7
  81. package/examples/feature-demos/theme-preset-project/site/public/robots.txt +2 -0
  82. package/examples/feature-demos/theme-preset-project/site/sitewide-content.yaml +3 -0
  83. package/examples/feature-demos/theme-preset-statement/README.md +16 -0
  84. package/examples/feature-demos/theme-preset-statement/site/.norna/generated-images.json +74 -0
  85. package/examples/feature-demos/theme-preset-statement/site/config.yaml +1 -0
  86. package/examples/feature-demos/{theme-presets/site/routes/040-statement → theme-preset-statement/site/pages/000-home}/content.md +4 -7
  87. package/examples/feature-demos/theme-preset-statement/site/public/robots.txt +2 -0
  88. package/examples/feature-demos/theme-preset-statement/site/sitewide-content.yaml +3 -0
  89. package/fixtures/basic/site/{content.md → pages/000-home/content.md} +5 -6
  90. package/fixtures/basic/site/sitewide-content.yaml +1 -8
  91. package/fixtures/basic/site/theme.yaml +1 -8
  92. package/fixtures/content-model-v2/site/{content.md → pages/000-home/content.md} +11 -13
  93. package/fixtures/content-model-v2/site/pages/010-guide/content.md +18 -0
  94. package/fixtures/nested-pages/.astro/collections/site.schema.json +35 -0
  95. package/fixtures/nested-pages/.astro/collections/sitewide.schema.json +97 -0
  96. package/fixtures/nested-pages/.astro/collections/theme.schema.json +844 -0
  97. package/fixtures/nested-pages/.astro/content-assets.mjs +4 -0
  98. package/fixtures/nested-pages/.astro/content-modules.mjs +1 -0
  99. package/fixtures/nested-pages/.astro/content.d.ts +183 -0
  100. package/fixtures/nested-pages/.astro/data-store.json +1 -0
  101. package/fixtures/nested-pages/.astro/dev-local.json +8 -0
  102. package/fixtures/nested-pages/.astro/dev.log +1128 -0
  103. package/fixtures/nested-pages/.astro/settings.json +5 -0
  104. package/fixtures/nested-pages/.astro/types.d.ts +2 -0
  105. package/fixtures/nested-pages/node_modules/.vite/deps/_metadata.json +74 -0
  106. package/fixtures/nested-pages/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  107. package/fixtures/nested-pages/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  108. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  109. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  110. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  111. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  112. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  113. package/fixtures/nested-pages/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  114. package/fixtures/nested-pages/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  115. package/fixtures/nested-pages/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  116. package/fixtures/nested-pages/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  117. package/fixtures/nested-pages/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  118. package/fixtures/nested-pages/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  119. package/fixtures/nested-pages/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  120. package/fixtures/nested-pages/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  121. package/fixtures/nested-pages/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  122. package/fixtures/nested-pages/node_modules/.vite/deps/package.json +3 -0
  123. package/fixtures/nested-pages/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  124. package/fixtures/nested-pages/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  125. package/fixtures/nested-pages/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  126. package/fixtures/nested-pages/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  127. package/fixtures/nested-pages/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  128. package/fixtures/nested-pages/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  129. package/fixtures/nested-pages/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  130. package/fixtures/nested-pages/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  131. package/fixtures/nested-pages/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  132. package/fixtures/nested-pages/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  133. package/fixtures/nested-pages/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  134. package/fixtures/nested-pages/site/.norna/generated-images.json +10 -0
  135. package/fixtures/nested-pages/site/config.yaml +3 -0
  136. package/fixtures/nested-pages/site/pages/000-home/content.md +15 -0
  137. package/fixtures/nested-pages/site/pages/010-guides/content.md +15 -0
  138. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/content.md +21 -0
  139. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/images/diagram.svg +21 -0
  140. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/pages/010-macos/content.md +18 -0
  141. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/theme.yaml +2 -0
  142. package/fixtures/nested-pages/site/pages/010-guides/pages/020-workflows/content.md +11 -0
  143. package/fixtures/nested-pages/site/pages/010-guides/pages/030-release-notes/content.md +9 -0
  144. package/fixtures/nested-pages/site/pages/010-guides/pages/040-configuration/content.md +3 -0
  145. package/fixtures/nested-pages/site/pages/010-guides/pages/050-content/content.md +3 -0
  146. package/fixtures/nested-pages/site/pages/010-guides/pages/060-themes/content.md +3 -0
  147. package/fixtures/nested-pages/site/pages/010-guides/pages/070-images/content.md +3 -0
  148. package/fixtures/nested-pages/site/pages/010-guides/pages/080-cards/content.md +3 -0
  149. package/fixtures/nested-pages/site/pages/010-guides/pages/090-notes/content.md +3 -0
  150. package/fixtures/nested-pages/site/pages/010-guides/pages/100-validation/content.md +3 -0
  151. package/fixtures/nested-pages/site/pages/010-guides/pages/110-synchronization/content.md +3 -0
  152. package/fixtures/nested-pages/site/pages/010-guides/pages/120-building/content.md +3 -0
  153. package/fixtures/nested-pages/site/pages/010-guides/pages/130-publishing/content.md +3 -0
  154. package/fixtures/nested-pages/site/pages/010-guides/pages/140-troubleshooting/content.md +3 -0
  155. package/fixtures/nested-pages/site/pages/010-guides/pages/150-migration/content.md +3 -0
  156. package/fixtures/nested-pages/site/pages/010-guides/pages/160-accessibility/content.md +3 -0
  157. package/fixtures/nested-pages/site/pages/010-guides/pages/170-performance/content.md +3 -0
  158. package/fixtures/nested-pages/site/pages/010-guides/pages/180-security/content.md +3 -0
  159. package/fixtures/nested-pages/site/pages/010-guides/pages/190-localization/content.md +3 -0
  160. package/fixtures/nested-pages/site/pages/010-guides/pages/200-upgrading/content.md +3 -0
  161. package/fixtures/nested-pages/site/pages/010-guides/pages/210-faq/content.md +3 -0
  162. package/fixtures/nested-pages/site/pages/010-guides/theme.yaml +7 -0
  163. package/fixtures/nested-pages/site/pages/020-reference/content.md +10 -0
  164. package/fixtures/nested-pages/site/pages/020-reference/pages/010-installation/content.md +11 -0
  165. package/fixtures/nested-pages/site/theme.yaml +1 -0
  166. package/package.json +8 -4
  167. package/schemas/config.schema.json +46 -5
  168. package/schemas/content-frontmatter.schema.json +28 -63
  169. package/schemas/manifest.json +4 -2
  170. package/schemas/page-theme.schema.json +192 -0
  171. package/schemas/sitewide-content.schema.json +58 -132
  172. package/schemas/theme.schema.json +434 -152
  173. package/scripts/check-config.mjs +8 -6
  174. package/scripts/deploy-site.mjs +6 -8
  175. package/scripts/doctor.mjs +2 -2
  176. package/scripts/generate-images.mjs +15 -29
  177. package/scripts/generate-schemas.mjs +7 -218
  178. package/scripts/lib/base-path-redirect.mjs +9 -0
  179. package/scripts/lib/editor-language-service.mjs +125 -79
  180. package/scripts/lib/example-sites.mjs +15 -3
  181. package/scripts/lib/heading-ids.mjs +161 -0
  182. package/scripts/lib/navigation-model.mjs +51 -0
  183. package/scripts/lib/norna-markdown-blocks.mjs +3 -4
  184. package/scripts/lib/page-model.mjs +102 -0
  185. package/scripts/lib/presentation.mjs +25 -17
  186. package/scripts/lib/project-config.mjs +46 -8
  187. package/scripts/lib/schema-definitions.mjs +78 -55
  188. package/scripts/lib/schema-editor-metadata.mjs +519 -0
  189. package/scripts/lib/schema-value-definitions.mjs +20 -6
  190. package/scripts/lib/site-content.mjs +159 -97
  191. package/scripts/lib/site-conventions.mjs +1 -0
  192. package/scripts/lib/site-paths.mjs +11 -5
  193. package/scripts/lib/theme-config.mjs +12 -18
  194. package/scripts/lib/theme-presets.mjs +55 -14
  195. package/scripts/release.mjs +1 -1
  196. package/scripts/show-typography.mjs +4 -30
  197. package/scripts/sync-content-sections.mjs +107 -68
  198. package/scripts/{test-temporary-visibility.mjs → test-banner-visibility.mjs} +8 -25
  199. package/scripts/test-client-javascript.mjs +50 -46
  200. package/scripts/test-content-check.mjs +153 -69
  201. package/scripts/test-content-model-v2.mjs +566 -285
  202. package/scripts/test-documentation.mjs +10 -3
  203. package/scripts/test-editor-language-service.mjs +172 -25
  204. package/scripts/test-engine-commands.mjs +6 -5
  205. package/scripts/test-examples.mjs +30 -1
  206. package/scripts/test-heading-ids.mjs +68 -0
  207. package/scripts/test-navigation-model.mjs +63 -0
  208. package/scripts/test-navigation.mjs +11 -2
  209. package/scripts/test-nested-pages.mjs +125 -0
  210. package/scripts/test-package-check.mjs +55 -95
  211. package/scripts/test-page-model.mjs +108 -0
  212. package/scripts/test-project-config.mjs +22 -3
  213. package/scripts/test-schemas.mjs +82 -15
  214. package/scripts/test-theme-presets.mjs +57 -42
  215. package/src/components/NavigationPageTree.astro +157 -0
  216. package/src/components/PageContentsLinks.astro +40 -0
  217. package/src/components/SectionNavigationScript.astro +117 -5
  218. package/src/components/SiteBreadcrumbs.astro +32 -0
  219. package/src/components/SiteNavigation.astro +97 -68
  220. package/src/components/SitePage.astro +93 -55
  221. package/src/components/SiteSection.astro +29 -27
  222. package/src/components/SiteTreeNavigation.astro +35 -0
  223. package/src/components/TreeNavigationScript.astro +83 -0
  224. package/src/content.config.ts +8 -10
  225. package/src/layouts/BaseLayout.astro +21 -20
  226. package/src/lib/pageThemes.ts +44 -0
  227. package/src/lib/sectionContent.ts +106 -107
  228. package/src/lib/siteNavigation.ts +49 -0
  229. package/src/lib/sitePages.ts +96 -38
  230. package/src/pages/{[slug].astro → [...slug].astro} +2 -2
  231. package/src/pages/index.astro +2 -1
  232. package/src/styles/global.css +743 -57
  233. package/starters/basic/README.md +9 -11
  234. package/starters/basic/site/{content.md → pages/000-home/content.md} +7 -4
  235. package/starters/basic/site/sitewide-content.yaml +1 -10
  236. package/starters/basic/site/theme.yaml +1 -105
  237. package/starters/project/README.md +14 -14
  238. package/starters/project/site/{content.md → pages/000-home/content.md} +5 -4
  239. package/starters/project/site/{routes → pages}/010-guide/content.md +5 -4
  240. package/starters/project/site/sitewide-content.yaml +1 -10
  241. package/starters/project/site/theme.yaml +1 -31
  242. package/docs/routes.md +0 -121
  243. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +0 -3
  244. package/examples/feature-demos/sitewide-content/site/content.md +0 -16
  245. package/examples/feature-demos/sitewide-content/site/routes/010-identity/content.md +0 -20
  246. package/examples/feature-demos/theme-presets/README.md +0 -18
  247. package/examples/feature-demos/theme-presets/site/.norna/generated-images.json +0 -290
  248. package/examples/feature-demos/theme-presets/site/content.md +0 -17
  249. package/examples/feature-demos/theme-presets/site/sitewide-content.yaml +0 -12
  250. package/examples/feature-demos/theme-presets/site/theme.yaml +0 -1
  251. package/fixtures/content-model-v2/site/routes/010-guide/content.md +0 -17
  252. package/scripts/lib/route-model.mjs +0 -17
  253. package/scripts/test-route-model.mjs +0 -32
  254. package/src/lib/routeThemes.ts +0 -41
  255. /package/examples/complete-sites/dog-shelter-multi-page/site/{images/intro → pages/000-home/images}/shelter-dog.jpg +0 -0
  256. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rex.jpg +0 -0
  257. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rover.jpg +0 -0
  258. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/020-adopt/images/adopt → pages/020-adopt/images}/adoption-dog.jpg +0 -0
  259. /package/examples/complete-sites/dog-shelter-single-page/site/{images/you-can-help → pages/000-home/images}/foster-dog.jpg +0 -0
  260. /package/examples/complete-sites/dog-shelter-single-page/site/{images/what-we-do → pages/000-home/images}/rescue-dog.jpg +0 -0
  261. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-carousel.svg +0 -0
  262. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-stack.svg +0 -0
  263. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-surfaces.svg +0 -0
  264. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-one.svg +0 -0
  265. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-three.svg +0 -0
  266. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-two.svg +0 -0
  267. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-one.svg +0 -0
  268. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-two.svg +0 -0
  269. /package/examples/feature-demos/{theme-presets → theme-preset-documentation}/site/config.yaml +0 -0
  270. /package/examples/feature-demos/{theme-presets/site/routes/020-documentation/images/documentation → theme-preset-documentation/site/pages/000-home/images}/black-schnauzer.jpg +0 -0
  271. /package/examples/feature-demos/{theme-presets/site/routes/020-documentation/images/documentation → theme-preset-documentation/site/pages/000-home/images}/retriever-portrait.jpg +0 -0
  272. /package/examples/feature-demos/{theme-presets/site/routes/020-documentation/images/documentation → theme-preset-documentation/site/pages/000-home/images}/schnauzer-portrait.jpg +0 -0
  273. /package/examples/feature-demos/{theme-presets → theme-preset-documentation}/site/public/robots.txt +0 -0
  274. /package/examples/feature-demos/{theme-presets/site/routes/020-documentation → theme-preset-documentation/site}/theme.yaml +0 -0
  275. /package/examples/feature-demos/{theme-presets/site/routes/010-portfolio/images/portfolio → theme-preset-portfolio/site/pages/000-home/images}/puppy-portrait.jpg +0 -0
  276. /package/examples/feature-demos/{theme-presets/site/routes/010-portfolio/images/portfolio → theme-preset-portfolio/site/pages/000-home/images}/shepherd-portrait.jpg +0 -0
  277. /package/examples/feature-demos/{theme-presets/site/routes/010-portfolio/images/portfolio → theme-preset-portfolio/site/pages/000-home/images}/white-puppy.jpg +0 -0
  278. /package/examples/feature-demos/{theme-presets/site/routes/010-portfolio → theme-preset-portfolio/site}/theme.yaml +0 -0
  279. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/collections/routeTheme.schema.json +0 -0
  280. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/collections/site.schema.json +0 -0
  281. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/collections/sitewide.schema.json +0 -0
  282. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/collections/theme.schema.json +0 -0
  283. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/content-assets.mjs +0 -0
  284. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/content-modules.mjs +0 -0
  285. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/content.d.ts +0 -0
  286. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/data-store.json +0 -0
  287. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/dev-local.json +0 -0
  288. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/dev.json +0 -0
  289. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/dev.log +0 -0
  290. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/settings.json +0 -0
  291. /package/examples/feature-demos/{theme-presets → theme-preset-project}/.astro/types.d.ts +0 -0
  292. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/_metadata.json +0 -0
  293. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro-D_GIpJIE.js +0 -0
  294. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro-D_GIpJIE.js.map +0 -0
  295. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_aria-query.js +0 -0
  296. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_aria-query.js.map +0 -0
  297. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_axobject-query.js +0 -0
  298. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_axobject-query.js.map +0 -0
  299. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_html-escaper.js +0 -0
  300. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_n_html-escaper.js.map +0 -0
  301. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -0
  302. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -0
  303. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/audit-DM9Hkl5B.js +0 -0
  304. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +0 -0
  305. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/embla-carousel.js +0 -0
  306. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/embla-carousel.js.map +0 -0
  307. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/highlight-DnZ1ULOL.js +0 -0
  308. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +0 -0
  309. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/icons-RyebKi45.js +0 -0
  310. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/icons-RyebKi45.js.map +0 -0
  311. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/package.json +0 -0
  312. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +0 -0
  313. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/settings-DaU-gNnK.js +0 -0
  314. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/settings-DaU-gNnK.js.map +0 -0
  315. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/toolbar-63PA619H.js +0 -0
  316. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/toolbar-63PA619H.js.map +0 -0
  317. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/ui-library--bkcNSYz.js +0 -0
  318. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +0 -0
  319. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/window-jPkpKVcy.js +0 -0
  320. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/window-jPkpKVcy.js.map +0 -0
  321. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/xray-BXCHpEJP.js +0 -0
  322. /package/examples/feature-demos/{theme-presets → theme-preset-project}/node_modules/.vite/deps/xray-BXCHpEJP.js.map +0 -0
  323. /package/examples/feature-demos/{theme-presets/site/routes/030-project/images/project → theme-preset-project/site/pages/000-home/images}/boxer-portrait.jpg +0 -0
  324. /package/examples/feature-demos/{theme-presets/site/routes/030-project/images/project → theme-preset-project/site/pages/000-home/images}/grass-puppy.jpg +0 -0
  325. /package/examples/feature-demos/{theme-presets/site/routes/030-project/images/project → theme-preset-project/site/pages/000-home/images}/grey-street-dog.jpg +0 -0
  326. /package/examples/feature-demos/{theme-presets/site/routes/030-project → theme-preset-project/site}/theme.yaml +0 -0
  327. /package/examples/feature-demos/{theme-presets/site/routes/040-statement/images/statement → theme-preset-statement/site/pages/000-home/images}/black-pedigree.jpg +0 -0
  328. /package/examples/feature-demos/{theme-presets/site/routes/040-statement/images/statement → theme-preset-statement/site/pages/000-home/images}/sheepdog-grass.jpg +0 -0
  329. /package/examples/feature-demos/{theme-presets/site/routes/040-statement/images/statement → theme-preset-statement/site/pages/000-home/images}/sheepdog-wide.jpg +0 -0
  330. /package/examples/feature-demos/{theme-presets/site/routes/040-statement → theme-preset-statement/site}/theme.yaml +0 -0
  331. /package/fixtures/basic/site/{images/work → pages/000-home/images}/.gitkeep +0 -0
  332. /package/fixtures/content-model-v2/site/{images/card-section → pages/000-home/images}/adopt.svg +0 -0
  333. /package/fixtures/content-model-v2/site/{images/intro → pages/000-home/images}/detail.jpg +0 -0
  334. /package/fixtures/content-model-v2/site/{images/intro → pages/000-home/images}/duplicate.jpg +0 -0
  335. /package/fixtures/content-model-v2/site/{images/card-section → pages/000-home/images}/foster.svg +0 -0
  336. /package/fixtures/content-model-v2/site/{images/intro → pages/000-home/images}/hero.jpg +0 -0
  337. /package/fixtures/content-model-v2/site/{images/plain → pages/000-home/images}/image.jpg +0 -0
  338. /package/fixtures/content-model-v2/site/{images/timed → pages/000-home/images}/slide-one.jpg +0 -0
  339. /package/fixtures/content-model-v2/site/{images/timed → pages/000-home/images}/slide-three.jpg +0 -0
  340. /package/fixtures/content-model-v2/site/{images/timed → pages/000-home/images}/slide-two.jpg +0 -0
  341. /package/fixtures/content-model-v2/site/{routes/010-guide/images/intro → pages/010-guide/images}/duplicate.jpg +0 -0
  342. /package/starters/basic/site/{images/work → pages/000-home/images}/.gitkeep +0 -0
@@ -50,7 +50,7 @@ const createTempSite = async ({ underRepoCache = false } = {}) => {
50
50
  await mkdir(tempParent, { recursive: true });
51
51
  const root = await mkdtemp(path.join(tempParent, 'norna-content-model-v2-'));
52
52
  const siteDir = path.join(root, 'site');
53
- await mkdir(siteDir, { recursive: true });
53
+ await mkdir(path.join(siteDir, 'pages', '000-home'), { recursive: true });
54
54
  await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\n');
55
55
  await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
56
56
  profile: reading
@@ -109,89 +109,255 @@ 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['pages/000-home/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"/);
114
119
  } finally {
115
120
  await rm(root, { recursive: true, force: true });
116
121
  }
117
122
  });
118
123
 
119
- test('route theme replaces visual theme while site identity stays at the root', async () => {
120
- const { root, siteDir } = await createTempSite({ underRepoCache: true });
124
+ test('the removed root page structure is rejected with a migration hint', async () => {
125
+ const { root, siteDir } = await createTempSite();
121
126
  try {
122
- await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
123
- profile: reading
127
+ await writeFile(path.join(siteDir, 'content.md'), `# Home
128
+ Home content.
124
129
  `);
125
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
126
- label: Root Brand
130
+
131
+ await assert.rejects(
132
+ runNorna(['--site-dir', siteDir, 'content:check']),
133
+ (error) => {
134
+ assert.match(error.output, /The old root-page structure is no longer supported: site\/content\.md\./);
135
+ assert.match(error.output, /Move the homepage content to site\/pages\/000-home\/content\.md/);
136
+ return true;
137
+ },
138
+ );
139
+ } finally {
140
+ await rm(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('the removed routes directory is rejected with a page migration hint', async () => {
145
+ const { root, siteDir } = await createTempSite();
146
+ try {
147
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `# Home
148
+ Home content.
127
149
  `);
128
150
  await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
129
- await writeFile(path.join(siteDir, 'content.md'), `---
130
- title: Root
131
- description: Root page
151
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), 'Old page content.\n');
152
+
153
+ await assert.rejects(
154
+ runNorna(['--site-dir', siteDir, 'content:check']),
155
+ (error) => {
156
+ assert.match(error.output, /site\/routes is no longer supported\. Rename it to site\/pages and use NNN-page-id directory names\./);
157
+ return true;
158
+ },
159
+ );
160
+ } finally {
161
+ await rm(root, { recursive: true, force: true });
162
+ }
163
+ });
164
+
165
+ test('the required home page cannot own child pages', async () => {
166
+ const { root, siteDir } = await createTempSite();
167
+ try {
168
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `# Home
169
+
170
+ ## Welcome {#welcome}
171
+
172
+ Home content.
173
+ `);
174
+ const homeChildDir = path.join(siteDir, 'pages', '000-home', 'pages', '010-news');
175
+ await mkdir(homeChildDir, { recursive: true });
176
+ await writeFile(path.join(homeChildDir, 'content.md'), '# News\n\nNews content.\n');
177
+
178
+ await assert.rejects(
179
+ runNorna(['--site-dir', siteDir, 'content:check']),
180
+ (error) => {
181
+ assert.match(error.output, /site\/pages\/000-home is the homepage and cannot contain child pages\./);
182
+ assert.match(error.output, /Move these page directories beside 000-home under site\/pages\//);
183
+ assert.match(error.output, /site\/pages\/000-home\/pages\/010-news/);
184
+ return true;
185
+ },
186
+ );
187
+ } finally {
188
+ await rm(root, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('page theme changes page presentation while preserving site visual identity', async () => {
193
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
194
+ try {
195
+ await writeFile(path.join(siteDir, 'theme.yaml'), `preset: documentation
196
+ palette: dark
197
+ `);
198
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
199
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
200
+ page:
201
+ description: Root page
132
202
  ---
133
203
 
204
+ # Root
205
+
134
206
  ## Home {#home}
135
207
 
136
208
  Root content.
137
209
  `);
138
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
139
- title: Guide
140
- description: Guide page
141
- navigation:
142
- label: Guide
210
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
211
+ page:
212
+ description: Guide page
143
213
  ---
144
214
 
145
- ## Guide {#guide}
215
+ # Guide
216
+ Page content.
217
+ `);
218
+ await mkdir(path.join(siteDir, 'pages', '010-guide', 'pages', '010-detail'), { recursive: true });
219
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'pages', '010-detail', 'content.md'), `# Detail
146
220
 
147
- Route content.
221
+ Inherited page content.
222
+ `);
223
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'theme.yaml'), `layout:
224
+ contentSpacing: spacious
225
+ textWidth: wide
226
+ sections:
227
+ backgroundPattern: cycling
148
228
  `);
149
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `typography:
150
- profile: statement
151
- palette: light
229
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'pages', '010-detail', 'theme.yaml'), `layout:
230
+ textWidth: narrow
152
231
  `);
153
232
 
154
233
  await runNorna(['--site-dir', siteDir, 'build']);
155
234
  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');
235
+ const pageHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'guide', 'index.html'), 'utf8');
236
+ const detailHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'guide', 'detail', 'index.html'), 'utf8');
157
237
  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>/);
238
+ assert.match(pageHtml, /--color-page: #000000/);
239
+ assert.match(rootHtml, /--font-sans: Georgia, 'Times New Roman', serif/);
240
+ assert.match(pageHtml, /--font-sans: Georgia, 'Times New Roman', serif/);
241
+ assert.match(pageHtml, /--space-section-to-section-desktop: clamp\(2\.25rem, 5vw, 4\.5rem\)/);
242
+ assert.match(pageHtml, /--section-body-width-desktop: min\(72ch,/);
243
+ assert.match(detailHtml, /--space-section-to-section-desktop: clamp\(2\.25rem, 5vw, 4\.5rem\)/);
244
+ assert.match(detailHtml, /--section-body-width-desktop: min\(60ch,/);
245
+ assert.match(pageHtml, /<a href="\/">\s*Root\s*<\/a>/);
160
246
  } finally {
161
247
  await rm(root, { recursive: true, force: true });
162
248
  }
163
249
  });
164
250
 
165
- test('route theme cannot define site identity', async () => {
251
+ test('page metadata, navigation logo, and page listing have separate roles', async () => {
166
252
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
167
253
  try {
168
- await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
169
- await writeFile(path.join(siteDir, 'content.md'), `---
170
- title: Root
171
- description: Root page
254
+ await mkdir(path.join(siteDir, 'public'), { recursive: true });
255
+ await writeFile(path.join(siteDir, 'public', 'logo.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>');
256
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `logo:
257
+ height: 2rem
258
+ `);
259
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `# Welcome
260
+
261
+ ## Intro {#intro}
262
+
263
+ Homepage content.
264
+ `);
265
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
266
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
267
+ page:
268
+ description: The visible guide page.
269
+ ---
270
+
271
+ # Guide
272
+ Guide content.
273
+ `);
274
+ await mkdir(path.join(siteDir, 'pages', '020-private'), { recursive: true });
275
+ await writeFile(path.join(siteDir, 'pages', '020-private', 'content.md'), `---
276
+ navigation:
277
+ listed: false
278
+ ---
279
+
280
+ # Unlisted
281
+ Public but unlisted content.
282
+ `);
283
+
284
+ await runNorna(['--site-dir', siteDir, 'build']);
285
+ const distDir = path.join(path.dirname(siteDir), 'dist');
286
+ const homeHtml = await readFile(path.join(distDir, 'index.html'), 'utf8');
287
+ const guideHtml = await readFile(path.join(distDir, 'guide', 'index.html'), 'utf8');
288
+ const unlistedHtml = await readFile(path.join(distDir, 'private', 'index.html'), 'utf8');
289
+
290
+ assert.match(homeHtml, /<title>Welcome<\/title>/);
291
+ assert.doesNotMatch(homeHtml, /<meta name="description"/);
292
+ assert.match(homeHtml, /<img class="site-brand-logo"[^>]+alt="Welcome"/);
293
+ assert.match(homeHtml, /<a href="\/" aria-current="page">\s*Welcome\s*<\/a>/);
294
+ assert.match(homeHtml, /<a href="\/guide\/">\s*Guide\s*<\/a>/);
295
+ assert.doesNotMatch(homeHtml, />\s*Unlisted\s*<\/a>/);
296
+ assert.match(guideHtml, /<meta name="description" content="The visible guide page\."/);
297
+ assert.match(unlistedHtml, /Public but unlisted content/);
298
+ } finally {
299
+ await rm(root, { recursive: true, force: true });
300
+ }
301
+ });
302
+
303
+ test('footer build information uses the site language and fixed formatting', async () => {
304
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
305
+ try {
306
+ await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\nlanguage: sv-SE\n');
307
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `footer:
308
+ buildInfo: true
309
+ `);
310
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
311
+ page:
312
+ description: Footer build information.
172
313
  ---
173
314
 
315
+ # Footer
316
+
174
317
  ## Home {#home}
175
318
 
176
- Root content.
319
+ Content.
177
320
  `);
178
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
179
- title: Guide
180
- description: Guide page
321
+
322
+ await runNorna(['--site-dir', siteDir, 'build']);
323
+ const html = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
324
+ assert.match(html, /<span>Byggd [^<]+<\/span>/);
325
+ } finally {
326
+ await rm(root, { recursive: true, force: true });
327
+ }
328
+ });
329
+
330
+ test('page theme cannot define navigation logo settings', async () => {
331
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
332
+ try {
333
+ await mkdir(path.join(siteDir, 'pages', '010-guide'), { recursive: true });
334
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
335
+ page:
336
+ description: Root page
181
337
  ---
182
338
 
183
- ## Guide {#guide}
339
+ # Root
340
+
341
+ ## Home {#home}
184
342
 
185
- Route content.
343
+ Root content.
186
344
  `);
187
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `navigation:
188
- label: Route Brand
345
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'content.md'), `---
346
+ page:
347
+ description: Guide page
348
+ ---
349
+
350
+ # Guide
351
+ Page content.
352
+ `);
353
+ await writeFile(path.join(siteDir, 'pages', '010-guide', 'theme.yaml'), `logo:
354
+ height: 3rem
189
355
  `);
190
356
 
191
357
  await assert.rejects(
192
358
  runNorna(['--site-dir', siteDir, 'config:check']),
193
359
  (error) => {
194
- assert.match(error.output, /may not define navigation\. The site label and logo settings belong in site\/sitewide-content\.yaml/);
360
+ assert.match(error.output, /may not define navigation logo settings\. Optional logo display settings belong under "logo:" in site\/sitewide-content\.yaml/);
195
361
  return true;
196
362
  },
197
363
  );
@@ -203,16 +369,14 @@ Route content.
203
369
  test('navigation logo settings require one conventional logo file', async () => {
204
370
  const { root, siteDir } = await createTempSite();
205
371
  try {
206
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
207
- label: Missing logo
208
- logo:
209
- height: 2rem
372
+ await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `logo:
373
+ height: 2rem
210
374
  `);
211
375
 
212
376
  await assert.rejects(
213
377
  runNorna(['--site-dir', siteDir, 'config:check']),
214
378
  (error) => {
215
- assert.match(error.output, /navigation\.logo is configured, but no logo file was found/);
379
+ assert.match(error.output, /Site-wide logo is configured, but no logo file was found/);
216
380
  assert.match(error.output, /site\/public\/logo\.svg/);
217
381
  return true;
218
382
  },
@@ -225,9 +389,6 @@ test('navigation logo settings require one conventional logo file', async () =>
225
389
  test('multiple conventional navigation logo files stop config validation', async () => {
226
390
  const { root, siteDir } = await createTempSite();
227
391
  try {
228
- await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
229
- label: Conflicting logos
230
- `);
231
392
  await mkdir(path.join(siteDir, 'public'), { recursive: true });
232
393
  await writeFile(path.join(siteDir, 'public', 'logo.svg'), '<svg/>');
233
394
  await writeFile(path.join(siteDir, 'public', 'logo.png'), 'png');
@@ -249,11 +410,13 @@ test('multiple conventional navigation logo files stop config validation', async
249
410
  test('content:check warns for local Markdown images but not external Markdown images', async () => {
250
411
  const { root, siteDir } = await createTempSite();
251
412
  try {
252
- await writeFile(path.join(siteDir, 'content.md'), `---
253
- title: Markdown Image Warning
254
- description: Fixture
413
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
414
+ page:
415
+ description: Fixture
255
416
  ---
256
417
 
418
+ # Markdown Image Warning
419
+
257
420
  ## Intro {#intro}
258
421
 
259
422
  ![Local](portrait.jpg)
@@ -275,11 +438,13 @@ description: Fixture
275
438
  test('managed SVG images are copied as static image output', async () => {
276
439
  const { root, siteDir } = await createTempSite();
277
440
  try {
278
- await writeFile(path.join(siteDir, 'content.md'), `---
279
- title: SVG Fixture
280
- description: Fixture
441
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
442
+ page:
443
+ description: Fixture
281
444
  ---
282
445
 
446
+ # SVG Fixture
447
+
283
448
  ## Intro {#intro}
284
449
 
285
450
  \`\`\`norna-image-stack
@@ -287,18 +452,18 @@ description: Fixture
287
452
  alt: Diagram
288
453
  \`\`\`
289
454
  `);
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');
455
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
456
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'diagram.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620"><rect width="900" height="620"/></svg>\n');
292
457
 
293
458
  await runContentScript(siteDir, ['--check']);
294
459
  await runNorna(['--site-dir', siteDir, 'images']);
295
460
 
296
461
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
297
- const entry = manifest['images/intro/diagram.svg'];
462
+ const entry = manifest['pages/000-home/images/diagram.svg'];
298
463
  assert.equal(entry.kind, 'static');
299
464
  assert.equal(entry.width, 900);
300
465
  assert.equal(entry.height, 620);
301
- assert.match(entry.src, /^\/images\/original\/images\/intro\/diagram-[a-f0-9]{8}\.svg$/);
466
+ assert.match(entry.src, /^\/images\/original\/pages\/000-home\/images\/diagram-[a-f0-9]{8}\.svg$/);
302
467
  assert.equal(entry.variants, undefined);
303
468
  assert.equal(await fileExists(path.join(siteDir, '.norna', 'public', entry.src.replace(/^\//, ''))), true);
304
469
  } finally {
@@ -309,11 +474,13 @@ description: Fixture
309
474
  test('inline notes render as linked numbered margin notes', async () => {
310
475
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
311
476
  try {
312
- await writeFile(path.join(siteDir, 'content.md'), `---
313
- title: Inline notes
314
- description: Fixture
477
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
478
+ page:
479
+ description: Fixture
315
480
  ---
316
481
 
482
+ # Inline notes
483
+
317
484
  ## Intro {#intro}
318
485
 
319
486
  The first paragraph points to an explanation.{note-ref}
@@ -347,11 +514,13 @@ The second paragraph has its own explanation.{note-ref}
347
514
  test('updated managed SVG images get updated static output', async () => {
348
515
  const { root, siteDir } = await createTempSite();
349
516
  try {
350
- await writeFile(path.join(siteDir, 'content.md'), `---
351
- title: SVG Update Fixture
352
- description: Fixture
517
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
518
+ page:
519
+ description: Fixture
353
520
  ---
354
521
 
522
+ # SVG Update Fixture
523
+
355
524
  ## Intro {#intro}
356
525
 
357
526
  \`\`\`norna-image-stack
@@ -359,22 +528,22 @@ description: Fixture
359
528
  alt: Diagram
360
529
  \`\`\`
361
530
  `);
362
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
363
- const sourcePath = path.join(siteDir, 'images', 'intro', 'diagram.svg');
531
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
532
+ const sourcePath = path.join(siteDir, 'pages', '000-home', 'images', 'diagram.svg');
364
533
  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
534
 
366
535
  await runContentScript(siteDir, ['--check']);
367
536
  await runNorna(['--site-dir', siteDir, 'images']);
368
537
 
369
538
  const firstManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
370
- const firstEntry = firstManifest['images/intro/diagram.svg'];
539
+ const firstEntry = firstManifest['pages/000-home/images/diagram.svg'];
371
540
  const firstOutputPath = path.join(siteDir, '.norna', 'public', firstEntry.src.replace(/^\//, ''));
372
541
 
373
542
  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
543
  await runNorna(['--site-dir', siteDir, 'images']);
375
544
 
376
545
  const secondManifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
377
- const secondEntry = secondManifest['images/intro/diagram.svg'];
546
+ const secondEntry = secondManifest['pages/000-home/images/diagram.svg'];
378
547
  const secondOutputPath = path.join(siteDir, '.norna', 'public', secondEntry.src.replace(/^\//, ''));
379
548
 
380
549
  assert.notEqual(secondEntry.sourceHash, firstEntry.sourceHash);
@@ -389,11 +558,13 @@ description: Fixture
389
558
  test('content:check warns when carousel SVG images have no intrinsic aspect ratio', async () => {
390
559
  const { root, siteDir } = await createTempSite();
391
560
  try {
392
- await writeFile(path.join(siteDir, 'content.md'), `---
393
- title: SVG Carousel Fixture
394
- description: Fixture
561
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
562
+ page:
563
+ description: Fixture
395
564
  ---
396
565
 
566
+ # SVG Carousel Fixture
567
+
397
568
  ## Intro {#intro}
398
569
 
399
570
  \`\`\`norna-image-carousel
@@ -403,9 +574,9 @@ description: Fixture
403
574
  alt: Second
404
575
  \`\`\`
405
576
  `);
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');
577
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
578
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'first.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>\n');
579
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'second.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100"/></svg>\n');
409
580
 
410
581
  const { stdout } = await runContentScript(siteDir, ['--check']);
411
582
  assert.match(stdout, /Content check completed with warnings\./);
@@ -418,11 +589,13 @@ description: Fixture
418
589
  test('norna-card-list images are managed image references', async () => {
419
590
  const { root, siteDir } = await createTempSite({ underRepoCache: true });
420
591
  try {
421
- await writeFile(path.join(siteDir, 'content.md'), `---
422
- title: Card List Fixture
423
- description: Fixture
592
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
593
+ page:
594
+ description: Fixture
424
595
  ---
425
596
 
597
+ # Card List Fixture
598
+
426
599
  ## Help {#help}
427
600
 
428
601
  \`\`\`norna-card-list
@@ -440,15 +613,15 @@ width: narrow
440
613
  text: Support the shelter.
441
614
  \`\`\`
442
615
  `);
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');
616
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
617
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'adopt.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"><rect width="160" height="100"/></svg>\n');
445
618
 
446
619
  const { stdout } = await runContentScript(siteDir, ['--check']);
447
620
  assert.match(stdout, /Content check passed\./);
448
621
 
449
622
  await runNorna(['--site-dir', siteDir, 'images']);
450
623
  const manifest = JSON.parse(await readFile(path.join(siteDir, '.norna', 'generated-images.json'), 'utf8'));
451
- assert.equal(manifest['images/help/adopt.svg'].kind, 'static');
624
+ assert.equal(manifest['pages/000-home/images/adopt.svg'].kind, 'static');
452
625
 
453
626
  await runNorna(['--site-dir', siteDir, 'build']);
454
627
  const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
@@ -460,7 +633,7 @@ width: narrow
460
633
  assert.match(html, /class="card-list-badge">Recommended<\/p>/);
461
634
  assert.match(html, /Give a dog a new home\./);
462
635
  assert.match(html, /href="\/adopt\/"/);
463
- assert.match(html, /src="\/images\/original\/images\/help\/adopt-[a-f0-9]{8}\.svg"/);
636
+ assert.match(html, /src="\/images\/original\/pages\/000-home\/images\/adopt-[a-f0-9]{8}\.svg"/);
464
637
  } finally {
465
638
  await rm(root, { recursive: true, force: true });
466
639
  }
@@ -469,11 +642,13 @@ width: narrow
469
642
  test('content:check fails when a norna-card-list image file is missing', async () => {
470
643
  const { root, siteDir } = await createTempSite();
471
644
  try {
472
- await writeFile(path.join(siteDir, 'content.md'), `---
473
- title: Missing Card Image
474
- description: Fixture
645
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
646
+ page:
647
+ description: Fixture
475
648
  ---
476
649
 
650
+ # Missing Card Image
651
+
477
652
  ## Help {#help}
478
653
 
479
654
  \`\`\`norna-card-list
@@ -485,7 +660,7 @@ description: Fixture
485
660
  await assert.rejects(
486
661
  () => runContentScript(siteDir, ['--check']),
487
662
  (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\./);
663
+ assert.match(error.output, /Image "missing\.svg" does not exist at .*site\/pages\/000-home\/images\/missing\.svg or anywhere under any page image root\./);
489
664
  return true;
490
665
  },
491
666
  );
@@ -497,11 +672,13 @@ description: Fixture
497
672
  test('content:check fails for malformed norna-card-list blocks', async () => {
498
673
  const { root, siteDir } = await createTempSite();
499
674
  try {
500
- await writeFile(path.join(siteDir, 'content.md'), `---
501
- title: Malformed Card List
502
- description: Fixture
675
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
676
+ page:
677
+ description: Fixture
503
678
  ---
504
679
 
680
+ # Malformed Card List
681
+
505
682
  ## Help {#help}
506
683
 
507
684
  \`\`\`norna-card-list
@@ -524,11 +701,13 @@ description: Fixture
524
701
  test('content:check fails for invalid norna-card-list options', async () => {
525
702
  const { root, siteDir } = await createTempSite();
526
703
  try {
527
- await writeFile(path.join(siteDir, 'content.md'), `---
528
- title: Invalid Card List Options
529
- description: Fixture
704
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
705
+ page:
706
+ description: Fixture
530
707
  ---
531
708
 
709
+ # Invalid Card List Options
710
+
532
711
  ## Help {#help}
533
712
 
534
713
  \`\`\`norna-card-list
@@ -557,11 +736,13 @@ width: full
557
736
  test('content:check fails for invalid norna-card-list width', async () => {
558
737
  const { root, siteDir } = await createTempSite();
559
738
  try {
560
- await writeFile(path.join(siteDir, 'content.md'), `---
561
- title: Invalid Card List Width
562
- description: Fixture
739
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
740
+ page:
741
+ description: Fixture
563
742
  ---
564
743
 
744
+ # Invalid Card List Width
745
+
565
746
  ## Help {#help}
566
747
 
567
748
  \`\`\`norna-card-list
@@ -587,11 +768,13 @@ width: full
587
768
  test('content:check fails for invalid norna-card-list size', async () => {
588
769
  const { root, siteDir } = await createTempSite();
589
770
  try {
590
- await writeFile(path.join(siteDir, 'content.md'), `---
591
- title: Invalid Card List Size
592
- description: Fixture
771
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
772
+ page:
773
+ description: Fixture
593
774
  ---
594
775
 
776
+ # Invalid Card List Size
777
+
595
778
  ## Help {#help}
596
779
 
597
780
  \`\`\`norna-card-list
@@ -617,11 +800,13 @@ size: huge
617
800
  test('content:sync moves Norna-managed images inside the same page image root', async () => {
618
801
  const { root, siteDir } = await createTempSite();
619
802
  try {
620
- await writeFile(path.join(siteDir, 'content.md'), `---
621
- title: Sync Fixture
622
- description: Fixture
803
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
804
+ page:
805
+ description: Fixture
623
806
  ---
624
807
 
808
+ # Sync Fixture
809
+
625
810
  ## Work {#work}
626
811
 
627
812
  \`\`\`norna-image-stack
@@ -629,12 +814,12 @@ description: Fixture
629
814
  alt: Moved image
630
815
  \`\`\`
631
816
  `);
632
- await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
633
- await writeFile(path.join(siteDir, 'images', 'old', 'moved.jpg'), 'fixture image');
817
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'old'), { recursive: true });
818
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'old', 'moved.jpg'), 'fixture image');
634
819
 
635
820
  await runContentScript(siteDir, ['--write', '--yes']);
636
821
 
637
- const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.jpg'), 'utf8');
822
+ const moved = await readFile(path.join(siteDir, 'pages', '000-home', 'images', 'moved.jpg'), 'utf8');
638
823
  assert.equal(moved, 'fixture image');
639
824
  } finally {
640
825
  await rm(root, { recursive: true, force: true });
@@ -644,11 +829,13 @@ description: Fixture
644
829
  test('content:sync moves managed SVG images inside the same page image root', async () => {
645
830
  const { root, siteDir } = await createTempSite();
646
831
  try {
647
- await writeFile(path.join(siteDir, 'content.md'), `---
648
- title: SVG Sync Fixture
649
- description: Fixture
832
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
833
+ page:
834
+ description: Fixture
650
835
  ---
651
836
 
837
+ # SVG Sync Fixture
838
+
652
839
  ## Work {#work}
653
840
 
654
841
  \`\`\`norna-image-stack
@@ -656,166 +843,184 @@ description: Fixture
656
843
  alt: Moved diagram
657
844
  \`\`\`
658
845
  `);
659
- await mkdir(path.join(siteDir, 'images', 'old'), { recursive: true });
660
- await writeFile(path.join(siteDir, 'images', 'old', 'moved.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"></svg>\n');
846
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'old'), { recursive: true });
847
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'old', 'moved.svg'), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"></svg>\n');
661
848
 
662
849
  await runContentScript(siteDir, ['--write', '--yes']);
663
850
 
664
- const moved = await readFile(path.join(siteDir, 'images', 'work', 'moved.svg'), 'utf8');
851
+ const moved = await readFile(path.join(siteDir, 'pages', '000-home', 'images', 'moved.svg'), 'utf8');
665
852
  assert.match(moved, /viewBox="0 0 10 10"/);
666
853
  } finally {
667
854
  await rm(root, { recursive: true, force: true });
668
855
  }
669
856
  });
670
857
 
671
- test('content:sync moves Norna-managed images across route image roots when git is clean', async () => {
858
+ test('content:sync moves Norna-managed images across page image roots when git is clean', async () => {
672
859
  const { root, siteDir } = await createTempSite();
673
860
  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 });
678
- await writeFile(path.join(siteDir, 'content.md'), `---
679
- title: Cross Route Sync
680
- description: Fixture
861
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
862
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
863
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
864
+ await mkdir(targetPageDir, { recursive: true });
865
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
866
+ page:
867
+ description: Fixture
681
868
  ---
682
869
 
870
+ # Cross Page Sync
871
+
683
872
  ## Home {#home}
684
873
 
685
874
  Text.
686
875
  `);
687
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
688
- title: Source
689
- description: Fixture
876
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
877
+ page:
878
+ description: Fixture
690
879
  ---
691
880
 
881
+ # Source
882
+
692
883
  ## Old {#old}
693
884
 
694
885
  Text.
695
886
  `);
696
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
697
- title: Target
698
- description: Fixture
887
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
888
+ page:
889
+ description: Fixture
699
890
  ---
700
891
 
892
+ # Target
893
+
701
894
  ## Work {#work}
702
895
 
703
896
  \`\`\`norna-image-stack
704
897
  - image: moved.jpg
705
898
  \`\`\`
706
899
  `);
707
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
900
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
708
901
  await initCleanGitWorktree(root);
709
902
 
710
903
  await runContentScript(siteDir, ['--write', '--yes']);
711
904
 
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);
905
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'moved.jpg')), true);
906
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'moved.jpg')), false);
714
907
  } finally {
715
908
  await rm(root, { recursive: true, force: true });
716
909
  }
717
910
  });
718
911
 
719
- test('content:sync refuses cross-route image moves when git status is dirty', async () => {
912
+ test('content:sync refuses cross-page image moves when git status is dirty', async () => {
720
913
  const { root, siteDir } = await createTempSite();
721
914
  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 });
726
- await writeFile(path.join(siteDir, 'content.md'), `---
727
- title: Dirty Cross Route Sync
728
- description: Fixture
915
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
916
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
917
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
918
+ await mkdir(targetPageDir, { recursive: true });
919
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
920
+ page:
921
+ description: Fixture
729
922
  ---
730
923
 
924
+ # Dirty Cross Page Sync
925
+
731
926
  ## Home {#home}
732
927
 
733
928
  Text.
734
929
  `);
735
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
736
- title: Source
737
- description: Fixture
930
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
931
+ page:
932
+ description: Fixture
738
933
  ---
739
934
 
935
+ # Source
936
+
740
937
  ## Old {#old}
741
938
 
742
939
  Text.
743
940
  `);
744
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
745
- title: Target
746
- description: Fixture
941
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
942
+ page:
943
+ description: Fixture
747
944
  ---
748
945
 
946
+ # Target
947
+
749
948
  ## Work {#work}
750
949
 
751
950
  \`\`\`norna-image-stack
752
951
  - image: moved.jpg
753
952
  \`\`\`
754
953
  `);
755
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
954
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
756
955
  await initCleanGitWorktree(root);
757
956
  await writeFile(path.join(root, 'dirty.txt'), 'dirty');
758
957
 
759
958
  await assert.rejects(
760
959
  () => runContentScript(siteDir, ['--write', '--yes']),
761
960
  (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\./);
961
+ assert.match(error.output, /Cross-page content sync requires a clean git working tree before moving files between page image roots\./);
763
962
  assert.match(error.output, /dirty\.txt/);
764
963
  return true;
765
964
  },
766
965
  );
767
966
 
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);
967
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'moved.jpg')), false);
968
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'moved.jpg')), true);
770
969
  } finally {
771
970
  await rm(root, { recursive: true, force: true });
772
971
  }
773
972
  });
774
973
 
775
- test('content:check reports cross-route image moves without requiring clean git status', async () => {
974
+ test('content:check reports cross-page image moves without requiring clean git status', async () => {
776
975
  const { root, siteDir } = await createTempSite();
777
976
  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 });
782
- await writeFile(path.join(siteDir, 'content.md'), `---
783
- title: Cross Route Check
784
- description: Fixture
977
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
978
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
979
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
980
+ await mkdir(targetPageDir, { recursive: true });
981
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
982
+ page:
983
+ description: Fixture
785
984
  ---
786
985
 
986
+ # Cross Page Check
987
+
787
988
  ## Home {#home}
788
989
 
789
990
  Text.
790
991
  `);
791
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
792
- title: Source
793
- description: Fixture
992
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
993
+ page:
994
+ description: Fixture
794
995
  ---
795
996
 
997
+ # Source
998
+
796
999
  ## Old {#old}
797
1000
 
798
1001
  Text.
799
1002
  `);
800
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
801
- title: Target
802
- description: Fixture
1003
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
1004
+ page:
1005
+ description: Fixture
803
1006
  ---
804
1007
 
1008
+ # Target
1009
+
805
1010
  ## Work {#work}
806
1011
 
807
1012
  \`\`\`norna-image-stack
808
1013
  - image: moved.jpg
809
1014
  \`\`\`
810
1015
  `);
811
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'moved.jpg'), 'route image');
1016
+ await writeFile(path.join(sourcePageDir, 'images', 'moved.jpg'), 'page image');
812
1017
  await writeFile(path.join(root, 'dirty.txt'), 'dirty');
813
1018
 
814
1019
  await assert.rejects(
815
1020
  () => runContentScript(siteDir, ['--check']),
816
1021
  (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\./);
1022
+ assert.match(error.output, /Image "moved\.jpg" is used here but is located in .*site\/pages\/010-source\/images\/moved\.jpg\./);
1023
+ assert.match(error.output, /Run norna content:sync to move it from .*site\/pages\/010-source to .*site\/pages\/020-target\./);
819
1024
  assert.doesNotMatch(error.output, /requires a clean git working tree before moving files/);
820
1025
  return true;
821
1026
  },
@@ -825,56 +1030,63 @@ description: Fixture
825
1030
  }
826
1031
  });
827
1032
 
828
- test('content:sync refuses to move a cross-route image still referenced by its current section', async () => {
1033
+ test('content:sync refuses to move a cross-page image still referenced by its current section', async () => {
829
1034
  const { root, siteDir } = await createTempSite();
830
1035
  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 });
835
- await writeFile(path.join(siteDir, 'content.md'), `---
836
- title: Cross Route Copy
837
- description: Fixture
1036
+ const sourcePageDir = path.join(siteDir, 'pages', '010-source');
1037
+ const targetPageDir = path.join(siteDir, 'pages', '020-target');
1038
+ await mkdir(path.join(sourcePageDir, 'images'), { recursive: true });
1039
+ await mkdir(targetPageDir, { recursive: true });
1040
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1041
+ page:
1042
+ description: Fixture
838
1043
  ---
839
1044
 
1045
+ # Cross Page Copy
1046
+
840
1047
  ## Home {#home}
841
1048
 
842
1049
  Text.
843
1050
  `);
844
- await writeFile(path.join(sourceRouteDir, 'content.md'), `---
845
- title: Source
846
- description: Fixture
1051
+ await writeFile(path.join(sourcePageDir, 'content.md'), `---
1052
+ page:
1053
+ description: Fixture
847
1054
  ---
848
1055
 
1056
+ # Source
1057
+
849
1058
  ## Old {#old}
850
1059
 
851
1060
  \`\`\`norna-image-stack
852
1061
  - image: shared.jpg
853
1062
  \`\`\`
854
1063
  `);
855
- await writeFile(path.join(targetRouteDir, 'content.md'), `---
856
- title: Target
857
- description: Fixture
1064
+ await writeFile(path.join(targetPageDir, 'content.md'), `---
1065
+ page:
1066
+ description: Fixture
858
1067
  ---
859
1068
 
1069
+ # Target
1070
+
860
1071
  ## Work {#work}
861
1072
 
862
1073
  \`\`\`norna-image-stack
863
1074
  - image: shared.jpg
864
1075
  \`\`\`
865
1076
  `);
866
- await writeFile(path.join(sourceRouteDir, 'images', 'old', 'shared.jpg'), 'route image');
1077
+ await writeFile(path.join(sourcePageDir, 'images', 'shared.jpg'), 'page image');
1078
+ await initCleanGitWorktree(root);
867
1079
 
868
1080
  await assert.rejects(
869
1081
  () => runContentScript(siteDir, ['--write', '--yes']),
870
1082
  (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\]\./);
1083
+ 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
1084
  return true;
873
1085
  },
874
1086
  );
875
1087
 
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);
1088
+ assert.equal(await fileExists(path.join(targetPageDir, 'images', 'shared.jpg')), false);
1089
+ assert.equal(await fileExists(path.join(sourcePageDir, 'images', 'shared.jpg')), true);
878
1090
  } finally {
879
1091
  await rm(root, { recursive: true, force: true });
880
1092
  }
@@ -883,11 +1095,13 @@ description: Fixture
883
1095
  test('CLI content:sync refreshes generated images after moving a carousel image', async () => {
884
1096
  const { root, siteDir } = await createTempSite();
885
1097
  try {
886
- await writeFile(path.join(siteDir, 'content.md'), `---
887
- title: Sync Fixture
888
- description: Fixture
1098
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1099
+ page:
1100
+ description: Fixture
889
1101
  ---
890
1102
 
1103
+ # Sync Fixture
1104
+
891
1105
  ## Work {#work}
892
1106
 
893
1107
  \`\`\`norna-image-carousel
@@ -897,42 +1111,49 @@ description: Fixture
897
1111
  alt: Slide two
898
1112
  \`\`\`
899
1113
  `);
900
- 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 });
1114
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'old'), { recursive: true });
1115
+ await cp(path.join(fixtureSiteDir, 'pages', '000-home', 'images', 'hero.jpg'), path.join(siteDir, 'pages', '000-home', 'images', 'old', 'slide-one.jpg'));
1116
+ await cp(path.join(fixtureSiteDir, 'pages', '000-home', 'images', 'detail.jpg'), path.join(siteDir, 'pages', '000-home', 'images', 'slide-two.jpg'), { force: true });
904
1117
 
905
1118
  await runNorna(['--site-dir', siteDir, 'content:sync', '--yes']);
906
1119
 
907
1120
  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']);
1121
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'slide-one.jpg')), true);
1122
+ assert.ok(manifest['pages/000-home/images/slide-one.jpg']);
1123
+ assert.ok(manifest['pages/000-home/images/slide-two.jpg']);
911
1124
  } finally {
912
1125
  await rm(root, { recursive: true, force: true });
913
1126
  }
914
1127
  });
915
1128
 
916
- test('content:check fails when a level 2 section is missing an explicit id', async () => {
917
- const { root, siteDir } = await createTempSite();
1129
+ test('automatic H2 and H3 ids render while explicit ids remain stable', async () => {
1130
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
918
1131
  try {
919
- await writeFile(path.join(siteDir, 'content.md'), `---
920
- title: Missing Id
921
- description: Fixture
1132
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1133
+ page:
1134
+ description: Fixture
922
1135
  ---
923
1136
 
924
- ## Intro
1137
+ # Heading IDs
1138
+
1139
+ ## Café and **code**
1140
+
1141
+ ### More details
1142
+
1143
+ Automatic identifiers.
925
1144
 
926
- This section has no explicit id.
1145
+ ## Renamed heading {#stable-id}
1146
+
1147
+ Explicit identifier.
927
1148
  `);
928
1149
 
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
- );
1150
+ await runContentScript(siteDir, ['--check']);
1151
+ await runNorna(['--site-dir', siteDir, 'build']);
1152
+
1153
+ const html = await readFile(path.join(root, 'dist', 'index.html'), 'utf8');
1154
+ assert.match(html, /<h2 id="cafe-and-code">Café and <strong>code<\/strong><\/h2>/);
1155
+ assert.match(html, /<h3 id="more-details">More details<\/h3>/);
1156
+ assert.match(html, /<h2 id="stable-id">Renamed heading<\/h2>/);
936
1157
  } finally {
937
1158
  await rm(root, { recursive: true, force: true });
938
1159
  }
@@ -941,11 +1162,13 @@ This section has no explicit id.
941
1162
  test('content:check fails for malformed Norna image blocks', async () => {
942
1163
  const { root, siteDir } = await createTempSite();
943
1164
  try {
944
- await writeFile(path.join(siteDir, 'content.md'), `---
945
- title: Malformed Block
946
- description: Fixture
1165
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1166
+ page:
1167
+ description: Fixture
947
1168
  ---
948
1169
 
1170
+ # Malformed Block
1171
+
949
1172
  ## Intro {#intro}
950
1173
 
951
1174
  \`\`\`norna-image-stack
@@ -969,13 +1192,15 @@ image hero.jpg
969
1192
  test('content:check fails for unknown Norna block names', async () => {
970
1193
  const { root, siteDir } = await createTempSite();
971
1194
  try {
972
- await mkdir(path.join(siteDir, 'images', 'plain'), { recursive: true });
973
- await writeFile(path.join(siteDir, 'images', 'plain', 'image.jpg'), 'fixture image');
974
- await writeFile(path.join(siteDir, 'content.md'), `---
975
- title: Unknown Block
976
- description: Fixture
1195
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'plain'), { recursive: true });
1196
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'plain', 'image.jpg'), 'fixture image');
1197
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1198
+ page:
1199
+ description: Fixture
977
1200
  ---
978
1201
 
1202
+ # Unknown Block
1203
+
979
1204
  ## Plain {#plain}
980
1205
 
981
1206
  \`\`\`norna-gallery-stack
@@ -1000,11 +1225,13 @@ description: Fixture
1000
1225
  test('content:check rejects the removed norna-note block', async () => {
1001
1226
  const { root, siteDir } = await createTempSite();
1002
1227
  try {
1003
- await writeFile(path.join(siteDir, 'content.md'), `---
1004
- title: Removed note block
1005
- description: Fixture
1228
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1229
+ page:
1230
+ description: Fixture
1006
1231
  ---
1007
1232
 
1233
+ # Removed note block
1234
+
1008
1235
  ## Intro {#intro}
1009
1236
 
1010
1237
  \`\`\`norna-note
@@ -1027,13 +1254,15 @@ This block is no longer supported.
1027
1254
  test('content:check supports tilde fenced Norna blocks', async () => {
1028
1255
  const { root, siteDir } = await createTempSite();
1029
1256
  try {
1030
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1031
- await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
1032
- await writeFile(path.join(siteDir, 'content.md'), `---
1033
- title: Tilde Fence
1034
- description: Fixture
1257
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
1258
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'hero.jpg'), 'fixture image');
1259
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1260
+ page:
1261
+ description: Fixture
1035
1262
  ---
1036
1263
 
1264
+ # Tilde Fence
1265
+
1037
1266
  ## Intro {#intro}
1038
1267
 
1039
1268
  ~~~norna-image-stack
@@ -1051,11 +1280,13 @@ description: Fixture
1051
1280
  test('content:check ignores Norna blocks shown inside longer code fences', async () => {
1052
1281
  const { root, siteDir } = await createTempSite();
1053
1282
  try {
1054
- await writeFile(path.join(siteDir, 'content.md'), `---
1055
- title: Code Example
1056
- description: Fixture
1283
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1284
+ page:
1285
+ description: Fixture
1057
1286
  ---
1058
1287
 
1288
+ # Code Example
1289
+
1059
1290
  ## Intro {#intro}
1060
1291
 
1061
1292
  \`\`\`\`md
@@ -1075,11 +1306,13 @@ description: Fixture
1075
1306
  test('content:check fails for likely Norna block names outside fences', async () => {
1076
1307
  const { root, siteDir } = await createTempSite();
1077
1308
  try {
1078
- await writeFile(path.join(siteDir, 'content.md'), `---
1079
- title: Missing Fence
1080
- description: Fixture
1309
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1310
+ page:
1311
+ description: Fixture
1081
1312
  ---
1082
1313
 
1314
+ # Missing Fence
1315
+
1083
1316
  ## Intro {#intro}
1084
1317
 
1085
1318
  norna-image-stack
@@ -1102,11 +1335,13 @@ norna-image-stack
1102
1335
  test('content:check fails for short Norna block fences', async () => {
1103
1336
  const { root, siteDir } = await createTempSite();
1104
1337
  try {
1105
- await writeFile(path.join(siteDir, 'content.md'), `---
1106
- title: Short Fence
1107
- description: Fixture
1338
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1339
+ page:
1340
+ description: Fixture
1108
1341
  ---
1109
1342
 
1343
+ # Short Fence
1344
+
1110
1345
  ## Intro {#intro}
1111
1346
 
1112
1347
  \`\`norna-image-stack
@@ -1130,11 +1365,13 @@ description: Fixture
1130
1365
  test('content:check fails for unclosed Norna blocks', async () => {
1131
1366
  const { root, siteDir } = await createTempSite();
1132
1367
  try {
1133
- await writeFile(path.join(siteDir, 'content.md'), `---
1134
- title: Unclosed Fence
1135
- description: Fixture
1368
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1369
+ page:
1370
+ description: Fixture
1136
1371
  ---
1137
1372
 
1373
+ # Unclosed Fence
1374
+
1138
1375
  ## Intro {#intro}
1139
1376
 
1140
1377
  \`\`\`norna-image-stack
@@ -1158,11 +1395,13 @@ description: Fixture
1158
1395
  test('content:check continues after malformed Norna blocks', async () => {
1159
1396
  const { root, siteDir } = await createTempSite();
1160
1397
  try {
1161
- await writeFile(path.join(siteDir, 'content.md'), `---
1162
- title: Multiple Issues
1163
- description: Fixture
1398
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1399
+ page:
1400
+ description: Fixture
1164
1401
  ---
1165
1402
 
1403
+ # Multiple Issues
1404
+
1166
1405
  ## Intro {#intro}
1167
1406
 
1168
1407
  \`\`\`norna-image-stack
@@ -1187,8 +1426,8 @@ image broken.jpg
1187
1426
  () => runContentScript(siteDir, ['--check']),
1188
1427
  (error) => {
1189
1428
  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\./);
1429
+ assert.match(error.output, /Image "missing-intro\.jpg" does not exist at .*site\/pages\/000-home\/images\/missing-intro\.jpg or anywhere under any page image root\./);
1430
+ assert.match(error.output, /Image "missing-more\.jpg" does not exist at .*site\/pages\/000-home\/images\/missing-more\.jpg or anywhere under any page image root\./);
1192
1431
  return true;
1193
1432
  },
1194
1433
  );
@@ -1200,13 +1439,15 @@ image broken.jpg
1200
1439
  test('content:check allows Norna images without alt text', async () => {
1201
1440
  const { root, siteDir } = await createTempSite();
1202
1441
  try {
1203
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1204
- await writeFile(path.join(siteDir, 'images', 'intro', 'hero.jpg'), 'fixture image');
1205
- await writeFile(path.join(siteDir, 'content.md'), `---
1206
- title: Missing Alt
1207
- description: Fixture
1442
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images'), { recursive: true });
1443
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'hero.jpg'), 'fixture image');
1444
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1445
+ page:
1446
+ description: Fixture
1208
1447
  ---
1209
1448
 
1449
+ # Missing Alt
1450
+
1210
1451
  ## Intro {#intro}
1211
1452
 
1212
1453
  \`\`\`norna-image-stack
@@ -1224,11 +1465,13 @@ description: Fixture
1224
1465
  test('content:check reports single-image carousel and missing file together', async () => {
1225
1466
  const { root, siteDir } = await createTempSite();
1226
1467
  try {
1227
- await writeFile(path.join(siteDir, 'content.md'), `---
1228
- title: Beginner Errors
1229
- description: Fixture
1468
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1469
+ page:
1470
+ description: Fixture
1230
1471
  ---
1231
1472
 
1473
+ # Beginner Errors
1474
+
1232
1475
  ## Plain {#plain}
1233
1476
 
1234
1477
  \`\`\`norna-image-carousel
@@ -1240,7 +1483,7 @@ description: Fixture
1240
1483
  () => runContentScript(siteDir, ['--check']),
1241
1484
  (error) => {
1242
1485
  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\./);
1486
+ assert.match(error.output, /Image "foo\.jpg" does not exist at .*site\/pages\/000-home\/images\/foo\.jpg or anywhere under any page image root\./);
1244
1487
  return true;
1245
1488
  },
1246
1489
  );
@@ -1252,11 +1495,13 @@ description: Fixture
1252
1495
  test('content:check fails when a Norna-managed image file is missing', async () => {
1253
1496
  const { root, siteDir } = await createTempSite();
1254
1497
  try {
1255
- await writeFile(path.join(siteDir, 'content.md'), `---
1256
- title: Missing Image
1257
- description: Fixture
1498
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1499
+ page:
1500
+ description: Fixture
1258
1501
  ---
1259
1502
 
1503
+ # Missing Image
1504
+
1260
1505
  ## Intro {#intro}
1261
1506
 
1262
1507
  \`\`\`norna-image-stack
@@ -1268,7 +1513,7 @@ description: Fixture
1268
1513
  await assert.rejects(
1269
1514
  () => runContentScript(siteDir, ['--check']),
1270
1515
  (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\./);
1516
+ assert.match(error.output, /Image "missing\.jpg" does not exist at .*site\/pages\/000-home\/images\/missing\.jpg or anywhere under any page image root\./);
1272
1517
  return true;
1273
1518
  },
1274
1519
  );
@@ -1280,11 +1525,13 @@ description: Fixture
1280
1525
  test('content:check fails when a page repeats a section id', async () => {
1281
1526
  const { root, siteDir } = await createTempSite();
1282
1527
  try {
1283
- await writeFile(path.join(siteDir, 'content.md'), `---
1284
- title: Duplicate Section
1285
- description: Fixture
1528
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1529
+ page:
1530
+ description: Fixture
1286
1531
  ---
1287
1532
 
1533
+ # Duplicate Section
1534
+
1288
1535
  ## Intro {#intro}
1289
1536
 
1290
1537
  First.
@@ -1297,7 +1544,7 @@ Second.
1297
1544
  await assert.rejects(
1298
1545
  () => runContentScript(siteDir, ['--check']),
1299
1546
  (error) => {
1300
- assert.match(error.output, /Duplicate Markdown section heading id "intro"\./);
1547
+ assert.match(error.output, /Two headings resolve to id "intro"\./);
1301
1548
  return true;
1302
1549
  },
1303
1550
  );
@@ -1306,16 +1553,51 @@ Second.
1306
1553
  }
1307
1554
  });
1308
1555
 
1309
- test('content:check fails when section metadata has no Markdown section', async () => {
1556
+ test('content:check reports collisions between automatic H2 and H3 ids', async () => {
1310
1557
  const { root, siteDir } = await createTempSite();
1311
1558
  try {
1312
- await writeFile(path.join(siteDir, 'content.md'), `---
1313
- title: Orphaned Metadata
1314
- description: Fixture
1559
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1560
+ page:
1561
+ description: Fixture
1562
+ ---
1563
+
1564
+ # Automatic collision
1565
+
1566
+ ## Förstå
1567
+
1568
+ Section text.
1569
+
1570
+ ### Forsta
1571
+
1572
+ Topic text.
1573
+ `);
1574
+
1575
+ await assert.rejects(
1576
+ () => runContentScript(siteDir, ['--check']),
1577
+ (error) => {
1578
+ assert.match(error.output, /Two headings resolve to id "forsta"\./);
1579
+ assert.match(error.output, /The other heading is on line \d+\./);
1580
+ assert.match(error.output, /Add a unique explicit id to at least one heading/);
1581
+ return true;
1582
+ },
1583
+ );
1584
+ } finally {
1585
+ await rm(root, { recursive: true, force: true });
1586
+ }
1587
+ });
1588
+
1589
+ test('content:check rejects removed sections frontmatter', async () => {
1590
+ const { root, siteDir } = await createTempSite();
1591
+ try {
1592
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1593
+ page:
1594
+ description: Fixture
1315
1595
  sections:
1316
- missing: {}
1596
+ intro: {}
1317
1597
  ---
1318
1598
 
1599
+ # Removed Section Metadata
1600
+
1319
1601
  ## Intro {#intro}
1320
1602
 
1321
1603
  Text.
@@ -1324,7 +1606,7 @@ Text.
1324
1606
  await assert.rejects(
1325
1607
  () => runContentScript(siteDir, ['--check']),
1326
1608
  (error) => {
1327
- assert.match(error.output, /Section metadata "missing" does not match any Markdown section\./);
1609
+ assert.match(error.output, /defines "sections" at the top level, but it is not a valid top-level content field\./);
1328
1610
  return true;
1329
1611
  },
1330
1612
  );
@@ -1336,11 +1618,13 @@ Text.
1336
1618
  test('content:sync refuses ambiguous image relocation', async () => {
1337
1619
  const { root, siteDir } = await createTempSite();
1338
1620
  try {
1339
- await writeFile(path.join(siteDir, 'content.md'), `---
1340
- title: Ambiguous Move
1341
- description: Fixture
1621
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1622
+ page:
1623
+ description: Fixture
1342
1624
  ---
1343
1625
 
1626
+ # Ambiguous Move
1627
+
1344
1628
  ## Work {#work}
1345
1629
 
1346
1630
  \`\`\`norna-image-stack
@@ -1348,37 +1632,39 @@ description: Fixture
1348
1632
  alt: Shared
1349
1633
  \`\`\`
1350
1634
  `);
1351
- await mkdir(path.join(siteDir, 'images', 'old-a'), { recursive: true });
1352
- await mkdir(path.join(siteDir, 'images', 'old-b'), { recursive: true });
1353
- await writeFile(path.join(siteDir, 'images', 'old-a', 'shared.jpg'), 'a');
1354
- await writeFile(path.join(siteDir, 'images', 'old-b', 'shared.jpg'), 'b');
1635
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'old-a'), { recursive: true });
1636
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'old-b'), { recursive: true });
1637
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'old-a', 'shared.jpg'), 'a');
1638
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'old-b', 'shared.jpg'), 'b');
1355
1639
 
1356
1640
  await assert.rejects(
1357
1641
  () => runContentScript(siteDir, ['--write', '--yes']),
1358
1642
  (error) => {
1359
1643
  assert.match(error.output, /Cannot relocate "shared\.jpg"\. Multiple files with this filename were found:/);
1360
- assert.match(error.output, /site\/images\/old-a\/shared\.jpg/);
1361
- assert.match(error.output, /site\/images\/old-b\/shared\.jpg/);
1644
+ assert.match(error.output, /site\/pages\/000-home\/images\/old-a\/shared\.jpg/);
1645
+ assert.match(error.output, /site\/pages\/000-home\/images\/old-b\/shared\.jpg/);
1362
1646
  return true;
1363
1647
  },
1364
1648
  );
1365
1649
 
1366
- assert.equal(await fileExists(path.join(siteDir, 'images', 'work', 'shared.jpg')), false);
1367
- assert.equal(await fileExists(path.join(siteDir, 'images', 'old-a', 'shared.jpg')), true);
1368
- assert.equal(await fileExists(path.join(siteDir, 'images', 'old-b', 'shared.jpg')), true);
1650
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'shared.jpg')), false);
1651
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'old-a', 'shared.jpg')), true);
1652
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'old-b', 'shared.jpg')), true);
1369
1653
  } finally {
1370
1654
  await rm(root, { recursive: true, force: true });
1371
1655
  }
1372
1656
  });
1373
1657
 
1374
- test('content:sync refuses to move an image still referenced by its current section', async () => {
1658
+ test('content:sync moves one page-local image once when multiple sections reference it', async () => {
1375
1659
  const { root, siteDir } = await createTempSite();
1376
1660
  try {
1377
- await writeFile(path.join(siteDir, 'content.md'), `---
1378
- title: Copied Image Reference
1379
- description: Fixture
1661
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'content.md'), `---
1662
+ page:
1663
+ description: Fixture
1380
1664
  ---
1381
1665
 
1666
+ # Copied Image Reference
1667
+
1382
1668
  ## Intro {#intro}
1383
1669
 
1384
1670
  \`\`\`norna-image-stack
@@ -1393,19 +1679,14 @@ description: Fixture
1393
1679
  alt: Shared copy
1394
1680
  \`\`\`
1395
1681
  `);
1396
- await mkdir(path.join(siteDir, 'images', 'intro'), { recursive: true });
1397
- await writeFile(path.join(siteDir, 'images', 'intro', 'shared.jpg'), 'shared image');
1682
+ await mkdir(path.join(siteDir, 'pages', '000-home', 'images', 'intro'), { recursive: true });
1683
+ await writeFile(path.join(siteDir, 'pages', '000-home', 'images', 'intro', 'shared.jpg'), 'shared image');
1398
1684
 
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
- );
1685
+ await runContentScript(siteDir, ['--write', '--yes']);
1406
1686
 
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);
1687
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'intro', 'shared.jpg')), false);
1688
+ assert.equal(await fileExists(path.join(siteDir, 'pages', '000-home', 'images', 'shared.jpg')), true);
1689
+ await runContentScript(siteDir, ['--check']);
1409
1690
  } finally {
1410
1691
  await rm(root, { recursive: true, force: true });
1411
1692
  }