@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
@@ -1,7 +1,12 @@
1
1
  <script>
2
2
  const siteTop = document.querySelector<HTMLElement>('.site-top');
3
3
  const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-nav-menu');
4
- const mobileSectionLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>('.mobile-page-nav a'));
4
+ const sectionLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>(
5
+ '.mobile-nav-sections a, .page-nav a',
6
+ ));
7
+ const submenuItems = Array.from(document.querySelectorAll<HTMLElement>('.site-nav-item-has-submenu'));
8
+ const mobileDestinationLinks = Array.from(mobileMenu?.querySelectorAll<HTMLAnchorElement>('.mobile-nav-destination') ?? []);
9
+ const mobileMenuSummary = mobileMenu?.querySelector<HTMLElement>(':scope > summary');
5
10
 
6
11
  const setAnchorOffset = () => {
7
12
  if (!siteTop) return;
@@ -10,17 +15,117 @@
10
15
  document.documentElement.style.setProperty('--site-top-anchor-offset', `${offset}px`);
11
16
  };
12
17
 
13
- mobileSectionLinks.forEach((link) => {
14
- link.addEventListener('click', (event) => {
18
+ const alignInitialHashTarget = () => {
19
+ if (!window.location.hash) return;
20
+
21
+ try {
22
+ const target = document.getElementById(decodeURIComponent(window.location.hash.slice(1)));
23
+ target?.scrollIntoView({ block: 'start' });
24
+ } catch {
25
+ // Invalid URL encoding is left to the browser's native hash handling.
26
+ }
27
+ };
28
+
29
+ const getSamePageTarget = (link: HTMLAnchorElement) => {
30
+ const linkUrl = new URL(link.href, window.location.href);
31
+ if (linkUrl.pathname !== window.location.pathname || !linkUrl.hash) return undefined;
32
+
33
+ try {
34
+ return document.getElementById(decodeURIComponent(linkUrl.hash.slice(1))) ?? undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ };
39
+
40
+ const updateCurrentSection = () => {
41
+ sectionLinks.forEach((link) => {
15
42
  const linkUrl = new URL(link.href, window.location.href);
16
- if (linkUrl.pathname !== window.location.pathname) return;
43
+ const isCurrent = linkUrl.pathname === window.location.pathname
44
+ && linkUrl.hash !== ''
45
+ && linkUrl.hash === window.location.hash;
46
+
47
+ if (isCurrent) {
48
+ link.setAttribute('aria-current', 'location');
49
+ } else if (link.getAttribute('aria-current') === 'location') {
50
+ link.removeAttribute('aria-current');
51
+ }
52
+ });
53
+ };
54
+
55
+ submenuItems.forEach((item) => {
56
+ const resetDismissal = () => delete item.dataset.submenuDismissed;
57
+ item.addEventListener('pointerleave', resetDismissal);
58
+ item.querySelector<HTMLAnchorElement>(':scope > a')?.addEventListener('focus', resetDismissal);
59
+ });
60
+
61
+ if (mobileMenu) {
62
+ mobileDestinationLinks.forEach((link) => {
63
+ link.addEventListener('click', () => {
64
+ mobileMenu.open = false;
65
+ });
66
+ });
67
+
68
+ mobileMenu.addEventListener('toggle', () => {
69
+ document.documentElement.classList.toggle('mobile-navigation-open', mobileMenu.open);
70
+ if (mobileMenu.open) window.requestAnimationFrame(() => mobileMenu.querySelector<HTMLElement>('h2')?.focus());
71
+ });
72
+
73
+ mobileMenu.addEventListener('keydown', (event) => {
74
+ if (!mobileMenu.open) return;
75
+
76
+ if (event.key === 'Escape') {
77
+ event.preventDefault();
78
+ mobileMenu.open = false;
79
+ mobileMenuSummary?.focus();
80
+ return;
81
+ }
82
+
83
+ if (event.key !== 'Tab') return;
84
+
85
+ const focusableElements = Array.from(mobileMenu.querySelectorAll<HTMLElement>(
86
+ 'summary, a[href]:not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])',
87
+ )).filter((element) => element.getClientRects().length > 0);
88
+ const firstFocusable = focusableElements[0];
89
+ const lastFocusable = focusableElements.at(-1);
90
+ if (!firstFocusable || !lastFocusable) return;
91
+
92
+ if (event.shiftKey && document.activeElement === firstFocusable) {
93
+ event.preventDefault();
94
+ lastFocusable.focus();
95
+ } else if (!event.shiftKey && document.activeElement === lastFocusable) {
96
+ event.preventDefault();
97
+ firstFocusable.focus();
98
+ }
99
+ });
100
+ }
101
+
102
+ sectionLinks.forEach((link) => {
103
+ link.addEventListener('click', (event) => {
104
+ const target = getSamePageTarget(link);
105
+ if (!target) return;
17
106
 
18
107
  if (mobileMenu) mobileMenu.open = false;
19
- if (event.detail > 0) link.blur();
108
+ link.closest<HTMLElement>('.site-nav-item')?.setAttribute('data-submenu-dismissed', 'true');
109
+
110
+ window.requestAnimationFrame(() => {
111
+ updateCurrentSection();
112
+ if (event.detail > 0) {
113
+ link.blur();
114
+ return;
115
+ }
116
+
117
+ target.setAttribute('tabindex', '-1');
118
+ target.focus({ preventScroll: true });
119
+ target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true });
120
+ });
20
121
  });
21
122
  });
22
123
 
124
+ window.addEventListener('hashchange', updateCurrentSection);
125
+ updateCurrentSection();
126
+
23
127
  setAnchorOffset();
128
+ if (window.location.hash) window.requestAnimationFrame(alignInitialHashTarget);
24
129
 
25
130
  if (siteTop && 'ResizeObserver' in window) {
26
131
  new ResizeObserver(setAnchorOffset).observe(siteTop);
@@ -0,0 +1,29 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { withBasePath } from '../lib/basePath';
4
+ import type { SitePage } from '../lib/sitePages';
5
+
6
+ interface Props {
7
+ currentPage: SitePage;
8
+ pages: SitePage[];
9
+ }
10
+
11
+ const { currentPage, pages } = Astro.props;
12
+ const pagesByPath = new Map(pages.map((page) => [page.pagePath, page]));
13
+ const ancestors = currentPage.pageIds
14
+ .slice(0, -1)
15
+ .map((_, index) => pagesByPath.get(currentPage.pageIds.slice(0, index + 1).join('/')))
16
+ .filter((page): page is SitePage => page !== undefined);
17
+ const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
18
+ ---
19
+
20
+ {ancestors.length > 0 && (
21
+ <nav class="site-breadcrumbs" aria-label={projectConfig.locale.labels.breadcrumb}>
22
+ <ol>
23
+ {ancestors.map((page) => (
24
+ <li><a href={pageHref(page)}>{page.title}</a></li>
25
+ ))}
26
+ <li><span aria-current="page">{currentPage.title}</span></li>
27
+ </ol>
28
+ </nav>
29
+ )}
@@ -2,7 +2,9 @@
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
4
  import type { SectionNavigation } from '../lib/sectionContent';
5
+ import { getListedPageNavigationTree, type PageNavigationEntry } from '../lib/siteNavigation';
5
6
  import type { SitePage } from '../lib/sitePages';
7
+ import NavigationPageTree from './NavigationPageTree.astro';
6
8
  import SectionNavigationScript from './SectionNavigationScript.astro';
7
9
 
8
10
  type FrameColors = {
@@ -11,21 +13,18 @@ type FrameColors = {
11
13
  };
12
14
 
13
15
  interface Props {
14
- navigationLabel: string;
15
16
  logo?: {
16
17
  src: string;
17
18
  alt: string;
18
19
  height?: string;
19
20
  };
20
21
  currentPage: SitePage;
21
- pageNavigation: Array<{
22
- page: SitePage;
23
- sections: SectionNavigation[];
24
- }>;
22
+ pageNavigation: PageNavigationEntry[];
23
+ navigationMode: 'sections' | 'top' | 'tree';
25
24
  frameColors: FrameColors;
26
25
  }
27
26
 
28
- const { navigationLabel, logo, currentPage, pageNavigation, frameColors } = Astro.props;
27
+ const { logo, currentPage, pageNavigation, navigationMode, frameColors } = Astro.props;
29
28
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
30
29
  ? 'var(--color-page)'
31
30
  : frameColors.backgroundColor;
@@ -34,54 +33,54 @@ const navigationStyle = [
34
33
  `--site-top-text-color: ${frameColors.textColor}`,
35
34
  ...(logo?.height ? [`--site-brand-logo-height: ${logo.height}`] : []),
36
35
  ].join('; ');
37
- const showSiteNavigation = pageNavigation.length > 1;
38
- const showNavigationRow = showSiteNavigation || Boolean(logo);
36
+ const siteNavigationPages = pageNavigation.filter(({ page }) => page.isHome || page.navigation.listed);
37
+ const siteNavigationTree = getListedPageNavigationTree(pageNavigation);
38
+ const topLevelNavigation = siteNavigationTree;
39
+ const showSiteNavigation = siteNavigationPages.some(({ page }) => !page.isHome);
39
40
  const homePage = pageNavigation.find(({ page }) => page.isHome)?.page ?? currentPage;
40
41
  const currentPageNavigation = pageNavigation.find(({ page }) => page.pathname === currentPage.pathname);
41
42
  const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
42
43
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
43
- const sectionHref = (page: SitePage, section: SectionNavigation) => `${pageHref(page)}#${section.id}`;
44
44
  const hasSectionMenu = (pageSections: SectionNavigation[]) => pageSections.length > 1;
45
- const showSinglePageSectionNavigation = Boolean(
46
- !showSiteNavigation && currentPageNavigation && hasSectionMenu(currentPageNavigation.sections),
47
- );
48
45
  const hasCurrentPageSectionMenu = Boolean(
49
- showSiteNavigation && currentPageNavigation && hasSectionMenu(currentPageNavigation.sections),
46
+ currentPageNavigation && hasSectionMenu(currentPageNavigation.sections),
50
47
  );
51
- const hasSectionNavigation = showSinglePageSectionNavigation || hasCurrentPageSectionMenu;
48
+ const showMobileNavigation = showSiteNavigation || hasCurrentPageSectionMenu;
49
+ const showNavigationRow = showMobileNavigation || Boolean(logo);
50
+ const hasSectionNavigation = hasCurrentPageSectionMenu;
52
51
  ---
53
52
 
54
53
  <a class="skip-link" href="#main-content">{projectConfig.locale.labels.skipToContent}</a>
55
54
 
56
55
  <header
57
56
  class:list={['site-top', { 'site-top-has-site-nav': showSiteNavigation }]}
57
+ data-navigation-mode={navigationMode}
58
58
  style={navigationStyle}
59
59
  >
60
60
  {showNavigationRow && (
61
61
  <div class="site-nav-row">
62
- <a class="site-brand" href={pageHref(homePage)}>
63
- {logo ? <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} /> : navigationLabel}
64
- </a>
62
+ {logo && (
63
+ <a class="site-brand" href={pageHref(homePage)}>
64
+ <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} />
65
+ </a>
66
+ )}
65
67
 
66
68
  {showSiteNavigation && (
67
69
  <nav class="site-nav" aria-label={projectConfig.locale.labels.siteNavigation}>
68
70
  <ul>
69
- {pageNavigation.map(({ page, sections: pageSections }) => (
70
- <li class:list={['site-nav-item', { 'site-nav-item-has-sections': hasSectionMenu(pageSections) }]}>
71
- <a href={pageHref(page)} aria-current={isCurrentPage(page) ? 'page' : undefined}>
72
- {page.navigation.label}
73
- </a>
74
- {hasSectionMenu(pageSections) && (
75
- <div class="site-nav-submenu">
76
- <nav aria-label={`${page.navigation.label} ${projectConfig.locale.labels.pageNavigation}`}>
77
- <ul>
78
- {pageSections.map((section) => (
79
- <li>
80
- <a href={sectionHref(page, section)}>{section.title}</a>
81
- </li>
82
- ))}
83
- </ul>
84
- </nav>
71
+ {topLevelNavigation.map((node) => (
72
+ <li class:list={['site-nav-item', {
73
+ 'site-nav-item-has-submenu': node.children.length > 0,
74
+ 'site-nav-item-current-branch': !node.page.isHome && currentPage.pagePath.startsWith(`${node.page.pagePath}/`),
75
+ }]}>
76
+ <a href={pageHref(node.page)} aria-current={isCurrentPage(node.page) ? 'page' : undefined}>
77
+ {node.page.title}
78
+ </a>
79
+ {node.children.length > 0 && (
80
+ <div class="site-nav-submenu">
81
+ <nav aria-label={`${node.page.title} ${projectConfig.locale.labels.siteNavigation}`}>
82
+ <NavigationPageTree nodes={[node]} currentPage={currentPage} variant="submenu" />
83
+ </nav>
85
84
  </div>
86
85
  )}
87
86
  </li>
@@ -90,52 +89,49 @@ const hasSectionNavigation = showSinglePageSectionNavigation || hasCurrentPageSe
90
89
  </nav>
91
90
  )}
92
91
 
93
- {showSiteNavigation && <details class="mobile-nav-menu">
94
- <summary>
95
- <span>{currentPage.navigation.label}</span>
96
- </summary>
97
- <div class="mobile-nav-panel">
98
- <nav class="mobile-site-nav" aria-label={projectConfig.locale.labels.siteNavigation}>
99
- <h2>{projectConfig.locale.labels.siteNavigation}</h2>
100
- <ul>
101
- {pageNavigation.map(({ page }) => (
102
- <li>
103
- <a href={pageHref(page)} aria-current={isCurrentPage(page) ? 'page' : undefined}>
104
- {page.navigation.label}
105
- </a>
106
- </li>
107
- ))}
108
- </ul>
109
- </nav>
92
+ {showMobileNavigation && <details class="mobile-nav-menu">
93
+ <summary>
94
+ <span>{projectConfig.locale.labels.navigationMenu}</span>
95
+ </summary>
96
+ <div class="mobile-nav-panel">
97
+ <div class="mobile-site-nav">
98
+ {showSiteNavigation && (
99
+ <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
100
+ <h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
101
+ <NavigationPageTree nodes={siteNavigationTree} currentPage={currentPage} variant="mobile" />
102
+ </nav>
103
+ )}
110
104
 
111
- {currentPageNavigation && hasCurrentPageSectionMenu && (
112
- <nav class="mobile-page-nav" aria-label={projectConfig.locale.labels.pageNavigation}>
113
- <h2>{projectConfig.locale.labels.pageNavigation}</h2>
114
- <ul>
115
- {currentPageNavigation.sections.map((section) => (
116
- <li>
117
- <a href={sectionHref(currentPage, section)}>
118
- {section.title}
119
- </a>
120
- </li>
105
+ {hasCurrentPageSectionMenu && currentPageNavigation && (
106
+ <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.pageNavigation}>
107
+ <h2 tabindex="-1">{projectConfig.locale.labels.pageNavigation}</h2>
108
+ <ul class="mobile-nav-sections">
109
+ {currentPageNavigation.sections.map((section) => (
110
+ <li>
111
+ <a class="mobile-nav-destination" href={`#${section.id}`}>{section.title}</a>
112
+ </li>
121
113
  ))}
122
114
  </ul>
123
115
  </nav>
124
116
  )}
125
117
  </div>
126
- </details>}
118
+ </div>
119
+ </details>}
127
120
  </div>
128
121
  )}
129
122
 
130
- {showSinglePageSectionNavigation && currentPageNavigation && (
123
+ {hasCurrentPageSectionMenu && currentPageNavigation && (
131
124
  <nav class="page-nav" aria-label={projectConfig.locale.labels.pageNavigation}>
132
- <ul>
133
- {currentPageNavigation.sections.map((section) => (
134
- <li>
135
- <a href={`#${section.id}`}>{section.title}</a>
136
- </li>
137
- ))}
138
- </ul>
125
+ <div class="page-nav-inner">
126
+ <span class="page-nav-label">{projectConfig.locale.labels.pageNavigation}</span>
127
+ <ul>
128
+ {currentPageNavigation.sections.map((section) => (
129
+ <li>
130
+ <a href={`#${section.id}`}>{section.title}</a>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ </div>
139
135
  </nav>
140
136
  )}
141
137
 
@@ -2,18 +2,20 @@
2
2
  import { getCollection, type CollectionEntry } from 'astro:content';
3
3
  import path from 'node:path';
4
4
  import BaseLayout from '../layouts/BaseLayout.astro';
5
+ import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
5
6
  import SiteNavigation from './SiteNavigation.astro';
6
7
  import SiteBanners from './SiteBanners.astro';
7
8
  import SiteNotesScript from './SiteNotesScript.astro';
8
9
  import SiteSection from './SiteSection.astro';
9
- import { getSectionNavigation, getSectionsContent } from '../lib/sectionContent';
10
- import { getRouteTheme } from '../lib/routeThemes';
11
- import { getNavigationPages, getSitePage, getSitePages } from '../lib/sitePages';
10
+ import { getHeadingNavigation, getSectionsContent, type ResolvedSection } from '../lib/sectionContent';
11
+ import { getPageTheme } from '../lib/pageThemes';
12
+ import { getSitePage, getSitePages, type SitePage } from '../lib/sitePages';
12
13
  import { getToday, isVisibleToday } from '../lib/visibility';
13
14
  import { getLogoAsset } from '../lib/sitePublicAssets';
14
15
  import { readSiteFile } from '../../scripts/lib/site-content.mjs';
15
- import { siteContentPath, siteRoutesDir } from '../../scripts/lib/site-paths.mjs';
16
- import { resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
16
+ import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
17
+ import { siteContentPath, sitePagesDir } from '../../scripts/lib/site-paths.mjs';
18
+ import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
17
19
  import { resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
18
20
 
19
21
  type SiteEntry = CollectionEntry<'site'>;
@@ -26,71 +28,93 @@ const { entry } = Astro.props;
26
28
  const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
27
29
  const sitePages = getSitePages(await getCollection('site'));
28
30
  const currentPage = getSitePage(entry);
29
- const siteContentLabel = entry.id.includes('-route-')
30
- ? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/content.md`
31
- : `${siteDirectory || 'site'}/content.md`;
32
- const pageContentPath = currentPage.routeDirectory
33
- ? path.join(siteRoutesDir, currentPage.routeDirectory, 'content.md')
34
- : siteContentPath;
35
- const rawMarkdown = (await readSiteFile(pageContentPath, siteContentLabel)).body;
31
+ const getPageContentFile = (page: SitePage) => page.pageDirectory
32
+ ? {
33
+ label: `${siteDirectory || 'site'}/pages/${page.pageDirectory}/content.md`,
34
+ path: path.join(sitePagesDir, page.pageDirectory, 'content.md'),
35
+ }
36
+ : {
37
+ label: `${siteDirectory || 'site'}/content.md`,
38
+ path: siteContentPath,
39
+ };
40
+ const pageContentFile = getPageContentFile(currentPage);
41
+ const siteContentLabel = pageContentFile.label;
42
+ const rawMarkdown = (await readSiteFile(pageContentFile.path, pageContentFile.label)).body;
36
43
  const [siteTheme] = await getCollection('theme');
37
44
  if (!siteTheme) {
38
45
  throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
39
46
  }
40
47
  const [sitewide] = await getCollection('sitewide');
41
48
  const sitewideData = sitewide?.data ?? {};
42
- const navigationPages = getNavigationPages(sitePages);
43
49
  const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
44
50
  const siteThemeData = siteTheme.data;
45
- const routeTheme = await getRouteTheme(currentPage.routeDirectory);
46
- const themeData = routeTheme?.data ?? siteThemeData;
47
- const themeLabel = routeTheme
48
- ? `${siteDirectory || 'site'}/${routeTheme.id}`
51
+ const pageTheme = await getPageTheme(currentPage.pageDirectory);
52
+ const themeData = pageTheme?.data ?? siteThemeData;
53
+ const themeLabel = pageTheme
54
+ ? `${siteDirectory || 'site'}/${pageTheme.id}`
49
55
  : `${siteDirectory || 'site'}/${siteTheme.id}`;
50
56
  const visualTheme = resolveThemeVisualConfig(themeData, themeLabel);
51
- const navigationLabel = sitewideData.navigation?.label ?? homePage.title;
52
57
  const logoAsset = getLogoAsset();
53
- const logoSettings = sitewideData.navigation?.logo;
58
+ const logoSettings = sitewideData.logo;
54
59
  const logo = logoAsset
55
60
  ? {
56
61
  src: logoAsset.href,
57
- alt: navigationLabel,
62
+ alt: homePage.title,
58
63
  height: logoSettings?.height,
59
64
  }
60
65
  : undefined;
61
66
  const pagePresentation = resolvePagePresentation(themeData, themeLabel);
62
67
  const frameColors = resolveFrameColors({ theme: themeData, sourceLabel: themeLabel });
63
- const sections = entry.data.sections;
64
68
  const siteHtml = entry.rendered?.html ?? '';
65
- const resolvedSections = await getSectionsContent(siteHtml, rawMarkdown, sections, currentPage);
69
+ const resolvedSections = await getSectionsContent(siteHtml, rawMarkdown, currentPage, siteContentLabel);
66
70
  const today = getToday();
67
71
  const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
68
- const visibleSections = resolvedSections.filter((section) => isVisibleToday(section.visible, today));
69
- const hasMarginNotes = visibleSections.some((section) => section.contentBlocks.some((block) => block.type === 'note'));
72
+ const hasMarginNotes = resolvedSections.some((section) => section.contentBlocks.some((block) => block.type === 'note'));
73
+ const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
74
+ block.type === 'image-stack'
75
+ || block.type === 'image-carousel'
76
+ ));
77
+ const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
78
+ let contentSectionIndex = 0;
79
+ const renderedSections = resolvedSections.map((section, index) => ({
80
+ section,
81
+ sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
82
+ prioritizeFirstImage: index === firstImageSectionIndex,
83
+ }));
70
84
 
71
85
  const getPageContent = async (page: SitePage) => {
72
- if (page.pathname === currentPage.pathname) {
73
- return visibleSections.map(({ id, title }) => ({ id, title }));
74
- }
75
-
76
- const pageSections = getSectionNavigation(page.entry.rendered?.html ?? '', page.entry.data.sections);
77
-
78
- return pageSections.filter((section) => isVisibleToday(page.entry.data.sections[section.id]?.visible, today));
86
+ const contentFile = getPageContentFile(page);
87
+ const pageMarkdown = page.pathname === currentPage.pathname
88
+ ? rawMarkdown
89
+ : (await readSiteFile(contentFile.path, contentFile.label)).body;
90
+ return getHeadingNavigation(pageMarkdown, contentFile.label);
79
91
  };
80
92
 
81
- const pageNavigation = await Promise.all(navigationPages.map(async (page) => ({
82
- page,
83
- sections: await getPageContent(page),
84
- })));
93
+ const pageNavigation = await Promise.all(sitePages.map(async (page) => {
94
+ const headings = await getPageContent(page);
95
+ return {
96
+ page,
97
+ headings,
98
+ sections: headings
99
+ .filter((heading) => heading.depth === 2)
100
+ .map(({ id, title }) => ({ id, title })),
101
+ };
102
+ }));
103
+ const navigationModel = resolveNavigationModel({
104
+ mode: projectConfig.navigation.mode,
105
+ pages: pageNavigation.map(({ page, headings }) => ({
106
+ depth: Math.max(1, page.depth),
107
+ headings,
108
+ isHome: page.isHome,
109
+ listed: page.navigation.listed,
110
+ })),
111
+ });
85
112
 
86
- if (visibleSections.length === 0) {
87
- throw new Error(`No sections are visible for ${today}. Check sections.<section-id>.visible in ${siteContentLabel}.`);
88
- }
89
113
  ---
90
114
 
91
115
  <BaseLayout
92
- title={entry.data.title}
93
- description={entry.data.description}
116
+ title={currentPage.title}
117
+ description={entry.data.page?.description}
94
118
  presentation={pagePresentation}
95
119
  visualTheme={visualTheme}
96
120
  frameColors={frameColors}
@@ -98,23 +122,23 @@ if (visibleSections.length === 0) {
98
122
  footer={sitewideData.footer}
99
123
  >
100
124
  <SiteNavigation
101
- navigationLabel={navigationLabel}
102
125
  logo={logo}
103
126
  currentPage={currentPage}
104
127
  pageNavigation={pageNavigation}
128
+ navigationMode={navigationModel.mode}
105
129
  frameColors={frameColors}
106
130
  />
107
131
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
108
132
 
109
- <main id="main-content" class="site-content">
110
- {visibleSections.map((section, index) => (
133
+ <main class:list={['site-content', { 'site-content-has-breadcrumbs': currentPage.depth > 1 }]} id="main-content">
134
+ {currentPage.depth > 1 && <SiteBreadcrumbs currentPage={currentPage} pages={sitePages} />}
135
+ {renderedSections.map(({ section, sectionIndex, prioritizeFirstImage }) => (
111
136
  <SiteSection
112
137
  section={section}
113
138
  pagePresentation={pagePresentation}
114
139
  visualTheme={visualTheme}
115
- sectionIndex={index}
116
- headingLevel={index === 0 ? 1 : 2}
117
- prioritizeFirstImage={index === 0}
140
+ sectionIndex={sectionIndex}
141
+ prioritizeFirstImage={prioritizeFirstImage}
118
142
  />
119
143
  ))}
120
144
  </main>