@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
@@ -0,0 +1,331 @@
1
+ ---
2
+ import { Maximize2, Minimize2, X } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+
5
+ const labels = projectConfig.locale.labels;
6
+ ---
7
+
8
+ <dialog class="image-inspector" data-image-inspector aria-label={labels.imageInspection}>
9
+ <div class="image-inspector-layout">
10
+ <div class="image-inspector-controls">
11
+ <button
12
+ type="button"
13
+ data-image-inspector-size
14
+ hidden
15
+ aria-label={labels.imageInspectionActualSize}
16
+ title={labels.imageInspectionActualSize}
17
+ data-fit-label={labels.imageInspectionFit}
18
+ data-actual-label={labels.imageInspectionActualSize}
19
+ >
20
+ <Maximize2 aria-hidden="true" data-image-inspector-size-icon="actual" size={20} stroke-width={2} />
21
+ <Minimize2 aria-hidden="true" data-image-inspector-size-icon="fit" hidden size={20} stroke-width={2} />
22
+ </button>
23
+ <button
24
+ type="button"
25
+ data-image-inspector-close
26
+ aria-label={labels.imageInspectionClose}
27
+ title={labels.imageInspectionClose}
28
+ >
29
+ <X aria-hidden="true" size={20} stroke-width={2} />
30
+ </button>
31
+ </div>
32
+ <div class="image-inspector-media" data-image-inspector-media data-image-inspector-mode="fit">
33
+ <img data-image-inspector-image alt="" />
34
+ </div>
35
+ <p class="image-inspector-caption" id="image-inspector-caption" data-image-inspector-caption hidden></p>
36
+ </div>
37
+ </dialog>
38
+
39
+ <script>
40
+ const dialog = document.querySelector<HTMLDialogElement>('[data-image-inspector]');
41
+ const triggers = Array.from(document.querySelectorAll<HTMLAnchorElement>('[data-image-inspection-trigger]'));
42
+ const figures = Array.from(document.querySelectorAll<HTMLElement>('[data-image-stack-figure]'));
43
+ const enlargementThreshold = 1.2;
44
+ const persistentCaptionViewportWidth = 1101;
45
+ const persistentCaptionMinimumWidth = 160;
46
+ const persistentCaptionHeightRatio = 0.9;
47
+ let captionScheduledFrame: number | null = null;
48
+
49
+ const isVisible = (element: Element) => {
50
+ const style = getComputedStyle(element);
51
+ const bounds = element.getBoundingClientRect();
52
+ return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
53
+ };
54
+
55
+ const rectanglesOverlap = (first: DOMRect, second: DOMRect) => (
56
+ first.left < second.right
57
+ && first.right > second.left
58
+ && first.top < second.bottom
59
+ && first.bottom > second.top
60
+ );
61
+
62
+ const resolveCssLength = (value: string, fallback: number) => {
63
+ const probe = document.createElement('span');
64
+ probe.style.cssText = [
65
+ 'position:fixed',
66
+ 'visibility:hidden',
67
+ 'pointer-events:none',
68
+ 'box-sizing:content-box',
69
+ 'height:0',
70
+ 'padding:0',
71
+ 'border:0',
72
+ `width:${value.trim() || `${fallback}px`}`,
73
+ ].join(';');
74
+ document.body.append(probe);
75
+ const width = probe.getBoundingClientRect().width;
76
+ probe.remove();
77
+ return Number.isFinite(width) && width > 0 ? width : fallback;
78
+ };
79
+
80
+ const clearPersistentCaption = (figure: HTMLElement) => {
81
+ delete figure.dataset.imageCaptionPlacement;
82
+ figure.style.removeProperty('--image-persistent-caption-inline-start');
83
+ figure.style.removeProperty('--image-persistent-caption-width');
84
+ figure.style.removeProperty('--image-persistent-caption-reserve');
85
+ figure.style.removeProperty('--image-persistent-caption-block-start');
86
+ figure.style.removeProperty('--image-persistent-caption-block-end');
87
+ };
88
+
89
+ const updatePersistentCaption = (figure: HTMLElement) => {
90
+ clearPersistentCaption(figure);
91
+ if (window.innerWidth < persistentCaptionViewportWidth) return;
92
+
93
+ const frame = figure.querySelector<HTMLElement>('.managed-image-frame');
94
+ const caption = figure.querySelector<HTMLElement>('.image-meta');
95
+ const image = frame?.querySelector<HTMLImageElement>('img');
96
+ const layout = figure.closest<HTMLElement>('.site-page-layout-tree')
97
+ ?? figure.closest<HTMLElement>('.site-content');
98
+ if (!frame || !caption || !image || !layout || !isVisible(frame) || !isVisible(caption)) return;
99
+
100
+ const headerOffset = Number.parseFloat(
101
+ getComputedStyle(document.documentElement).getPropertyValue('--site-top-anchor-offset'),
102
+ ) || 0;
103
+ const usableViewportHeight = Math.max(0, window.innerHeight - headerOffset);
104
+ const imageBounds = image.getBoundingClientRect();
105
+ if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
106
+
107
+ const visibleContentsRail = Array.from(
108
+ layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
109
+ ).some(isVisible);
110
+ const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
111
+ && document.documentElement.dataset.focusReading !== 'on';
112
+ if (visibleContentsRail || reservesContentsLane) return;
113
+
114
+ const frameBounds = frame.getBoundingClientRect();
115
+ const figureBounds = figure.getBoundingClientRect();
116
+ const captionBounds = caption.getBoundingClientRect();
117
+ const layoutBounds = layout.getBoundingClientRect();
118
+ const rootStyle = getComputedStyle(document.documentElement);
119
+ const figureStyle = getComputedStyle(figure);
120
+ const gap = resolveCssLength(figureStyle.getPropertyValue('--image-caption-inline-gap'), 12);
121
+ const preferredWidth = resolveCssLength(rootStyle.getPropertyValue('--section-note-width'), 192);
122
+ const isRightToLeft = getComputedStyle(figure).direction === 'rtl';
123
+ const availableWidth = isRightToLeft
124
+ ? imageBounds.left - layoutBounds.left - gap
125
+ : layoutBounds.right - imageBounds.right - gap;
126
+ const captionWidth = Math.min(preferredWidth, availableWidth);
127
+ if (captionWidth < persistentCaptionMinimumWidth || captionBounds.height > imageBounds.height) return;
128
+
129
+ const proposedBounds = new DOMRect(
130
+ isRightToLeft ? imageBounds.left - gap - captionWidth : imageBounds.right + gap,
131
+ imageBounds.top,
132
+ captionWidth,
133
+ imageBounds.height,
134
+ );
135
+ const laneClaims = Array.from(figure.closest('.site-section')?.querySelectorAll<HTMLElement>([
136
+ '.section-note',
137
+ '[data-table-layout="end"]',
138
+ '[data-table-layout="canvas"]',
139
+ ].join(', ')) ?? []).filter((element) => element !== figure && isVisible(element));
140
+ if (laneClaims.some((element) => rectanglesOverlap(proposedBounds, element.getBoundingClientRect()))) return;
141
+
142
+ const reserve = Math.max(0, captionBounds.bottom - frameBounds.bottom);
143
+ const inlineStart = isRightToLeft
144
+ ? figureBounds.right - imageBounds.left + gap
145
+ : imageBounds.right - figureBounds.left + gap;
146
+ const blockStart = imageBounds.top - figureBounds.top;
147
+ const blockEnd = figureBounds.bottom - imageBounds.bottom;
148
+ figure.style.setProperty('--image-persistent-caption-inline-start', `${inlineStart}px`);
149
+ figure.style.setProperty('--image-persistent-caption-width', `${captionWidth}px`);
150
+ figure.style.setProperty('--image-persistent-caption-reserve', `${reserve}px`);
151
+ figure.style.setProperty('--image-persistent-caption-block-start', `${blockStart}px`);
152
+ figure.style.setProperty('--image-persistent-caption-block-end', `${blockEnd}px`);
153
+ figure.dataset.imageCaptionPlacement = 'persistent';
154
+ };
155
+
156
+ const updatePersistentCaptions = () => {
157
+ captionScheduledFrame = null;
158
+ figures.forEach(updatePersistentCaption);
159
+ };
160
+
161
+ const schedulePersistentCaptionUpdate = () => {
162
+ if (captionScheduledFrame !== null) return;
163
+ captionScheduledFrame = requestAnimationFrame(updatePersistentCaptions);
164
+ };
165
+
166
+ if (figures.length > 0) {
167
+ figures.forEach((figure) => {
168
+ const image = figure.querySelector('img');
169
+ if (image && !image.complete) image.addEventListener('load', schedulePersistentCaptionUpdate, { once: true });
170
+ });
171
+ if ('ResizeObserver' in window) {
172
+ const observer = new ResizeObserver(schedulePersistentCaptionUpdate);
173
+ figures.forEach((figure) => {
174
+ const frame = figure.querySelector('.managed-image-frame');
175
+ if (frame) observer.observe(frame);
176
+ });
177
+ }
178
+ const preferenceObserver = new MutationObserver(schedulePersistentCaptionUpdate);
179
+ preferenceObserver.observe(document.documentElement, {
180
+ attributes: true,
181
+ attributeFilter: ['data-focus-reading', 'data-reading-width'],
182
+ });
183
+ window.addEventListener('resize', schedulePersistentCaptionUpdate, { passive: true });
184
+ document.fonts?.ready.then(schedulePersistentCaptionUpdate).catch(() => {});
185
+ updatePersistentCaptions();
186
+ }
187
+
188
+ if (dialog && typeof dialog.showModal === 'function' && triggers.length > 0) {
189
+ const inspectedImage = dialog.querySelector<HTMLImageElement>('[data-image-inspector-image]');
190
+ const media = dialog.querySelector<HTMLElement>('[data-image-inspector-media]');
191
+ const caption = dialog.querySelector<HTMLElement>('[data-image-inspector-caption]');
192
+ const sizeButton = dialog.querySelector<HTMLButtonElement>('[data-image-inspector-size]');
193
+ const closeButton = dialog.querySelector<HTMLButtonElement>('[data-image-inspector-close]');
194
+ let activeTrigger: HTMLAnchorElement | null = null;
195
+ let scheduledFrame: number | null = null;
196
+ let sizeControlScheduledFrame: number | null = null;
197
+
198
+ const setInspectorMode = (mode: 'fit' | 'actual') => {
199
+ if (!media || !sizeButton) return;
200
+ media.dataset.imageInspectorMode = mode;
201
+ const nextLabel = mode === 'fit'
202
+ ? sizeButton.dataset.actualLabel
203
+ : sizeButton.dataset.fitLabel;
204
+ if (nextLabel) {
205
+ sizeButton.setAttribute('aria-label', nextLabel);
206
+ sizeButton.title = nextLabel;
207
+ }
208
+ sizeButton.querySelectorAll<SVGElement>('[data-image-inspector-size-icon]').forEach((icon) => {
209
+ icon.hidden = icon.dataset.imageInspectorSizeIcon !== (mode === 'fit' ? 'actual' : 'fit');
210
+ });
211
+ };
212
+
213
+ const canInspect = (trigger: HTMLAnchorElement) => {
214
+ const image = trigger.querySelector('img');
215
+ const width = Number(trigger.dataset.imageIntrinsicWidth);
216
+ const height = Number(trigger.dataset.imageIntrinsicHeight);
217
+ if (!image || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
218
+ return false;
219
+ }
220
+
221
+ const bounds = image.getBoundingClientRect();
222
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
223
+ const captionReserve = trigger.getAttribute('aria-describedby') ? 176 : 112;
224
+ const availableWidth = Math.max(0, window.innerWidth - 48);
225
+ const availableHeight = Math.max(0, window.innerHeight - captionReserve);
226
+ const fitScale = Math.min(1, availableWidth / width, availableHeight / height);
227
+ const renderedScale = Math.min(bounds.width / width, bounds.height / height);
228
+ const fitGain = fitScale / renderedScale;
229
+ const actualSizeGain = 1 / renderedScale;
230
+
231
+ return Math.max(fitGain, actualSizeGain) >= enlargementThreshold;
232
+ };
233
+
234
+ const updateSizeControlAvailability = () => {
235
+ sizeControlScheduledFrame = null;
236
+ if (!activeTrigger || !inspectedImage || !media || !sizeButton) return;
237
+
238
+ const isScalable = activeTrigger.dataset.imageInspectionScalable === 'true';
239
+ const intrinsicWidth = inspectedImage.naturalWidth;
240
+ const intrinsicHeight = inspectedImage.naturalHeight;
241
+ const fitScale = intrinsicWidth > 0 && intrinsicHeight > 0
242
+ ? Math.min(1, media.clientWidth / intrinsicWidth, media.clientHeight / intrinsicHeight)
243
+ : 1;
244
+ const actualSizeGain = fitScale > 0 ? 1 / fitScale : 1;
245
+ const isUseful = !isScalable && actualSizeGain >= enlargementThreshold;
246
+
247
+ if (!isUseful && media.dataset.imageInspectorMode === 'actual') setInspectorMode('fit');
248
+ if (!isUseful && document.activeElement === sizeButton) closeButton?.focus();
249
+ sizeButton.hidden = !isUseful;
250
+ sizeButton.disabled = !isUseful;
251
+ };
252
+
253
+ const scheduleSizeControlUpdate = () => {
254
+ if (sizeControlScheduledFrame !== null) return;
255
+ sizeControlScheduledFrame = requestAnimationFrame(updateSizeControlAvailability);
256
+ };
257
+
258
+ const updateAvailability = () => {
259
+ scheduledFrame = null;
260
+ triggers.forEach((trigger) => {
261
+ trigger.dataset.imageInspectionAvailable = canInspect(trigger) ? 'true' : 'false';
262
+ });
263
+ };
264
+
265
+ const scheduleAvailabilityUpdate = () => {
266
+ if (scheduledFrame !== null) return;
267
+ scheduledFrame = requestAnimationFrame(updateAvailability);
268
+ };
269
+
270
+ const openInspector = (trigger: HTMLAnchorElement) => {
271
+ if (!inspectedImage || !caption || !closeButton) return;
272
+ const sourceImage = trigger.querySelector('img');
273
+ const captionId = trigger.getAttribute('aria-describedby');
274
+ const sourceCaption = captionId ? document.getElementById(captionId) : null;
275
+ activeTrigger = trigger;
276
+ sizeButton?.setAttribute('disabled', '');
277
+ if (sizeButton) sizeButton.hidden = true;
278
+ inspectedImage.src = trigger.href;
279
+ inspectedImage.alt = sourceImage?.alt ?? '';
280
+ if (sourceCaption?.textContent?.trim()) {
281
+ caption.textContent = sourceCaption.textContent.trim();
282
+ caption.hidden = false;
283
+ inspectedImage.setAttribute('aria-describedby', caption.id);
284
+ } else {
285
+ caption.textContent = '';
286
+ caption.hidden = true;
287
+ inspectedImage.removeAttribute('aria-describedby');
288
+ }
289
+ setInspectorMode('fit');
290
+ document.documentElement.dataset.imageInspectorOpen = 'true';
291
+ dialog.showModal();
292
+ closeButton.focus();
293
+ if (inspectedImage.complete) scheduleSizeControlUpdate();
294
+ };
295
+
296
+ triggers.forEach((trigger) => {
297
+ const image = trigger.querySelector('img');
298
+ if (image && !image.complete) image.addEventListener('load', scheduleAvailabilityUpdate, { once: true });
299
+ trigger.addEventListener('click', (event) => {
300
+ if (trigger.dataset.imageInspectionAvailable !== 'true') return;
301
+ event.preventDefault();
302
+ openInspector(trigger);
303
+ });
304
+ });
305
+
306
+ sizeButton?.addEventListener('click', () => {
307
+ setInspectorMode(media?.dataset.imageInspectorMode === 'actual' ? 'fit' : 'actual');
308
+ });
309
+ closeButton?.addEventListener('click', () => dialog.close());
310
+ dialog.addEventListener('close', () => {
311
+ delete document.documentElement.dataset.imageInspectorOpen;
312
+ activeTrigger?.focus();
313
+ activeTrigger = null;
314
+ if (inspectedImage) inspectedImage.removeAttribute('src');
315
+ });
316
+
317
+ if ('ResizeObserver' in window) {
318
+ const observer = new ResizeObserver(scheduleAvailabilityUpdate);
319
+ triggers.forEach((trigger) => observer.observe(trigger));
320
+ if (media) new ResizeObserver(scheduleSizeControlUpdate).observe(media);
321
+ } else {
322
+ window.addEventListener('resize', () => {
323
+ scheduleAvailabilityUpdate();
324
+ scheduleSizeControlUpdate();
325
+ }, { passive: true });
326
+ }
327
+ inspectedImage?.addEventListener('load', scheduleSizeControlUpdate);
328
+ document.fonts?.ready.then(scheduleAvailabilityUpdate).catch(() => {});
329
+ updateAvailability();
330
+ }
331
+ </script>
@@ -7,7 +7,7 @@ import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
- currentPage?: SitePage;
10
+ currentPage?: SiteNode;
11
11
  variant: 'mobile' | 'sidebar' | 'submenu';
12
12
  integratePageSections?: boolean;
13
13
  }
@@ -0,0 +1,4 @@
1
+ <script>
2
+ import { installNoteNavigation } from '../lib/noteNavigation';
3
+ installNoteNavigation();
4
+ </script>
@@ -6,9 +6,10 @@ import { withBasePath } from '../lib/basePath';
6
6
  interface Props {
7
7
  targetPathname: string;
8
8
  targetTitle: string;
9
+ categoryTitle?: string;
9
10
  }
10
11
 
11
- const { targetPathname, targetTitle } = Astro.props;
12
+ const { targetPathname, targetTitle, categoryTitle } = Astro.props;
12
13
  const { labels, lang } = projectConfig.locale;
13
14
  const targetHref = withBasePath(projectConfig.site.basePath, targetPathname);
14
15
  const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
@@ -22,7 +23,7 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
22
23
  <meta name="robots" content="noindex, follow" />
23
24
  <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
24
25
  <link rel="canonical" href={canonicalUrl} />
25
- <title>{labels.pageMoved}: {targetTitle}</title>
26
+ <title>{categoryTitle ?? labels.pageMoved}: {targetTitle}</title>
26
27
  <style is:inline>
27
28
  :root {
28
29
  color-scheme: light dark;
@@ -42,8 +43,8 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
42
43
  </head>
43
44
  <body>
44
45
  <main>
45
- <h1>{labels.pageMoved}</h1>
46
- <p>{labels.pageMovedText} <a href={targetHref}>{targetTitle}</a>.</p>
46
+ <h1>{categoryTitle ?? labels.pageMoved}</h1>
47
+ <p>{!categoryTitle && labels.pageMovedText} <a href={targetHref}>{targetTitle}</a>.</p>
47
48
  </main>
48
49
  </body>
49
50
  </html>
@@ -6,14 +6,13 @@ import PageContentsLinks from './PageContentsLinks.astro';
6
6
  interface Props {
7
7
  headings: HeadingNavigation[];
8
8
  pageTitle: string;
9
- variant: 'inline' | 'rail';
10
9
  }
11
10
 
12
- const { headings, pageTitle, variant } = Astro.props;
11
+ const { headings, pageTitle } = Astro.props;
13
12
  const label = `${projectConfig.locale.labels.pageNavigation}: ${pageTitle}`;
14
13
  ---
15
14
 
16
- <aside class={`page-contents-navigation page-contents-navigation-${variant}`}>
15
+ <aside class="page-contents-navigation page-contents-navigation-rail">
17
16
  <nav aria-label={label}>
18
17
  <p class="page-contents-navigation-label">{projectConfig.locale.labels.pageNavigation}</p>
19
18
  <PageContentsLinks headings={headings} variant="desktop" />
@@ -0,0 +1,4 @@
1
+ <script>
2
+ import { setupSearchNavigation } from '../lib/searchNavigation';
3
+ setupSearchNavigation();
4
+ </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
+ import { ArrowLeft } from '@lucide/astro';
3
4
  import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
4
5
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
5
6
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
@@ -82,6 +83,9 @@ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
82
83
  && (navigationModel.hasNestedPages || navigationModel.hasCategories)
83
84
  );
84
85
  const bundlePath = withBasePath(projectConfig.site.basePath, '/pagefind/');
86
+ const pagefindTranslationOption = projectConfig.locale.pagefindTranslations
87
+ ? `,\n\t\t\ttranslations: ${JSON.stringify(projectConfig.locale.pagefindTranslations)}`
88
+ : '';
85
89
  const searchScript = `(() => {
86
90
  const root = document.querySelector('#norna-search');
87
91
  const status = root?.querySelector('[data-search-status]');
@@ -95,7 +99,7 @@ const searchScript = `(() => {
95
99
  bundlePath: ${JSON.stringify(bundlePath)},
96
100
  baseUrl: ${JSON.stringify(projectConfig.site.basePath)},
97
101
  showImages: false,
98
- showSubResults: true
102
+ showSubResults: true${pagefindTranslationOption}
99
103
  });
100
104
  const labelInput = () => {
101
105
  const input = root.querySelector('.pagefind-ui__search-input');
@@ -145,7 +149,44 @@ const searchScript = `(() => {
145
149
  pagePresentation={pagePresentation}
146
150
  visualTheme={visualTheme}
147
151
  sectionIndex={0}
148
- />
152
+ >
153
+ <div slot="before-heading" class="search-return">
154
+ <a href={projectConfig.site.basePath} data-search-return data-search-return-label={labels.searchReturn}>
155
+ <ArrowLeft aria-hidden="true" />
156
+ <span>{labels.returnHome}</span>
157
+ </a>
158
+ </div>
159
+ </SiteSection>
149
160
  </main>
150
161
  <script is:inline set:html={searchScript} />
151
162
  </BaseLayout>
163
+
164
+ <style>
165
+ .search-return {
166
+ width: var(--layout-heading-width);
167
+ margin-inline: var(--layout-inline-start-margin) var(--layout-inline-end-margin);
168
+ margin-bottom: 1rem;
169
+ }
170
+
171
+ .search-return a {
172
+ display: inline-flex;
173
+ align-items: center;
174
+ gap: 0.5rem;
175
+ min-height: 2.75rem;
176
+ max-width: 100%;
177
+ color: var(--color-link-text);
178
+ font-size: 0.9375rem;
179
+ line-height: 1.5;
180
+ text-underline-offset: 0.2em;
181
+ }
182
+
183
+ .search-return svg {
184
+ width: 1.125rem;
185
+ height: 1.125rem;
186
+ flex-shrink: 0;
187
+ }
188
+
189
+ .search-return span {
190
+ overflow-wrap: anywhere;
191
+ }
192
+ </style>
@@ -1,13 +1,18 @@
1
1
  <script>
2
+ import { setupNavigationFollowing } from '../lib/navigationFollowing';
3
+
2
4
  const siteTop = document.querySelector<HTMLElement>('.site-top');
3
5
  const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-nav-menu');
4
6
  const sectionLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>(
5
- '.mobile-nav-sections a, .page-contents-links a, .page-nav a',
7
+ '.mobile-nav-sections a, .page-contents-links a, .page-nav a, .top-page-sections a',
6
8
  ));
7
- const submenuItems = Array.from(document.querySelectorAll<HTMLElement>('.site-nav-item-has-submenu'));
9
+ const topPageMenus = Array.from(document.querySelectorAll<HTMLDetailsElement>('.top-page-menu'));
8
10
  const mobileDestinationLinks = Array.from(mobileMenu?.querySelectorAll<HTMLAnchorElement>('.mobile-nav-destination') ?? []);
9
11
  const mobileMenuSummary = mobileMenu?.querySelector<HTMLElement>(':scope > summary');
12
+ const mobileMenuPanel = mobileMenu?.querySelector<HTMLElement>('[data-compact-navigation-panel]');
13
+ const mobileMenuClose = mobileMenu?.querySelector<HTMLButtonElement>('[data-compact-navigation-close]');
10
14
  const sectionTrackingEnabled = document.documentElement.dataset.sectionTracking === 'enabled';
15
+ const navigationFollowing = sectionTrackingEnabled ? setupNavigationFollowing() : undefined;
11
16
  const trackedHeadingIds = new Set(sectionLinks.flatMap((link) => {
12
17
  const linkUrl = new URL(link.href, window.location.href);
13
18
  if (linkUrl.pathname !== window.location.pathname || !linkUrl.hash) return [];
@@ -99,6 +104,7 @@
99
104
  link.removeAttribute('aria-current');
100
105
  }
101
106
  });
107
+ navigationFollowing?.update();
102
108
  };
103
109
 
104
110
  const updateReadingPosition = () => {
@@ -144,6 +150,7 @@
144
150
  link.removeAttribute('aria-current');
145
151
  }
146
152
  });
153
+ navigationFollowing?.update();
147
154
  };
148
155
 
149
156
  const scheduleReadingPositionUpdate = () => {
@@ -151,25 +158,90 @@
151
158
  sectionTrackingFrame = window.requestAnimationFrame(updateReadingPosition);
152
159
  };
153
160
 
154
- submenuItems.forEach((item) => {
155
- const resetDismissal = () => delete item.dataset.submenuDismissed;
156
- item.addEventListener('pointerleave', resetDismissal);
157
- item.querySelector<HTMLAnchorElement>(':scope > a')?.addEventListener('focus', resetDismissal);
161
+ topPageMenus.forEach((menu) => {
162
+ menu.addEventListener('toggle', () => {
163
+ if (!menu.open) return;
164
+ topPageMenus.forEach((other) => { if (other !== menu) other.open = false; });
165
+ const panel = menu.querySelector<HTMLElement>('.top-page-panel');
166
+ if (!panel) return;
167
+ panel.style.removeProperty('--top-page-panel-shift');
168
+ const rectangle = panel.getBoundingClientRect();
169
+ const shift = Math.max(0, 16 - rectangle.left)
170
+ - Math.max(0, rectangle.right - document.documentElement.clientWidth + 16);
171
+ panel.style.setProperty('--top-page-panel-shift', `${shift}px`);
172
+ });
173
+ menu.addEventListener('keydown', (event) => {
174
+ if (event.key !== 'Escape' || !menu.open) return;
175
+ event.preventDefault();
176
+ menu.open = false;
177
+ menu.querySelector('summary')?.focus();
178
+ });
179
+ menu.addEventListener('focusout', (event) => {
180
+ if (event.relatedTarget instanceof Node && !menu.contains(event.relatedTarget)) menu.open = false;
181
+ });
182
+ menu.querySelectorAll('a').forEach((link) => {
183
+ link.addEventListener('click', () => { menu.open = false; });
184
+ });
158
185
  });
186
+ if (topPageMenus.length > 0) {
187
+ document.addEventListener('pointerdown', (event) => {
188
+ topPageMenus.forEach((menu) => {
189
+ if (event.target instanceof Node && !menu.contains(event.target)) menu.open = false;
190
+ });
191
+ });
192
+ }
159
193
 
160
194
  if (mobileMenu) {
195
+ const siteHeader = mobileMenu.closest<HTMLElement>('.site-top');
196
+ const navigationRow = mobileMenu.closest<HTMLElement>('.site-nav-row');
197
+ const navigationActions = mobileMenu.closest<HTMLElement>('.site-nav-actions');
198
+ const backgroundTargets = new Set<HTMLElement>([
199
+ ...Array.from(document.body.children)
200
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== siteHeader),
201
+ ...Array.from(siteHeader?.children ?? [])
202
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== navigationRow),
203
+ ...Array.from(navigationRow?.children ?? [])
204
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== navigationActions),
205
+ ...Array.from(navigationActions?.children ?? [])
206
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== mobileMenu),
207
+ ]);
208
+ const previousInertState = new Map<HTMLElement, boolean>();
209
+ const setBackgroundInert = (inert: boolean) => {
210
+ if (inert) {
211
+ backgroundTargets.forEach((element) => {
212
+ if (!previousInertState.has(element)) {
213
+ previousInertState.set(element, element.hasAttribute('inert'));
214
+ }
215
+ element.setAttribute('inert', '');
216
+ });
217
+ return;
218
+ }
219
+
220
+ previousInertState.forEach((wasInert, element) => {
221
+ if (!wasInert) element.removeAttribute('inert');
222
+ });
223
+ previousInertState.clear();
224
+ };
225
+ const closeMenu = (restoreFocus: boolean) => {
226
+ mobileMenu.open = false;
227
+ if (restoreFocus) mobileMenuSummary?.focus({ preventScroll: true });
228
+ };
229
+
161
230
  mobileDestinationLinks.forEach((link) => {
162
231
  link.addEventListener('click', () => {
163
- mobileMenu.open = false;
232
+ closeMenu(false);
164
233
  });
165
234
  });
235
+ mobileMenuClose?.addEventListener('click', () => closeMenu(true));
166
236
 
167
237
  mobileMenu.addEventListener('toggle', () => {
168
238
  document.documentElement.classList.toggle('mobile-navigation-open', mobileMenu.open);
239
+ setBackgroundInert(mobileMenu.open);
169
240
  if (mobileMenu.open) {
170
- window.requestAnimationFrame(() => (
171
- mobileMenu.querySelector<HTMLElement>('.mobile-nav-view h2, .mobile-nav-view a')?.focus()
172
- ));
241
+ window.requestAnimationFrame(() => {
242
+ mobileMenuClose?.focus({ preventScroll: true });
243
+ navigationFollowing?.revealCompact();
244
+ });
173
245
  }
174
246
  });
175
247
 
@@ -178,16 +250,15 @@
178
250
 
179
251
  if (event.key === 'Escape') {
180
252
  event.preventDefault();
181
- mobileMenu.open = false;
182
- mobileMenuSummary?.focus();
253
+ closeMenu(true);
183
254
  return;
184
255
  }
185
256
 
186
257
  if (event.key !== 'Tab') return;
187
258
 
188
- const focusableElements = Array.from(mobileMenu.querySelectorAll<HTMLElement>(
189
- 'summary, a[href]:not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])',
190
- )).filter((element) => element.getClientRects().length > 0);
259
+ const focusableElements = Array.from(mobileMenuPanel?.querySelectorAll<HTMLElement>(
260
+ 'a[href]:not([hidden]), button:not([disabled]):not([hidden]), input:not([disabled]):not([hidden]), summary, [tabindex]:not([tabindex="-1"]):not([hidden])',
261
+ ) ?? []).filter((element) => element.getClientRects().length > 0);
191
262
  const firstFocusable = focusableElements[0];
192
263
  const lastFocusable = focusableElements.at(-1);
193
264
  if (!firstFocusable || !lastFocusable) return;
@@ -209,7 +280,6 @@
209
280
 
210
281
  pauseSectionTrackingForAnchorNavigation();
211
282
  if (mobileMenu) mobileMenu.open = false;
212
- link.closest<HTMLElement>('.site-nav-item')?.setAttribute('data-submenu-dismissed', 'true');
213
283
 
214
284
  window.requestAnimationFrame(() => {
215
285
  updateCurrentSection();
@@ -4,7 +4,7 @@ import { withBasePath } from '../lib/basePath';
4
4
  import type { SiteNode, SitePage } from '../lib/sitePages';
5
5
 
6
6
  interface Props {
7
- currentPage: SitePage;
7
+ currentPage: SiteNode;
8
8
  nodes: SiteNode[];
9
9
  }
10
10