@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
@@ -0,0 +1,42 @@
1
+ import type { SiteNavigationNode } from './siteNavigation';
2
+ import type { SiteNode } from './sitePages';
3
+
4
+ const containsPage = (root: SiteNavigationNode, page: SiteNode) => (
5
+ root.node.pagePath === page.pagePath
6
+ || (root.node.pagePath !== '' && page.pagePath.startsWith(`${root.node.pagePath}/`))
7
+ );
8
+
9
+ // Beyond twelve choices, use a destination with a local tree instead of a panel.
10
+ export const hasLargeAreaMenu = (root: SiteNavigationNode) => (
11
+ root.children.length + (root.node.kind === 'page' ? 1 : 0) > 12
12
+ );
13
+
14
+ export const getAreaMenuGroups = (root: SiteNavigationNode) => {
15
+ const choices = root.node.kind === 'page' ? [root, ...root.children] : root.children;
16
+ return {
17
+ branches: choices.filter(({ children }) => children.length > 0),
18
+ pages: choices.filter(({ children }) => children.length === 0),
19
+ };
20
+ };
21
+
22
+ // Only the listed tree enters this resolver. Flat collections keep their pages
23
+ // together; collections with subgroups select child branches. Large collections
24
+ // and a parent's own overview retain the full tree. Scope never depends on the
25
+ // arrival route, stored state or the number of headings on a page.
26
+ export const resolveAreaNavigation = (roots: SiteNavigationNode[], page?: SiteNode) => {
27
+ const globalRoot = page ? roots.find((root) => containsPage(root, page)) : undefined;
28
+ if (globalRoot && globalRoot.children.length > 0 && (
29
+ globalRoot.children.every((child) => child.children.length === 0)
30
+ || hasLargeAreaMenu(globalRoot) || page?.pagePath === globalRoot.node.pagePath
31
+ )) return { globalRoot, localRoot: globalRoot };
32
+ const area = page && globalRoot?.children.find((child) => (
33
+ child.children.length > 0 && containsPage(child, page)
34
+ ));
35
+ return { globalRoot, localRoot: area };
36
+ };
37
+
38
+ export const withH2Outlines = (roots: SiteNavigationNode[]): SiteNavigationNode[] => roots.map((root) => ({
39
+ ...root,
40
+ headings: root.headings.filter(({ depth }) => depth === 2),
41
+ children: withH2Outlines(root.children),
42
+ }));
@@ -0,0 +1,212 @@
1
+ import { isNavigationElementDisplayed } from './navigationVisibility';
2
+
3
+ type Prefetch = (url: string) => void;
4
+
5
+ const setupTreeScrollport = () => {
6
+ const tree = document.querySelector<HTMLElement>('.tree-local-navigation');
7
+ const layout = tree?.parentElement;
8
+ if (!tree || !layout) return;
9
+ const update = () => {
10
+ if (!tree.getClientRects().length) return;
11
+ // Notices above the layout can place the tree below its sticky offset.
12
+ // Reserve that space even after document scrolling, so the tree does not
13
+ // resize and move its rows while the reader scrolls the article.
14
+ const flowTop = layout.getBoundingClientRect().top + window.scrollY;
15
+ const stickyTop = parseFloat(getComputedStyle(tree).top) || 0;
16
+ tree.style.setProperty('--navigation-scrollport-top', `${Math.ceil(Math.max(flowTop, stickyTop))}px`);
17
+ };
18
+ const observer = new ResizeObserver(update);
19
+ for (const element of document.querySelectorAll('.site-top, .site-banners')) observer.observe(element);
20
+ window.addEventListener('resize', update);
21
+ window.addEventListener('pageshow', update);
22
+ void document.fonts.ready.then(update);
23
+ update();
24
+ };
25
+
26
+ const setupDisclosures = (reducedMotion: MediaQueryList) => {
27
+ const finishers = new Set<() => void>();
28
+ for (const details of document.querySelectorAll<HTMLDetailsElement>(
29
+ '.navigation-page-disclosure, .navigation-page-sections-disclosure',
30
+ )) {
31
+ const summary = details.querySelector<HTMLElement>(':scope > summary');
32
+ const contentId = summary?.getAttribute('aria-controls');
33
+ const content = contentId ? document.getElementById(contentId) : null;
34
+ if (!summary || !content) continue;
35
+ let animation: Animation | null = null;
36
+ let destination = details.open;
37
+ const originallyInert = content.inert;
38
+ const finish = () => {
39
+ details.open = destination;
40
+ animation?.cancel();
41
+ animation = null;
42
+ content.inert = originallyInert;
43
+ content.removeAttribute('data-navigation-motion-active');
44
+ details.removeAttribute('data-navigation-motion-open');
45
+ summary.removeAttribute('aria-expanded');
46
+ finishers.delete(finish);
47
+ };
48
+ summary.addEventListener('click', (event) => {
49
+ if (event.defaultPrevented || reducedMotion.matches || typeof content.animate !== 'function') return;
50
+ if (event.target instanceof Element && event.target.closest('a')) return;
51
+ event.preventDefault();
52
+ const height = isNavigationElementDisplayed(content) ? content.getBoundingClientRect().height : 0;
53
+ const opacity = height ? getComputedStyle(content).opacity : '0';
54
+ destination = animation ? !destination : !details.open;
55
+ animation?.cancel();
56
+ if (!destination && content.contains(document.activeElement)) summary.focus({ preventScroll: true });
57
+ details.open = true;
58
+ const fullHeight = content.getBoundingClientRect().height;
59
+ content.inert = !destination || originallyInert;
60
+ content.setAttribute('data-navigation-motion-active', '');
61
+ details.dataset.navigationMotionOpen = String(destination);
62
+ summary.setAttribute('aria-expanded', String(destination));
63
+ animation = content.animate([
64
+ { height: `${height}px`, opacity },
65
+ { height: `${destination ? fullHeight : 0}px`, opacity: destination ? '1' : '0' },
66
+ ], {
67
+ duration: 180,
68
+ easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
69
+ fill: 'both',
70
+ });
71
+ animation.onfinish = finish;
72
+ finishers.add(finish);
73
+ });
74
+ }
75
+ const finishAll = () => {
76
+ for (const finish of finishers) finish();
77
+ };
78
+ reducedMotion.addEventListener('change', () => {
79
+ if (reducedMotion.matches) finishAll();
80
+ });
81
+ window.addEventListener('pagehide', finishAll, { capture: true });
82
+ // Let filtering and the existing tree controls act on settled disclosure state.
83
+ for (const event of ['click', 'input']) {
84
+ document.addEventListener(event, (event) => {
85
+ if (!(event.target instanceof Element)) return;
86
+ if (event.target.closest('[data-tree-controls]') || (
87
+ event.type === 'click' && event.target.closest('a[href]')
88
+ )) finishAll();
89
+ }, { capture: true });
90
+ }
91
+ };
92
+
93
+ const setupOutlineMarkers = () => {
94
+ const outlines = [...document.querySelectorAll<HTMLOListElement>('.page-contents-links')];
95
+ let frame: number | undefined;
96
+ const update = () => {
97
+ frame = undefined;
98
+ for (const outline of outlines) {
99
+ const current = outline.querySelector<HTMLElement>('a[aria-current="location"]');
100
+ if (!current || !isNavigationElementDisplayed(current)) {
101
+ outline.style.setProperty('--navigation-marker-opacity', '0');
102
+ continue;
103
+ }
104
+ const initial = !outline.hasAttribute('data-navigation-marker');
105
+ outline.setAttribute('data-navigation-marker', '');
106
+ if (initial) outline.setAttribute('data-navigation-marker-initial', '');
107
+ const bounds = current.getBoundingClientRect();
108
+ const style = getComputedStyle(current);
109
+ const paddingTop = parseFloat(style.paddingTop);
110
+ const paddingBottom = parseFloat(style.paddingBottom);
111
+ outline.style.setProperty('--navigation-marker-top', `${bounds.top + paddingTop - outline.getBoundingClientRect().top}px`);
112
+ outline.style.setProperty('--navigation-marker-height', `${bounds.height - paddingTop - paddingBottom}px`);
113
+ outline.style.setProperty('--navigation-marker-opacity', '1');
114
+ if (initial) requestAnimationFrame(() => outline.removeAttribute('data-navigation-marker-initial'));
115
+ }
116
+ };
117
+ const schedule = () => { frame ??= requestAnimationFrame(update); };
118
+ new MutationObserver(schedule).observe(document.body, {
119
+ subtree: true,
120
+ attributes: true,
121
+ attributeFilter: ['aria-current', 'open', 'hidden'],
122
+ });
123
+ const observer = new ResizeObserver(schedule);
124
+ for (const outline of outlines) observer.observe(outline);
125
+ window.addEventListener('resize', schedule);
126
+ window.addEventListener('pageshow', schedule);
127
+ schedule();
128
+ };
129
+
130
+ const setupScrollArrival = () => {
131
+ const root = document.documentElement;
132
+ const originalHash = location.hash;
133
+ const scrollBehavior = root.style.scrollBehavior;
134
+ let interrupted = false;
135
+ const interrupt = () => { interrupted = true; };
136
+ for (const event of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
137
+ window.addEventListener(event, interrupt, { once: true, passive: true });
138
+ }
139
+ const ready = async (restoreHistory: boolean) => {
140
+ await document.fonts.ready;
141
+ requestAnimationFrame(() => requestAnimationFrame(() => {
142
+ if (!restoreHistory && !interrupted && originalHash && location.hash === originalHash) {
143
+ try {
144
+ const target = document.getElementById(decodeURIComponent(originalHash.slice(1)));
145
+ const header = document.querySelector<HTMLElement>('.site-top');
146
+ if (target) {
147
+ const offset = Math.ceil(header?.getBoundingClientRect().height ?? 0);
148
+ window.scrollTo({ top: target.getBoundingClientRect().top + scrollY - offset, behavior: 'instant' });
149
+ }
150
+ } catch {
151
+ // Leave malformed URL fragments to the browser's native handling.
152
+ }
153
+ }
154
+ root.style.scrollBehavior = scrollBehavior;
155
+ root.setAttribute('data-navigation-motion-ready', '');
156
+ }));
157
+ };
158
+ window.addEventListener('pageswap', () => root.removeAttribute('data-navigation-motion-ready'));
159
+ window.addEventListener('pageshow', (event) => {
160
+ if (event.persisted) void ready(true);
161
+ });
162
+ const initialReady = () => {
163
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
164
+ void ready(navigation?.type === 'back_forward');
165
+ };
166
+ if (document.readyState === 'complete') initialReady();
167
+ else window.addEventListener('load', initialReady, { once: true });
168
+ };
169
+
170
+ const setupIntentPrefetch = (prefetch: Prefetch) => {
171
+ const basePath = document.documentElement.dataset.navigationContinuity ?? '/';
172
+ let timer: number | undefined;
173
+ let pending: HTMLAnchorElement | null = null;
174
+ const eligible = (target: EventTarget | null) => {
175
+ const link = target instanceof Element ? target.closest<HTMLAnchorElement>('a[href]') : null;
176
+ if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) return null;
177
+ const url = new URL(link.href, location.href);
178
+ if (url.origin !== location.origin || !url.pathname.startsWith(basePath) || url.search) return null;
179
+ if (!url.pathname.endsWith('/') || url.pathname === location.pathname) return null;
180
+ return link;
181
+ };
182
+ const cancel = () => {
183
+ window.clearTimeout(timer);
184
+ pending = null;
185
+ };
186
+ const prepare = (event: Event) => {
187
+ const link = eligible(event.target);
188
+ if (link === pending) return;
189
+ cancel();
190
+ if (!link) return;
191
+ pending = link;
192
+ timer = window.setTimeout(() => {
193
+ prefetch(link.href);
194
+ }, 100);
195
+ };
196
+ document.addEventListener('pointerover', prepare, { passive: true });
197
+ document.addEventListener('focusin', prepare);
198
+ document.addEventListener('pointerout', (event) => {
199
+ if (pending && !(event.relatedTarget instanceof Node && pending.contains(event.relatedTarget))) cancel();
200
+ }, { passive: true });
201
+ document.addEventListener('focusout', cancel);
202
+ window.addEventListener('pagehide', cancel);
203
+ };
204
+
205
+ export const setupNavigationContinuity = (prefetch: Prefetch) => {
206
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
207
+ setupTreeScrollport();
208
+ setupDisclosures(reducedMotion);
209
+ setupOutlineMarkers();
210
+ setupScrollArrival();
211
+ setupIntentPrefetch(prefetch);
212
+ };
@@ -1,15 +1,7 @@
1
- const isDisplayed = (element: HTMLElement) => {
2
- if (!element.getClientRects().length || getComputedStyle(element).visibility === 'hidden') return false;
3
- // Closed native details can retain layout rectangles for their hidden links.
4
- for (let parent = element.parentElement; parent; parent = parent.parentElement) {
5
- if (parent instanceof HTMLDetailsElement && !parent.open
6
- && !parent.querySelector(':scope > summary')?.contains(element)) return false;
7
- }
8
- return true;
9
- };
1
+ import { isNavigationElementDisplayed } from './navigationVisibility';
10
2
 
11
3
  const visibleEntry = (link: HTMLElement, container: HTMLElement): HTMLElement | undefined => {
12
- if (isDisplayed(link)) return link;
4
+ if (isNavigationElementDisplayed(link)) return link;
13
5
 
14
6
  let node = link.closest<HTMLElement>('.navigation-page-node');
15
7
  while (node && container.contains(node)) {
@@ -18,7 +10,7 @@ const visibleEntry = (link: HTMLElement, container: HTMLElement): HTMLElement |
18
10
  ':scope > .navigation-page-open-link',
19
11
  ':scope > .navigation-category-disclosure > summary',
20
12
  ].join(', '));
21
- if (entry && isDisplayed(entry)) return entry;
13
+ if (entry && isNavigationElementDisplayed(entry)) return entry;
22
14
  node = node.parentElement?.closest<HTMLElement>('.navigation-page-node') ?? null;
23
15
  }
24
16
  return undefined;
@@ -33,7 +25,7 @@ const revealEntry = (container: HTMLElement, entry: HTMLElement) => {
33
25
  for (const control of container.querySelectorAll<HTMLElement>(
34
26
  '.navigation-tree-controls, .mobile-nav-panel-actions',
35
27
  )) {
36
- if (isDisplayed(control) && getComputedStyle(control).position === 'sticky') {
28
+ if (isNavigationElementDisplayed(control) && getComputedStyle(control).position === 'sticky') {
37
29
  top = Math.max(top, control.getBoundingClientRect().bottom);
38
30
  }
39
31
  }
@@ -49,14 +41,15 @@ const revealEntry = (container: HTMLElement, entry: HTMLElement) => {
49
41
  };
50
42
 
51
43
  export const setupNavigationFollowing = () => {
44
+ const areaNavigation = document.documentElement.hasAttribute('data-area-navigation');
52
45
  const left = document.querySelector<HTMLElement>('.tree-local-navigation');
53
- const right = document.querySelector<HTMLElement>('.page-contents-navigation-rail');
54
46
  const compact = document.querySelector<HTMLElement>('[data-compact-navigation-panel]');
55
- const states = [left, right, compact]
47
+ const states = [left, compact]
56
48
  .filter((container): container is HTMLElement => container !== null)
57
49
  .map((container) => ({
58
50
  container,
59
51
  paused: false,
52
+ arrivalPaused: areaNavigation,
60
53
  pointerDown: false,
61
54
  expectedScrollTop: container.scrollTop,
62
55
  ancestor: undefined as HTMLElement | undefined,
@@ -67,7 +60,7 @@ export const setupNavigationFollowing = () => {
67
60
  const { container } = state;
68
61
  state.ancestor?.removeAttribute('data-reading-position-ancestor');
69
62
  state.ancestor = undefined;
70
- if (!isDisplayed(container)) return;
63
+ if (!isNavigationElementDisplayed(container)) return;
71
64
  if (container.matches('[data-tree-filter-active="true"]')
72
65
  || container.querySelector('[data-tree-filter-active="true"]')) return;
73
66
 
@@ -86,11 +79,10 @@ export const setupNavigationFollowing = () => {
86
79
 
87
80
  const update = () => {
88
81
  frame = undefined;
89
- const selected = right && isDisplayed(right) ? right : left;
90
82
  for (const state of states) {
91
83
  const ownsFocus = state.container.contains(document.activeElement);
92
- updateContainer(state, state.container === selected
93
- && !state.paused && !state.pointerDown && !ownsFocus
84
+ updateContainer(state, state.container === left
85
+ && !state.paused && !state.arrivalPaused && !state.pointerDown && !ownsFocus
94
86
  && !document.querySelector('.mobile-nav-menu[open]'));
95
87
  }
96
88
  };
@@ -122,6 +114,24 @@ export const setupNavigationFollowing = () => {
122
114
  document.addEventListener('pointerup', releasePointer);
123
115
  document.addEventListener('pointercancel', releasePointer);
124
116
  window.addEventListener('blur', releasePointer);
117
+ if (areaNavigation) {
118
+ // A restored menu may deliberately be scrolled away from the current
119
+ // heading. Resume following when the reader acts outside navigation,
120
+ // not when native history/hash restoration emits a window scroll.
121
+ const resumeAfterArrival = (event: Event) => {
122
+ if (event.target instanceof Node && states.some(({ container }) => container.contains(event.target as Node))) return;
123
+ states.forEach((state) => { state.arrivalPaused = false; state.paused = false; });
124
+ // A wheel gesture at the document's end may not emit scroll. Resume
125
+ // the outline after that real input too, without moving the document.
126
+ schedule();
127
+ };
128
+ for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
129
+ window.addEventListener(type, resumeAfterArrival, { passive: true });
130
+ }
131
+ const pauseArrival = () => states.forEach((state) => { state.arrivalPaused = true; });
132
+ window.addEventListener('popstate', pauseArrival);
133
+ window.addEventListener('pageshow', (event) => { if (event.persisted) pauseArrival(); });
134
+ }
125
135
  window.addEventListener('scroll', () => {
126
136
  states.forEach((state) => { state.paused = false; });
127
137
  // Section tracking schedules its own update for the new document position.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @typedef {{ openPaths: string[], sectionOpenByPath: Record<string, boolean> }} DisclosureState
3
+ * @typedef {DisclosureState & { scrollTop: number }} NavigationState
4
+ * @typedef {{ pagePath?: string, currentBranch?: string, currentPage?: string }} BranchData
5
+ * @typedef {{ onArrival?: boolean, preserveCurrentPage?: boolean, preserveCurrentOutline?: boolean }} DisclosurePolicy
6
+ */
7
+
8
+ // Keep this factory self-contained: the early inline script embeds its source,
9
+ // while the deferred runtime calls it as a normal module. No imported or outer
10
+ // values may be referenced inside it.
11
+ export function createNavigationStateHelpers() {
12
+ /** @param {'desktop' | 'mobile'} scope @param {string} root @param {string} sharedRoot */
13
+ const storageKeys = (scope, root = '/', sharedRoot = root) => ({
14
+ stateKey: `norna:tree-navigation:${scope}:${root}`,
15
+ sharedStateKey: `norna:tree-navigation:shared:${sharedRoot}`,
16
+ });
17
+ /** @param {string | null} value @returns {NavigationState} */
18
+ const parseState = (value) => {
19
+ try {
20
+ const parsedState = JSON.parse(value ?? '{}');
21
+ return {
22
+ openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
23
+ sectionOpenByPath: parsedState.sectionOpenByPath
24
+ && typeof parsedState.sectionOpenByPath === 'object'
25
+ ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
26
+ .filter((entry) => typeof entry[1] === 'boolean'))
27
+ : {},
28
+ scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
29
+ };
30
+ } catch {
31
+ return { openPaths: [], sectionOpenByPath: {}, scrollTop: 0 };
32
+ }
33
+ };
34
+ /** @param {string | null} localSource @param {string | null} sharedSource @param {string | null} entrySource */
35
+ const initialState = (localSource, sharedSource, entrySource = null) => {
36
+ if (entrySource !== null) {
37
+ const entry = parseState(entrySource);
38
+ return { local: entry, disclosure: entry, hasSavedState: true, fromEntry: true };
39
+ }
40
+ const local = parseState(localSource);
41
+ return {
42
+ local,
43
+ // An existing invalid shared record is empty, not a local fallback.
44
+ disclosure: sharedSource === null ? local : parseState(sharedSource),
45
+ hasSavedState: localSource !== null || sharedSource !== null,
46
+ fromEntry: false,
47
+ };
48
+ };
49
+ const arrivalPolicy = (state) => ({ onArrival: !state.fromEntry });
50
+ // Keep snapshots per history entry. Only the identity goes
51
+ // into history.state: scrolling must not repeatedly call replaceState.
52
+ let activeEntry = null;
53
+ const currentEntry = () => {
54
+ const entry = history.state?.nornaAreaNavigationEntry;
55
+ return entry?.url === location.href && typeof entry.id === 'string'
56
+ ? entry : null;
57
+ };
58
+ const readEntryState = (stateKey) => {
59
+ activeEntry = currentEntry();
60
+ return activeEntry === null ? null : sessionStorage.getItem(`${stateKey}:entry:${activeEntry.id}`);
61
+ };
62
+ const saveEntryState = (stateKey, state) => {
63
+ // WebKit can expose the destination's history.state during the departing
64
+ // document's pagehide. Save into this document's remembered entry instead
65
+ // of replacing the destination's identity. Traversal reads and hash changes
66
+ // select a new entry while the document is active.
67
+ if (activeEntry?.url !== location.href) activeEntry = currentEntry();
68
+ if (activeEntry === null) {
69
+ activeEntry = { url: location.href, id: `${Date.now()}-${Math.random().toString(36).slice(2)}` };
70
+ history.replaceState({ ...history.state, nornaAreaNavigationEntry: activeEntry }, '');
71
+ }
72
+ sessionStorage.setItem(`${stateKey}:entry:${activeEntry.id}`, JSON.stringify(state));
73
+ };
74
+ /** @param {DisclosureState} state @param {BranchData} branch @param {DisclosurePolicy} policy */
75
+ const pageIsOpen = (state, branch, policy = {}) => (
76
+ (Boolean(policy.onArrival) && branch.currentBranch === 'true'
77
+ && (!policy.preserveCurrentPage || branch.currentPage !== 'true'))
78
+ || state.openPaths.includes(branch.pagePath ?? '')
79
+ );
80
+ /** @param {DisclosureState} state @param {BranchData} branch @param {DisclosurePolicy} policy */
81
+ const sectionsAreOpen = (state, branch, policy = {}) => {
82
+ const storedOpen = state.sectionOpenByPath[branch.pagePath ?? ''];
83
+ if (policy.onArrival && branch.currentPage === 'true'
84
+ && !(policy.preserveCurrentOutline && typeof storedOpen === 'boolean')) return true;
85
+ return typeof storedOpen === 'boolean' ? storedOpen : branch.currentPage === 'true';
86
+ };
87
+ return { storageKeys, parseState, initialState, arrivalPolicy, readEntryState, saveEntryState, pageIsOpen, sectionsAreOpen };
88
+ }
@@ -0,0 +1,76 @@
1
+ import { createNavigationStateHelpers } from './navigationState.mjs';
2
+
3
+ // Serialized into a parser-time script. Like the helpers, this entry point must
4
+ // only reference its arguments and browser globals, never module-scope values.
5
+ function restoreEarlyNavigationState(createHelpers) {
6
+ // Restore branches as the parser adds them, before they can be painted.
7
+ // pagereveal may precede this tree and is unavailable in some browsers.
8
+ const script = document.currentScript;
9
+ const container = script?.parentElement;
10
+ if (!container?.hasAttribute('data-navigation-root')) return;
11
+ const scope = script.dataset.navigationStateScope;
12
+ const { storageKeys, initialState, arrivalPolicy, readEntryState, pageIsOpen, sectionsAreOpen } = createHelpers();
13
+ const initialized = new WeakSet();
14
+ const restore = (refresh = false) => {
15
+ try {
16
+ // Establish the scroll limit before restoring a position near its end.
17
+ if (scope === 'desktop' && container.parentElement) {
18
+ const flowTop = container.parentElement.getBoundingClientRect().top + window.scrollY;
19
+ const stickyTop = parseFloat(getComputedStyle(container).top) || 0;
20
+ container.style.setProperty('--navigation-scrollport-top', `${Math.ceil(Math.max(flowTop, stickyTop))}px`);
21
+ }
22
+ const { stateKey, sharedStateKey } = storageKeys(scope,
23
+ container.dataset.navigationRoot, container.dataset.navigationStateRoot);
24
+ const state = initialState(sessionStorage.getItem(stateKey), sessionStorage.getItem(sharedStateKey),
25
+ readEntryState(stateKey));
26
+ const policy = arrivalPolicy(state);
27
+ for (const branch of container.querySelectorAll('.navigation-page-disclosure[data-page-path]')) {
28
+ if (!refresh && initialized.has(branch)) continue;
29
+ branch.open = pageIsOpen(state.disclosure, branch.dataset, policy);
30
+ initialized.add(branch);
31
+ }
32
+ for (const branch of container.querySelectorAll('.navigation-page-sections-disclosure[data-page-path]')) {
33
+ if (!refresh && initialized.has(branch)) continue;
34
+ branch.open = sectionsAreOpen(state.disclosure, branch.dataset, policy);
35
+ initialized.add(branch);
36
+ }
37
+ let scrollContainer = container;
38
+ for (let candidate = container; candidate; candidate = candidate.parentElement) {
39
+ if (/auto|scroll/.test(getComputedStyle(candidate).overflowY)) {
40
+ scrollContainer = candidate;
41
+ break;
42
+ }
43
+ }
44
+ if (state.local.scrollTop > 0 || state.fromEntry) scrollContainer.scrollTop = Math.max(0, state.local.scrollTop);
45
+ if (scope === 'desktop' && !state.fromEntry && state.local.scrollTop <= 0) {
46
+ // A direct arrival in a short viewport can put the selected page
47
+ // below the fold. Reveal its title now, before heading following is
48
+ // paused. An already visible title or restored position stays put.
49
+ const node = container.querySelector('[data-navigation-current-page="true"]');
50
+ const link = node?.querySelector(':scope > .navigation-page-link, :scope > .navigation-page-open-link');
51
+ if (link && link.getBoundingClientRect().width > 0) {
52
+ const box = scrollContainer.getBoundingClientRect();
53
+ const controls = container.querySelector('[data-tree-controls]')?.getBoundingClientRect();
54
+ const top = Math.max(box.top, controls?.bottom ?? box.top) + 12;
55
+ const bottom = box.bottom - 16;
56
+ const row = link.getBoundingClientRect();
57
+ if (row.top < top) scrollContainer.scrollTop += row.top - top;
58
+ else if (row.bottom > bottom) scrollContainer.scrollTop += row.bottom - bottom;
59
+ }
60
+ }
61
+ } catch {
62
+ // Ordinary links and native details remain usable without storage.
63
+ }
64
+ };
65
+ restore();
66
+ const observer = new MutationObserver(() => restore());
67
+ observer.observe(container, { childList: true, subtree: true });
68
+ document.addEventListener('DOMContentLoaded', () => {
69
+ restore();
70
+ observer.disconnect();
71
+ }, { once: true });
72
+ // Reapply before a transition snapshot, including a cached history entry.
73
+ window.addEventListener('pagereveal', () => restore(true));
74
+ }
75
+
76
+ export const getNavigationStateScript = () => `(${restoreEarlyNavigationState.toString()})(${createNavigationStateHelpers.toString()});`;
@@ -0,0 +1,9 @@
1
+ export const isNavigationElementDisplayed = (element: HTMLElement) => {
2
+ if (!element.getClientRects().length || getComputedStyle(element).visibility === 'hidden') return false;
3
+ // Closed native details can retain layout rectangles for their hidden links.
4
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
5
+ if (parent instanceof HTMLDetailsElement && !parent.open
6
+ && !parent.querySelector(':scope > summary')?.contains(element)) return false;
7
+ }
8
+ return true;
9
+ };
@@ -1,44 +1,7 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import {
4
- validatePageThemeYamlStructure,
5
- } from '../../scripts/lib/site-content.mjs';
6
- import { pageThemeSchema } from '../../scripts/lib/schema-definitions.mjs';
7
- import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
8
- import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
9
- import { parseYamlConfig } from '../../scripts/lib/yaml-config.mjs';
10
- import { getPageDirectoryAncestors } from '../../scripts/lib/page-model.mjs';
1
+ import { selectPageTheme } from '../../scripts/lib/theme-packages.mjs';
2
+ import { siteDir } from '../../scripts/lib/site-paths.mjs';
11
3
 
12
- type PageTheme = {
13
- id: string;
14
- data: Record<string, unknown>;
15
- };
16
-
17
- export const getPageTheme = async (pageDirectory: string | null): Promise<PageTheme | null> => {
18
- if (!pageDirectory) return null;
19
-
20
- const pageAncestors = getPageDirectoryAncestors(pageDirectory);
21
- let inheritedData: Record<string, unknown> = {};
22
- const inheritedIds: string[] = [];
23
- for (const pageAncestor of pageAncestors) {
24
- const themeSegments = pageAncestor.split('/');
25
- const themePath = path.join(sitePagesDir, ...themeSegments, 'theme.yaml');
26
- const source = await readFile(themePath, 'utf8').catch((error) => {
27
- if (error?.code === 'ENOENT') return null;
28
- throw error;
29
- });
30
- if (!source) continue;
31
- const themeLabel = `${sitePagesLabel}/${themeSegments.join('/')}/theme.yaml`;
32
-
33
- const data = parseYamlConfig(source, themeLabel, {
34
- schema: pageThemeSchema,
35
- validateStructure: validatePageThemeYamlStructure,
36
- });
37
- inheritedData = mergePageThemeConfig(inheritedData, data);
38
- inheritedIds.push(`pages/${themeSegments.join('/')}/theme`);
39
- }
40
-
41
- return inheritedIds.length > 0
42
- ? { id: inheritedIds.join(' + '), data: inheritedData }
43
- : null;
4
+ export const getPageTheme = async (pageDirectory: string) => {
5
+ const { selected } = await selectPageTheme({ siteRoot: siteDir, pageDirectory });
6
+ return { id: selected.label, data: selected.config };
44
7
  };
@@ -1,3 +1,4 @@
1
+ import { getPageImageSourceKey } from '../../scripts/lib/site-conventions.mjs';
1
2
  import { markdownToHtml } from 'satteri';
2
3
  import { groupRenderedTabs } from '../../scripts/lib/content-tabs.mjs';
3
4
  import projectConfig from '../../scripts/lib/project-config.mjs';
@@ -111,7 +112,7 @@ const applyH3HeadingIds = (
111
112
  return result;
112
113
  };
113
114
 
114
- const getImageSourceKey = (page: SitePage, image: string) => `pages/${page.pageDirectory}/images/${image}`;
115
+ const getImageSourceKey = (page: SitePage, image: string) => getPageImageSourceKey(page.pageDirectory, image);
115
116
 
116
117
  const renderHeadingTitleHtml = async (headingSource: string) => {
117
118
  const headingMarkdown = headingSource
@@ -178,7 +179,7 @@ const resolveContentBlocks = async (
178
179
  }
179
180
  if (block.type === 'page-list') {
180
181
  if (childPages.length === 0) {
181
- throw new Error(`${page.contentLabel} line ${block.line}: page-list has no listed direct child pages to display. Navigation categories are not pages. Add a listed direct child page or remove the block.`);
182
+ throw new Error(`${page.contentLabel} line ${block.line}: page-list has no listed direct child pages to display. Add a listed direct child page or remove the block.`);
182
183
  }
183
184
  resolvedBlocks.push({
184
185
  type: 'page-list',
@@ -7,6 +7,12 @@ import {
7
7
  getSequentialPageNavigation as getSequentialPageNavigationShared,
8
8
  } from '../../scripts/lib/site-navigation-tree.mjs';
9
9
 
10
+ export type CategoryDestinationMap = ReadonlyMap<string, { target: SitePage | null }>;
11
+
12
+ export const getNavigationDestination = (node: SiteNode, categoryDestinations?: CategoryDestinationMap): SiteNode => (
13
+ categoryDestinations?.get(node.pathname)?.target ?? node
14
+ );
15
+
10
16
  export type SiteNavigationEntry = {
11
17
  node: SiteNode;
12
18
  headings: HeadingNavigation[];