@janga/norna 0.7.23 → 0.7.25

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 (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -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?: SitePage;
11
11
  variant: 'mobile' | 'sidebar' | 'submenu';
12
12
  integratePageSections?: boolean;
13
13
  }
@@ -15,26 +15,31 @@ interface Props {
15
15
  const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
16
16
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
17
17
  const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
18
- const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
19
- const isCurrentBranch = (node: SiteNode) => (
18
+ const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
19
+ const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
20
20
  currentPage.pageDirectory === node.pageDirectory
21
21
  || currentPage.pageDirectory.startsWith(`${node.pageDirectory}/pages/`)
22
- );
22
+ ));
23
23
  const getBranchId = (node: SiteNode) => [
24
24
  'navigation',
25
25
  variant,
26
26
  node.pagePath || 'home',
27
27
  'children',
28
28
  ].join('-').replace(/[^a-z0-9-]+/gi, '-');
29
+ const getSectionsId = (node: SiteNode) => [
30
+ 'navigation',
31
+ variant,
32
+ node.pagePath || 'home',
33
+ 'sections',
34
+ ].join('-').replace(/[^a-z0-9-]+/gi, '-');
29
35
  const hasPageSections = (node: SiteNavigationNode) => (
30
36
  node.node.kind === 'page' && node.headings.some((heading) => heading.depth === 2)
31
37
  );
32
- const hasPageDisclosure = (node: SiteNavigationNode) => hasBranch(node) || hasPageSections(node);
33
- const usesPageDisclosure = (node: SiteNavigationNode) => (
34
- variant === 'sidebar'
35
- ? hasBranch(node)
36
- : integratePageSections && hasPageDisclosure(node)
38
+ const showsPageSections = (node: SiteNavigationNode) => (
39
+ integratePageSections
40
+ && hasPageSections(node)
37
41
  );
42
+ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' && hasBranch(node);
38
43
  ---
39
44
 
40
45
  <ul class:list={['navigation-page-tree', `navigation-page-tree-${variant}`]}>
@@ -42,9 +47,13 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
42
47
  <li
43
48
  class:list={['navigation-page-node', {
44
49
  'navigation-page-node-category': node.node.kind === 'category',
50
+ 'navigation-page-node-page-disclosure': node.node.kind === 'page' && usesPageDisclosure(node),
45
51
  'navigation-page-node-current': node.node.kind === 'page' && isCurrentPage(node.node),
46
52
  'navigation-page-node-current-branch': isCurrentBranch(node.node),
47
53
  }]}
54
+ data-navigation-title={node.node.title}
55
+ data-navigation-kind={node.node.kind}
56
+ data-navigation-current-page={node.node.kind === 'page' && isCurrentPage(node.node) ? 'true' : undefined}
48
57
  >
49
58
  {node.node.kind === 'category' ? (
50
59
  <details
@@ -80,26 +89,69 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
80
89
  data-page-path={node.node.pagePath || '/'}
81
90
  data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
82
91
  data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
83
- data-has-sections={hasPageSections(node) ? 'true' : undefined}
84
92
  open={isCurrentBranch(node.node)}
85
93
  >
86
94
  <summary
87
95
  class="navigation-page-summary"
88
96
  aria-controls={getBranchId(node.node)}
97
+ aria-label={`${projectConfig.locale.labels.navigationChildren}: ${node.node.title}`}
89
98
  >
90
- <span class="navigation-page-summary-title">{node.node.title}</span>
91
99
  <span class="navigation-page-chevron" aria-hidden="true"></span>
92
100
  </summary>
101
+ </details>
102
+ <a
103
+ class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
104
+ href={pageHref(node.node)}
105
+ aria-current={isCurrentPage(node.node) ? 'page' : undefined}
106
+ >
107
+ {node.node.title}
108
+ </a>
109
+ <div id={getBranchId(node.node)} class="navigation-page-branch-content">
110
+ {showsPageSections(node) && (
111
+ <nav
112
+ class="navigation-page-sections"
113
+ aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
114
+ >
115
+ <PageContentsLinks
116
+ headings={node.headings}
117
+ variant={variant === 'mobile' ? 'mobile' : 'desktop'}
118
+ pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
119
+ />
120
+ </nav>
121
+ )}
122
+ {hasBranch(node) && (
123
+ <Astro.self
124
+ nodes={node.children}
125
+ currentPage={currentPage}
126
+ variant={variant}
127
+ integratePageSections={integratePageSections}
128
+ />
129
+ )}
130
+ </div>
131
+ ) : variant !== 'submenu' ? (
132
+ <>
93
133
  <a
94
- class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
134
+ class:list={['navigation-page-link', { 'mobile-nav-destination': variant === 'mobile' }]}
95
135
  href={pageHref(node.node)}
96
136
  aria-current={isCurrentPage(node.node) ? 'page' : undefined}
97
137
  >
98
138
  {node.node.title}
99
139
  </a>
100
- <div id={getBranchId(node.node)} class="navigation-page-branch-content">
101
- {integratePageSections && hasPageSections(node) && (
140
+ {showsPageSections(node) && (
141
+ <details
142
+ class="navigation-page-sections-disclosure"
143
+ data-page-path={node.node.pagePath || '/'}
144
+ data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
145
+ open={isCurrentPage(node.node)}
146
+ >
147
+ <summary
148
+ aria-controls={getSectionsId(node.node)}
149
+ aria-label={`${projectConfig.locale.labels.pageSections}: ${node.node.title}`}
150
+ >
151
+ <span class="navigation-page-chevron" aria-hidden="true"></span>
152
+ </summary>
102
153
  <nav
154
+ id={getSectionsId(node.node)}
103
155
  class="navigation-page-sections"
104
156
  aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
105
157
  >
@@ -109,47 +161,6 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
109
161
  pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
110
162
  />
111
163
  </nav>
112
- )}
113
- {hasBranch(node) && (
114
- <Astro.self
115
- nodes={node.children}
116
- currentPage={currentPage}
117
- variant={variant}
118
- integratePageSections={integratePageSections}
119
- />
120
- )}
121
- </div>
122
- </details>
123
- ) : variant !== 'submenu' ? (
124
- <>
125
- <a
126
- class:list={['navigation-page-link', { 'mobile-nav-destination': variant === 'mobile' }]}
127
- href={pageHref(node.node)}
128
- aria-current={isCurrentPage(node.node) ? 'page' : undefined}
129
- >
130
- {node.node.title}
131
- </a>
132
- {hasBranch(node) && (
133
- <details
134
- class="navigation-page-branch"
135
- data-page-path={node.node.pagePath || '/'}
136
- data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
137
- open={isCurrentBranch(node.node)}
138
- >
139
- <summary aria-controls={getBranchId(node.node)}>
140
- <span class="visually-hidden">
141
- {projectConfig.locale.labels.navigationChildren}: {node.node.title}
142
- </span>
143
- <span aria-hidden="true"></span>
144
- </summary>
145
- <div id={getBranchId(node.node)} class="navigation-page-branch-content">
146
- <Astro.self
147
- nodes={node.children}
148
- currentPage={currentPage}
149
- variant={variant}
150
- integratePageSections={integratePageSections}
151
- />
152
- </div>
153
164
  </details>
154
165
  )}
155
166
  </>
@@ -0,0 +1,77 @@
1
+ ---
2
+ import { ChevronsDownUp, ChevronsUpDown, ListFilter, LocateFixed } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import type { SiteNavigationNode } from '../lib/siteNavigation';
5
+
6
+ interface Props {
7
+ idPrefix: string;
8
+ nodes: SiteNavigationNode[];
9
+ }
10
+
11
+ const { idPrefix, nodes } = Astro.props;
12
+ const countNodes = (tree: SiteNavigationNode[]): number => tree.reduce(
13
+ (total, node) => total + 1 + countNodes(node.children),
14
+ 0,
15
+ );
16
+ const minimumUsefulNodeCount = 12;
17
+ const showControls = countNodes(nodes) >= minimumUsefulNodeCount;
18
+ const labels = projectConfig.locale.labels;
19
+ const filterId = `${idPrefix}-filter`;
20
+ const statusId = `${idPrefix}-status`;
21
+ ---
22
+
23
+ {showControls && (
24
+ <div
25
+ class="navigation-tree-controls"
26
+ data-tree-controls
27
+ data-tree-label-collapsed={labels.navigationCollapsedAll}
28
+ data-tree-label-expanded={labels.navigationExpandedAll}
29
+ data-tree-label-filter-matches={labels.navigationFilterMatches}
30
+ data-tree-label-located={labels.navigationLocatedCurrent}
31
+ hidden
32
+ >
33
+ <label class="navigation-tree-filter" for={filterId}>
34
+ <ListFilter aria-hidden="true" />
35
+ <span class="visually-hidden">{labels.navigationFilter}</span>
36
+ <input
37
+ id={filterId}
38
+ type="search"
39
+ autocomplete="off"
40
+ spellcheck="false"
41
+ placeholder={labels.navigationFilter}
42
+ aria-describedby={statusId}
43
+ data-tree-filter
44
+ />
45
+ </label>
46
+ <div class="navigation-tree-commands" role="group" aria-label={labels.navigationControls}>
47
+ <button
48
+ type="button"
49
+ aria-label={labels.navigationExpandAll}
50
+ title={labels.navigationExpandAll}
51
+ data-tree-expand-all
52
+ >
53
+ <ChevronsUpDown aria-hidden="true" />
54
+ </button>
55
+ <button
56
+ type="button"
57
+ aria-label={labels.navigationCollapseAll}
58
+ title={labels.navigationCollapseAll}
59
+ data-tree-collapse-all
60
+ >
61
+ <ChevronsDownUp aria-hidden="true" />
62
+ </button>
63
+ <button
64
+ type="button"
65
+ aria-label={labels.navigationLocateCurrent}
66
+ title={labels.navigationLocateCurrent}
67
+ data-tree-locate-current
68
+ >
69
+ <LocateFixed aria-hidden="true" />
70
+ </button>
71
+ </div>
72
+ <p class="navigation-tree-filter-empty" data-tree-filter-empty hidden>
73
+ {labels.navigationFilterEmpty}
74
+ </p>
75
+ <p id={statusId} class="visually-hidden" role="status" aria-live="polite" data-tree-status></p>
76
+ </div>
77
+ )}
@@ -15,7 +15,7 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
15
15
  ---
16
16
 
17
17
  <!doctype html>
18
- <html lang={lang}>
18
+ <html lang={lang} data-pagefind-ignore="all">
19
19
  <head>
20
20
  <meta charset="utf-8" />
21
21
  <meta name="viewport" content="width=device-width" />
@@ -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,33 @@
1
+ ---
2
+ import { ArrowRight } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+
6
+ type PageListItem = {
7
+ title: string;
8
+ pathname: string;
9
+ description?: string;
10
+ };
11
+
12
+ interface Props {
13
+ pages: PageListItem[];
14
+ }
15
+
16
+ const { pages } = Astro.props;
17
+ ---
18
+
19
+ <nav class="child-page-list" aria-label={projectConfig.locale.labels.navigationChildren}>
20
+ <ul>
21
+ {pages.map((page) => (
22
+ <li>
23
+ <a href={withBasePath(projectConfig.site.basePath, page.pathname)}>
24
+ <span class="child-page-list-copy">
25
+ <strong>{page.title}</strong>
26
+ {page.description && <span>{page.description}</span>}
27
+ </span>
28
+ <ArrowRight aria-hidden="true" />
29
+ </a>
30
+ </li>
31
+ ))}
32
+ </ul>
33
+ </nav>