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