@janga/norna 0.7.27 → 0.7.29

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 (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -66,17 +66,6 @@
66
66
  document.documentElement.style.setProperty('--site-top-anchor-offset', `${offset}px`);
67
67
  };
68
68
 
69
- const alignInitialHashTarget = () => {
70
- if (!window.location.hash) return;
71
-
72
- try {
73
- const target = document.getElementById(decodeURIComponent(window.location.hash.slice(1)));
74
- target?.scrollIntoView({ block: 'start' });
75
- } catch {
76
- // Invalid URL encoding is left to the browser's native hash handling.
77
- }
78
- };
79
-
80
69
  const getSamePageTarget = (link: HTMLAnchorElement) => {
81
70
  const linkUrl = new URL(link.href, window.location.href);
82
71
  if (linkUrl.pathname !== window.location.pathname || !linkUrl.hash) return undefined;
@@ -158,35 +147,118 @@
158
147
  sectionTrackingFrame = window.requestAnimationFrame(updateReadingPosition);
159
148
  };
160
149
 
150
+ const positionTopPageMenu = (menu: HTMLDetailsElement) => {
151
+ const panel = menu.querySelector<HTMLElement>('.top-page-panel');
152
+ if (!panel) return;
153
+ panel.style.removeProperty('--top-page-panel-shift');
154
+ const rectangle = panel.getBoundingClientRect();
155
+ let shift = Math.max(0, 16 - rectangle.left)
156
+ - Math.max(0, rectangle.right - document.documentElement.clientWidth + 16);
157
+ if (menu.hasAttribute('data-area-menu')) {
158
+ const trigger = menu.querySelector('summary')!.getBoundingClientRect();
159
+ const row = menu.closest('.site-nav-row')?.getBoundingClientRect();
160
+ const rightEdge = Math.min(row?.right ?? Infinity, document.documentElement.clientWidth - 16);
161
+ const left = Math.max(16, Math.min(trigger.left, rightEdge - rectangle.width));
162
+ shift = left - rectangle.left;
163
+ }
164
+ panel.style.setProperty('--top-page-panel-shift', `${shift}px`);
165
+ };
166
+
167
+ const closeTopPageMenu = new Map<HTMLDetailsElement, () => void>();
161
168
  topPageMenus.forEach((menu) => {
169
+ let openTimer: number | undefined;
170
+ let closeTimer: number | undefined;
171
+ const clearTimers = () => {
172
+ window.clearTimeout(openTimer);
173
+ window.clearTimeout(closeTimer);
174
+ };
175
+ const close = () => {
176
+ clearTimers();
177
+ menu.open = false;
178
+ };
179
+ closeTopPageMenu.set(menu, close);
162
180
  menu.addEventListener('toggle', () => {
163
181
  if (!menu.open) return;
164
- topPageMenus.forEach((other) => { if (other !== menu) other.open = false; });
165
- const panel = menu.querySelector<HTMLElement>('.top-page-panel');
166
- if (!panel) return;
167
- panel.style.removeProperty('--top-page-panel-shift');
168
- const rectangle = panel.getBoundingClientRect();
169
- const shift = Math.max(0, 16 - rectangle.left)
170
- - Math.max(0, rectangle.right - document.documentElement.clientWidth + 16);
171
- panel.style.setProperty('--top-page-panel-shift', `${shift}px`);
182
+ topPageMenus.forEach((other) => { if (other !== menu) closeTopPageMenu.get(other)?.(); });
183
+ positionTopPageMenu(menu);
172
184
  });
173
185
  menu.addEventListener('keydown', (event) => {
174
186
  if (event.key !== 'Escape' || !menu.open) return;
175
187
  event.preventDefault();
176
- menu.open = false;
188
+ close();
177
189
  menu.querySelector('summary')?.focus();
178
190
  });
179
191
  menu.addEventListener('focusout', (event) => {
180
- if (event.relatedTarget instanceof Node && !menu.contains(event.relatedTarget)) menu.open = false;
192
+ if (event.relatedTarget instanceof Node && !menu.contains(event.relatedTarget)) close();
181
193
  });
182
194
  menu.querySelectorAll('a').forEach((link) => {
183
- link.addEventListener('click', () => { menu.open = false; });
195
+ link.addEventListener('click', close);
184
196
  });
197
+ if (menu.hasAttribute('data-area-switcher')) {
198
+ menu.addEventListener('keydown', (event) => {
199
+ if (event.key !== 'ArrowDown' || event.target !== menu.querySelector('summary')) return;
200
+ event.preventDefault();
201
+ menu.open = true;
202
+ menu.querySelector('a')?.focus();
203
+ });
204
+ }
205
+ if (menu.hasAttribute('data-area-menu')) {
206
+ const canHover = window.matchMedia('(any-hover: hover)');
207
+ const hoverRegion = menu.closest<HTMLElement>('.site-nav-row') ?? menu;
208
+ const isHoverPointer = (event: PointerEvent) => event.pointerType !== 'touch' && canHover.matches;
209
+ menu.addEventListener('pointerenter', (event) => {
210
+ if (!isHoverPointer(event)) return;
211
+ clearTimers();
212
+ openTimer = window.setTimeout(() => { menu.open = true; }, 120);
213
+ });
214
+ menu.addEventListener('pointerleave', (event) => {
215
+ if (isHoverPointer(event)) window.clearTimeout(openTimer);
216
+ });
217
+ // The row and its descendant panel form one continuous hover region.
218
+ // Crossing or pausing in its padding must not start a closing timer.
219
+ // Other titles remain real hit targets; no invisible overlay is needed.
220
+ hoverRegion.addEventListener('pointerenter', (event) => {
221
+ if (isHoverPointer(event)) window.clearTimeout(closeTimer);
222
+ });
223
+ hoverRegion.addEventListener('pointerleave', (event) => {
224
+ if (!isHoverPointer(event)) return;
225
+ clearTimers();
226
+ closeTimer = window.setTimeout(() => {
227
+ const focus = document.activeElement;
228
+ if (focus instanceof Element && menu.contains(focus) && focus.matches(':focus-visible')) return;
229
+ close();
230
+ }, 180);
231
+ });
232
+ menu.querySelector('summary')?.addEventListener('click', (event) => {
233
+ // Mouse users open on hover; a click must not immediately fold it
234
+ // away. Native activation remains available for touch and keyboard.
235
+ if (event.detail === 0 || event.pointerType === 'touch' || !canHover.matches) return;
236
+ event.preventDefault();
237
+ clearTimers();
238
+ menu.open = true;
239
+ });
240
+ menu.addEventListener('keydown', (event) => {
241
+ if (event.key !== 'ArrowDown' || event.target !== menu.querySelector('summary')) return;
242
+ event.preventDefault();
243
+ clearTimers();
244
+ menu.open = true;
245
+ menu.querySelector('a')?.focus();
246
+ });
247
+ }
185
248
  });
186
249
  if (topPageMenus.length > 0) {
250
+ // Hover does not move focus into the panel, so Escape must also work
251
+ // while focus remains in the article or elsewhere on the page.
252
+ document.addEventListener('keydown', (event) => {
253
+ if (event.key !== 'Escape') return;
254
+ topPageMenus.filter((menu) => menu.hasAttribute('data-area-menu') || menu.hasAttribute('data-area-switcher')).forEach((menu) => {
255
+ if (menu.open) event.preventDefault();
256
+ closeTopPageMenu.get(menu)?.();
257
+ });
258
+ });
187
259
  document.addEventListener('pointerdown', (event) => {
188
260
  topPageMenus.forEach((menu) => {
189
- if (event.target instanceof Node && !menu.contains(event.target)) menu.open = false;
261
+ if (event.target instanceof Node && !menu.contains(event.target)) closeTopPageMenu.get(menu)?.();
190
262
  });
191
263
  });
192
264
  }
@@ -309,12 +381,20 @@
309
381
  }
310
382
 
311
383
  setAnchorOffset();
312
- if (window.location.hash) window.requestAnimationFrame(alignInitialHashTarget);
313
384
 
314
385
  if (siteTop && 'ResizeObserver' in window) {
315
- new ResizeObserver(() => {
386
+ const navigationResizeObserver = new ResizeObserver(() => {
316
387
  setAnchorOffset();
317
388
  scheduleReadingPositionUpdate();
318
- }).observe(siteTop);
389
+ topPageMenus.filter((menu) => menu.open).forEach((menu) => {
390
+ if (!menu.getClientRects().length) closeTopPageMenu.get(menu)?.();
391
+ else positionTopPageMenu(menu);
392
+ });
393
+ });
394
+ navigationResizeObserver.observe(siteTop);
395
+ if (topPageMenus.some((menu) => menu.hasAttribute('data-area-menu') || menu.hasAttribute('data-area-switcher'))) {
396
+ const row = siteTop.querySelector('.site-nav-row');
397
+ if (row) navigationResizeObserver.observe(row);
398
+ }
319
399
  }
320
400
  </script>
@@ -1,18 +1,24 @@
1
1
  ---
2
+ import NavigationState from './NavigationState.astro';
2
3
  import { Menu, Search, X } from '@lucide/astro';
3
4
  import projectConfig from '../../scripts/lib/project-config.mjs';
4
5
  import { withBasePath } from '../lib/basePath';
5
6
  import type { SectionNavigation } from '../lib/sectionContent';
6
7
  import {
7
8
  getListedSiteNavigationTree,
9
+ getNavigationDestination,
10
+ type CategoryDestinationMap,
8
11
  type SiteNavigationEntry,
9
- type SiteNavigationNode,
10
12
  } from '../lib/siteNavigation';
11
13
  import type { SiteNode } from '../lib/sitePages';
12
14
  import DisplaySettings from './DisplaySettings.astro';
13
15
  import NavigationPageTree from './NavigationPageTree.astro';
14
16
  import NavigationTreeControls from './NavigationTreeControls.astro';
15
17
  import TopPageMenu from './TopPageMenu.astro';
18
+ import AreaNavigationMenu from './AreaNavigationMenu.astro';
19
+ import AreaNavigationSwitcher from './AreaNavigationSwitcher.astro';
20
+ import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
21
+ import { hasLargeAreaMenu, withH2Outlines } from '../lib/areaNavigation';
16
22
  import SectionNavigationScript from './SectionNavigationScript.astro';
17
23
  import SearchNavigationScript from './SearchNavigationScript.astro';
18
24
 
@@ -29,6 +35,7 @@ interface Props {
29
35
  };
30
36
  currentPage?: SiteNode;
31
37
  navigationEntries: SiteNavigationEntry[];
38
+ categoryDestinations?: CategoryDestinationMap;
32
39
  navigationMode: 'sections' | 'top' | 'tree';
33
40
  showTopNavigationSubmenus: boolean;
34
41
  hasNavigationRailFrame: boolean;
@@ -46,18 +53,21 @@ interface Props {
46
53
  };
47
54
  };
48
55
  searchPage?: boolean;
56
+ areaReadingFrame?: boolean;
49
57
  }
50
58
 
51
59
  const {
52
60
  logo,
53
61
  currentPage,
54
62
  navigationEntries,
63
+ categoryDestinations,
55
64
  navigationMode,
56
65
  showTopNavigationSubmenus,
57
66
  hasNavigationRailFrame,
58
67
  frameColors,
59
68
  readerPreferences,
60
69
  searchPage = false,
70
+ areaReadingFrame = false,
61
71
  } = Astro.props;
62
72
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
63
73
  ? 'var(--color-page)'
@@ -68,12 +78,7 @@ const navigationStyle = [
68
78
  ...(logo?.height ? [`--site-brand-logo-height: ${logo.height}`] : []),
69
79
  ].join('; ');
70
80
  const siteNavigationTree = getListedSiteNavigationTree(navigationEntries);
71
- const mobileNavigationTree = navigationMode === 'top'
72
- ? getListedSiteNavigationTree(navigationEntries.map((entry) => ({
73
- ...entry,
74
- headings: entry.headings.filter((heading) => heading.depth === 2),
75
- })))
76
- : siteNavigationTree;
81
+ const mobileNavigationTree = withH2Outlines(siteNavigationTree);
77
82
  const topLevelNavigation = siteNavigationTree;
78
83
  const showSiteNavigation = topLevelNavigation.some(({ node }) => !node.isHome);
79
84
  const homeNode = navigationEntries.find(({ node }) => node.isHome)?.node;
@@ -88,7 +93,6 @@ const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
88
93
  || currentPage.pageDirectory.startsWith(`${node.pageDirectory}/pages/`)
89
94
  ));
90
95
  const pageHref = (page: SiteNode) => withBasePath(projectConfig.site.basePath, page.pathname);
91
- const getNavigationPage = (node: SiteNavigationNode) => node.node;
92
96
  const hasSectionMenu = (pageSections: SectionNavigation[]) => pageSections.length > 1;
93
97
  const showSinglePageTopLink = !showSiteNavigation;
94
98
  const currentPageSectionCount = currentPageNavigation?.sections.length ?? 0;
@@ -131,35 +135,41 @@ const needsNavigationScript = showMobileNavigation || hasCurrentPageSectionMenu
131
135
  >
132
136
  {showNavigationRow && (
133
137
  <div class:list={['site-nav-row', { 'site-nav-row-mobile-only': !showDesktopNavigationRow }]}>
134
- {logo && (
138
+ {(logo || areaReadingFrame) && (
135
139
  <div class="site-nav-leading">
136
- <a class="site-brand" href={pageHref(homePage)}>
137
- <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} />
140
+ <a class="site-brand" href={pageHref(homePage)} aria-label={projectConfig.locale.labels.returnHome} title={projectConfig.locale.labels.returnHome}>
141
+ {logo ? <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} /> : homePage.title}
138
142
  </a>
139
143
  </div>
140
144
  )}
141
145
 
142
146
  <div class="site-nav-actions">
143
- {showSiteNavigation && (
147
+ {showSiteNavigation && !areaReadingFrame && (
144
148
  <nav class="site-nav" aria-label={projectConfig.locale.labels.siteNavigation}>
145
149
  <ul>
146
150
  {topLevelNavigation.map((navigationNode) => {
147
- const destination = getNavigationPage(navigationNode);
151
+ const destination = getNavigationDestination(navigationNode.node, categoryDestinations);
148
152
  if (!destination) return null;
149
153
  const nodeIsCurrentPage = isCurrentPage(navigationNode.node);
154
+ const useAreaMenu = navigationMode === 'tree';
150
155
  return (
151
156
  <li class:list={['site-nav-item', {
152
- 'site-nav-item-current-branch': !nodeIsCurrentPage && isCurrentBranch(navigationNode.node),
157
+ 'site-nav-item-current-branch': (!nodeIsCurrentPage || useAreaMenu) && isCurrentBranch(navigationNode.node),
153
158
  }]}>
154
- <a href={pageHref(destination)} aria-current={nodeIsCurrentPage ? 'page' : undefined}>
159
+ {useAreaMenu && navigationNode.children.length > 0 && !hasLargeAreaMenu(navigationNode) ? <AreaNavigationMenu
160
+ node={navigationNode}
161
+ currentPage={currentPage}
162
+ categoryDestinations={categoryDestinations}
163
+ /> : <a href={pageHref(destination)} aria-current={nodeIsCurrentPage ? 'page' : undefined}>
155
164
  {navigationNode.node.title}
156
- </a>
157
- <TopPageMenu
165
+ </a>}
166
+ {!useAreaMenu && <TopPageMenu
158
167
  node={navigationNode}
159
168
  currentPage={currentPage}
169
+ categoryDestinations={categoryDestinations}
160
170
  showSections={navigationMode === 'top'}
161
171
  showChildren={showTopNavigationSubmenus}
162
- />
172
+ />}
163
173
  </li>
164
174
  );
165
175
  })}
@@ -211,9 +221,10 @@ const needsNavigationScript = showMobileNavigation || hasCurrentPageSectionMenu
211
221
  </div>
212
222
  <div
213
223
  class="mobile-site-nav"
214
- data-navigation-root={pageHref(homePage)}
215
- data-navigation-state-root={pageHref(homePage)}
224
+ data-navigation-root={`area:${pageHref(homePage)}`}
225
+ data-navigation-state-root={`area:${pageHref(homePage)}`}
216
226
  >
227
+ <NavigationState scope="mobile" />
217
228
  {showSiteNavigation && (
218
229
  <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
219
230
  <h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
@@ -224,6 +235,7 @@ const needsNavigationScript = showMobileNavigation || hasCurrentPageSectionMenu
224
235
  <NavigationPageTree
225
236
  nodes={mobileNavigationTree}
226
237
  currentPage={currentPage}
238
+ categoryDestinations={categoryDestinations}
227
239
  variant="mobile"
228
240
  integratePageSections={navigationMode === 'tree' || navigationMode === 'top'}
229
241
  />
@@ -260,6 +272,19 @@ const needsNavigationScript = showMobileNavigation || hasCurrentPageSectionMenu
260
272
  </details>
261
273
  )}
262
274
  </div>
275
+ {areaReadingFrame && (
276
+ <div class="site-reading-location">
277
+ <AreaNavigationSwitcher
278
+ nodes={siteNavigationTree}
279
+ currentPage={currentPage}
280
+ categoryDestinations={categoryDestinations}
281
+ label={searchPage ? projectConfig.locale.labels.search : undefined}
282
+ />
283
+ {currentPage
284
+ ? <SiteBreadcrumbs currentPage={currentPage} nodes={navigationEntries.map(({ node }) => node)} />
285
+ : <span class="site-reading-location-label">{projectConfig.locale.labels.search}</span>}
286
+ </div>
287
+ )}
263
288
  </div>
264
289
  )}
265
290
 
@@ -9,7 +9,6 @@ import ImageStackEnhancement from './ImageStackEnhancement.astro';
9
9
  import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
10
10
  import SiteNavigation from './SiteNavigation.astro';
11
11
  import SiteBanners from './SiteBanners.astro';
12
- import PageContentsNavigation from './PageContentsNavigation.astro';
13
12
  import PageSequenceNavigation from './PageSequenceNavigation.astro';
14
13
  import SiteSection from './SiteSection.astro';
15
14
  import SiteTreeNavigation from './SiteTreeNavigation.astro';
@@ -22,47 +21,37 @@ import {
22
21
  flattenSiteNavigationTree,
23
22
  getDirectChildPages,
24
23
  getListedSiteNavigationTree,
24
+ getNavigationDestination,
25
25
  getSequentialPageNavigation,
26
26
  } from '../lib/siteNavigation';
27
27
  import { getToday, isVisibleToday } from '../lib/visibility';
28
28
  import { getLogoAsset } from '../lib/sitePublicAssets';
29
29
  import {
30
30
  resolveNavigationModel,
31
- resolvePageContentsPlacement,
32
31
  } from '../../scripts/lib/navigation-model.mjs';
33
32
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
34
33
  import { resolveEditSourceTarget } from '../../scripts/lib/edit-source-link.mjs';
35
34
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
36
- import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
37
35
 
38
36
  type SiteEntry = CollectionEntry<'site'>;
39
37
 
40
38
  interface Props {
41
39
  entry?: SiteEntry;
42
- categoryPath?: string;
43
40
  }
44
41
 
45
- const { entry, categoryPath } = Astro.props;
42
+ const { entry } = Astro.props;
46
43
  const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
47
44
  const siteModel = await getSiteModel(await getCollection('site'));
45
+ const categoryDestinations = siteModel.categoryDestinations.byPathname;
48
46
  const { nodes: siteNodes, pages: sitePages } = siteModel;
49
- const categoryDestination = categoryPath ? siteModel.categoryDestinations.byPathname.get(categoryPath) : null;
50
- const currentPage = categoryDestination?.category
51
- ?? sitePages.find((page) => page.entry.id === entry?.id);
52
- if (!currentPage) throw new Error(`Page entry "${entry?.id ?? categoryPath}" is missing from the site page model.`);
53
- const [siteTheme] = await getCollection('theme');
54
- if (!siteTheme) {
55
- throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
56
- }
47
+ const currentPage = sitePages.find((page) => page.entry.id === entry?.id);
48
+ if (!currentPage) throw new Error(`Page entry "${entry?.id}" is missing from the site page model.`);
57
49
  const [sitewide] = await getCollection('sitewide');
58
50
  const sitewideData = sitewide?.data ?? {};
59
51
  const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
60
- const siteThemeData = siteTheme.data;
61
52
  const pageTheme = await getPageTheme(currentPage.pageDirectory);
62
- const themeData = mergePageThemeConfig(siteThemeData, pageTheme?.data);
63
- const themeLabel = pageTheme
64
- ? `${siteDirectory || 'site'}/${pageTheme.id}`
65
- : `${siteDirectory || 'site'}/${siteTheme.id}`;
53
+ const themeData = pageTheme.data;
54
+ const themeLabel = pageTheme.id;
66
55
  const visualTheme = resolveThemeVisualConfig(themeData, themeLabel);
67
56
  const logoAsset = getLogoAsset();
68
57
  const logoSettings = sitewideData.logo;
@@ -99,20 +88,20 @@ const renderedNavigationEntries = flattenSiteNavigationTree(listedNavigationTree
99
88
  const pageSequence = getSequentialPageNavigation(listedNavigationTree, currentPage.pagePath);
100
89
  const childPages = getDirectChildPages(listedNavigationTree, currentPage.pagePath);
101
90
  const siteHtml = entry?.rendered?.html ?? '';
102
- const resolvedSections: ResolvedSection[] = categoryDestination ? [{
103
- id: null,
104
- title: currentPage.title,
105
- titleHtml: currentPage.title.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'),
106
- headingLevel: 1,
107
- contentBlocks: [{
108
- type: 'page-list',
109
- pages: categoryDestination.children.map((node) => ({
110
- title: node.title,
111
- pathname: node.pathname,
112
- description: node.kind === 'page' ? node.entry.data.page?.description : undefined,
113
- })),
114
- }],
115
- }] : await getSectionsContent(siteHtml, currentPage, childPages);
91
+ const resolvedSections: ResolvedSection[] = await getSectionsContent(siteHtml, currentPage, childPages);
92
+ if (currentPage.kind === 'page' && currentPage.entry.data.page?.listChildren) {
93
+ const lastSection = resolvedSections.at(-1);
94
+ if (lastSection && childPages.length > 0) {
95
+ lastSection.contentBlocks.push({
96
+ type: 'page-list',
97
+ pages: childPages.map((child) => ({
98
+ title: child.title,
99
+ pathname: child.pathname,
100
+ description: child.entry.data.page?.description,
101
+ })),
102
+ });
103
+ }
104
+ }
116
105
  const allContentBlocks = resolvedSections.flatMap((section) => flattenContentBlocks(section.contentBlocks));
117
106
  const hasTabs = allContentBlocks.some((block) => block.type === 'tabs');
118
107
  const hasNotes = allContentBlocks.some((block) => (
@@ -126,6 +115,7 @@ const hasTables = allContentBlocks.some((block) => (
126
115
  ));
127
116
  const hasImageStack = allContentBlocks.some((block) => (
128
117
  block.type === 'image-stack'
118
+ || (block.type === 'html' && block.html.includes('data-norna-managed-image='))
129
119
  ));
130
120
  const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
131
121
  block.type === 'image-stack'
@@ -167,33 +157,21 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
167
157
  navigationMode: navigationModel.mode,
168
158
  });
169
159
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
170
- const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
160
+ const frameColors = resolveFrameColors({ theme: themeData, sourceLabel: themeLabel });
171
161
  const hasTreeNavigation = navigationModel.mode === 'tree';
172
- const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
162
+ // Keep one reading frame across tree-mode pages, including pages with no H2.
163
+ // Home remains the site's entry point with the global navigation row.
164
+ const areaReadingFrame = hasTreeNavigation && !currentPage.isHome;
173
165
  const currentPageNavigation = siteNavigation.find(({ node }) => (
174
166
  node.kind === 'page' && node.pathname === currentPage.pathname
175
167
  ));
176
168
  const pageContentsHeadings = currentPageNavigation?.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [];
177
- const pageContentsPlacement = resolvePageContentsPlacement({
178
- navigationMode: navigationModel.mode,
179
- nodes: renderedNavigationEntries.map(({ node }) => ({
180
- depth: Math.max(1, node.depth),
181
- isHome: node.isHome,
182
- listed: node.navigation.listed,
183
- pagePath: node.pagePath,
184
- })),
185
- currentPage: {
186
- isHome: currentPage.isHome,
187
- pagePath: currentPage.pagePath,
188
- },
189
- headingCount: pageContentsHeadings.length,
190
- });
191
- const hasContentsNavigation = pageContentsPlacement.placement !== 'none';
192
- const hasTrackedSectionNavigation = hasContentsNavigation
193
- || (hasTreeNavigation && currentPage.isHome && pageContentsHeadings.length >= 2);
169
+ const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
170
+ const hasContentsNavigation = showTreeNavigation && pageContentsHeadings.some(({ depth }) => depth === 2);
171
+ const hasTrackedSectionNavigation = hasTreeNavigation && pageContentsHeadings.some(({ depth }) => depth === 2);
194
172
  const integratePageContentsInTree = hasTreeNavigation;
195
- const showContentsRail = pageContentsPlacement.placement === 'contents-rail';
196
- const hasNavigationRailFrame = !currentPage.isHome && (
173
+ // Home's global row and the reading layout share the site's wide coordinates.
174
+ const hasNavigationRailFrame = (
197
175
  navigationModel.requestedMode === 'tree'
198
176
  || (
199
177
  navigationModel.requestedMode === 'automatic'
@@ -215,30 +193,35 @@ const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
215
193
  footer={sitewideData.footer}
216
194
  contentsNavigation={hasTrackedSectionNavigation}
217
195
  searchable
196
+ areaNavigation={hasTreeNavigation}
197
+ areaReadingFrame={areaReadingFrame}
218
198
  >
219
199
  <SiteNavigation
220
200
  logo={logo}
221
201
  currentPage={currentPage}
222
202
  navigationEntries={siteNavigation}
203
+ categoryDestinations={categoryDestinations}
223
204
  navigationMode={navigationModel.mode}
224
205
  showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
225
206
  hasNavigationRailFrame={hasNavigationRailFrame}
226
207
  frameColors={frameColors}
227
208
  readerPreferences={pagePresentation.readerPreferences}
209
+ areaReadingFrame={areaReadingFrame}
228
210
  />
229
211
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
230
212
 
231
213
  <div class:list={['site-page-layout', {
232
214
  'site-page-layout-tree': showTreeNavigation,
233
- 'site-page-layout-contents': showContentsRail,
234
215
  }]}
235
- data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
216
+ data-page-contents-placement={hasContentsNavigation ? 'page-tree' : undefined}
236
217
  >
237
218
  {showTreeNavigation && (
238
219
  <SiteTreeNavigation
239
220
  currentPage={currentPage}
240
221
  navigationEntries={siteNavigation}
222
+ categoryDestinations={categoryDestinations}
241
223
  integratePageSections={integratePageContentsInTree}
224
+ areaReadingFrame={areaReadingFrame}
242
225
  />
243
226
  )}
244
227
  <main
@@ -273,12 +256,6 @@ const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
273
256
  )}
274
257
  <PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
275
258
  </main>
276
- {showContentsRail && (
277
- <PageContentsNavigation
278
- headings={pageContentsHeadings}
279
- pageTitle={currentPage.title}
280
- />
281
- )}
282
259
  </div>
283
260
  {hasTreeNavigation && <TreeNavigationScript />}
284
261
  {hasCodeBlocks && <CodeBlockCopyScript />}
@@ -109,6 +109,7 @@ const bodyWidth = {
109
109
  };
110
110
  const hasMedia = flattenContentBlocks(section.contentBlocks).some((block) => (
111
111
  block.type === 'image-stack'
112
+ || (block.type === 'html' && block.html.includes('data-norna-managed-image='))
112
113
  || block.type === 'image-carousel'
113
114
  || (block.type === 'card-list' && block.cards.some((card) => card.image))
114
115
  ));
@@ -1,45 +1,65 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
- import { getListedSiteNavigationTree, type SiteNavigationEntry } from '../lib/siteNavigation';
4
+ import { getListedSiteNavigationTree, getNavigationDestination, type CategoryDestinationMap, type SiteNavigationEntry } from '../lib/siteNavigation';
5
+ import { resolveAreaNavigation, withH2Outlines } from '../lib/areaNavigation';
5
6
  import type { SiteNode } from '../lib/sitePages';
6
7
  import NavigationPageTree from './NavigationPageTree.astro';
7
8
  import NavigationTreeControls from './NavigationTreeControls.astro';
9
+ import NavigationState from './NavigationState.astro';
10
+ import PageContentsLinks from './PageContentsLinks.astro';
8
11
 
9
12
  interface Props {
10
13
  currentPage: SiteNode;
11
14
  navigationEntries: SiteNavigationEntry[];
15
+ categoryDestinations?: CategoryDestinationMap;
12
16
  integratePageSections?: boolean;
17
+ areaReadingFrame?: boolean;
13
18
  }
14
19
 
15
- const { currentPage, navigationEntries, integratePageSections = false } = Astro.props;
16
- const roots = getListedSiteNavigationTree(navigationEntries);
17
- const activeRoot = roots.find((node) => (
18
- currentPage.pageDirectory === node.node.pageDirectory
19
- || currentPage.pageDirectory.startsWith(`${node.node.pageDirectory}/pages/`)
20
- ));
21
- const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}/` : '/';
20
+ const { currentPage, navigationEntries, categoryDestinations, integratePageSections = false, areaReadingFrame = false } = Astro.props;
21
+ const roots = withH2Outlines(getListedSiteNavigationTree(navigationEntries));
22
+ const area = resolveAreaNavigation(roots, currentPage);
23
+ const activeRoot = area.localRoot;
24
+ const ownOutline = !activeRoot
25
+ ? navigationEntries.find(({ node }) => node.pagePath === currentPage.pagePath)?.headings.filter(({ depth }) => depth === 2) ?? []
26
+ : [];
27
+ const navigationPage = activeRoot?.node ?? currentPage;
28
+ const navigationRoot = navigationPage.pagePath ? `/${navigationPage.pagePath}/` : '/';
22
29
  const navigationStateRoot = withBasePath(projectConfig.site.basePath, '/');
30
+ const showAreaTitle = activeRoot?.node.kind === 'category';
31
+ const showReadingAreaTitle = areaReadingFrame
32
+ && navigationPage.pagePath !== area?.globalRoot?.node.pagePath;
33
+ const localNodes = activeRoot ? (showAreaTitle ? activeRoot.children : [activeRoot]) : [];
34
+ // Retain the reviewed state namespace so existing sessions keep their position.
23
35
  ---
24
36
 
25
- {activeRoot && (
37
+ {(activeRoot || ownOutline.length > 0) && (
26
38
  <aside
27
39
  id="tree-local-navigation"
28
40
  class="tree-local-navigation"
29
- data-navigation-root={navigationRoot}
30
- data-navigation-state-root={navigationStateRoot}
41
+ data-navigation-root={`area:${navigationRoot}`}
42
+ data-navigation-state-root={`area:${navigationStateRoot}`}
43
+ data-navigation-area={activeRoot?.node.pagePath}
44
+ data-navigation-outline={ownOutline.length > 0 ? currentPage.pagePath : undefined}
31
45
  >
32
- <NavigationTreeControls
46
+ <NavigationState scope="desktop" />
47
+ {showReadingAreaTitle && <p class="navigation-area-title">{navigationPage.title}</p>}
48
+ {!areaReadingFrame && (showAreaTitle || ownOutline.length > 0) && <a class="navigation-area-title" href={withBasePath(projectConfig.site.basePath,
49
+ getNavigationDestination(navigationPage, categoryDestinations).pathname)}>{navigationPage.title}</a>}
50
+ {activeRoot && <NavigationTreeControls
33
51
  idPrefix="desktop-tree-navigation"
34
- nodes={[activeRoot]}
35
- />
36
- <nav aria-label={`${activeRoot.node.title}: ${projectConfig.locale.labels.siteNavigation}`}>
37
- <NavigationPageTree
38
- nodes={[activeRoot]}
52
+ nodes={localNodes}
53
+ forceVisible
54
+ />}
55
+ <nav aria-label={`${navigationPage.title}: ${projectConfig.locale.labels.siteNavigation}`}>
56
+ {activeRoot ? <NavigationPageTree
57
+ nodes={localNodes}
39
58
  currentPage={currentPage}
59
+ categoryDestinations={categoryDestinations}
40
60
  variant="sidebar"
41
61
  integratePageSections={integratePageSections}
42
- />
62
+ /> : <PageContentsLinks headings={ownOutline} variant="desktop" />}
43
63
  </nav>
44
64
  </aside>
45
65
  )}
@@ -2,17 +2,18 @@
2
2
  import { ChevronDown } from '@lucide/astro';
3
3
  import projectConfig from '../../scripts/lib/project-config.mjs';
4
4
  import { withBasePath } from '../lib/basePath';
5
- import type { SiteNavigationNode } from '../lib/siteNavigation';
5
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
6
6
  import type { SiteNode } from '../lib/sitePages';
7
7
  import NavigationPageTree from './NavigationPageTree.astro';
8
8
 
9
9
  interface Props {
10
10
  node: SiteNavigationNode;
11
11
  currentPage?: SiteNode;
12
+ categoryDestinations?: CategoryDestinationMap;
12
13
  showSections: boolean;
13
14
  showChildren: boolean;
14
15
  }
15
- const { node, currentPage, showSections, showChildren } = Astro.props;
16
+ const { node, currentPage, categoryDestinations, showSections, showChildren } = Astro.props;
16
17
  const sections = showSections ? node.sections : [];
17
18
  const children = showChildren ? node.children : [];
18
19
  const pageHref = node.node.kind === 'page'
@@ -39,7 +40,7 @@ const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}
39
40
  {children.length > 0 && (
40
41
  <nav class="top-page-children" aria-label={`${projectConfig.locale.labels.navigationChildren}: ${node.node.title}`}>
41
42
  {sections.length > 0 && <p>{projectConfig.locale.labels.navigationChildren}</p>}
42
- <NavigationPageTree nodes={children} currentPage={currentPage} variant="submenu" />
43
+ <NavigationPageTree nodes={children} currentPage={currentPage} categoryDestinations={categoryDestinations} variant="submenu" />
43
44
  </nav>
44
45
  )}
45
46
  </div>