@janga/norna 0.7.24 → 0.7.26

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 (142) hide show
  1. package/README.md +5 -4
  2. package/astro.config.mjs +27 -11
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +26 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +342 -33
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/check-config.mjs +5 -1
  16. package/scripts/dev-local.mjs +130 -13
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/init-site.mjs +2 -0
  19. package/scripts/lib/category-destinations.mjs +48 -0
  20. package/scripts/lib/code-fence-metadata.mjs +25 -43
  21. package/scripts/lib/content-tabs.mjs +155 -0
  22. package/scripts/lib/details-headings.mjs +58 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  24. package/scripts/lib/edit-source-link.mjs +53 -0
  25. package/scripts/lib/editor-language-service.mjs +238 -116
  26. package/scripts/lib/locale-registry.mjs +597 -0
  27. package/scripts/lib/locales/az-Latn.mjs +78 -0
  28. package/scripts/lib/locales/be.mjs +78 -0
  29. package/scripts/lib/locales/bg.mjs +78 -0
  30. package/scripts/lib/locales/bs.mjs +78 -0
  31. package/scripts/lib/locales/ca.mjs +78 -0
  32. package/scripts/lib/locales/cnr.mjs +78 -0
  33. package/scripts/lib/locales/cs.mjs +78 -0
  34. package/scripts/lib/locales/da.mjs +78 -0
  35. package/scripts/lib/locales/de.mjs +78 -0
  36. package/scripts/lib/locales/el.mjs +78 -0
  37. package/scripts/lib/locales/en.mjs +78 -0
  38. package/scripts/lib/locales/es.mjs +78 -0
  39. package/scripts/lib/locales/et.mjs +78 -0
  40. package/scripts/lib/locales/fi.mjs +78 -0
  41. package/scripts/lib/locales/fil.mjs +78 -0
  42. package/scripts/lib/locales/fr.mjs +78 -0
  43. package/scripts/lib/locales/ga.mjs +78 -0
  44. package/scripts/lib/locales/ha.mjs +78 -0
  45. package/scripts/lib/locales/hr.mjs +78 -0
  46. package/scripts/lib/locales/hu.mjs +78 -0
  47. package/scripts/lib/locales/id.mjs +78 -0
  48. package/scripts/lib/locales/is.mjs +78 -0
  49. package/scripts/lib/locales/it.mjs +78 -0
  50. package/scripts/lib/locales/jv.mjs +78 -0
  51. package/scripts/lib/locales/lb.mjs +78 -0
  52. package/scripts/lib/locales/lt.mjs +78 -0
  53. package/scripts/lib/locales/lv.mjs +78 -0
  54. package/scripts/lib/locales/mk.mjs +78 -0
  55. package/scripts/lib/locales/mt.mjs +78 -0
  56. package/scripts/lib/locales/nb.mjs +78 -0
  57. package/scripts/lib/locales/nl.mjs +78 -0
  58. package/scripts/lib/locales/nn.mjs +78 -0
  59. package/scripts/lib/locales/pcm.mjs +78 -0
  60. package/scripts/lib/locales/pl.mjs +78 -0
  61. package/scripts/lib/locales/pt.mjs +78 -0
  62. package/scripts/lib/locales/ro.mjs +78 -0
  63. package/scripts/lib/locales/ru.mjs +78 -0
  64. package/scripts/lib/locales/sk.mjs +78 -0
  65. package/scripts/lib/locales/sl.mjs +78 -0
  66. package/scripts/lib/locales/sq.mjs +78 -0
  67. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  68. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  69. package/scripts/lib/locales/sv.mjs +78 -0
  70. package/scripts/lib/locales/sw.mjs +78 -0
  71. package/scripts/lib/locales/tr.mjs +78 -0
  72. package/scripts/lib/locales/uk.mjs +78 -0
  73. package/scripts/lib/locales/vi.mjs +78 -0
  74. package/scripts/lib/markdown-links.mjs +4 -25
  75. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  76. package/scripts/lib/markdown-notes.mjs +165 -0
  77. package/scripts/lib/navigation-model.mjs +1 -1
  78. package/scripts/lib/norna-markdown-blocks.mjs +123 -447
  79. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  80. package/scripts/lib/page-aliases.mjs +12 -1
  81. package/scripts/lib/page-markdown.mjs +63 -16
  82. package/scripts/lib/page-move-plan.mjs +22 -5
  83. package/scripts/lib/pagefind-translations.mjs +620 -0
  84. package/scripts/lib/presentation-contract.mjs +6 -1
  85. package/scripts/lib/presentation.mjs +29 -30
  86. package/scripts/lib/project-config.mjs +22 -163
  87. package/scripts/lib/schema-definitions.mjs +13 -12
  88. package/scripts/lib/schema-editor-metadata.mjs +42 -46
  89. package/scripts/lib/schema-value-definitions.mjs +7 -8
  90. package/scripts/lib/semantic-callouts.mjs +4 -2
  91. package/scripts/lib/site-content.mjs +4 -2
  92. package/scripts/lib/site-link-graph.mjs +16 -6
  93. package/scripts/lib/site-navigation-tree.mjs +0 -11
  94. package/scripts/lib/site-paths.mjs +14 -2
  95. package/scripts/lib/sitemap.mjs +5 -3
  96. package/scripts/lib/structured-blocks.mjs +108 -0
  97. package/scripts/lib/table-render-plugin.mjs +76 -0
  98. package/scripts/lib/table-row-headers.mjs +218 -0
  99. package/scripts/lib/theme-presets.mjs +3 -10
  100. package/scripts/lib/yaml-config.mjs +2 -6
  101. package/scripts/migrate-check.mjs +19 -0
  102. package/scripts/sync-content-sections.mjs +39 -10
  103. package/scripts/sync-site-public.mjs +7 -1
  104. package/src/components/CardList.astro +1 -0
  105. package/src/components/CodeBlockCopyScript.astro +49 -6
  106. package/src/components/ContentBlocks.astro +39 -0
  107. package/src/components/ContentTabsScript.astro +60 -0
  108. package/src/components/DisplaySettings.astro +17 -19
  109. package/src/components/ImageCarousel.astro +4 -1
  110. package/src/components/ImageStack.astro +34 -9
  111. package/src/components/ImageStackEnhancement.astro +331 -0
  112. package/src/components/NavigationPageTree.astro +1 -1
  113. package/src/components/NoteNavigationScript.astro +4 -0
  114. package/src/components/PageAliasRedirect.astro +5 -4
  115. package/src/components/PageContentsNavigation.astro +2 -3
  116. package/src/components/SearchNavigationScript.astro +4 -0
  117. package/src/components/SearchPage.astro +43 -2
  118. package/src/components/SectionNavigationScript.astro +86 -16
  119. package/src/components/SiteBreadcrumbs.astro +1 -1
  120. package/src/components/SiteNavigation.astro +98 -65
  121. package/src/components/SitePage.astro +73 -33
  122. package/src/components/SiteSection.astro +5 -81
  123. package/src/components/SiteTreeNavigation.astro +5 -2
  124. package/src/components/TableOverflowScript.astro +579 -0
  125. package/src/components/TopPageMenu.astro +47 -0
  126. package/src/components/TreeNavigationScript.astro +86 -23
  127. package/src/layouts/BaseLayout.astro +34 -12
  128. package/src/lib/generatedImages.ts +18 -0
  129. package/src/lib/navigationFollowing.ts +142 -0
  130. package/src/lib/noteNavigation.ts +50 -0
  131. package/src/lib/searchNavigation.ts +107 -0
  132. package/src/lib/sectionContent.ts +23 -116
  133. package/src/lib/siteNavigation.ts +0 -5
  134. package/src/lib/sitePages.ts +18 -4
  135. package/src/lib/table-sort-tooltip.ts +109 -0
  136. package/src/pages/[...slug].astro +16 -6
  137. package/src/styles/content.css +563 -26
  138. package/src/styles/foundations.css +9 -0
  139. package/src/styles/media.css +192 -3
  140. package/src/styles/navigation.css +214 -60
  141. package/src/styles/page-layout.css +55 -16
  142. package/src/styles/responsive.css +62 -20
@@ -4,6 +4,7 @@
4
4
  sectionOpenByPath: Record<string, boolean>;
5
5
  scrollTop: number;
6
6
  };
7
+ type SharedNavigationState = Pick<NavigationState, 'openPaths' | 'sectionOpenByPath'>;
7
8
  type NavigationSnapshot = {
8
9
  branchOpen: boolean[];
9
10
  sectionOpen: boolean[];
@@ -12,7 +13,9 @@
12
13
 
13
14
  const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
14
15
  const root = container.dataset.navigationRoot ?? '/';
16
+ const sharedRoot = container.dataset.navigationStateRoot ?? root;
15
17
  const stateKey = `norna:tree-navigation:${scope}:${root}`;
18
+ const sharedStateKey = `norna:tree-navigation:shared:${sharedRoot}`;
16
19
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
17
20
  '.navigation-page-disclosure[data-page-path]',
18
21
  ));
@@ -34,31 +37,76 @@
34
37
  };
35
38
  const scrollContainer = getScrollContainer();
36
39
  let filterSnapshot: NavigationSnapshot | null = null;
37
-
38
- try {
39
- const parsedState = JSON.parse(sessionStorage.getItem(stateKey) ?? '{}');
40
- const storedState: NavigationState = {
41
- openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
42
- sectionOpenByPath: parsedState.sectionOpenByPath
43
- && typeof parsedState.sectionOpenByPath === 'object'
44
- ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
45
- .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
46
- : {},
47
- scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
48
- };
49
-
40
+ let lastSharedState = '';
41
+ const parseState = (value: string | null): NavigationState => {
42
+ try {
43
+ const parsedState = JSON.parse(value ?? '{}');
44
+ return {
45
+ openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
46
+ sectionOpenByPath: parsedState.sectionOpenByPath
47
+ && typeof parsedState.sectionOpenByPath === 'object'
48
+ ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
49
+ .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
50
+ : {},
51
+ scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
52
+ };
53
+ } catch {
54
+ return { openPaths: [], sectionOpenByPath: {}, scrollTop: 0 };
55
+ }
56
+ };
57
+ const getDisclosureState = (): SharedNavigationState => ({
58
+ openPaths: branches.filter((branch) => branch.open)
59
+ .map((branch) => branch.dataset.pagePath ?? ''),
60
+ sectionOpenByPath: Object.fromEntries(sectionBranches.map((branch) => [
61
+ branch.dataset.pagePath ?? '',
62
+ branch.open,
63
+ ])),
64
+ });
65
+ const applyDisclosureState = (
66
+ state: SharedNavigationState,
67
+ openCurrentPageOnArrival = false,
68
+ ) => {
50
69
  branches.forEach((branch) => {
51
70
  const pagePath = branch.dataset.pagePath ?? '';
52
- branch.open = branch.dataset.currentBranch === 'true'
53
- || storedState.openPaths.includes(pagePath);
71
+ branch.open = (openCurrentPageOnArrival && branch.dataset.currentBranch === 'true')
72
+ || state.openPaths.includes(pagePath);
54
73
  });
55
74
  sectionBranches.forEach((branch) => {
56
75
  const pagePath = branch.dataset.pagePath ?? '';
57
- const storedOpen = storedState.sectionOpenByPath[pagePath];
76
+ const storedOpen = state.sectionOpenByPath[pagePath];
77
+ if (openCurrentPageOnArrival && branch.dataset.currentPage === 'true') {
78
+ branch.open = true;
79
+ return;
80
+ }
58
81
  branch.open = typeof storedOpen === 'boolean'
59
82
  ? storedOpen
60
83
  : branch.dataset.currentPage === 'true';
61
84
  });
85
+ };
86
+ const mergeSharedState = (state: SharedNavigationState): SharedNavigationState => {
87
+ const existingState = parseState(sessionStorage.getItem(sharedStateKey));
88
+ const knownBranchPaths = new Set(branches.map((branch) => branch.dataset.pagePath ?? ''));
89
+ return {
90
+ openPaths: [
91
+ ...existingState.openPaths.filter((pagePath) => !knownBranchPaths.has(pagePath)),
92
+ ...state.openPaths,
93
+ ],
94
+ sectionOpenByPath: {
95
+ ...existingState.sectionOpenByPath,
96
+ ...state.sectionOpenByPath,
97
+ },
98
+ };
99
+ };
100
+
101
+ try {
102
+ const storedState = parseState(sessionStorage.getItem(stateKey));
103
+ const storedSharedState = sessionStorage.getItem(sharedStateKey);
104
+ applyDisclosureState(storedSharedState === null
105
+ ? storedState
106
+ : parseState(storedSharedState), true);
107
+ const initialSharedState = mergeSharedState(getDisclosureState());
108
+ lastSharedState = JSON.stringify(initialSharedState);
109
+ sessionStorage.setItem(sharedStateKey, lastSharedState);
62
110
 
63
111
  if (storedState.scrollTop > 0) {
64
112
  requestAnimationFrame(() => {
@@ -73,21 +121,36 @@
73
121
  if (filterSnapshot) return;
74
122
 
75
123
  try {
76
- const openBranches = branches.filter((branch) => branch.open);
77
- const sectionOpenByPath = Object.fromEntries(sectionBranches.map((branch) => [
78
- branch.dataset.pagePath ?? '',
79
- branch.open,
80
- ]));
124
+ const disclosureState = getDisclosureState();
81
125
  const state: NavigationState = {
82
- openPaths: openBranches.map((branch) => branch.dataset.pagePath ?? ''),
83
- sectionOpenByPath,
126
+ ...disclosureState,
84
127
  scrollTop: scrollContainer.scrollTop,
85
128
  };
86
129
  sessionStorage.setItem(stateKey, JSON.stringify(state));
130
+ const sharedState = mergeSharedState(disclosureState);
131
+ const serializedSharedState = JSON.stringify(sharedState);
132
+ sessionStorage.setItem(sharedStateKey, serializedSharedState);
133
+ if (serializedSharedState !== lastSharedState) {
134
+ lastSharedState = serializedSharedState;
135
+ window.dispatchEvent(new CustomEvent('norna:tree-navigation-state', {
136
+ detail: { root: sharedRoot, state: sharedState },
137
+ }));
138
+ }
87
139
  } catch {
88
140
  // Navigation state persistence is an optional enhancement.
89
141
  }
90
142
  };
143
+ window.addEventListener('norna:tree-navigation-state', (event) => {
144
+ const detail = (event as CustomEvent<{
145
+ root?: string;
146
+ state?: SharedNavigationState;
147
+ }>).detail;
148
+ if (detail?.root !== sharedRoot || !detail.state) return;
149
+ const serializedSharedState = JSON.stringify(detail.state);
150
+ if (serializedSharedState === lastSharedState) return;
151
+ lastSharedState = serializedSharedState;
152
+ applyDisclosureState(detail.state);
153
+ });
91
154
  const controls = container.querySelector<HTMLElement>('[data-tree-controls]');
92
155
  const filterInput = controls?.querySelector<HTMLInputElement>('[data-tree-filter]') ?? null;
93
156
  const expandButton = controls?.querySelector<HTMLButtonElement>('[data-tree-expand-all]') ?? null;
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { marginNoteLayoutDeclarations } from '../../scripts/lib/markdown-notes-render-plugin.mjs';
3
4
  import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs';
4
5
  import { getIconLinks, getSocialImageAsset } from '../lib/sitePublicAssets';
5
6
  import { withBasePath } from '../lib/basePath';
@@ -145,27 +146,48 @@ const marginNoteReadingWidths = [
145
146
  ['standard', 'min(72ch, 680px)'],
146
147
  ['wide', `min(80ch, ${visualTheme.images.width})`],
147
148
  ] as const;
148
- const vacantContentsTrackWidth = `11rem + ${visualTheme.layout.localNavigationGap}`;
149
- const marginNoteDeclarations = [
150
- 'float: right;',
151
- 'clear: right;',
152
- 'width: var(--layout-note-width);',
153
- 'margin: 0 calc(0px - var(--layout-note-width) - var(--layout-note-gap)) 0.75rem var(--layout-note-gap);',
154
- ].join('\n');
149
+ const marginNoteOuterReserve = '0.5rem';
150
+ const marginNoteDeclarations = marginNoteLayoutDeclarations;
155
151
  const marginNoteLayoutCss = [
156
152
  '@media (min-width: 1101px) {',
157
153
  ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
158
154
  [
159
155
  `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
160
- `:root[data-reading-width="${readingWidth}"] .section-note {`,
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
+ ...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
172
+ `@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 {`,
161
184
  marginNoteDeclarations,
162
185
  '}',
163
186
  '}',
164
187
  ].join('\n'),
165
188
  [
166
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} - ${vacantContentsTrackWidth})) {`,
167
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note,`,
168
- `:root[data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note {`,
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 {`,
169
191
  marginNoteDeclarations,
170
192
  '}',
171
193
  '}',
@@ -176,7 +198,7 @@ const marginNoteLayoutCss = [
176
198
  const { buildInfo, copyrightMessage } = footer ?? {};
177
199
  const showBuildInfo = buildInfo === true;
178
200
  const buildTime = showBuildInfo
179
- ? new Intl.DateTimeFormat(projectConfig.locale.lang, {
201
+ ? new Intl.DateTimeFormat(projectConfig.locale.formattingLocale, {
180
202
  dateStyle: 'short',
181
203
  timeStyle: 'short',
182
204
  timeZone: 'UTC',
@@ -45,6 +45,24 @@ export const getGeneratedImage = (src: string) => readGeneratedImages()[src];
45
45
 
46
46
  const displaySrc = (src: string) => withBasePath(projectConfig.site.basePath, src);
47
47
 
48
+ export const getImageInspectionAttributes = (src: string) => {
49
+ const image = getGeneratedImage(src);
50
+ const largestVariant = image?.variants
51
+ ? [...image.variants].sort((left, right) => left.width - right.width).at(-1)
52
+ : undefined;
53
+ const publishedSource = image?.kind === 'static'
54
+ ? image.src
55
+ : largestVariant?.src;
56
+
57
+ return {
58
+ href: displaySrc(publishedSource ?? src),
59
+ scalable: image?.kind === 'static' || /\.svg(?:[?#]|$)/iu.test(publishedSource ?? src),
60
+ ...(Number.isFinite(image?.width) && Number.isFinite(image?.height)
61
+ ? { width: image?.width, height: image?.height }
62
+ : {}),
63
+ };
64
+ };
65
+
48
66
  const getDisplayVariants = (variants: NonNullable<GeneratedImage['variants']>) => {
49
67
  const sortedVariants = [...variants].sort((a, b) => a.width - b.width);
50
68
  const displayVariants = sortedVariants.filter((variant) => variant.width <= maxDisplayImageWidth);
@@ -0,0 +1,142 @@
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
+ };
10
+
11
+ const visibleEntry = (link: HTMLElement, container: HTMLElement): HTMLElement | undefined => {
12
+ if (isDisplayed(link)) return link;
13
+
14
+ let node = link.closest<HTMLElement>('.navigation-page-node');
15
+ while (node && container.contains(node)) {
16
+ const entry = node.querySelector<HTMLElement>([
17
+ ':scope > .navigation-page-link',
18
+ ':scope > .navigation-page-open-link',
19
+ ':scope > .navigation-category-disclosure > summary',
20
+ ].join(', '));
21
+ if (entry && isDisplayed(entry)) return entry;
22
+ node = node.parentElement?.closest<HTMLElement>('.navigation-page-node') ?? null;
23
+ }
24
+ return undefined;
25
+ };
26
+
27
+ // Adjust only the navigation scrollport, never the document or its focus.
28
+ const revealEntry = (container: HTMLElement, entry: HTMLElement) => {
29
+ const box = container.getBoundingClientRect();
30
+ const inset = Math.min(16, container.clientHeight / 8);
31
+ let top = Math.max(0, box.top + container.clientTop);
32
+ const bottom = Math.min(window.innerHeight, box.top + container.clientTop + container.clientHeight);
33
+ for (const control of container.querySelectorAll<HTMLElement>(
34
+ '.navigation-tree-controls, .mobile-nav-panel-actions',
35
+ )) {
36
+ if (isDisplayed(control) && getComputedStyle(control).position === 'sticky') {
37
+ top = Math.max(top, control.getBoundingClientRect().bottom);
38
+ }
39
+ }
40
+ const bounds = entry.getBoundingClientRect();
41
+ const available = bottom - top - inset * 2;
42
+ if (available <= 0) return;
43
+ const delta = bounds.top < top + inset || bounds.height > available
44
+ ? bounds.top - top - inset
45
+ : Math.max(0, bounds.bottom - bottom + inset);
46
+ if (Math.abs(delta) > 1) {
47
+ container.scrollTo({ top: container.scrollTop + delta, behavior: 'instant' });
48
+ }
49
+ };
50
+
51
+ export const setupNavigationFollowing = () => {
52
+ const left = document.querySelector<HTMLElement>('.tree-local-navigation');
53
+ const right = document.querySelector<HTMLElement>('.page-contents-navigation-rail');
54
+ const compact = document.querySelector<HTMLElement>('[data-compact-navigation-panel]');
55
+ const states = [left, right, compact]
56
+ .filter((container): container is HTMLElement => container !== null)
57
+ .map((container) => ({
58
+ container,
59
+ paused: false,
60
+ pointerDown: false,
61
+ expectedScrollTop: container.scrollTop,
62
+ ancestor: undefined as HTMLElement | undefined,
63
+ }));
64
+ let frame: number | undefined;
65
+
66
+ const updateContainer = (state: typeof states[number], reveal: boolean) => {
67
+ const { container } = state;
68
+ state.ancestor?.removeAttribute('data-reading-position-ancestor');
69
+ state.ancestor = undefined;
70
+ if (!isDisplayed(container)) return;
71
+ if (container.matches('[data-tree-filter-active="true"]')
72
+ || container.querySelector('[data-tree-filter-active="true"]')) return;
73
+
74
+ const link = container.querySelector<HTMLElement>('a[aria-current="location"]');
75
+ if (!link) return;
76
+ const entry = visibleEntry(link, container);
77
+ if (!entry) return;
78
+ if (entry !== link) {
79
+ entry.setAttribute('data-reading-position-ancestor', 'true');
80
+ state.ancestor = entry;
81
+ }
82
+ if (!reveal) return;
83
+ revealEntry(container, entry);
84
+ state.expectedScrollTop = container.scrollTop;
85
+ };
86
+
87
+ const update = () => {
88
+ frame = undefined;
89
+ const selected = right && isDisplayed(right) ? right : left;
90
+ for (const state of states) {
91
+ const ownsFocus = state.container.contains(document.activeElement);
92
+ updateContainer(state, state.container === selected
93
+ && !state.paused && !state.pointerDown && !ownsFocus
94
+ && !document.querySelector('.mobile-nav-menu[open]'));
95
+ }
96
+ };
97
+ const schedule = () => {
98
+ if (frame === undefined) frame = requestAnimationFrame(update);
99
+ };
100
+
101
+ for (const state of states) {
102
+ const pause = () => { state.paused = true; };
103
+ state.container.addEventListener('wheel', pause, { passive: true });
104
+ state.container.addEventListener('touchstart', pause, { passive: true });
105
+ state.container.addEventListener('keydown', pause);
106
+ state.container.addEventListener('focusin', pause);
107
+ state.container.addEventListener('input', pause);
108
+ state.container.addEventListener('input', schedule);
109
+ state.container.addEventListener('pointerdown', () => {
110
+ pause();
111
+ state.pointerDown = true;
112
+ });
113
+ state.container.addEventListener('scroll', () => {
114
+ if (Math.abs(state.container.scrollTop - state.expectedScrollTop) > 1) pause();
115
+ }, { passive: true });
116
+ // A disclosure can hide the active link without changing the scrollport size.
117
+ state.container.addEventListener('toggle', schedule, true);
118
+ }
119
+ const releasePointer = () => {
120
+ states.forEach((state) => { state.pointerDown = false; });
121
+ };
122
+ document.addEventListener('pointerup', releasePointer);
123
+ document.addEventListener('pointercancel', releasePointer);
124
+ window.addEventListener('blur', releasePointer);
125
+ window.addEventListener('scroll', () => {
126
+ states.forEach((state) => { state.paused = false; });
127
+ // Section tracking schedules its own update for the new document position.
128
+ }, { passive: true });
129
+ window.addEventListener('resize', schedule);
130
+ if ('ResizeObserver' in window) {
131
+ const observer = new ResizeObserver(schedule);
132
+ states.forEach(({ container }) => observer.observe(container));
133
+ }
134
+
135
+ return {
136
+ update: schedule,
137
+ revealCompact: () => {
138
+ const state = states.find(({ container }) => container === compact);
139
+ if (state) updateContainer(state, true);
140
+ },
141
+ };
142
+ };
@@ -0,0 +1,50 @@
1
+ export const installNoteNavigation = () => {
2
+ const revealReference = (target: HTMLElement) => {
3
+ const panel = target.closest<HTMLElement>('[data-tab-panel]');
4
+ if (panel?.hidden) {
5
+ const group = panel.closest('[data-content-tabs]');
6
+ const button = Array.from(group?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])
7
+ .find((candidate) => candidate.getAttribute('aria-controls') === panel.id);
8
+ button?.click();
9
+ }
10
+ target.focus({ preventScroll: true });
11
+ };
12
+ const targetFor = (hash: string) => {
13
+ try { return document.getElementById(decodeURIComponent(hash.slice(1))); } catch { return null; }
14
+ };
15
+ document.querySelectorAll<HTMLAnchorElement>('.section-note-ref a').forEach((link) => {
16
+ const note = targetFor(link.hash);
17
+ if (!note?.classList.contains('section-note')) return;
18
+ let hovered = false;
19
+ const updateHighlight = () => {
20
+ note.classList.toggle('is-reference-highlighted', hovered || link.matches(':focus-visible'));
21
+ };
22
+ link.addEventListener('pointerenter', (event) => {
23
+ hovered = event.pointerType !== 'touch';
24
+ updateHighlight();
25
+ });
26
+ link.addEventListener('pointerleave', () => {
27
+ hovered = false;
28
+ updateHighlight();
29
+ });
30
+ link.addEventListener('pointercancel', () => {
31
+ hovered = false;
32
+ updateHighlight();
33
+ });
34
+ link.addEventListener('focus', updateHighlight);
35
+ link.addEventListener('blur', updateHighlight);
36
+ });
37
+ // Capture before native fragment navigation so a previously hidden reference
38
+ // is visible and focusable when the browser follows the real return link.
39
+ document.addEventListener('click', (event) => {
40
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
41
+ const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('a[data-footnote-backref]');
42
+ if (!link) return;
43
+ const target = targetFor(link.hash);
44
+ if (target?.hasAttribute('data-footnote-ref')) revealReference(target);
45
+ }, true);
46
+ window.addEventListener('hashchange', () => {
47
+ const target = targetFor(location.hash);
48
+ if (target?.hasAttribute('data-footnote-ref')) revealReference(target);
49
+ });
50
+ };
@@ -0,0 +1,107 @@
1
+ type SearchOrigin = {
2
+ id: string;
3
+ href: string;
4
+ title: string;
5
+ x: number;
6
+ y: number;
7
+ };
8
+
9
+ const returnParameter = 'norna-return';
10
+ const historyKey = 'nornaSearchOrigin';
11
+
12
+ export const setupSearchNavigation = () => {
13
+ const searchLink = document.querySelector<HTMLAnchorElement>('.site-search-link[data-search-base]');
14
+ if (!searchLink) return;
15
+ const basePath = searchLink.dataset.searchBase!;
16
+ const searchUrl = new URL(searchLink.href);
17
+ const departureKey = `norna:search:${basePath}:departure`;
18
+ const restoreKey = `norna:search:${basePath}:restore`;
19
+ const localHref = () => location.pathname + location.search + location.hash;
20
+ const isNormalClick = (event: MouseEvent, link: HTMLAnchorElement) => !event.defaultPrevented
21
+ && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
22
+ && !link.hasAttribute('download') && (!link.target || link.target === '_self');
23
+ const readStorage = (key: string): unknown => {
24
+ try { return JSON.parse(sessionStorage.getItem(key) ?? 'null'); } catch { return null; }
25
+ };
26
+ const store = (key: string, value: SearchOrigin) => {
27
+ try { sessionStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; }
28
+ };
29
+ const remove = (key: string) => {
30
+ try { sessionStorage.removeItem(key); } catch {}
31
+ };
32
+ const validate = (value: unknown): SearchOrigin | null => {
33
+ if (!value || typeof value !== 'object') return null;
34
+ const origin = value as SearchOrigin;
35
+ if (typeof origin.id !== 'string' || !/^[a-z0-9-]{1,100}$/i.test(origin.id)
36
+ || typeof origin.href !== 'string' || !origin.href.startsWith('/')
37
+ || origin.href.startsWith('//') || /[\\\u0000-\u0020]/.test(origin.href)
38
+ || typeof origin.title !== 'string' || !origin.title.trim() || origin.title.length > 1000
39
+ || !Number.isFinite(origin.x) || origin.x < 0
40
+ || !Number.isFinite(origin.y) || origin.y < 0) return null;
41
+ try {
42
+ const url = new URL(origin.href, location.origin);
43
+ if (url.origin !== location.origin || !url.pathname.startsWith(basePath)
44
+ || url.pathname === searchUrl.pathname) return null;
45
+ return origin;
46
+ } catch { return null; }
47
+ };
48
+
49
+ document.addEventListener('click', (event) => {
50
+ const title = searchLink.dataset.searchSourceTitle;
51
+ const link = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null;
52
+ if (!title || !link || !isNormalClick(event, link)
53
+ || link.origin !== searchUrl.origin || link.pathname !== searchUrl.pathname) return;
54
+ const origin: SearchOrigin = {
55
+ id: crypto.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
56
+ href: localHref(), title, x: Math.max(0, scrollX), y: Math.max(0, scrollY),
57
+ };
58
+ if (!store(departureKey, origin)) return;
59
+ try { history.replaceState({ ...history.state, [historyKey]: origin }, ''); } catch {}
60
+ const destination = new URL(link.href);
61
+ destination.searchParams.set(returnParameter, origin.id);
62
+ event.preventDefault();
63
+ location.assign(destination.href);
64
+ });
65
+
66
+ const returnLink = document.querySelector<HTMLAnchorElement>('[data-search-return]');
67
+ if (returnLink) {
68
+ const url = new URL(location.href);
69
+ const token = url.searchParams.get(returnParameter);
70
+ let origin = validate(history.state?.[historyKey]);
71
+ if (url.searchParams.has(returnParameter)) {
72
+ const departure = validate(readStorage(departureKey));
73
+ origin = departure?.id === token ? departure : null;
74
+ url.searchParams.delete(returnParameter);
75
+ // Keep return context on this history entry, not on subsequent direct visits.
76
+ try {
77
+ history.replaceState({ ...history.state, [historyKey]: origin }, '', url);
78
+ } catch { origin = null; }
79
+ if (departure?.id === token) remove(departureKey);
80
+ }
81
+ if (origin) {
82
+ const destination = origin;
83
+ returnLink.href = destination.href;
84
+ returnLink.querySelector('span')!.textContent = returnLink.dataset.searchReturnLabel!.replace('{page}', () => destination.title);
85
+ returnLink.addEventListener('click', (event) => {
86
+ if (isNormalClick(event, returnLink)) store(restoreKey, destination);
87
+ });
88
+ }
89
+ return;
90
+ }
91
+
92
+ const restore = (event: PageTransitionEvent) => {
93
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
94
+ const fromHistory = event.persisted || navigation?.type === 'back_forward';
95
+ const origin = validate(readStorage(restoreKey))
96
+ ?? (fromHistory ? validate(history.state?.[historyKey]) : null);
97
+ remove(restoreKey);
98
+ if (!origin || origin.href !== localHref()) return;
99
+ try { history.replaceState({ ...history.state, [historyKey]: null }, ''); } catch {}
100
+ // Wait for initial anchor alignment and font layout, then restore the actual reading position.
101
+ void document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(() => {
102
+ searchLink.focus({ preventScroll: true });
103
+ window.scrollTo({ left: origin.x, top: origin.y, behavior: 'instant' });
104
+ })));
105
+ };
106
+ window.addEventListener('pageshow', restore);
107
+ };