@janga/norna 0.7.16 → 0.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +6 -6
  2. package/astro.config.mjs +35 -3
  3. package/docs/README.md +10 -10
  4. package/docs/commands.md +2 -2
  5. package/docs/content.md +82 -102
  6. package/docs/design/navigation-and-theme-plan.md +115 -0
  7. package/docs/design/norna-diagram-design.md +9 -9
  8. package/docs/design/site-examples-structure.md +9 -9
  9. package/docs/engine-development.md +9 -9
  10. package/docs/getting-started.md +20 -15
  11. package/docs/images-and-metadata.md +10 -9
  12. package/docs/pages.md +134 -0
  13. package/docs/public-files.md +49 -36
  14. package/docs/publishing.md +19 -3
  15. package/docs/site-files.md +29 -28
  16. package/docs/sitewide-content.md +20 -36
  17. package/docs/theme.md +20 -20
  18. package/docs/typography.md +10 -10
  19. package/examples/README.md +2 -2
  20. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/site.schema.json +17 -41
  21. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/sitewide.schema.json +26 -67
  22. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/theme.schema.json +185 -88
  23. package/examples/complete-sites/dog-shelter-multi-page/.astro/data-store.json +1 -1
  24. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev-local.json +4 -4
  25. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.json +5 -5
  26. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.log +33 -23
  27. package/examples/complete-sites/dog-shelter-multi-page/README.md +1 -1
  28. package/examples/complete-sites/dog-shelter-multi-page/node_modules/.vite/deps/_metadata.json +8 -8
  29. package/examples/complete-sites/dog-shelter-multi-page/site/.norna/generated-images.json +20 -20
  30. package/examples/complete-sites/dog-shelter-multi-page/site/content.md +3 -4
  31. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/010-dogs/content.md +3 -4
  32. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/020-adopt/content.md +3 -4
  33. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +2 -4
  34. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/site.schema.json +17 -41
  35. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/sitewide.schema.json +26 -67
  36. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/theme.schema.json +185 -88
  37. package/examples/complete-sites/dog-shelter-single-page/.astro/data-store.json +1 -1
  38. package/examples/complete-sites/dog-shelter-single-page/.astro/dev-local.json +8 -0
  39. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.json +5 -5
  40. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.log +16 -12
  41. package/examples/complete-sites/dog-shelter-single-page/README.md +1 -1
  42. package/examples/complete-sites/dog-shelter-single-page/node_modules/.vite/deps/_metadata.json +8 -8
  43. package/examples/complete-sites/dog-shelter-single-page/site/.norna/generated-images.json +12 -12
  44. package/examples/complete-sites/dog-shelter-single-page/site/content.md +5 -2
  45. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +2 -4
  46. package/examples/feature-demos/media-and-surfaces/.astro/collections/site.schema.json +17 -41
  47. package/examples/feature-demos/media-and-surfaces/.astro/collections/sitewide.schema.json +26 -67
  48. package/examples/feature-demos/media-and-surfaces/.astro/collections/theme.schema.json +185 -88
  49. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  50. package/examples/feature-demos/media-and-surfaces/.astro/dev.log +4 -14
  51. package/examples/feature-demos/media-and-surfaces/README.md +2 -2
  52. package/examples/feature-demos/media-and-surfaces/node_modules/.vite/deps/_metadata.json +9 -9
  53. package/examples/feature-demos/media-and-surfaces/site/.norna/generated-images.json +16 -16
  54. package/examples/feature-demos/media-and-surfaces/site/content.md +4 -7
  55. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/010-media/content.md +6 -5
  56. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/content.md +7 -6
  57. package/examples/feature-demos/media-and-surfaces/site/sitewide-content.yaml +0 -2
  58. package/examples/feature-demos/sitewide-content/README.md +3 -3
  59. package/examples/feature-demos/sitewide-content/site/content.md +8 -7
  60. package/examples/feature-demos/sitewide-content/site/pages/010-logo-and-navigation/content.md +17 -0
  61. package/examples/feature-demos/sitewide-content/site/{routes → pages}/020-notices/content.md +7 -6
  62. package/examples/feature-demos/sitewide-content/site/{routes → pages}/030-footer/content.md +6 -5
  63. package/examples/feature-demos/sitewide-content/site/sitewide-content.yaml +4 -13
  64. package/examples/feature-demos/theme-presets/README.md +1 -1
  65. package/examples/feature-demos/theme-presets/site/.norna/generated-images.json +60 -60
  66. package/examples/feature-demos/theme-presets/site/content.md +4 -7
  67. package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/content.md +4 -7
  68. package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/content.md +4 -7
  69. package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/content.md +4 -7
  70. package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/content.md +4 -7
  71. package/examples/feature-demos/theme-presets/site/sitewide-content.yaml +1 -10
  72. package/fixtures/basic/site/content.md +5 -6
  73. package/fixtures/basic/site/sitewide-content.yaml +1 -8
  74. package/fixtures/content-model-v2/site/content.md +11 -13
  75. package/fixtures/content-model-v2/site/pages/010-guide/content.md +18 -0
  76. package/fixtures/nested-pages/site/.norna/generated-images.json +10 -0
  77. package/fixtures/nested-pages/site/config.yaml +1 -0
  78. package/fixtures/nested-pages/site/content.md +15 -0
  79. package/fixtures/nested-pages/site/pages/010-guides/content.md +15 -0
  80. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/content.md +21 -0
  81. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/images/diagram.svg +21 -0
  82. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/pages/010-macos/content.md +14 -0
  83. package/fixtures/nested-pages/site/pages/010-guides/pages/020-workflows/content.md +11 -0
  84. package/fixtures/nested-pages/site/pages/010-guides/theme.yaml +3 -0
  85. package/fixtures/nested-pages/site/pages/020-reference/content.md +10 -0
  86. package/fixtures/nested-pages/site/pages/020-reference/pages/010-installation/content.md +11 -0
  87. package/fixtures/nested-pages/site/theme.yaml +3 -0
  88. package/package.json +7 -4
  89. package/schemas/config.schema.json +5 -5
  90. package/schemas/content-frontmatter.schema.json +28 -63
  91. package/schemas/manifest.json +1 -0
  92. package/schemas/sitewide-content.schema.json +58 -132
  93. package/schemas/theme.schema.json +396 -86
  94. package/scripts/check-config.mjs +6 -5
  95. package/scripts/deploy-site.mjs +6 -8
  96. package/scripts/doctor.mjs +2 -2
  97. package/scripts/generate-images.mjs +11 -18
  98. package/scripts/generate-schemas.mjs +3 -217
  99. package/scripts/lib/base-path-redirect.mjs +9 -0
  100. package/scripts/lib/editor-language-service.mjs +126 -66
  101. package/scripts/lib/heading-ids.mjs +161 -0
  102. package/scripts/lib/navigation-model.mjs +51 -0
  103. package/scripts/lib/norna-markdown-blocks.mjs +3 -4
  104. package/scripts/lib/page-model.mjs +83 -0
  105. package/scripts/lib/project-config.mjs +28 -0
  106. package/scripts/lib/schema-definitions.mjs +41 -46
  107. package/scripts/lib/schema-editor-metadata.mjs +475 -0
  108. package/scripts/lib/schema-value-definitions.mjs +6 -6
  109. package/scripts/lib/site-content.mjs +124 -87
  110. package/scripts/lib/site-paths.mjs +2 -2
  111. package/scripts/lib/theme-config.mjs +9 -16
  112. package/scripts/lib/theme-presets.mjs +9 -1
  113. package/scripts/release.mjs +1 -1
  114. package/scripts/show-typography.mjs +26 -20
  115. package/scripts/sync-content-sections.mjs +106 -65
  116. package/scripts/{test-temporary-visibility.mjs → test-banner-visibility.mjs} +7 -24
  117. package/scripts/test-client-javascript.mjs +39 -42
  118. package/scripts/test-content-check.mjs +138 -54
  119. package/scripts/test-content-model-v2.mjs +445 -228
  120. package/scripts/test-documentation.mjs +6 -1
  121. package/scripts/test-editor-language-service.mjs +168 -24
  122. package/scripts/test-engine-commands.mjs +2 -1
  123. package/scripts/test-heading-ids.mjs +68 -0
  124. package/scripts/test-navigation-model.mjs +63 -0
  125. package/scripts/test-nested-pages.mjs +110 -0
  126. package/scripts/test-package-check.mjs +31 -40
  127. package/scripts/test-page-model.mjs +80 -0
  128. package/scripts/test-project-config.mjs +25 -1
  129. package/scripts/test-schemas.mjs +66 -14
  130. package/scripts/test-theme-presets.mjs +33 -24
  131. package/src/components/NavigationPageTree.astro +68 -0
  132. package/src/components/SectionNavigationScript.astro +110 -5
  133. package/src/components/SiteBreadcrumbs.astro +29 -0
  134. package/src/components/SiteNavigation.astro +65 -69
  135. package/src/components/SitePage.astro +70 -46
  136. package/src/components/SiteSection.astro +27 -20
  137. package/src/content.config.ts +9 -5
  138. package/src/layouts/BaseLayout.astro +11 -20
  139. package/src/lib/pageThemes.ts +42 -0
  140. package/src/lib/sectionContent.ts +109 -106
  141. package/src/lib/siteNavigation.ts +48 -0
  142. package/src/lib/sitePages.ts +85 -38
  143. package/src/pages/{[slug].astro → [...slug].astro} +2 -2
  144. package/src/styles/global.css +373 -55
  145. package/starters/basic/README.md +5 -7
  146. package/starters/basic/site/content.md +7 -4
  147. package/starters/basic/site/sitewide-content.yaml +1 -10
  148. package/starters/project/README.md +8 -8
  149. package/starters/project/site/content.md +5 -4
  150. package/starters/project/site/{routes → pages}/010-guide/content.md +5 -4
  151. package/starters/project/site/sitewide-content.yaml +1 -10
  152. package/docs/routes.md +0 -121
  153. package/examples/feature-demos/sitewide-content/site/routes/010-identity/content.md +0 -20
  154. package/fixtures/content-model-v2/site/routes/010-guide/content.md +0 -17
  155. package/scripts/lib/route-model.mjs +0 -17
  156. package/scripts/test-route-model.mjs +0 -32
  157. package/src/lib/routeThemes.ts +0 -41
  158. /package/examples/complete-sites/dog-shelter-multi-page/site/images/{intro/shelter-dog.jpg → shelter-dog.jpg} +0 -0
  159. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rex.jpg +0 -0
  160. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rover.jpg +0 -0
  161. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/020-adopt/images/adopt → pages/020-adopt/images}/adoption-dog.jpg +0 -0
  162. /package/examples/complete-sites/dog-shelter-single-page/site/images/{you-can-help/foster-dog.jpg → foster-dog.jpg} +0 -0
  163. /package/examples/complete-sites/dog-shelter-single-page/site/images/{what-we-do/rescue-dog.jpg → rescue-dog.jpg} +0 -0
  164. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-carousel.svg +0 -0
  165. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-stack.svg +0 -0
  166. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-surfaces.svg +0 -0
  167. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-one.svg +0 -0
  168. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-three.svg +0 -0
  169. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-two.svg +0 -0
  170. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-one.svg +0 -0
  171. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-two.svg +0 -0
  172. /package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/theme.yaml +0 -0
  173. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/puppy-portrait.jpg +0 -0
  174. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/shepherd-portrait.jpg +0 -0
  175. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/white-puppy.jpg +0 -0
  176. /package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/theme.yaml +0 -0
  177. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/black-schnauzer.jpg +0 -0
  178. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/retriever-portrait.jpg +0 -0
  179. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/schnauzer-portrait.jpg +0 -0
  180. /package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/theme.yaml +0 -0
  181. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/boxer-portrait.jpg +0 -0
  182. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grass-puppy.jpg +0 -0
  183. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grey-street-dog.jpg +0 -0
  184. /package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/theme.yaml +0 -0
  185. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/black-pedigree.jpg +0 -0
  186. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-grass.jpg +0 -0
  187. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-wide.jpg +0 -0
  188. /package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/theme.yaml +0 -0
  189. /package/fixtures/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
  190. /package/fixtures/content-model-v2/site/images/{card-section/adopt.svg → adopt.svg} +0 -0
  191. /package/fixtures/content-model-v2/site/images/{intro/detail.jpg → detail.jpg} +0 -0
  192. /package/fixtures/content-model-v2/site/images/{intro/duplicate.jpg → duplicate.jpg} +0 -0
  193. /package/fixtures/content-model-v2/site/images/{card-section/foster.svg → foster.svg} +0 -0
  194. /package/fixtures/content-model-v2/site/images/{intro/hero.jpg → hero.jpg} +0 -0
  195. /package/fixtures/content-model-v2/site/images/{plain/image.jpg → image.jpg} +0 -0
  196. /package/fixtures/content-model-v2/site/images/{timed/slide-one.jpg → slide-one.jpg} +0 -0
  197. /package/fixtures/content-model-v2/site/images/{timed/slide-three.jpg → slide-three.jpg} +0 -0
  198. /package/fixtures/content-model-v2/site/images/{timed/slide-two.jpg → slide-two.jpg} +0 -0
  199. /package/fixtures/content-model-v2/site/{routes/010-guide/images/intro → pages/010-guide/images}/duplicate.jpg +0 -0
  200. /package/starters/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
@@ -35,34 +35,36 @@ interface Props {
35
35
  pagePresentation: ResolvedPagePresentation;
36
36
  visualTheme: VisualTheme;
37
37
  sectionIndex: number;
38
- headingLevel: 1 | 2;
39
38
  prioritizeFirstImage?: boolean;
40
39
  }
41
40
 
42
- const { section, pagePresentation, visualTheme, sectionIndex, headingLevel, prioritizeFirstImage = false } = Astro.props;
43
- const HeadingTag = headingLevel === 1 ? 'h1' : 'h2';
44
- // Sections are authored with Markdown ## headings even when the first one renders as HTML h1.
45
- const sectionHeadingVisualLevel = 'h2';
46
- const firstContentBlockIsVisual = section.contentBlocks[0]?.type !== 'html'
47
- && section.contentBlocks[0]?.type !== 'note';
41
+ const { section, pagePresentation, visualTheme, sectionIndex, prioritizeFirstImage = false } = Astro.props;
42
+ const isPageTitle = section.headingLevel === 1;
43
+ const HeadingTag = section.headingLevel === 1 ? 'h1' : 'h2';
44
+ const sectionHeadingVisualLevel = section.headingLevel === 1 ? 'h1' : 'h2';
45
+ const headingId = section.id ?? 'page-title';
46
+ const firstContentBlock = section.contentBlocks[0];
47
+ const firstContentBlockIsVisual = firstContentBlock !== undefined
48
+ && firstContentBlock.type !== 'html'
49
+ && firstContentBlock.type !== 'note';
48
50
  type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4';
49
51
  const headingSizeValues: Record<HeadingLevel, Record<TextSize, ResponsiveValue<string>>> = {
50
52
  h1: {
51
53
  small: {
52
- desktop: 'clamp(1.5rem, 1.18rem + 1.15vw, 2.25rem)',
53
- mobile: 'clamp(1.42rem, 1.12rem + 1.5vw, 2rem)',
54
+ desktop: 'clamp(1.35rem, 1.2rem + 0.55vw, 1.7rem)',
55
+ mobile: 'clamp(1.32rem, 1.16rem + 0.8vw, 1.58rem)',
54
56
  },
55
57
  medium: {
56
- desktop: 'clamp(1.85rem, 1.35rem + 1.8vw, 3.1rem)',
57
- mobile: 'clamp(1.65rem, 1.18rem + 2.2vw, 2.45rem)',
58
+ desktop: 'clamp(1.65rem, 1.35rem + 1.1vw, 2.4rem)',
59
+ mobile: 'clamp(1.5rem, 1.22rem + 1.4vw, 2rem)',
58
60
  },
59
61
  large: {
60
- desktop: 'clamp(2.25rem, 1.45rem + 2.75vw, 4.25rem)',
61
- mobile: 'clamp(1.9rem, 1.26rem + 3.2vw, 3.15rem)',
62
+ desktop: 'clamp(1.95rem, 1.48rem + 1.7vw, 3.2rem)',
63
+ mobile: 'clamp(1.75rem, 1.32rem + 2.15vw, 2.5rem)',
62
64
  },
63
65
  xlarge: {
64
- desktop: 'clamp(2.75rem, 1.6rem + 3.8vw, 5.5rem)',
65
- mobile: 'clamp(2.2rem, 1.35rem + 4.4vw, 3.9rem)',
66
+ desktop: 'clamp(2.35rem, 1.58rem + 2.75vw, 4.4rem)',
67
+ mobile: 'clamp(2rem, 1.4rem + 3vw, 3.1rem)',
66
68
  },
67
69
  },
68
70
  h2: {
@@ -168,7 +170,9 @@ const textWidthValues: Record<TextWidth, string> = {
168
170
  normal: 'min(72ch, var(--text-width))',
169
171
  wide: 'min(72ch, var(--image-layout-width))',
170
172
  };
171
- const surface = resolveSectionSurface(pagePresentation, sectionIndex);
173
+ const surface = isPageTitle
174
+ ? pagePresentation.palette.surfaces.base
175
+ : resolveSectionSurface(pagePresentation, sectionIndex);
172
176
  const typography = pagePresentation.typography.values;
173
177
  const heading = typography.headings[sectionHeadingVisualLevel] as {
174
178
  align: ResponsiveValue<TextAlign>;
@@ -249,6 +253,7 @@ for (const block of section.contentBlocks) {
249
253
  }
250
254
 
251
255
  flushReadingBlocks();
256
+ const firstMediaGroupIndex = contentGroups.findIndex((group) => group.type === 'media');
252
257
  const markdownHeadingStyle = (level: 'h3' | 'h4') => {
253
258
  const headingConfig = markdownHeadings[level];
254
259
  const fontSize = getHeadingSizeValue(level, headingConfig.size);
@@ -298,10 +303,12 @@ const sectionStyle = [
298
303
  ---
299
304
 
300
305
  <section
301
- aria-labelledby={section.id}
306
+ aria-labelledby={headingId}
302
307
  class:list={[
303
308
  'site-section',
304
309
  {
310
+ 'site-section-page-title': isPageTitle,
311
+ 'site-section-page-title-empty': isPageTitle && section.contentBlocks.length === 0,
305
312
  'site-section-first-block-is-visual': firstContentBlockIsVisual,
306
313
  'site-section-has-margin-notes': hasMarginNotes,
307
314
  },
@@ -311,7 +318,7 @@ const sectionStyle = [
311
318
  >
312
319
  <div class="section-card">
313
320
  <header class="section-header">
314
- <HeadingTag id={section.id} set:html={section.titleHtml} />
321
+ <HeadingTag id={headingId} set:html={section.titleHtml} />
315
322
  </header>
316
323
 
317
324
  <div class:list={['section-body', { 'section-body-has-media': hasMedia, 'section-body-text-only': !hasMedia }]}>
@@ -381,7 +388,7 @@ const sectionStyle = [
381
388
  title={section.title}
382
389
  images={block.images}
383
390
  maxAvailableWidthPercent={visualTheme.images.maxAvailableWidthPercent}
384
- priorityFirstImage={prioritizeFirstImage && groupIndex === 0}
391
+ priorityFirstImage={prioritizeFirstImage && groupIndex === firstMediaGroupIndex}
385
392
  />
386
393
  );
387
394
  }
@@ -395,7 +402,7 @@ const sectionStyle = [
395
402
  title={section.title}
396
403
  images={block.images}
397
404
  maxAvailableWidthPercent={visualTheme.images.maxAvailableWidthPercent}
398
- priorityFirstImage={prioritizeFirstImage && groupIndex === 0}
405
+ priorityFirstImage={prioritizeFirstImage && groupIndex === firstMediaGroupIndex}
399
406
  />
400
407
  );
401
408
  })}
@@ -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,16 +23,20 @@ const siteThemeSchema = themeVisualSchema;
23
23
 
24
24
  const site = defineCollection({
25
25
  loader: glob({
26
- pattern: ['content.md', 'routes/*/content.md'],
26
+ pattern: ['content.md', 'pages/**/content.md'],
27
27
  base: pathToFileURL(siteDir),
28
28
  generateId: ({ entry }) => {
29
29
  if (entry === 'content.md') {
30
30
  return siteEntryId;
31
31
  }
32
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}`
33
+ const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
34
+ const pageDirectory = pageEntryDirectory
35
+ .split('/')
36
+ .filter((segment) => segment !== 'pages')
37
+ .join('/pages/');
38
+ return pageDirectory
39
+ ? `${siteEntryId.replace(/-content$/, '')}-page-${encodePageDirectoryPath(parsePageDirectoryPath(pageDirectory, `page directory pages/${pageEntryDirectory}`).pageDirectory)}`
36
40
  : entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
37
41
  },
38
42
  }),
@@ -39,7 +39,7 @@ type VisualTheme = {
39
39
  };
40
40
 
41
41
  interface Props {
42
- title?: string;
42
+ title: string;
43
43
  description?: string;
44
44
  presentation: Presentation;
45
45
  visualTheme: VisualTheme;
@@ -47,22 +47,13 @@ interface Props {
47
47
  pathname?: string;
48
48
  footer?: {
49
49
  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
- };
50
+ buildInfo?: boolean;
60
51
  };
61
52
  }
62
53
 
63
54
  const {
64
- title = 'norna',
65
- description = 'A norna site.',
55
+ title,
56
+ description,
66
57
  presentation,
67
58
  visualTheme,
68
59
  frameColors,
@@ -103,12 +94,12 @@ const documentStyle = [
103
94
  `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
104
95
  ].join('; ');
105
96
  const { buildInfo, copyrightMessage } = footer ?? {};
106
- const showBuildInfo = Boolean(buildInfo?.enabled);
97
+ const showBuildInfo = buildInfo === true;
107
98
  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,
99
+ ? new Intl.DateTimeFormat(projectConfig.locale.lang, {
100
+ dateStyle: 'short',
101
+ timeStyle: 'short',
102
+ timeZone: 'UTC',
112
103
  }).format(new Date())
113
104
  : null;
114
105
  const showFooter = Boolean(copyrightMessage || showBuildInfo);
@@ -124,7 +115,7 @@ const footerStyle = [
124
115
  <meta charset="utf-8" />
125
116
  <meta name="viewport" content="width=device-width, initial-scale=1" />
126
117
  <meta name="format-detection" content="telephone=no" />
127
- <meta name="description" content={description} />
118
+ {description && <meta name="description" content={description} />}
128
119
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
129
120
  <link rel="canonical" href={canonicalUrl} />
130
121
  <meta name="generator" content={Astro.generator} />
@@ -144,7 +135,7 @@ const footerStyle = [
144
135
  <footer class="site-footer" style={footerStyle}>
145
136
  <p>
146
137
  {copyrightMessage}
147
- {showBuildInfo && <span>{buildInfo.text} {buildTime}</span>}
138
+ {showBuildInfo && <span>{projectConfig.locale.labels.built} {buildTime}</span>}
148
139
  </p>
149
140
  </footer>
150
141
  )}
@@ -0,0 +1,42 @@
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 { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
7
+ import { parseYamlConfig } from '../../scripts/lib/yaml-config.mjs';
8
+ import { getPageDirectoryAncestors } from '../../scripts/lib/page-model.mjs';
9
+
10
+ type PageTheme = {
11
+ id: string;
12
+ data: Record<string, unknown>;
13
+ };
14
+
15
+ export const getPageTheme = async (pageDirectory: string | null): Promise<PageTheme | null> => {
16
+ if (!pageDirectory) return null;
17
+
18
+ const pageAncestors = getPageDirectoryAncestors(pageDirectory).reverse();
19
+ for (const pageAncestor of pageAncestors) {
20
+ const themeSegments = pageAncestor.split('/');
21
+ const themePath = path.join(sitePagesDir, ...themeSegments, 'theme.yaml');
22
+ const source = await readFile(themePath, 'utf8').catch((error) => {
23
+ if (error?.code === 'ENOENT') return null;
24
+ throw error;
25
+ });
26
+ if (!source) continue;
27
+ const themeLabel = `${sitePagesLabel}/${themeSegments.join('/')}/theme.yaml`;
28
+
29
+ const data = parseYamlConfig(source, themeLabel, {
30
+ validateStructure: validatePageThemeYamlStructure,
31
+ });
32
+ if (Object.hasOwn(data, 'navigation')) {
33
+ throw new Error(`${themeLabel} may not define navigation. Set the site-wide navigation mode in the root theme.yaml.`);
34
+ }
35
+ return {
36
+ id: `pages/${themeSegments.join('/')}/theme`,
37
+ data,
38
+ };
39
+ }
40
+
41
+ return null;
42
+ };
@@ -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,29 +212,44 @@ 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}`
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) => (
250
+ page.pageDirectory
251
+ ? `pages/${page.pageDirectory}/images/${image}`
252
+ : `images/${image}`
257
253
  );
258
254
 
259
255
  const renderInlineNoteMarkdown = async (markdown: string) => {
@@ -275,7 +271,6 @@ const resolveContentBlocks = async (
275
271
  html: string,
276
272
  rawMarkdown: string,
277
273
  page: SitePage,
278
- sectionId: string,
279
274
  inlineNotes: InlineNote[] = [],
280
275
  ) => {
281
276
  const rawBlocks = extractNornaMarkdownBlocks(rawMarkdown);
@@ -323,7 +318,7 @@ const resolveContentBlocks = async (
323
318
  width: block.width,
324
319
  cards: block.cards.map((card: CardListItem) => ({
325
320
  ...card,
326
- ...(card.image ? { src: getImageSourceKey(page, sectionId, card.image) } : {}),
321
+ ...(card.image ? { src: getImageSourceKey(page, card.image) } : {}),
327
322
  })),
328
323
  });
329
324
  continue;
@@ -333,7 +328,7 @@ const resolveContentBlocks = async (
333
328
  type: block.type,
334
329
  images: block.images.map((image: { image: string; alt?: string; caption?: string }) => ({
335
330
  ...image,
336
- src: getImageSourceKey(page, sectionId, image.image),
331
+ src: getImageSourceKey(page, image.image),
337
332
  })),
338
333
  });
339
334
  }
@@ -344,38 +339,54 @@ const resolveContentBlocks = async (
344
339
  export const getSectionsContent = async (
345
340
  html: string,
346
341
  rawMarkdown: string,
347
- sectionMetadata: SiteSectionMetadataMap,
348
342
  page: SitePage,
343
+ sourceLabel = 'Markdown',
349
344
  ) => {
350
- const matches = Array.from(html.matchAll(headingRegex));
351
- const rawSections = getRawMarkdownSections(rawMarkdown);
352
- const metadataIds = new Set(Object.keys(sectionMetadata));
345
+ const matches = Array.from(html.matchAll(contentHeadingRegex));
346
+ const bodyStructure = await getBodySections(rawMarkdown);
347
+ const headingIdentifierIssues = getHeadingIdentifierIssues(bodyStructure.headings);
348
+ if (headingIdentifierIssues.length > 0) {
349
+ throw new Error(formatHeadingIdentifierIssue(headingIdentifierIssues[0], sourceLabel));
350
+ }
351
+ const rawSections = getRawMarkdownSections(bodyStructure.sections);
353
352
  const sections: ResolvedSection[] = [];
354
353
  const sectionIds = new Set<string>();
355
354
  let nextNoteNumber = 1;
355
+ const pageHeadingCount = matches.filter((match) => match[1] === '1').length;
356
+
357
+ if (pageHeadingCount !== 1 || matches[0]?.[1] !== '1') {
358
+ throw new Error('Each Norna page must start with exactly one Markdown H1 page title, for example "# About".');
359
+ }
356
360
 
357
361
  for (let index = 0; index < matches.length; index += 1) {
358
362
  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);
363
+ const bodySection = bodyStructure.sections[index];
364
+ const headingLevel = Number.parseInt(match[1] ?? '', 10) as 1 | 2;
365
+ const headingHtml = match[3] ?? '';
366
+ if (!bodySection || bodySection.level !== headingLevel) {
367
+ throw new Error('Markdown H1/H2 headings could not be matched to the rendered page content.');
368
+ }
369
+ const id = bodySection.id;
363
370
  const contentStart = (match.index ?? 0) + match[0].length;
364
371
  const nextMatch = matches[index + 1];
365
372
  const contentEnd = nextMatch?.index ?? html.length;
366
- const content = html.slice(contentStart, contentEnd).trim();
373
+ const nextBodySection = bodyStructure.sections[index + 1];
374
+ const h3Headings = bodyStructure.headings.filter((heading) => (
375
+ heading.depth === 3
376
+ && heading.index > bodySection.index
377
+ && heading.index < (nextBodySection?.index ?? Number.POSITIVE_INFINITY)
378
+ ));
379
+ const content = applyH3HeadingIds(html.slice(contentStart, contentEnd).trim(), h3Headings);
367
380
  const title = getHeadingTitle(headingHtml);
368
381
 
369
- if (!explicitId) {
370
- throw new Error(`Section heading "${title}" is missing an explicit id. Write it as: ## ${title} {#${id}}`);
371
- }
382
+ if (headingLevel === 2 && !id) throw new Error(`Section heading "${title}" cannot produce an automatic ASCII id.`);
372
383
 
373
- if (sectionIds.has(id)) {
384
+ if (id && sectionIds.has(id)) {
374
385
  throw new Error(`Duplicate Markdown section heading id: ${id}`);
375
386
  }
376
387
 
377
- sectionIds.add(id);
378
- const rawSection = rawSections.get(id) ?? '';
388
+ if (id) sectionIds.add(id);
389
+ const rawSection = rawSections.get(id ?? '__page-title') ?? '';
379
390
  const inlineNoteDiagnostics = extractInlineNoteDiagnostics(rawSection, {
380
391
  label: 'Markdown section',
381
392
  });
@@ -386,8 +397,8 @@ export const getSectionsContent = async (
386
397
  const inlineNotes = inlineNoteDiagnostics.notes.map((note) => {
387
398
  const number = nextNoteNumber;
388
399
  nextNoteNumber += 1;
389
- const pageKey = page.routeId || 'home';
390
- const noteKey = `${pageKey}-${id}-${number}`;
400
+ const pageKey = page.pageId || 'home';
401
+ const noteKey = `${pageKey}-${id ?? 'page-title'}-${number}`;
391
402
  return {
392
403
  ...note,
393
404
  number,
@@ -397,21 +408,13 @@ export const getSectionsContent = async (
397
408
  });
398
409
 
399
410
  sections.push({
400
- ...(sectionMetadata[id] ?? {}),
401
411
  id,
402
412
  title,
403
413
  titleHtml: getHeadingTitleHtml(headingHtml),
404
- contentBlocks: await resolveContentBlocks(content, rawSection, page, id, inlineNotes),
414
+ headingLevel,
415
+ contentBlocks: await resolveContentBlocks(content, rawSection, page, inlineNotes),
405
416
  });
406
417
  }
407
418
 
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
419
  return sections;
417
420
  };
@@ -0,0 +1,48 @@
1
+ import type { SectionNavigation } from './sectionContent';
2
+ import type { SitePage } from './sitePages';
3
+
4
+ export type PageNavigationEntry = {
5
+ page: SitePage;
6
+ sections: SectionNavigation[];
7
+ };
8
+
9
+ export type PageNavigationNode = PageNavigationEntry & {
10
+ children: PageNavigationNode[];
11
+ };
12
+
13
+ export const getPageNavigationTree = (entries: PageNavigationEntry[]) => {
14
+ const nodes = new Map<string, PageNavigationNode>();
15
+ const roots: PageNavigationNode[] = [];
16
+
17
+ for (const entry of entries) {
18
+ nodes.set(entry.page.pagePath, { ...entry, children: [] });
19
+ }
20
+
21
+ for (const entry of entries) {
22
+ const node = nodes.get(entry.page.pagePath);
23
+ if (!node) continue;
24
+
25
+ if (entry.page.isHome || entry.page.parentPagePath === null) {
26
+ roots.push(node);
27
+ continue;
28
+ }
29
+
30
+ const parent = nodes.get(entry.page.parentPagePath);
31
+ if (!parent) {
32
+ throw new Error(`Page "${entry.page.pathname}" has no parent page "${entry.page.parentPagePath}".`);
33
+ }
34
+
35
+ parent.children.push(node);
36
+ }
37
+
38
+ return roots;
39
+ };
40
+
41
+ export const getListedPageNavigationTree = (entries: PageNavigationEntry[]) => {
42
+ const filterListed = (nodes: PageNavigationNode[]): PageNavigationNode[] => nodes.flatMap((node) => {
43
+ if (!node.page.isHome && !node.page.navigation.listed) return [];
44
+ return [{ ...node, children: filterListed(node.children) }];
45
+ });
46
+
47
+ return filterListed(getPageNavigationTree(entries));
48
+ };