@janga/norna 0.7.27 → 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 (109) hide show
  1. package/README.md +37 -28
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +5 -3
  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 +16 -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 +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 +1 -1
  22. package/scripts/lib/documentation-routes.json +67 -60
  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 +17 -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 +11 -38
  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/SearchPage.astro +9 -1
  76. package/src/components/SectionNavigationScript.astro +106 -26
  77. package/src/components/SiteNavigation.astro +45 -20
  78. package/src/components/SitePage.astro +37 -61
  79. package/src/components/SiteTreeNavigation.astro +38 -18
  80. package/src/components/TopPageMenu.astro +4 -3
  81. package/src/components/TreeNavigationScript.astro +50 -57
  82. package/src/content.config.ts +10 -8
  83. package/src/layouts/BaseLayout.astro +15 -39
  84. package/src/lib/areaNavigation.ts +42 -0
  85. package/src/lib/navigationContinuity.ts +212 -0
  86. package/src/lib/navigationFollowing.ts +28 -18
  87. package/src/lib/navigationState.mjs +88 -0
  88. package/src/lib/navigationStateScript.mjs +76 -0
  89. package/src/lib/navigationVisibility.ts +9 -0
  90. package/src/lib/pageThemes.ts +5 -42
  91. package/src/lib/sectionContent.ts +3 -2
  92. package/src/lib/siteNavigation.ts +6 -0
  93. package/src/lib/sitePages.ts +9 -6
  94. package/src/pages/404.astro +1 -1
  95. package/src/pages/[...slug].astro +5 -15
  96. package/src/pages/index.astro +1 -3
  97. package/src/styles/area-navigation.css +399 -0
  98. package/src/styles/content.css +2 -29
  99. package/src/styles/navigation-continuity.css +163 -0
  100. package/src/styles/page-layout.css +57 -68
  101. package/src/styles/responsive.css +0 -4
  102. package/starters/basic/README.md +9 -8
  103. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  104. package/schemas/category.schema.json +0 -20
  105. package/src/components/PageContentsNavigation.astro +0 -20
  106. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  107. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  108. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  109. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -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) => (
@@ -167,33 +156,21 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
167
156
  navigationMode: navigationModel.mode,
168
157
  });
169
158
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
170
- const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
159
+ const frameColors = resolveFrameColors({ theme: themeData, sourceLabel: themeLabel });
171
160
  const hasTreeNavigation = navigationModel.mode === 'tree';
172
- const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
161
+ // Keep one reading frame across tree-mode pages, including pages with no H2.
162
+ // Home remains the site's entry point with the global navigation row.
163
+ const areaReadingFrame = hasTreeNavigation && !currentPage.isHome;
173
164
  const currentPageNavigation = siteNavigation.find(({ node }) => (
174
165
  node.kind === 'page' && node.pathname === currentPage.pathname
175
166
  ));
176
167
  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);
168
+ const showTreeNavigation = hasTreeNavigation && !currentPage.isHome;
169
+ const hasContentsNavigation = showTreeNavigation && pageContentsHeadings.some(({ depth }) => depth === 2);
170
+ const hasTrackedSectionNavigation = hasTreeNavigation && pageContentsHeadings.some(({ depth }) => depth === 2);
194
171
  const integratePageContentsInTree = hasTreeNavigation;
195
- const showContentsRail = pageContentsPlacement.placement === 'contents-rail';
196
- const hasNavigationRailFrame = !currentPage.isHome && (
172
+ // Home's global row and the reading layout share the site's wide coordinates.
173
+ const hasNavigationRailFrame = (
197
174
  navigationModel.requestedMode === 'tree'
198
175
  || (
199
176
  navigationModel.requestedMode === 'automatic'
@@ -215,30 +192,35 @@ const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
215
192
  footer={sitewideData.footer}
216
193
  contentsNavigation={hasTrackedSectionNavigation}
217
194
  searchable
195
+ areaNavigation={hasTreeNavigation}
196
+ areaReadingFrame={areaReadingFrame}
218
197
  >
219
198
  <SiteNavigation
220
199
  logo={logo}
221
200
  currentPage={currentPage}
222
201
  navigationEntries={siteNavigation}
202
+ categoryDestinations={categoryDestinations}
223
203
  navigationMode={navigationModel.mode}
224
204
  showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
225
205
  hasNavigationRailFrame={hasNavigationRailFrame}
226
206
  frameColors={frameColors}
227
207
  readerPreferences={pagePresentation.readerPreferences}
208
+ areaReadingFrame={areaReadingFrame}
228
209
  />
229
210
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
230
211
 
231
212
  <div class:list={['site-page-layout', {
232
213
  'site-page-layout-tree': showTreeNavigation,
233
- 'site-page-layout-contents': showContentsRail,
234
214
  }]}
235
- data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
215
+ data-page-contents-placement={hasContentsNavigation ? 'page-tree' : undefined}
236
216
  >
237
217
  {showTreeNavigation && (
238
218
  <SiteTreeNavigation
239
219
  currentPage={currentPage}
240
220
  navigationEntries={siteNavigation}
221
+ categoryDestinations={categoryDestinations}
241
222
  integratePageSections={integratePageContentsInTree}
223
+ areaReadingFrame={areaReadingFrame}
242
224
  />
243
225
  )}
244
226
  <main
@@ -273,12 +255,6 @@ const reserveBreadcrumbSpace = showTreeNavigation || showBreadcrumbs;
273
255
  )}
274
256
  <PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
275
257
  </main>
276
- {showContentsRail && (
277
- <PageContentsNavigation
278
- headings={pageContentsHeadings}
279
- pageTitle={currentPage.title}
280
- />
281
- )}
282
258
  </div>
283
259
  {hasTreeNavigation && <TreeNavigationScript />}
284
260
  {hasCodeBlocks && <CodeBlockCopyScript />}
@@ -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>
@@ -1,4 +1,6 @@
1
1
  <script>
2
+ import { createNavigationStateHelpers } from '../lib/navigationState.mjs';
3
+
2
4
  type NavigationState = {
3
5
  openPaths: string[];
4
6
  sectionOpenByPath: Record<string, boolean>;
@@ -10,12 +12,12 @@
10
12
  sectionOpen: boolean[];
11
13
  scrollTop: number;
12
14
  };
15
+ const { storageKeys, parseState, initialState, arrivalPolicy, readEntryState, saveEntryState, pageIsOpen, sectionsAreOpen } = createNavigationStateHelpers();
13
16
 
14
17
  const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
15
18
  const root = container.dataset.navigationRoot ?? '/';
16
19
  const sharedRoot = container.dataset.navigationStateRoot ?? root;
17
- const stateKey = `norna:tree-navigation:${scope}:${root}`;
18
- const sharedStateKey = `norna:tree-navigation:shared:${sharedRoot}`;
20
+ const { stateKey, sharedStateKey } = storageKeys(scope, root, sharedRoot);
19
21
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
20
22
  '.navigation-page-disclosure[data-page-path]',
21
23
  ));
@@ -38,22 +40,7 @@
38
40
  const scrollContainer = getScrollContainer();
39
41
  let filterSnapshot: NavigationSnapshot | null = null;
40
42
  let lastSharedState = '';
41
- const parseState = (value: string | null): NavigationState => {
42
- try {
43
- const parsedState = JSON.parse(value ?? '{}');
44
- return {
45
- openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
46
- sectionOpenByPath: parsedState.sectionOpenByPath
47
- && typeof parsedState.sectionOpenByPath === 'object'
48
- ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
49
- .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
50
- : {},
51
- scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
52
- };
53
- } catch {
54
- return { openPaths: [], sectionOpenByPath: {}, scrollTop: 0 };
55
- }
56
- };
43
+ let preserveOutgoingEntry = false;
57
44
  const getDisclosureState = (): SharedNavigationState => ({
58
45
  openPaths: branches.filter((branch) => branch.open)
59
46
  .map((branch) => branch.dataset.pagePath ?? ''),
@@ -64,23 +51,13 @@
64
51
  });
65
52
  const applyDisclosureState = (
66
53
  state: SharedNavigationState,
67
- openCurrentPageOnArrival = false,
54
+ policy: { onArrival?: boolean; preserveCurrentPage?: boolean; preserveCurrentOutline?: boolean } = {},
68
55
  ) => {
69
56
  branches.forEach((branch) => {
70
- const pagePath = branch.dataset.pagePath ?? '';
71
- branch.open = (openCurrentPageOnArrival && branch.dataset.currentBranch === 'true')
72
- || state.openPaths.includes(pagePath);
57
+ branch.open = pageIsOpen(state, branch.dataset, policy);
73
58
  });
74
59
  sectionBranches.forEach((branch) => {
75
- const pagePath = branch.dataset.pagePath ?? '';
76
- const storedOpen = state.sectionOpenByPath[pagePath];
77
- if (openCurrentPageOnArrival && branch.dataset.currentPage === 'true') {
78
- branch.open = true;
79
- return;
80
- }
81
- branch.open = typeof storedOpen === 'boolean'
82
- ? storedOpen
83
- : branch.dataset.currentPage === 'true';
60
+ branch.open = sectionsAreOpen(state, branch.dataset, policy);
84
61
  });
85
62
  };
86
63
  const mergeSharedState = (state: SharedNavigationState): SharedNavigationState => {
@@ -99,18 +76,16 @@
99
76
  };
100
77
 
101
78
  try {
102
- const storedState = parseState(sessionStorage.getItem(stateKey));
103
- const storedSharedState = sessionStorage.getItem(sharedStateKey);
104
- applyDisclosureState(storedSharedState === null
105
- ? storedState
106
- : parseState(storedSharedState), true);
79
+ const state = initialState(sessionStorage.getItem(stateKey), sessionStorage.getItem(sharedStateKey),
80
+ readEntryState(stateKey));
81
+ applyDisclosureState(state.disclosure, arrivalPolicy(state));
107
82
  const initialSharedState = mergeSharedState(getDisclosureState());
108
83
  lastSharedState = JSON.stringify(initialSharedState);
109
84
  sessionStorage.setItem(sharedStateKey, lastSharedState);
110
85
 
111
- if (storedState.scrollTop > 0) {
86
+ if (state.local.scrollTop > 0 || state.fromEntry) {
112
87
  requestAnimationFrame(() => {
113
- scrollContainer.scrollTop = storedState.scrollTop;
88
+ scrollContainer.scrollTop = Math.max(0, state.local.scrollTop);
114
89
  });
115
90
  }
116
91
  } catch {
@@ -127,6 +102,7 @@
127
102
  scrollTop: scrollContainer.scrollTop,
128
103
  };
129
104
  sessionStorage.setItem(stateKey, JSON.stringify(state));
105
+ if (!preserveOutgoingEntry) saveEntryState(stateKey, state);
130
106
  const sharedState = mergeSharedState(disclosureState);
131
107
  const serializedSharedState = JSON.stringify(sharedState);
132
108
  sessionStorage.setItem(sharedStateKey, serializedSharedState);
@@ -278,23 +254,6 @@
278
254
  - Math.max(0, (scrollContainer.clientHeight - linkBox.height) / 2);
279
255
  setStatus(labels.located);
280
256
  };
281
- const openLinkedPage = (link: HTMLAnchorElement) => {
282
- if (filterInput?.value) {
283
- filterInput.value = '';
284
- clearFilter();
285
- }
286
- const pageNode = link.closest<HTMLElement>('.navigation-page-node');
287
- const pageDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
288
- ':scope > .navigation-page-disclosure:not(.navigation-category-disclosure)',
289
- );
290
- const sectionDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
291
- ':scope > .navigation-page-sections-disclosure[data-page-path]',
292
- );
293
-
294
- if (pageDisclosure) pageDisclosure.open = true;
295
- if (sectionDisclosure) sectionDisclosure.open = true;
296
- saveState();
297
- };
298
257
 
299
258
  requestAnimationFrame(() => {
300
259
  branches.forEach((branch) => branch.addEventListener('toggle', saveState));
@@ -314,8 +273,42 @@
314
273
  collapseButton?.addEventListener('click', () => setAllOpen(false));
315
274
  locateButton?.addEventListener('click', locateCurrentPage);
316
275
  scrollContainer.addEventListener('scroll', saveState, { passive: true });
317
- container.querySelectorAll<HTMLAnchorElement>('.navigation-page-link, .navigation-page-open-link')
318
- .forEach((link) => link.addEventListener('click', () => openLinkedPage(link)));
276
+ // Cross-document arrivals open their current branch in the early
277
+ // restore. Do not expand the departing tree and paint an extra frame.
278
+ document.addEventListener('click', (event) => {
279
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
280
+ const link = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null;
281
+ if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
282
+ const url = new URL(link.href, location.href);
283
+ if (url.origin !== location.origin || url.pathname !== location.pathname || url.search !== location.search || !currentPageNode) return;
284
+ // A same-document fragment creates a new history entry after this
285
+ // click. Keep the old entry's disclosure snapshot until then.
286
+ preserveOutgoingEntry = url.href !== location.href;
287
+ if (filterInput?.value) { filterInput.value = ''; clearFilter(); }
288
+ const scrollTop = scrollContainer.scrollTop;
289
+ revealNodeAndAncestors(currentPageNode);
290
+ currentPageNode.querySelectorAll<HTMLDetailsElement>(
291
+ ':scope > .navigation-page-disclosure, :scope > .navigation-page-sections-disclosure',
292
+ ).forEach((branch) => { branch.open = true; });
293
+ scrollContainer.scrollTop = scrollTop;
294
+ saveState();
295
+ });
296
+ const restoreEntry = () => {
297
+ preserveOutgoingEntry = false;
298
+ try {
299
+ const source = readEntryState(stateKey);
300
+ if (source === null) return;
301
+ const state = parseState(source);
302
+ applyDisclosureState(state);
303
+ scrollContainer.scrollTop = Math.max(0, state.scrollTop);
304
+ saveState();
305
+ } catch { /* Native links work when storage is unavailable. */ }
306
+ };
307
+ window.addEventListener('pageshow', (event) => { if (event.persisted) restoreEntry(); });
308
+ window.addEventListener('popstate', restoreEntry);
309
+ window.addEventListener('hashchange', () => { preserveOutgoingEntry = false; saveState(); });
310
+ requestAnimationFrame(saveState);
311
+
319
312
  window.addEventListener('pagehide', saveState);
320
313
  };
321
314