@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
@@ -233,7 +233,6 @@ try {
233
233
  const siteStructure = await getSiteStructure();
234
234
  const contentFiles = siteStructure.contentFiles;
235
235
  for (const warning of siteStructure.warnings) {
236
- if (warning.code === 'empty-category') continue;
237
236
  addIssue({
238
237
  severity: 'warning',
239
238
  message: warning.message,
@@ -424,17 +423,20 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
424
423
  return {
425
424
  node: {
426
425
  ...node,
427
- navigation: {
428
- listed: node.kind === 'category'
429
- ? true
430
- : context?.frontmatterData.navigation?.listed ?? true,
431
- },
426
+ navigation: { listed: context?.frontmatterData.navigation?.listed ?? true },
432
427
  },
433
428
  };
434
429
  }));
435
430
 
436
431
  for (const context of contentFileContexts) {
437
432
  const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
433
+ if (context.frontmatterData.page?.listChildren && childPages.length === 0) {
434
+ addContentIssue(context.contentFile, {
435
+ severity: 'warning',
436
+ message: 'page.listChildren has no listed direct child pages to display.',
437
+ fix: 'Add a listed direct child page, or remove page.listChildren while this page has no children.',
438
+ });
439
+ }
438
440
  const warnedChildren = new Set();
439
441
 
440
442
  for (const section of context.sections) {
@@ -444,7 +446,7 @@ for (const context of contentFileContexts) {
444
446
  if (childPages.length === 0) {
445
447
  addSectionIssue(context.contentFile, section, {
446
448
  severity: 'error',
447
- message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
449
+ message: `page-list on line ${block.line} has no listed direct child pages to display.`,
448
450
  fix: 'Add a listed direct child page or remove the block.',
449
451
  });
450
452
  continue;
@@ -33,7 +33,7 @@ const sourceEntries = await readDirectory(sitePublicDir);
33
33
  const generatedPublicFiles = [
34
34
  {
35
35
  filename: sitemapFilename,
36
- explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
36
+ explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/site-config/settings.yaml.`,
37
37
  },
38
38
  {
39
39
  filename: '404.html',
@@ -0,0 +1,17 @@
1
+ import { siteDir } from './lib/site-paths.mjs';
2
+ import { planSiteUpgrade } from './lib/site-upgrade.mjs';
3
+
4
+ const usage = 'Usage: norna site:upgrade [--apply]\nCheck the root/ source layout. Former layouts require manual conversion; --apply is retained but does not convert old sources.';
5
+ const args = process.argv.slice(2);
6
+ try {
7
+ if (args.includes('--help') || args.includes('-h')) {
8
+ console.log(usage);
9
+ } else {
10
+ if (args.length > 1 || args.some((arg) => arg !== '--apply')) throw new Error(usage);
11
+ await planSiteUpgrade(siteDir);
12
+ console.log('The site already uses the current configuration and homepage layout. No files changed.');
13
+ }
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
4
+ import type { SiteNode } from '../lib/sitePages';
5
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
6
+
7
+ interface Props {
8
+ node: SiteNavigationNode;
9
+ currentPage?: SiteNode;
10
+ categoryDestinations?: CategoryDestinationMap;
11
+ }
12
+ const { node, currentPage, categoryDestinations } = Astro.props;
13
+ const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}`;
14
+ const panelId = `area-menu-${encodeURIComponent(node.node.pagePath)}`;
15
+ ---
16
+
17
+ {node.children.length > 0 && (
18
+ <details class="top-page-menu area-navigation-menu" data-area-menu={node.node.pagePath}>
19
+ <summary aria-label={label} aria-controls={panelId}>
20
+ <span class="top-page-menu-title">{node.node.title}</span>
21
+ </summary>
22
+ <AreaNavigationPanel id={panelId} node={node} currentPage={currentPage} categoryDestinations={categoryDestinations} />
23
+ </details>
24
+ )}
@@ -0,0 +1,55 @@
1
+ ---
2
+ import { Check } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
6
+ import { getAreaMenuGroups, resolveAreaNavigation } from '../lib/areaNavigation';
7
+ import type { SiteNode } from '../lib/sitePages';
8
+
9
+ interface Props {
10
+ id: string;
11
+ node?: SiteNavigationNode;
12
+ currentPage?: SiteNode;
13
+ categoryDestinations?: CategoryDestinationMap;
14
+ otherRoots?: SiteNavigationNode[];
15
+ }
16
+ const { id, node, currentPage, categoryDestinations, otherRoots = [] } = Astro.props;
17
+ const { localRoot } = resolveAreaNavigation(node ? [node] : [], currentPage);
18
+ const { branches, pages } = node ? getAreaMenuGroups(node) : { branches: [], pages: [] };
19
+ const columnsFor = (count: number, maximum: number) => count === 0 ? 0 : Math.ceil(count / Math.ceil(count / maximum));
20
+ const branchColumns = columnsFor(branches.length, pages.length > 0 ? 2 : 3);
21
+ const pageColumns = branches.length === 0 ? columnsFor(pages.length, 3) : pages.length > 0 ? 1 : 0;
22
+ const groups = [
23
+ { kind: 'branches', choices: branches, columns: branchColumns },
24
+ { kind: 'pages', choices: pages, columns: pageColumns },
25
+ ].filter(({ choices }) => choices.length > 0);
26
+ ---
27
+
28
+ <nav id={id} class="top-page-panel area-navigation-panel"
29
+ style={`--area-menu-columns: ${branchColumns + pageColumns || 2}`}
30
+ aria-label={node ? `${node.node.title}: ${projectConfig.locale.labels.navigationChildren}` : projectConfig.locale.labels.siteNavigation}>
31
+ {groups.map((group) => <ul class="area-navigation-choices" data-area-choice-group={group.kind}
32
+ style={`--area-group-columns: ${group.columns}; --area-group-rows: ${Math.ceil(group.choices.length / group.columns)}`}>
33
+ {group.choices.map((child) => {
34
+ const destination = getNavigationDestination(child.node, categoryDestinations);
35
+ const selected = localRoot === child || currentPage?.pagePath === child.node.pagePath;
36
+ const description = child.node.kind === 'page'
37
+ ? child.node.entry.data.page?.description
38
+ : child.node.description;
39
+ return <li>
40
+ <a href={withBasePath(projectConfig.site.basePath, destination.pathname)}
41
+ aria-current={selected ? 'true' : undefined} data-area-choice={child.node.pagePath}>
42
+ <span class="area-navigation-title">{child.node.title}{selected && <Check aria-hidden="true" />}</span>
43
+ {group.kind === 'branches' && description && <span class="area-navigation-description">{description}</span>}
44
+ </a>
45
+ </li>;
46
+ })}
47
+ </ul>)}
48
+ {otherRoots.length > 0 && <ul class="area-navigation-other-roots" aria-label={projectConfig.locale.labels.siteNavigation}>
49
+ {otherRoots.map(({ node: root }) => <li>
50
+ <a href={withBasePath(projectConfig.site.basePath, getNavigationDestination(root, categoryDestinations).pathname)}>
51
+ {root.isHome ? projectConfig.locale.labels.returnHome : root.title}
52
+ </a>
53
+ </li>)}
54
+ </ul>}
55
+ </nav>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { Menu } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { hasLargeAreaMenu, resolveAreaNavigation } from '../lib/areaNavigation';
5
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
6
+ import type { SiteNode } from '../lib/sitePages';
7
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
8
+
9
+ interface Props {
10
+ nodes: SiteNavigationNode[];
11
+ currentPage?: SiteNode;
12
+ categoryDestinations?: CategoryDestinationMap;
13
+ label?: string;
14
+ }
15
+
16
+ const { nodes, currentPage, categoryDestinations, label } = Astro.props;
17
+ const { globalRoot } = resolveAreaNavigation(nodes, currentPage);
18
+ const currentLabel = label ?? globalRoot?.node.title ?? projectConfig.locale.labels.navigationMenu;
19
+ const menuRoot = globalRoot && globalRoot.children.length > 0 && !hasLargeAreaMenu(globalRoot) ? globalRoot : undefined;
20
+ const otherRoots = nodes.filter((root) => root !== menuRoot);
21
+ const menuLabel = `${projectConfig.locale.labels.navigationMenu}: ${currentLabel}`;
22
+ ---
23
+
24
+ <details class="top-page-menu area-navigation-switcher" data-area-switcher>
25
+ <summary aria-label={menuLabel} title={menuLabel} aria-controls="area-switcher-panel">
26
+ <Menu aria-hidden="true" />
27
+ </summary>
28
+ <AreaNavigationPanel id="area-switcher-panel" node={menuRoot} currentPage={currentPage}
29
+ categoryDestinations={categoryDestinations} otherRoots={otherRoots} />
30
+ </details>
@@ -104,13 +104,6 @@ const labels = projectConfig.locale.labels;
104
104
  const imageBounds = image.getBoundingClientRect();
105
105
  if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
106
106
 
107
- const visibleContentsRail = Array.from(
108
- layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
109
- ).some(isVisible);
110
- const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
111
- && document.documentElement.dataset.focusReading !== 'on';
112
- if (visibleContentsRail || reservesContentsLane) return;
113
-
114
107
  const frameBounds = frame.getBoundingClientRect();
115
108
  const figureBounds = figure.getBoundingClientRect();
116
109
  const captionBounds = caption.getBoundingClientRect();
@@ -0,0 +1,26 @@
1
+ ---
2
+ import styles from '../styles/navigation-continuity.css?inline';
3
+ ---
4
+
5
+ <style is:inline set:html={styles} />
6
+ <script is:inline>
7
+ // A cold history arrival can restore its position at pagereveal, then lose
8
+ // it to native fragment scrolling when deferred modules finish parsing.
9
+ // Keep the browser's restored position until that initial load completes.
10
+ window.addEventListener('pagereveal', () => {
11
+ if (!location.hash || document.readyState === 'complete'
12
+ || performance.getEntriesByType('navigation')[0]?.type !== 'back_forward'
13
+ || history.scrollRestoration !== 'auto') return;
14
+
15
+ history.scrollRestoration = 'manual';
16
+ const release = () => {
17
+ history.scrollRestoration = 'auto';
18
+ window.removeEventListener('load', release);
19
+ window.removeEventListener('pagehide', release);
20
+ };
21
+ window.addEventListener('load', release);
22
+ window.addEventListener('pagehide', release);
23
+ }, { once: true });
24
+ </script>
25
+ <!-- A streamed response must finish the page before its transition snapshot. -->
26
+ <link rel="expect" blocking="render" href="#norna-navigation-ready" />
@@ -1,19 +1,23 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
- import type { SiteNavigationNode } from '../lib/siteNavigation';
4
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
5
5
  import type { SiteNode, SitePage } from '../lib/sitePages';
6
6
  import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
10
  currentPage?: SiteNode;
11
+ categoryDestinations?: CategoryDestinationMap;
11
12
  variant: 'mobile' | 'sidebar' | 'submenu';
12
13
  integratePageSections?: boolean;
13
14
  }
14
15
 
15
- const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
16
+ const { nodes, currentPage, categoryDestinations, variant, integratePageSections = false } = Astro.props;
16
17
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
18
+ const categoryHref = (node: SiteNavigationNode) => withBasePath(
19
+ projectConfig.site.basePath, getNavigationDestination(node.node, categoryDestinations).pathname,
20
+ );
17
21
  const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
18
22
  const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
19
23
  const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
@@ -71,13 +75,17 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
71
75
  class="navigation-page-summary"
72
76
  aria-controls={getBranchId(node.node)}
73
77
  >
74
- <span class="navigation-page-summary-title">{node.node.title}</span>
78
+ <a class:list={['navigation-page-summary-title', 'navigation-category-link', {
79
+ 'mobile-nav-destination': variant === 'mobile',
80
+ }]}
81
+ href={categoryHref(node)}>{node.node.title}</a>
75
82
  <span class="navigation-page-chevron" aria-hidden="true"></span>
76
83
  </summary>
77
84
  <div id={getBranchId(node.node)} class="navigation-page-branch-content">
78
85
  <Astro.self
79
86
  nodes={node.children}
80
87
  currentPage={currentPage}
88
+ categoryDestinations={categoryDestinations}
81
89
  variant={variant}
82
90
  integratePageSections={integratePageSections}
83
91
  />
@@ -123,6 +131,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
123
131
  <Astro.self
124
132
  nodes={node.children}
125
133
  currentPage={currentPage}
134
+ categoryDestinations={categoryDestinations}
126
135
  variant={variant}
127
136
  integratePageSections={integratePageSections}
128
137
  />
@@ -174,7 +183,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
174
183
  {node.node.title}
175
184
  </a>
176
185
  {node.children.length > 0 && (
177
- <Astro.self nodes={node.children} currentPage={currentPage} variant={variant} />
186
+ <Astro.self nodes={node.children} currentPage={currentPage} categoryDestinations={categoryDestinations} variant={variant} />
178
187
  )}
179
188
  </>
180
189
  )}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { getNavigationStateScript } from '../lib/navigationStateScript.mjs';
3
+
4
+ interface Props {
5
+ scope: 'desktop' | 'mobile';
6
+ }
7
+
8
+ const { scope } = Astro.props;
9
+ ---
10
+
11
+ <script is:inline data-navigation-state-scope={scope} set:html={getNavigationStateScript()} />
@@ -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`;
@@ -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>