@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,31 +1,33 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import { z } from 'astro/zod';
4
- import { basename } from 'node:path';
4
+ import { basename, dirname } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import {
7
- siteDir,
7
+ siteHomePageDir,
8
8
  siteDirLabel,
9
9
  siteThemePath,
10
10
  sitewideContentPath,
11
11
  } from '../scripts/lib/site-paths.mjs';
12
12
  import { encodePageEntryId, getSiteEntryPrefix, parsePageDirectoryPath } from '../scripts/lib/page-model.mjs';
13
+ import { homePageDirectory } from '../scripts/lib/site-conventions.mjs';
13
14
  import {
14
15
  siteSchema,
15
16
  sitewideSchema,
16
- themeVisualSchema,
17
+ rootThemeSchema,
17
18
  } from '../scripts/lib/schema-definitions.mjs';
18
19
 
19
20
  const siteEntryPrefix = getSiteEntryPrefix(siteDirLabel);
20
21
  const emptyYamlMapping = (value: unknown) => value ?? {};
21
- const siteThemeSchema = z.preprocess(emptyYamlMapping, themeVisualSchema);
22
+ const siteThemeSchema = z.preprocess(emptyYamlMapping, rootThemeSchema);
22
23
  const sitewideContentSchema = z.preprocess(emptyYamlMapping, sitewideSchema);
23
24
 
24
25
  const site = defineCollection({
25
26
  loader: glob({
26
- pattern: 'pages/**/content.md',
27
- base: pathToFileURL(siteDir),
27
+ pattern: ['content.md', 'pages/**/content.md'],
28
+ base: pathToFileURL(siteHomePageDir),
28
29
  generateId: ({ entry }) => {
30
+ if (entry === 'content.md') return encodePageEntryId(siteDirLabel, homePageDirectory);
29
31
  const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
30
32
  const pageDirectory = pageEntryDirectory
31
33
  .split('/')
@@ -40,7 +42,7 @@ const site = defineCollection({
40
42
  const theme = defineCollection({
41
43
  loader: glob({
42
44
  pattern: basename(siteThemePath),
43
- base: pathToFileURL(siteDir),
45
+ base: pathToFileURL(dirname(siteThemePath)),
44
46
  generateId: () => `${siteEntryPrefix}-theme`,
45
47
  }),
46
48
  schema: siteThemeSchema,
@@ -49,7 +51,7 @@ const theme = defineCollection({
49
51
  const sitewide = defineCollection({
50
52
  loader: glob({
51
53
  pattern: basename(sitewideContentPath),
52
- base: pathToFileURL(siteDir),
54
+ base: pathToFileURL(dirname(sitewideContentPath)),
53
55
  generateId: () => `${siteEntryPrefix}-sitewide`,
54
56
  }),
55
57
  schema: sitewideContentSchema,
@@ -5,6 +5,8 @@ import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs'
5
5
  import { getIconLinks, getSocialImageAsset } from '../lib/sitePublicAssets';
6
6
  import { withBasePath } from '../lib/basePath';
7
7
  import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
8
+ import NavigationContinuity from '../components/NavigationContinuity.astro';
9
+ import areaNavigationStyles from '../styles/area-navigation.css?inline';
8
10
  import '../styles/global.css';
9
11
 
10
12
  type FrameColors = {
@@ -80,6 +82,8 @@ interface Props {
80
82
  contentsNavigation?: boolean;
81
83
  indexable?: boolean;
82
84
  searchable?: boolean;
85
+ areaNavigation?: boolean;
86
+ areaReadingFrame?: boolean;
83
87
  }
84
88
 
85
89
  const {
@@ -93,6 +97,8 @@ const {
93
97
  contentsNavigation = false,
94
98
  indexable = true,
95
99
  searchable = false,
100
+ areaNavigation = false,
101
+ areaReadingFrame = false,
96
102
  } = Astro.props;
97
103
 
98
104
  const documentTitle = title;
@@ -150,49 +156,13 @@ const marginNoteOuterReserve = '0.5rem';
150
156
  const marginNoteDeclarations = marginNoteLayoutDeclarations;
151
157
  const marginNoteLayoutCss = [
152
158
  '@media (min-width: 1101px) {',
153
- ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
154
- [
155
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
156
- `:root[data-reading-width="${readingWidth}"] .site-page-layout:not(.site-page-layout-tree) .section-note-paragraph {`,
157
- marginNoteDeclarations,
158
- '}',
159
- '}',
160
- ].join('\n'),
161
- [
162
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
163
- `:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note-paragraph {`,
164
- marginNoteDeclarations,
165
- '}',
166
- '}',
167
- ].join('\n'),
168
- ]),
169
- '}',
170
- '@media (min-width: 1101px) and (max-width: 1280px) {',
171
159
  ...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
172
160
  `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve})) {`,
173
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
174
- marginNoteDeclarations,
175
- '}',
176
- '}',
177
- ].join('\n')),
178
- '}',
179
- '@media (min-width: 1281px) {',
180
- ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
181
- [
182
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
183
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note-paragraph {`,
184
- marginNoteDeclarations,
185
- '}',
161
+ `:root[data-reading-width="${readingWidth}"] .section-note-paragraph {`,
162
+ marginNoteDeclarations,
186
163
  '}',
187
- ].join('\n'),
188
- [
189
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
190
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
191
- marginNoteDeclarations,
192
- '}',
193
164
  '}',
194
- ].join('\n'),
195
- ]),
165
+ ].join('\n')),
196
166
  '}',
197
167
  ].join('\n');
198
168
  const { buildInfo, copyrightMessage } = footer ?? {};
@@ -227,6 +197,9 @@ const readerPreferencesScript = readerPreferencesEnabled
227
197
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
228
198
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
229
199
  data-section-tracking={contentsNavigation ? 'enabled' : undefined}
200
+ data-navigation-continuity={projectConfig.site.basePath}
201
+ data-area-navigation={areaNavigation ? '' : undefined}
202
+ data-area-reading-frame={areaReadingFrame ? '' : undefined}
230
203
  data-pagefind-ignore={searchable ? undefined : 'all'}
231
204
  style={documentStyle}
232
205
  >
@@ -248,6 +221,8 @@ const readerPreferencesScript = readerPreferencesEnabled
248
221
  {indexable && socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
249
222
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
250
223
  {readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
224
+ <NavigationContinuity />
225
+ {areaNavigation && <style is:inline set:html={areaNavigationStyles} />}
251
226
  <style is:inline set:html={marginNoteLayoutCss} />
252
227
  {indexable && <link rel="canonical" href={canonicalUrl} />}
253
228
  <meta name="generator" content={Astro.generator} />
@@ -272,5 +247,6 @@ const readerPreferencesScript = readerPreferencesEnabled
272
247
  </p>
273
248
  </footer>
274
249
  )}
250
+ <span id="norna-navigation-ready" hidden></span>
275
251
  </body>
276
252
  </html>
@@ -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
+ }