@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
@@ -0,0 +1,83 @@
1
+ <script>
2
+ type NavigationState = {
3
+ openPaths: string[];
4
+ sectionPaths: string[];
5
+ scrollTop: number;
6
+ };
7
+
8
+ const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
9
+ const root = container.dataset.navigationRoot ?? '/';
10
+ const stateKey = `norna:tree-navigation:${scope}:${root}`;
11
+ const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
12
+ '.navigation-page-disclosure[data-page-path]',
13
+ ));
14
+ let storedState: NavigationState = { openPaths: [], sectionPaths: [], scrollTop: 0 };
15
+
16
+ try {
17
+ const parsedState = JSON.parse(sessionStorage.getItem(stateKey) ?? '{}');
18
+ storedState = {
19
+ openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
20
+ sectionPaths: Array.isArray(parsedState.sectionPaths) ? parsedState.sectionPaths : [],
21
+ scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
22
+ };
23
+
24
+ branches.forEach((branch) => {
25
+ const pagePath = branch.dataset.pagePath ?? '';
26
+ branch.open = branch.dataset.currentBranch === 'true'
27
+ || storedState.openPaths.includes(pagePath);
28
+
29
+ if (storedState.sectionPaths.includes(pagePath)) {
30
+ branch.dataset.sectionsOpen = 'true';
31
+ }
32
+ });
33
+
34
+ if (storedState.scrollTop > 0) {
35
+ requestAnimationFrame(() => {
36
+ container.scrollTop = storedState.scrollTop;
37
+ });
38
+ }
39
+ } catch {
40
+ // Navigation remains fully usable when browser storage is unavailable.
41
+ }
42
+
43
+ const saveState = () => {
44
+ try {
45
+ const openBranches = branches.filter((branch) => branch.open);
46
+ const state: NavigationState = {
47
+ openPaths: openBranches.map((branch) => branch.dataset.pagePath ?? ''),
48
+ sectionPaths: openBranches
49
+ .filter((branch) => (
50
+ branch.dataset.sectionsOpen === 'true'
51
+ || branch.dataset.currentPage === 'true'
52
+ ))
53
+ .map((branch) => branch.dataset.pagePath ?? ''),
54
+ scrollTop: container.scrollTop,
55
+ };
56
+ sessionStorage.setItem(stateKey, JSON.stringify(state));
57
+ } catch {
58
+ // Navigation state persistence is an optional enhancement.
59
+ }
60
+ };
61
+
62
+ requestAnimationFrame(() => {
63
+ branches.forEach((branch) => branch.addEventListener('toggle', () => {
64
+ if (branch.open && branch.dataset.hasSections === 'true') {
65
+ branch.dataset.sectionsOpen = 'true';
66
+ } else if (!branch.open) {
67
+ delete branch.dataset.sectionsOpen;
68
+ }
69
+ saveState();
70
+ }));
71
+ });
72
+
73
+ container.addEventListener('scroll', saveState, { passive: true });
74
+ container.querySelectorAll('a').forEach((link) => link.addEventListener('click', saveState));
75
+ window.addEventListener('pagehide', saveState);
76
+ };
77
+
78
+ const desktopNavigation = document.querySelector<HTMLElement>('.tree-local-navigation');
79
+ const mobileNavigation = document.querySelector<HTMLElement>('.mobile-site-nav[data-navigation-root]');
80
+
81
+ if (desktopNavigation) setupNavigationState(desktopNavigation, 'desktop');
82
+ if (mobileNavigation) setupNavigationState(mobileNavigation, 'mobile');
83
+ </script>
@@ -8,7 +8,7 @@ import {
8
8
  siteThemePath,
9
9
  sitewideContentPath,
10
10
  } from '../scripts/lib/site-paths.mjs';
11
- import { parseRouteDirectory } from '../scripts/lib/route-model.mjs';
11
+ import { encodePageDirectoryPath, parsePageDirectoryPath } from '../scripts/lib/page-model.mjs';
12
12
  import {
13
13
  siteSchema,
14
14
  sitewideSchema,
@@ -23,17 +23,15 @@ const siteThemeSchema = themeVisualSchema;
23
23
 
24
24
  const site = defineCollection({
25
25
  loader: glob({
26
- pattern: ['content.md', 'routes/*/content.md'],
26
+ pattern: 'pages/**/content.md',
27
27
  base: pathToFileURL(siteDir),
28
28
  generateId: ({ entry }) => {
29
- if (entry === 'content.md') {
30
- return siteEntryId;
31
- }
32
-
33
- const routeDirectory = entry.match(/^routes\/([^/]+)\/content\.md$/)?.[1];
34
- return routeDirectory
35
- ? `${siteEntryId.replace(/-content$/, '')}-route-${parseRouteDirectory(routeDirectory, `route directory routes/${routeDirectory}`).routeDirectory}`
36
- : entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
29
+ const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
30
+ const pageDirectory = pageEntryDirectory
31
+ .split('/')
32
+ .filter((segment) => segment !== 'pages')
33
+ .join('/pages/');
34
+ return `${siteEntryId.replace(/-content$/, '')}-page-${encodePageDirectoryPath(parsePageDirectoryPath(pageDirectory, `page directory pages/${pageEntryDirectory}`).pageDirectory)}`;
37
35
  },
38
36
  }),
39
37
  schema: siteSchema,
@@ -20,6 +20,7 @@ type Presentation = {
20
20
  type VisualTheme = {
21
21
  layout: {
22
22
  gutter: { desktop: string; mobile: string };
23
+ localNavigationGap: string;
23
24
  pageWidth: string;
24
25
  spacing: {
25
26
  firstSectionTop: { desktop: string; mobile: string };
@@ -36,10 +37,15 @@ type VisualTheme = {
36
37
  maxAvailableHeightPercent: { desktop: number; mobile: number };
37
38
  };
38
39
  typography: { fontFamily: string };
40
+ shape: {
41
+ radiusSmall: string;
42
+ radiusMedium: string;
43
+ radiusLarge: string;
44
+ };
39
45
  };
40
46
 
41
47
  interface Props {
42
- title?: string;
48
+ title: string;
43
49
  description?: string;
44
50
  presentation: Presentation;
45
51
  visualTheme: VisualTheme;
@@ -47,22 +53,13 @@ interface Props {
47
53
  pathname?: string;
48
54
  footer?: {
49
55
  copyrightMessage?: string;
50
- buildInfo?: {
51
- enabled: boolean;
52
- text: string;
53
- dateTimeFormat: {
54
- locale: string;
55
- timeZone: string;
56
- dateStyle: string;
57
- timeStyle: string;
58
- };
59
- };
56
+ buildInfo?: boolean;
60
57
  };
61
58
  }
62
59
 
63
60
  const {
64
- title = 'norna',
65
- description = 'A norna site.',
61
+ title,
62
+ description,
66
63
  presentation,
67
64
  visualTheme,
68
65
  frameColors,
@@ -81,7 +78,11 @@ const documentStyle = [
81
78
  `scroll-behavior: ${scrollBehavior}`,
82
79
  ...Object.entries(getPresentationCssVariables(presentation)).map(([name, value]) => `${name}: ${value}`),
83
80
  `--font-sans: ${visualTheme.typography.fontFamily}`,
81
+ `--shape-radius-small: ${visualTheme.shape.radiusSmall}`,
82
+ `--shape-radius-medium: ${visualTheme.shape.radiusMedium}`,
83
+ `--shape-radius-large: ${visualTheme.shape.radiusLarge}`,
84
84
  `--page-width: ${visualTheme.layout.pageWidth}`,
85
+ `--local-navigation-gap: ${visualTheme.layout.localNavigationGap}`,
85
86
  `--content-gutter-desktop: ${visualTheme.layout.gutter.desktop}`,
86
87
  `--content-gutter-mobile: ${visualTheme.layout.gutter.mobile}`,
87
88
  `--space-nav-to-first-section-desktop: ${layoutSpacing.firstSectionTop.desktop}`,
@@ -103,12 +104,12 @@ const documentStyle = [
103
104
  `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
104
105
  ].join('; ');
105
106
  const { buildInfo, copyrightMessage } = footer ?? {};
106
- const showBuildInfo = Boolean(buildInfo?.enabled);
107
+ const showBuildInfo = buildInfo === true;
107
108
  const buildTime = showBuildInfo
108
- ? new Intl.DateTimeFormat(buildInfo.dateTimeFormat.locale, {
109
- dateStyle: buildInfo.dateTimeFormat.dateStyle,
110
- timeStyle: buildInfo.dateTimeFormat.timeStyle,
111
- timeZone: buildInfo.dateTimeFormat.timeZone,
109
+ ? new Intl.DateTimeFormat(projectConfig.locale.lang, {
110
+ dateStyle: 'short',
111
+ timeStyle: 'short',
112
+ timeZone: 'UTC',
112
113
  }).format(new Date())
113
114
  : null;
114
115
  const showFooter = Boolean(copyrightMessage || showBuildInfo);
@@ -124,7 +125,7 @@ const footerStyle = [
124
125
  <meta charset="utf-8" />
125
126
  <meta name="viewport" content="width=device-width, initial-scale=1" />
126
127
  <meta name="format-detection" content="telephone=no" />
127
- <meta name="description" content={description} />
128
+ {description && <meta name="description" content={description} />}
128
129
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
129
130
  <link rel="canonical" href={canonicalUrl} />
130
131
  <meta name="generator" content={Astro.generator} />
@@ -144,7 +145,7 @@ const footerStyle = [
144
145
  <footer class="site-footer" style={footerStyle}>
145
146
  <p>
146
147
  {copyrightMessage}
147
- {showBuildInfo && <span>{buildInfo.text} {buildTime}</span>}
148
+ {showBuildInfo && <span>{projectConfig.locale.labels.built} {buildTime}</span>}
148
149
  </p>
149
150
  </footer>
150
151
  )}
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ validatePageThemeYamlStructure,
5
+ } from '../../scripts/lib/site-content.mjs';
6
+ import { pageThemeSchema } from '../../scripts/lib/schema-definitions.mjs';
7
+ import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
8
+ import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
9
+ import { parseYamlConfig } from '../../scripts/lib/yaml-config.mjs';
10
+ import { getPageDirectoryAncestors } from '../../scripts/lib/page-model.mjs';
11
+
12
+ type PageTheme = {
13
+ id: string;
14
+ data: Record<string, unknown>;
15
+ };
16
+
17
+ export const getPageTheme = async (pageDirectory: string | null): Promise<PageTheme | null> => {
18
+ if (!pageDirectory) return null;
19
+
20
+ const pageAncestors = getPageDirectoryAncestors(pageDirectory);
21
+ let inheritedData: Record<string, unknown> = {};
22
+ const inheritedIds: string[] = [];
23
+ for (const pageAncestor of pageAncestors) {
24
+ const themeSegments = pageAncestor.split('/');
25
+ const themePath = path.join(sitePagesDir, ...themeSegments, 'theme.yaml');
26
+ const source = await readFile(themePath, 'utf8').catch((error) => {
27
+ if (error?.code === 'ENOENT') return null;
28
+ throw error;
29
+ });
30
+ if (!source) continue;
31
+ const themeLabel = `${sitePagesLabel}/${themeSegments.join('/')}/theme.yaml`;
32
+
33
+ const data = parseYamlConfig(source, themeLabel, {
34
+ schema: pageThemeSchema,
35
+ validateStructure: validatePageThemeYamlStructure,
36
+ });
37
+ inheritedData = mergePageThemeConfig(inheritedData, data);
38
+ inheritedIds.push(`pages/${themeSegments.join('/')}/theme`);
39
+ }
40
+
41
+ return inheritedIds.length > 0
42
+ ? { id: inheritedIds.join(' + '), data: inheritedData }
43
+ : null;
44
+ };
@@ -1,6 +1,10 @@
1
- import type { CollectionEntry } from 'astro:content';
2
1
  import { markdownToHtml } from 'satteri';
3
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import {
4
+ formatHeadingIdentifierIssue,
5
+ getHeadingIdentifierIssues,
6
+ } from '../../scripts/lib/heading-ids.mjs';
7
+ import { getBodySections } from '../../scripts/lib/site-content.mjs';
4
8
  import {
5
9
  extractInlineNoteDiagnostics,
6
10
  extractNornaMarkdownBlocks,
@@ -10,8 +14,6 @@ import {
10
14
  import { applyBasePathToHtml } from './basePath';
11
15
  import type { SitePage } from './sitePages';
12
16
 
13
- type SiteSectionMetadataMap = CollectionEntry<'site'>['data']['sections'];
14
- type SiteSectionMetadata = SiteSectionMetadataMap[string];
15
17
  type ManagedImage = {
16
18
  image: string;
17
19
  src: string;
@@ -42,10 +44,11 @@ type SectionContentBlock =
42
44
  | { type: 'image-carousel'; images: ManagedImage[] }
43
45
  | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width: CardListWidth; cards: CardListItem[] }
44
46
  | { type: 'note'; html: string; number?: number; id?: string; referenceId?: string };
45
- export type ResolvedSection = SiteSectionMetadata & {
46
- id: string;
47
+ export type ResolvedSection = {
48
+ id: string | null;
47
49
  title: string;
48
50
  titleHtml: string;
51
+ headingLevel: 1 | 2;
49
52
  contentBlocks: SectionContentBlock[];
50
53
  };
51
54
 
@@ -54,9 +57,13 @@ export type SectionNavigation = {
54
57
  title: string;
55
58
  };
56
59
 
57
- const headingRegex = /<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi;
60
+ export type HeadingNavigation = SectionNavigation & {
61
+ depth: 2 | 3;
62
+ parentId: string | null;
63
+ };
64
+
65
+ const contentHeadingRegex = /<h([12])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
58
66
  const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
59
- const markdownH2Regex = /^##\s+.*$/gm;
60
67
  const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
61
68
 
62
69
  const stripTags = (html: string) => html.replace(/<[^>]*>/g, '');
@@ -69,69 +76,43 @@ const decodeHtmlEntities = (value: string) =>
69
76
  .replace(/&quot;/g, '"')
70
77
  .replace(/&#39;/g, "'");
71
78
 
72
- const slugify = (value: string) =>
73
- decodeHtmlEntities(stripTags(value))
74
- .trim()
75
- .toLowerCase()
76
- .normalize('NFD')
77
- .replace(/[\u0300-\u036f]/g, '')
78
- .replace(/å/g, 'a')
79
- .replace(/ä/g, 'a')
80
- .replace(/ö/g, 'o')
81
- .replace(/[^a-z0-9]+/g, '-')
82
- .replace(/^-+|-+$/g, '');
83
-
84
- const getHeadingId = (attributes: string, headingHtml: string) => {
85
- const headingText = decodeHtmlEntities(stripTags(headingHtml)).trim();
86
- const explicitId = headingText.match(explicitHeadingIdRegex)?.[1];
87
- if (explicitId) return explicitId;
88
-
89
- const id = attributes.match(/\sid=(["'])(.*?)\1/i)?.[2];
90
- return id ? decodeHtmlEntities(id) : slugify(headingHtml);
91
- };
92
-
93
- const getExplicitHeadingId = (headingHtml: string) =>
94
- decodeHtmlEntities(stripTags(headingHtml)).trim().match(explicitHeadingIdRegex)?.[1];
95
-
96
79
  const getHeadingTitle = (headingHtml: string) =>
97
80
  decodeHtmlEntities(stripTags(headingHtml)).replace(explicitHeadingIdRegex, '').trim();
98
81
 
99
82
  const getHeadingTitleHtml = (headingHtml: string) =>
100
83
  prepareContentHtml(headingHtml.replace(explicitHeadingIdRegex, '').trim());
101
84
 
102
- export const getSectionNavigation = (html: string, sectionMetadata: SiteSectionMetadataMap): SectionNavigation[] => {
103
- const matches = Array.from(html.matchAll(headingRegex));
104
- const sectionIds = new Set<string>();
105
- const sections = matches.map((match) => {
106
- const attributes = match[1] ?? '';
107
- const headingHtml = match[2] ?? '';
108
- const explicitId = getExplicitHeadingId(headingHtml);
109
- const id = getHeadingId(attributes, headingHtml);
110
- const title = getHeadingTitle(headingHtml);
111
-
112
- if (!explicitId) {
113
- throw new Error(`Section heading "${title}" is missing an explicit id. Write it as: ## ${title} {#${id}}`);
114
- }
115
-
116
- if (sectionIds.has(id)) {
117
- throw new Error(`Duplicate Markdown section heading id: ${id}`);
118
- }
119
-
120
- sectionIds.add(id);
121
- return { id, title };
85
+ export const getHeadingNavigation = async (
86
+ markdown: string,
87
+ sourceLabel = 'Markdown',
88
+ ): Promise<HeadingNavigation[]> => {
89
+ const bodyStructure = await getBodySections(markdown);
90
+ const issues = getHeadingIdentifierIssues(bodyStructure.headings);
91
+ if (issues.length > 0) throw new Error(formatHeadingIdentifierIssue(issues[0], sourceLabel));
92
+
93
+ let parentId: string | null = null;
94
+ return bodyStructure.headings.flatMap((heading) => {
95
+ if (heading.depth === 1 || !heading.id) return [];
96
+ if (heading.depth === 2) parentId = heading.id;
97
+
98
+ return [{
99
+ depth: heading.depth,
100
+ id: heading.id,
101
+ parentId: heading.depth === 3 ? parentId : null,
102
+ title: heading.title,
103
+ }];
122
104
  });
123
-
124
- for (const id of Object.keys(sectionMetadata)) {
125
- if (!sectionIds.has(id)) {
126
- throw new Error(
127
- `Section metadata "${id}" does not match any Markdown section. Add "## Heading {#${id}}" or remove sections.${id}.`,
128
- );
129
- }
130
- }
131
-
132
- return sections;
133
105
  };
134
106
 
107
+ export const getSectionNavigation = async (
108
+ markdown: string,
109
+ sourceLabel = 'Markdown',
110
+ ): Promise<SectionNavigation[]> => (
111
+ (await getHeadingNavigation(markdown, sourceLabel))
112
+ .filter((heading) => heading.depth === 2)
113
+ .map(({ id, title }) => ({ id, title }))
114
+ );
115
+
135
116
  const stripImageProvenanceComments = (html: string) => html.replace(imageProvenanceCommentRegex, '');
136
117
 
137
118
  const prepareContentHtml = (html: string) =>
@@ -231,30 +212,41 @@ const applyInlineNoteMarkup = (html: string, notes: InlineNote[]) => {
231
212
  });
232
213
  };
233
214
 
234
- const getRawMarkdownSections = (markdown: string) => {
235
- const matches = Array.from(markdown.matchAll(markdownH2Regex));
215
+ const getRawMarkdownSections = (bodySections: Awaited<ReturnType<typeof getBodySections>>['sections']) => {
236
216
  const sections = new Map<string, string>();
237
217
 
238
- for (let index = 0; index < matches.length; index += 1) {
239
- const match = matches[index];
240
- const heading = match[0] ?? '';
241
- const id = heading.match(explicitHeadingIdRegex)?.[1];
218
+ for (const section of bodySections) {
219
+ const id = section.isPageTitle ? '__page-title' : section.id;
242
220
  if (!id) continue;
243
-
244
- const start = match.index ?? 0;
245
- const next = matches[index + 1];
246
- const end = next?.index ?? markdown.length;
247
- sections.set(id, markdown.slice(start, end));
221
+ sections.set(id, section.text);
248
222
  }
249
223
 
250
224
  return sections;
251
225
  };
252
226
 
253
- const getImageSourceKey = (page: SitePage, sectionId: string, image: string) => (
254
- page.routeDirectory
255
- ? `routes/${page.routeDirectory}/images/${sectionId}/${image}`
256
- : `images/${sectionId}/${image}`
257
- );
227
+ const applyH3HeadingIds = (
228
+ html: string,
229
+ headings: Awaited<ReturnType<typeof getBodySections>>['headings'],
230
+ ) => {
231
+ let headingIndex = 0;
232
+ const result = html.replace(/<h3\b([^>]*)>([\s\S]*?)<\/h3>/gi, (_match, attributes, headingHtml) => {
233
+ const heading = headings[headingIndex];
234
+ headingIndex += 1;
235
+ if (!heading?.id) throw new Error('Rendered Markdown H3 could not be matched to its heading id.');
236
+
237
+ const cleanAttributes = String(attributes).replace(/\s+id=(["']).*?\1/gi, '');
238
+ const cleanHeadingHtml = String(headingHtml).replace(explicitHeadingIdRegex, '').trim();
239
+ return `<h3${cleanAttributes} id="${heading.id}">${cleanHeadingHtml}</h3>`;
240
+ });
241
+
242
+ if (headingIndex !== headings.length) {
243
+ throw new Error('Markdown H3 headings could not be matched to the rendered page content.');
244
+ }
245
+
246
+ return result;
247
+ };
248
+
249
+ const getImageSourceKey = (page: SitePage, image: string) => `pages/${page.pageDirectory}/images/${image}`;
258
250
 
259
251
  const renderInlineNoteMarkdown = async (markdown: string) => {
260
252
  const result = await markdownToHtml(markdown, {
@@ -275,7 +267,6 @@ const resolveContentBlocks = async (
275
267
  html: string,
276
268
  rawMarkdown: string,
277
269
  page: SitePage,
278
- sectionId: string,
279
270
  inlineNotes: InlineNote[] = [],
280
271
  ) => {
281
272
  const rawBlocks = extractNornaMarkdownBlocks(rawMarkdown);
@@ -323,7 +314,7 @@ const resolveContentBlocks = async (
323
314
  width: block.width,
324
315
  cards: block.cards.map((card: CardListItem) => ({
325
316
  ...card,
326
- ...(card.image ? { src: getImageSourceKey(page, sectionId, card.image) } : {}),
317
+ ...(card.image ? { src: getImageSourceKey(page, card.image) } : {}),
327
318
  })),
328
319
  });
329
320
  continue;
@@ -333,7 +324,7 @@ const resolveContentBlocks = async (
333
324
  type: block.type,
334
325
  images: block.images.map((image: { image: string; alt?: string; caption?: string }) => ({
335
326
  ...image,
336
- src: getImageSourceKey(page, sectionId, image.image),
327
+ src: getImageSourceKey(page, image.image),
337
328
  })),
338
329
  });
339
330
  }
@@ -344,38 +335,54 @@ const resolveContentBlocks = async (
344
335
  export const getSectionsContent = async (
345
336
  html: string,
346
337
  rawMarkdown: string,
347
- sectionMetadata: SiteSectionMetadataMap,
348
338
  page: SitePage,
339
+ sourceLabel = 'Markdown',
349
340
  ) => {
350
- const matches = Array.from(html.matchAll(headingRegex));
351
- const rawSections = getRawMarkdownSections(rawMarkdown);
352
- const metadataIds = new Set(Object.keys(sectionMetadata));
341
+ const matches = Array.from(html.matchAll(contentHeadingRegex));
342
+ const bodyStructure = await getBodySections(rawMarkdown);
343
+ const headingIdentifierIssues = getHeadingIdentifierIssues(bodyStructure.headings);
344
+ if (headingIdentifierIssues.length > 0) {
345
+ throw new Error(formatHeadingIdentifierIssue(headingIdentifierIssues[0], sourceLabel));
346
+ }
347
+ const rawSections = getRawMarkdownSections(bodyStructure.sections);
353
348
  const sections: ResolvedSection[] = [];
354
349
  const sectionIds = new Set<string>();
355
350
  let nextNoteNumber = 1;
351
+ const pageHeadingCount = matches.filter((match) => match[1] === '1').length;
352
+
353
+ if (pageHeadingCount !== 1 || matches[0]?.[1] !== '1') {
354
+ throw new Error('Each Norna page must start with exactly one Markdown H1 page title, for example "# About".');
355
+ }
356
356
 
357
357
  for (let index = 0; index < matches.length; index += 1) {
358
358
  const match = matches[index];
359
- const attributes = match[1] ?? '';
360
- const headingHtml = match[2] ?? '';
361
- const explicitId = getExplicitHeadingId(headingHtml);
362
- const id = getHeadingId(attributes, headingHtml);
359
+ const bodySection = bodyStructure.sections[index];
360
+ const headingLevel = Number.parseInt(match[1] ?? '', 10) as 1 | 2;
361
+ const headingHtml = match[3] ?? '';
362
+ if (!bodySection || bodySection.level !== headingLevel) {
363
+ throw new Error('Markdown H1/H2 headings could not be matched to the rendered page content.');
364
+ }
365
+ const id = bodySection.id;
363
366
  const contentStart = (match.index ?? 0) + match[0].length;
364
367
  const nextMatch = matches[index + 1];
365
368
  const contentEnd = nextMatch?.index ?? html.length;
366
- const content = html.slice(contentStart, contentEnd).trim();
369
+ const nextBodySection = bodyStructure.sections[index + 1];
370
+ const h3Headings = bodyStructure.headings.filter((heading) => (
371
+ heading.depth === 3
372
+ && heading.index > bodySection.index
373
+ && heading.index < (nextBodySection?.index ?? Number.POSITIVE_INFINITY)
374
+ ));
375
+ const content = applyH3HeadingIds(html.slice(contentStart, contentEnd).trim(), h3Headings);
367
376
  const title = getHeadingTitle(headingHtml);
368
377
 
369
- if (!explicitId) {
370
- throw new Error(`Section heading "${title}" is missing an explicit id. Write it as: ## ${title} {#${id}}`);
371
- }
378
+ if (headingLevel === 2 && !id) throw new Error(`Section heading "${title}" cannot produce an automatic ASCII id.`);
372
379
 
373
- if (sectionIds.has(id)) {
380
+ if (id && sectionIds.has(id)) {
374
381
  throw new Error(`Duplicate Markdown section heading id: ${id}`);
375
382
  }
376
383
 
377
- sectionIds.add(id);
378
- const rawSection = rawSections.get(id) ?? '';
384
+ if (id) sectionIds.add(id);
385
+ const rawSection = rawSections.get(id ?? '__page-title') ?? '';
379
386
  const inlineNoteDiagnostics = extractInlineNoteDiagnostics(rawSection, {
380
387
  label: 'Markdown section',
381
388
  });
@@ -386,8 +393,8 @@ export const getSectionsContent = async (
386
393
  const inlineNotes = inlineNoteDiagnostics.notes.map((note) => {
387
394
  const number = nextNoteNumber;
388
395
  nextNoteNumber += 1;
389
- const pageKey = page.routeId || 'home';
390
- const noteKey = `${pageKey}-${id}-${number}`;
396
+ const pageKey = page.pageId || 'home';
397
+ const noteKey = `${pageKey}-${id ?? 'page-title'}-${number}`;
391
398
  return {
392
399
  ...note,
393
400
  number,
@@ -397,21 +404,13 @@ export const getSectionsContent = async (
397
404
  });
398
405
 
399
406
  sections.push({
400
- ...(sectionMetadata[id] ?? {}),
401
407
  id,
402
408
  title,
403
409
  titleHtml: getHeadingTitleHtml(headingHtml),
404
- contentBlocks: await resolveContentBlocks(content, rawSection, page, id, inlineNotes),
410
+ headingLevel,
411
+ contentBlocks: await resolveContentBlocks(content, rawSection, page, inlineNotes),
405
412
  });
406
413
  }
407
414
 
408
- for (const id of metadataIds) {
409
- if (!sectionIds.has(id)) {
410
- throw new Error(
411
- `Section metadata "${id}" does not match any Markdown section. Add "## Heading {#${id}}" or remove sections.${id}.`,
412
- );
413
- }
414
- }
415
-
416
415
  return sections;
417
416
  };
@@ -0,0 +1,49 @@
1
+ import type { HeadingNavigation, SectionNavigation } from './sectionContent';
2
+ import type { SitePage } from './sitePages';
3
+
4
+ export type PageNavigationEntry = {
5
+ page: SitePage;
6
+ headings: HeadingNavigation[];
7
+ sections: SectionNavigation[];
8
+ };
9
+
10
+ export type PageNavigationNode = PageNavigationEntry & {
11
+ children: PageNavigationNode[];
12
+ };
13
+
14
+ export const getPageNavigationTree = (entries: PageNavigationEntry[]) => {
15
+ const nodes = new Map<string, PageNavigationNode>();
16
+ const roots: PageNavigationNode[] = [];
17
+
18
+ for (const entry of entries) {
19
+ nodes.set(entry.page.pagePath, { ...entry, children: [] });
20
+ }
21
+
22
+ for (const entry of entries) {
23
+ const node = nodes.get(entry.page.pagePath);
24
+ if (!node) continue;
25
+
26
+ if (entry.page.isHome || entry.page.parentPagePath === null) {
27
+ roots.push(node);
28
+ continue;
29
+ }
30
+
31
+ const parent = nodes.get(entry.page.parentPagePath);
32
+ if (!parent) {
33
+ throw new Error(`Page "${entry.page.pathname}" has no parent page "${entry.page.parentPagePath}".`);
34
+ }
35
+
36
+ parent.children.push(node);
37
+ }
38
+
39
+ return roots;
40
+ };
41
+
42
+ export const getListedPageNavigationTree = (entries: PageNavigationEntry[]) => {
43
+ const filterListed = (nodes: PageNavigationNode[]): PageNavigationNode[] => nodes.flatMap((node) => {
44
+ if (!node.page.isHome && !node.page.navigation.listed) return [];
45
+ return [{ ...node, children: filterListed(node.children) }];
46
+ });
47
+
48
+ return filterListed(getPageNavigationTree(entries));
49
+ };