@janga/norna 0.7.26 → 0.7.28

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 (110) hide show
  1. package/README.md +51 -35
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +6 -4
  4. package/package.json +9 -5
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +66 -66
  7. package/schemas/content-frontmatter.schema.json +17 -8
  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 +13 -161
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +17 -10
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +7 -6
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/lib/category-destinations.mjs +13 -12
  21. package/scripts/lib/documentation-links.mjs +33 -2
  22. package/scripts/lib/documentation-routes.json +229 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
  24. package/scripts/lib/editor-file-policy.mjs +18 -0
  25. package/scripts/lib/editor-language-service.mjs +53 -55
  26. package/scripts/lib/editor-page-addresses.mjs +55 -0
  27. package/scripts/lib/editor-page-placement.mjs +172 -0
  28. package/scripts/lib/editor-site-editing-tree.mjs +146 -0
  29. package/scripts/lib/editor-site-files.mjs +153 -0
  30. package/scripts/lib/editor-site-links.mjs +79 -0
  31. package/scripts/lib/editor-site-tree.mjs +151 -0
  32. package/scripts/lib/editor-source-files.mjs +87 -0
  33. package/scripts/lib/editor-theme-help.mjs +43 -0
  34. package/scripts/lib/locale-registry.mjs +1 -1
  35. package/scripts/lib/locales/en.mjs +1 -1
  36. package/scripts/lib/navigation-model.mjs +2 -44
  37. package/scripts/lib/navigation-review.mjs +14 -50
  38. package/scripts/lib/norna-markdown-blocks.mjs +1 -1
  39. package/scripts/lib/page-aliases.mjs +3 -1
  40. package/scripts/lib/page-model.mjs +39 -20
  41. package/scripts/lib/page-move-apply.mjs +117 -0
  42. package/scripts/lib/page-move-plan.mjs +27 -59
  43. package/scripts/lib/presentation.mjs +3 -3
  44. package/scripts/lib/project-config.mjs +7 -8
  45. package/scripts/lib/schema-definitions.mjs +12 -39
  46. package/scripts/lib/schema-editor-metadata.mjs +12 -59
  47. package/scripts/lib/schema-value-definitions.mjs +1 -1
  48. package/scripts/lib/site-content.mjs +7 -24
  49. package/scripts/lib/site-conventions.mjs +19 -1
  50. package/scripts/lib/site-navigation-tree.mjs +2 -7
  51. package/scripts/lib/site-node-create.mjs +169 -0
  52. package/scripts/lib/site-paths.mjs +22 -10
  53. package/scripts/lib/site-structure.mjs +72 -82
  54. package/scripts/lib/site-upgrade.mjs +30 -0
  55. package/scripts/lib/source-files.mjs +34 -0
  56. package/scripts/lib/theme-config.mjs +26 -68
  57. package/scripts/lib/theme-packages.mjs +47 -0
  58. package/scripts/lib/theme-presets.mjs +20 -53
  59. package/scripts/lib/typography.mjs +2 -2
  60. package/scripts/lib/yaml-config.mjs +1 -1
  61. package/scripts/lib/yaml-source-edit.mjs +94 -0
  62. package/scripts/move-site-page.mjs +3 -124
  63. package/scripts/show-typography.mjs +32 -17
  64. package/scripts/sync-content-sections.mjs +9 -7
  65. package/scripts/sync-site-public.mjs +1 -1
  66. package/scripts/upgrade-site.mjs +17 -0
  67. package/src/components/AreaNavigationMenu.astro +24 -0
  68. package/src/components/AreaNavigationPanel.astro +55 -0
  69. package/src/components/AreaNavigationSwitcher.astro +30 -0
  70. package/src/components/ImageStackEnhancement.astro +0 -7
  71. package/src/components/NavigationContinuity.astro +26 -0
  72. package/src/components/NavigationPageTree.astro +13 -4
  73. package/src/components/NavigationState.astro +11 -0
  74. package/src/components/NavigationTreeControls.astro +3 -2
  75. package/src/components/PageAliasRedirect.astro +44 -3
  76. package/src/components/SearchPage.astro +9 -1
  77. package/src/components/SectionNavigationScript.astro +106 -26
  78. package/src/components/SiteNavigation.astro +45 -20
  79. package/src/components/SitePage.astro +37 -61
  80. package/src/components/SiteTreeNavigation.astro +38 -18
  81. package/src/components/TopPageMenu.astro +4 -3
  82. package/src/components/TreeNavigationScript.astro +50 -57
  83. package/src/content.config.ts +15 -16
  84. package/src/layouts/BaseLayout.astro +15 -39
  85. package/src/lib/areaNavigation.ts +42 -0
  86. package/src/lib/navigationContinuity.ts +212 -0
  87. package/src/lib/navigationFollowing.ts +28 -18
  88. package/src/lib/navigationState.mjs +88 -0
  89. package/src/lib/navigationStateScript.mjs +76 -0
  90. package/src/lib/navigationVisibility.ts +9 -0
  91. package/src/lib/pageThemes.ts +5 -42
  92. package/src/lib/sectionContent.ts +3 -2
  93. package/src/lib/siteNavigation.ts +6 -0
  94. package/src/lib/sitePages.ts +12 -23
  95. package/src/pages/404.astro +1 -1
  96. package/src/pages/[...slug].astro +5 -15
  97. package/src/pages/index.astro +1 -3
  98. package/src/styles/area-navigation.css +399 -0
  99. package/src/styles/content.css +5 -31
  100. package/src/styles/navigation-continuity.css +163 -0
  101. package/src/styles/page-layout.css +57 -68
  102. package/src/styles/responsive.css +0 -4
  103. package/starters/basic/README.md +10 -9
  104. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  105. package/schemas/category.schema.json +0 -20
  106. package/src/components/PageContentsNavigation.astro +0 -20
  107. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  108. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  109. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  110. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -6,15 +6,16 @@ import type { SiteNavigationNode } from '../lib/siteNavigation';
6
6
  interface Props {
7
7
  idPrefix: string;
8
8
  nodes: SiteNavigationNode[];
9
+ forceVisible?: boolean;
9
10
  }
10
11
 
11
- const { idPrefix, nodes } = Astro.props;
12
+ const { idPrefix, nodes, forceVisible = false } = Astro.props;
12
13
  const countNodes = (tree: SiteNavigationNode[]): number => tree.reduce(
13
14
  (total, node) => total + 1 + countNodes(node.children),
14
15
  0,
15
16
  );
16
17
  const minimumUsefulNodeCount = 12;
17
- const showControls = countNodes(nodes) >= minimumUsefulNodeCount;
18
+ const showControls = forceVisible || countNodes(nodes) >= minimumUsefulNodeCount;
18
19
  const labels = projectConfig.locale.labels;
19
20
  const filterId = `${idPrefix}-filter`;
20
21
  const statusId = `${idPrefix}-status`;
@@ -1,7 +1,11 @@
1
1
  ---
2
+ import { getCollection } from 'astro:content';
3
+ import { getPresentationCssVariables, resolveThemePresentation } from '../../scripts/lib/presentation.mjs';
2
4
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
5
  import { getAbsolutePageUrl } from '../../scripts/lib/site-page-urls.mjs';
6
+ import { siteThemeLabel } from '../../scripts/lib/site-paths.mjs';
4
7
  import { withBasePath } from '../lib/basePath';
8
+ import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
5
9
 
6
10
  interface Props {
7
11
  targetPathname: string;
@@ -13,23 +17,54 @@ const { targetPathname, targetTitle, categoryTitle } = Astro.props;
13
17
  const { labels, lang } = projectConfig.locale;
14
18
  const targetHref = withBasePath(projectConfig.site.basePath, targetPathname);
15
19
  const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
20
+ const [siteTheme] = await getCollection('theme');
21
+ if (!siteTheme) throw new Error(`${siteThemeLabel} is required. Add a root theme before building the site.`);
22
+ const presentation = resolveThemePresentation(siteTheme.data, siteThemeLabel);
23
+ const documentStyle = Object.entries(getPresentationCssVariables(presentation))
24
+ .map(([name, value]) => `${name}: ${value}`).join('; ');
25
+ const readerPreferencesScript = getReaderPreferencesScript({
26
+ ...presentation.readerPreferences,
27
+ cookiePath: projectConfig.site.basePath,
28
+ });
16
29
  ---
17
30
 
18
31
  <!doctype html>
19
- <html lang={lang} data-pagefind-ignore="all">
32
+ <html lang={lang} data-pagefind-ignore="all" data-appearance={presentation.appearance.default} style={documentStyle}>
20
33
  <head>
21
34
  <meta charset="utf-8" />
22
35
  <meta name="viewport" content="width=device-width" />
23
36
  <meta name="robots" content="noindex, follow" />
24
- <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
25
37
  <link rel="canonical" href={canonicalUrl} />
26
38
  <title>{categoryTitle ?? labels.pageMoved}: {targetTitle}</title>
39
+ <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
27
40
  <style is:inline>
28
41
  :root {
29
- color-scheme: light dark;
42
+ --color-page: var(--palette-light-page-background);
43
+ --color-primary-text: var(--palette-light-primary-text);
44
+ --color-link-text: var(--palette-light-link-text);
45
+ color-scheme: light;
46
+ min-height: 100%;
47
+ background: var(--color-page);
48
+ color: var(--color-primary-text);
30
49
  font-family: system-ui, sans-serif;
31
50
  }
32
51
 
52
+ :root[data-appearance='dark'] {
53
+ --color-page: var(--palette-dark-page-background);
54
+ --color-primary-text: var(--palette-dark-primary-text);
55
+ --color-link-text: var(--palette-dark-link-text);
56
+ color-scheme: dark;
57
+ }
58
+
59
+ @media (prefers-color-scheme: dark) {
60
+ :root[data-appearance='system'] {
61
+ --color-page: var(--palette-dark-page-background);
62
+ --color-primary-text: var(--palette-dark-primary-text);
63
+ --color-link-text: var(--palette-dark-link-text);
64
+ color-scheme: dark;
65
+ }
66
+ }
67
+
33
68
  body {
34
69
  margin: 0;
35
70
  padding: 2rem;
@@ -39,7 +74,13 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
39
74
  max-width: 42rem;
40
75
  margin-inline: auto;
41
76
  }
77
+
78
+ a {
79
+ color: var(--color-link-text);
80
+ }
42
81
  </style>
82
+ <script is:inline set:html={readerPreferencesScript} />
83
+ <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
43
84
  </head>
44
85
  <body>
45
86
  <main>
@@ -17,12 +17,13 @@ import { withBasePath } from '../lib/basePath';
17
17
 
18
18
  const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
19
19
  const siteModel = await getSiteModel(await getCollection('site'));
20
+ const categoryDestinations = siteModel.categoryDestinations.byPathname;
20
21
  const { nodes: siteNodes, pages: sitePages } = siteModel;
21
22
  const homePage = sitePages.find((page) => page.isHome);
22
23
  if (!homePage) throw new Error('The required homepage is missing from the site page model.');
23
24
 
24
25
  const [siteTheme] = await getCollection('theme');
25
- if (!siteTheme) throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
26
+ if (!siteTheme) throw new Error('site/root/tree-theme.yaml is required. Add the shared site theme before building the site.');
26
27
  const [sitewide] = await getCollection('sitewide');
27
28
  const sitewideData = sitewide?.data ?? {};
28
29
  const siteThemeData = siteTheme.data;
@@ -55,6 +56,7 @@ const pagePresentation = resolvePagePresentation(siteThemeData, themeLabel, {
55
56
  navigationMode: navigationModel.mode,
56
57
  });
57
58
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
59
+ const areaReadingFrame = navigationModel.mode === 'tree';
58
60
  const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: themeLabel });
59
61
  const logoAsset = getLogoAsset();
60
62
  const logo = logoAsset
@@ -130,19 +132,24 @@ const searchScript = `(() => {
130
132
  pathname="/search/"
131
133
  footer={sitewideData.footer}
132
134
  indexable={false}
135
+ areaNavigation={navigationModel.mode === 'tree'}
136
+ areaReadingFrame={areaReadingFrame}
133
137
  >
134
138
  <link slot="head" rel="stylesheet" href={`${bundlePath}pagefind-ui.css`} />
135
139
  <SiteNavigation
136
140
  logo={logo}
137
141
  navigationEntries={siteNavigation}
142
+ categoryDestinations={categoryDestinations}
138
143
  navigationMode={navigationModel.mode}
139
144
  showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
140
145
  hasNavigationRailFrame={hasNavigationRailFrame}
141
146
  frameColors={frameColors}
142
147
  readerPreferences={pagePresentation.readerPreferences}
143
148
  searchPage
149
+ areaReadingFrame={areaReadingFrame}
144
150
  />
145
151
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
152
+ <div class:list={['site-page-layout', { 'site-page-layout-tree': areaReadingFrame }]}>
146
153
  <main class="site-content search-page-content" id="main-content" style={`--page-text-width: ${pageTextWidth}`}>
147
154
  <SiteSection
148
155
  section={searchSection}
@@ -158,6 +165,7 @@ const searchScript = `(() => {
158
165
  </div>
159
166
  </SiteSection>
160
167
  </main>
168
+ </div>
161
169
  <script is:inline set:html={searchScript} />
162
170
  </BaseLayout>
163
171
 
@@ -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