@janga/norna 0.7.27 → 0.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -28
- package/astro.config.mjs +19 -1
- package/bin/norna-cli.mjs +5 -3
- package/package.json +9 -5
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +66 -66
- package/schemas/content-frontmatter.schema.json +16 -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 +13 -161
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +17 -10
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +7 -6
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +67 -60
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
- package/scripts/lib/editor-file-policy.mjs +18 -0
- package/scripts/lib/editor-language-service.mjs +53 -55
- package/scripts/lib/editor-page-addresses.mjs +55 -0
- package/scripts/lib/editor-page-placement.mjs +172 -0
- package/scripts/lib/editor-site-editing-tree.mjs +146 -0
- package/scripts/lib/editor-site-files.mjs +153 -0
- package/scripts/lib/editor-site-links.mjs +79 -0
- package/scripts/lib/editor-site-tree.mjs +151 -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/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -1
- package/scripts/lib/page-aliases.mjs +3 -1
- package/scripts/lib/page-model.mjs +17 -20
- package/scripts/lib/page-move-apply.mjs +117 -0
- package/scripts/lib/page-move-plan.mjs +27 -59
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +7 -8
- package/scripts/lib/schema-definitions.mjs +11 -38
- package/scripts/lib/schema-editor-metadata.mjs +12 -59
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +7 -24
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +169 -0
- package/scripts/lib/site-paths.mjs +22 -10
- package/scripts/lib/site-structure.mjs +72 -82
- package/scripts/lib/site-upgrade.mjs +30 -0
- package/scripts/lib/source-files.mjs +34 -0
- 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 +3 -124
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +9 -7
- package/scripts/sync-site-public.mjs +1 -1
- 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/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 +9 -1
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +37 -61
- 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 +10 -8
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -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 +3 -2
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +9 -6
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +5 -15
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +399 -0
- package/src/styles/content.css +2 -29
- 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/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/{config.yaml → site-config/settings.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
package/src/content.config.ts
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
import { defineCollection } from 'astro:content';
|
|
2
2
|
import { glob } from 'astro/loaders';
|
|
3
3
|
import { z } from 'astro/zod';
|
|
4
|
-
import { basename } from 'node:path';
|
|
4
|
+
import { basename, dirname } from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
siteHomePageDir,
|
|
8
8
|
siteDirLabel,
|
|
9
9
|
siteThemePath,
|
|
10
10
|
sitewideContentPath,
|
|
11
11
|
} from '../scripts/lib/site-paths.mjs';
|
|
12
12
|
import { encodePageEntryId, getSiteEntryPrefix, parsePageDirectoryPath } from '../scripts/lib/page-model.mjs';
|
|
13
|
+
import { homePageDirectory } from '../scripts/lib/site-conventions.mjs';
|
|
13
14
|
import {
|
|
14
15
|
siteSchema,
|
|
15
16
|
sitewideSchema,
|
|
16
|
-
|
|
17
|
+
rootThemeSchema,
|
|
17
18
|
} from '../scripts/lib/schema-definitions.mjs';
|
|
18
19
|
|
|
19
20
|
const siteEntryPrefix = getSiteEntryPrefix(siteDirLabel);
|
|
20
21
|
const emptyYamlMapping = (value: unknown) => value ?? {};
|
|
21
|
-
const siteThemeSchema = z.preprocess(emptyYamlMapping,
|
|
22
|
+
const siteThemeSchema = z.preprocess(emptyYamlMapping, rootThemeSchema);
|
|
22
23
|
const sitewideContentSchema = z.preprocess(emptyYamlMapping, sitewideSchema);
|
|
23
24
|
|
|
24
25
|
const site = defineCollection({
|
|
25
26
|
loader: glob({
|
|
26
|
-
pattern: 'pages/**/content.md',
|
|
27
|
-
base: pathToFileURL(
|
|
27
|
+
pattern: ['content.md', 'pages/**/content.md'],
|
|
28
|
+
base: pathToFileURL(siteHomePageDir),
|
|
28
29
|
generateId: ({ entry }) => {
|
|
30
|
+
if (entry === 'content.md') return encodePageEntryId(siteDirLabel, homePageDirectory);
|
|
29
31
|
const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
|
|
30
32
|
const pageDirectory = pageEntryDirectory
|
|
31
33
|
.split('/')
|
|
@@ -40,7 +42,7 @@ const site = defineCollection({
|
|
|
40
42
|
const theme = defineCollection({
|
|
41
43
|
loader: glob({
|
|
42
44
|
pattern: basename(siteThemePath),
|
|
43
|
-
base: pathToFileURL(
|
|
45
|
+
base: pathToFileURL(dirname(siteThemePath)),
|
|
44
46
|
generateId: () => `${siteEntryPrefix}-theme`,
|
|
45
47
|
}),
|
|
46
48
|
schema: siteThemeSchema,
|
|
@@ -49,7 +51,7 @@ const theme = defineCollection({
|
|
|
49
51
|
const sitewide = defineCollection({
|
|
50
52
|
loader: glob({
|
|
51
53
|
pattern: basename(sitewideContentPath),
|
|
52
|
-
base: pathToFileURL(
|
|
54
|
+
base: pathToFileURL(dirname(sitewideContentPath)),
|
|
53
55
|
generateId: () => `${siteEntryPrefix}-sitewide`,
|
|
54
56
|
}),
|
|
55
57
|
schema: sitewideContentSchema,
|
|
@@ -5,6 +5,8 @@ import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs'
|
|
|
5
5
|
import { getIconLinks, getSocialImageAsset } from '../lib/sitePublicAssets';
|
|
6
6
|
import { withBasePath } from '../lib/basePath';
|
|
7
7
|
import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
|
|
8
|
+
import NavigationContinuity from '../components/NavigationContinuity.astro';
|
|
9
|
+
import areaNavigationStyles from '../styles/area-navigation.css?inline';
|
|
8
10
|
import '../styles/global.css';
|
|
9
11
|
|
|
10
12
|
type FrameColors = {
|
|
@@ -80,6 +82,8 @@ interface Props {
|
|
|
80
82
|
contentsNavigation?: boolean;
|
|
81
83
|
indexable?: boolean;
|
|
82
84
|
searchable?: boolean;
|
|
85
|
+
areaNavigation?: boolean;
|
|
86
|
+
areaReadingFrame?: boolean;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
const {
|
|
@@ -93,6 +97,8 @@ const {
|
|
|
93
97
|
contentsNavigation = false,
|
|
94
98
|
indexable = true,
|
|
95
99
|
searchable = false,
|
|
100
|
+
areaNavigation = false,
|
|
101
|
+
areaReadingFrame = false,
|
|
96
102
|
} = Astro.props;
|
|
97
103
|
|
|
98
104
|
const documentTitle = title;
|
|
@@ -150,49 +156,13 @@ const marginNoteOuterReserve = '0.5rem';
|
|
|
150
156
|
const marginNoteDeclarations = marginNoteLayoutDeclarations;
|
|
151
157
|
const marginNoteLayoutCss = [
|
|
152
158
|
'@media (min-width: 1101px) {',
|
|
153
|
-
...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
|
|
154
|
-
[
|
|
155
|
-
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
|
|
156
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout:not(.site-page-layout-tree) .section-note-paragraph {`,
|
|
157
|
-
marginNoteDeclarations,
|
|
158
|
-
'}',
|
|
159
|
-
'}',
|
|
160
|
-
].join('\n'),
|
|
161
|
-
[
|
|
162
|
-
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
|
|
163
|
-
`:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note-paragraph {`,
|
|
164
|
-
marginNoteDeclarations,
|
|
165
|
-
'}',
|
|
166
|
-
'}',
|
|
167
|
-
].join('\n'),
|
|
168
|
-
]),
|
|
169
|
-
'}',
|
|
170
|
-
'@media (min-width: 1101px) and (max-width: 1280px) {',
|
|
171
159
|
...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
|
|
172
160
|
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve})) {`,
|
|
173
|
-
`:root[data-reading-width="${readingWidth}"] .
|
|
174
|
-
|
|
175
|
-
'}',
|
|
176
|
-
'}',
|
|
177
|
-
].join('\n')),
|
|
178
|
-
'}',
|
|
179
|
-
'@media (min-width: 1281px) {',
|
|
180
|
-
...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
|
|
181
|
-
[
|
|
182
|
-
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
|
|
183
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note-paragraph {`,
|
|
184
|
-
marginNoteDeclarations,
|
|
185
|
-
'}',
|
|
161
|
+
`:root[data-reading-width="${readingWidth}"] .section-note-paragraph {`,
|
|
162
|
+
marginNoteDeclarations,
|
|
186
163
|
'}',
|
|
187
|
-
].join('\n'),
|
|
188
|
-
[
|
|
189
|
-
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
|
|
190
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
|
|
191
|
-
marginNoteDeclarations,
|
|
192
|
-
'}',
|
|
193
164
|
'}',
|
|
194
|
-
|
|
195
|
-
]),
|
|
165
|
+
].join('\n')),
|
|
196
166
|
'}',
|
|
197
167
|
].join('\n');
|
|
198
168
|
const { buildInfo, copyrightMessage } = footer ?? {};
|
|
@@ -227,6 +197,9 @@ const readerPreferencesScript = readerPreferencesEnabled
|
|
|
227
197
|
data-reading-width={presentation.readerPreferences.defaults.readingWidth}
|
|
228
198
|
data-focus-reading={presentation.readerPreferences.defaults.focusReading}
|
|
229
199
|
data-section-tracking={contentsNavigation ? 'enabled' : undefined}
|
|
200
|
+
data-navigation-continuity={projectConfig.site.basePath}
|
|
201
|
+
data-area-navigation={areaNavigation ? '' : undefined}
|
|
202
|
+
data-area-reading-frame={areaReadingFrame ? '' : undefined}
|
|
230
203
|
data-pagefind-ignore={searchable ? undefined : 'all'}
|
|
231
204
|
style={documentStyle}
|
|
232
205
|
>
|
|
@@ -248,6 +221,8 @@ const readerPreferencesScript = readerPreferencesEnabled
|
|
|
248
221
|
{indexable && socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
|
|
249
222
|
<meta name="theme-color" content={presentation.palette.page.backgroundColor} />
|
|
250
223
|
{readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
|
|
224
|
+
<NavigationContinuity />
|
|
225
|
+
{areaNavigation && <style is:inline set:html={areaNavigationStyles} />}
|
|
251
226
|
<style is:inline set:html={marginNoteLayoutCss} />
|
|
252
227
|
{indexable && <link rel="canonical" href={canonicalUrl} />}
|
|
253
228
|
<meta name="generator" content={Astro.generator} />
|
|
@@ -272,5 +247,6 @@ const readerPreferencesScript = readerPreferencesEnabled
|
|
|
272
247
|
</p>
|
|
273
248
|
</footer>
|
|
274
249
|
)}
|
|
250
|
+
<span id="norna-navigation-ready" hidden></span>
|
|
275
251
|
</body>
|
|
276
252
|
</html>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SiteNavigationNode } from './siteNavigation';
|
|
2
|
+
import type { SiteNode } from './sitePages';
|
|
3
|
+
|
|
4
|
+
const containsPage = (root: SiteNavigationNode, page: SiteNode) => (
|
|
5
|
+
root.node.pagePath === page.pagePath
|
|
6
|
+
|| (root.node.pagePath !== '' && page.pagePath.startsWith(`${root.node.pagePath}/`))
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
// Beyond twelve choices, use a destination with a local tree instead of a panel.
|
|
10
|
+
export const hasLargeAreaMenu = (root: SiteNavigationNode) => (
|
|
11
|
+
root.children.length + (root.node.kind === 'page' ? 1 : 0) > 12
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const getAreaMenuGroups = (root: SiteNavigationNode) => {
|
|
15
|
+
const choices = root.node.kind === 'page' ? [root, ...root.children] : root.children;
|
|
16
|
+
return {
|
|
17
|
+
branches: choices.filter(({ children }) => children.length > 0),
|
|
18
|
+
pages: choices.filter(({ children }) => children.length === 0),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Only the listed tree enters this resolver. Flat collections keep their pages
|
|
23
|
+
// together; collections with subgroups select child branches. Large collections
|
|
24
|
+
// and a parent's own overview retain the full tree. Scope never depends on the
|
|
25
|
+
// arrival route, stored state or the number of headings on a page.
|
|
26
|
+
export const resolveAreaNavigation = (roots: SiteNavigationNode[], page?: SiteNode) => {
|
|
27
|
+
const globalRoot = page ? roots.find((root) => containsPage(root, page)) : undefined;
|
|
28
|
+
if (globalRoot && globalRoot.children.length > 0 && (
|
|
29
|
+
globalRoot.children.every((child) => child.children.length === 0)
|
|
30
|
+
|| hasLargeAreaMenu(globalRoot) || page?.pagePath === globalRoot.node.pagePath
|
|
31
|
+
)) return { globalRoot, localRoot: globalRoot };
|
|
32
|
+
const area = page && globalRoot?.children.find((child) => (
|
|
33
|
+
child.children.length > 0 && containsPage(child, page)
|
|
34
|
+
));
|
|
35
|
+
return { globalRoot, localRoot: area };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const withH2Outlines = (roots: SiteNavigationNode[]): SiteNavigationNode[] => roots.map((root) => ({
|
|
39
|
+
...root,
|
|
40
|
+
headings: root.headings.filter(({ depth }) => depth === 2),
|
|
41
|
+
children: withH2Outlines(root.children),
|
|
42
|
+
}));
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { isNavigationElementDisplayed } from './navigationVisibility';
|
|
2
|
+
|
|
3
|
+
type Prefetch = (url: string) => void;
|
|
4
|
+
|
|
5
|
+
const setupTreeScrollport = () => {
|
|
6
|
+
const tree = document.querySelector<HTMLElement>('.tree-local-navigation');
|
|
7
|
+
const layout = tree?.parentElement;
|
|
8
|
+
if (!tree || !layout) return;
|
|
9
|
+
const update = () => {
|
|
10
|
+
if (!tree.getClientRects().length) return;
|
|
11
|
+
// Notices above the layout can place the tree below its sticky offset.
|
|
12
|
+
// Reserve that space even after document scrolling, so the tree does not
|
|
13
|
+
// resize and move its rows while the reader scrolls the article.
|
|
14
|
+
const flowTop = layout.getBoundingClientRect().top + window.scrollY;
|
|
15
|
+
const stickyTop = parseFloat(getComputedStyle(tree).top) || 0;
|
|
16
|
+
tree.style.setProperty('--navigation-scrollport-top', `${Math.ceil(Math.max(flowTop, stickyTop))}px`);
|
|
17
|
+
};
|
|
18
|
+
const observer = new ResizeObserver(update);
|
|
19
|
+
for (const element of document.querySelectorAll('.site-top, .site-banners')) observer.observe(element);
|
|
20
|
+
window.addEventListener('resize', update);
|
|
21
|
+
window.addEventListener('pageshow', update);
|
|
22
|
+
void document.fonts.ready.then(update);
|
|
23
|
+
update();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const setupDisclosures = (reducedMotion: MediaQueryList) => {
|
|
27
|
+
const finishers = new Set<() => void>();
|
|
28
|
+
for (const details of document.querySelectorAll<HTMLDetailsElement>(
|
|
29
|
+
'.navigation-page-disclosure, .navigation-page-sections-disclosure',
|
|
30
|
+
)) {
|
|
31
|
+
const summary = details.querySelector<HTMLElement>(':scope > summary');
|
|
32
|
+
const contentId = summary?.getAttribute('aria-controls');
|
|
33
|
+
const content = contentId ? document.getElementById(contentId) : null;
|
|
34
|
+
if (!summary || !content) continue;
|
|
35
|
+
let animation: Animation | null = null;
|
|
36
|
+
let destination = details.open;
|
|
37
|
+
const originallyInert = content.inert;
|
|
38
|
+
const finish = () => {
|
|
39
|
+
details.open = destination;
|
|
40
|
+
animation?.cancel();
|
|
41
|
+
animation = null;
|
|
42
|
+
content.inert = originallyInert;
|
|
43
|
+
content.removeAttribute('data-navigation-motion-active');
|
|
44
|
+
details.removeAttribute('data-navigation-motion-open');
|
|
45
|
+
summary.removeAttribute('aria-expanded');
|
|
46
|
+
finishers.delete(finish);
|
|
47
|
+
};
|
|
48
|
+
summary.addEventListener('click', (event) => {
|
|
49
|
+
if (event.defaultPrevented || reducedMotion.matches || typeof content.animate !== 'function') return;
|
|
50
|
+
if (event.target instanceof Element && event.target.closest('a')) return;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
const height = isNavigationElementDisplayed(content) ? content.getBoundingClientRect().height : 0;
|
|
53
|
+
const opacity = height ? getComputedStyle(content).opacity : '0';
|
|
54
|
+
destination = animation ? !destination : !details.open;
|
|
55
|
+
animation?.cancel();
|
|
56
|
+
if (!destination && content.contains(document.activeElement)) summary.focus({ preventScroll: true });
|
|
57
|
+
details.open = true;
|
|
58
|
+
const fullHeight = content.getBoundingClientRect().height;
|
|
59
|
+
content.inert = !destination || originallyInert;
|
|
60
|
+
content.setAttribute('data-navigation-motion-active', '');
|
|
61
|
+
details.dataset.navigationMotionOpen = String(destination);
|
|
62
|
+
summary.setAttribute('aria-expanded', String(destination));
|
|
63
|
+
animation = content.animate([
|
|
64
|
+
{ height: `${height}px`, opacity },
|
|
65
|
+
{ height: `${destination ? fullHeight : 0}px`, opacity: destination ? '1' : '0' },
|
|
66
|
+
], {
|
|
67
|
+
duration: 180,
|
|
68
|
+
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
69
|
+
fill: 'both',
|
|
70
|
+
});
|
|
71
|
+
animation.onfinish = finish;
|
|
72
|
+
finishers.add(finish);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const finishAll = () => {
|
|
76
|
+
for (const finish of finishers) finish();
|
|
77
|
+
};
|
|
78
|
+
reducedMotion.addEventListener('change', () => {
|
|
79
|
+
if (reducedMotion.matches) finishAll();
|
|
80
|
+
});
|
|
81
|
+
window.addEventListener('pagehide', finishAll, { capture: true });
|
|
82
|
+
// Let filtering and the existing tree controls act on settled disclosure state.
|
|
83
|
+
for (const event of ['click', 'input']) {
|
|
84
|
+
document.addEventListener(event, (event) => {
|
|
85
|
+
if (!(event.target instanceof Element)) return;
|
|
86
|
+
if (event.target.closest('[data-tree-controls]') || (
|
|
87
|
+
event.type === 'click' && event.target.closest('a[href]')
|
|
88
|
+
)) finishAll();
|
|
89
|
+
}, { capture: true });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const setupOutlineMarkers = () => {
|
|
94
|
+
const outlines = [...document.querySelectorAll<HTMLOListElement>('.page-contents-links')];
|
|
95
|
+
let frame: number | undefined;
|
|
96
|
+
const update = () => {
|
|
97
|
+
frame = undefined;
|
|
98
|
+
for (const outline of outlines) {
|
|
99
|
+
const current = outline.querySelector<HTMLElement>('a[aria-current="location"]');
|
|
100
|
+
if (!current || !isNavigationElementDisplayed(current)) {
|
|
101
|
+
outline.style.setProperty('--navigation-marker-opacity', '0');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const initial = !outline.hasAttribute('data-navigation-marker');
|
|
105
|
+
outline.setAttribute('data-navigation-marker', '');
|
|
106
|
+
if (initial) outline.setAttribute('data-navigation-marker-initial', '');
|
|
107
|
+
const bounds = current.getBoundingClientRect();
|
|
108
|
+
const style = getComputedStyle(current);
|
|
109
|
+
const paddingTop = parseFloat(style.paddingTop);
|
|
110
|
+
const paddingBottom = parseFloat(style.paddingBottom);
|
|
111
|
+
outline.style.setProperty('--navigation-marker-top', `${bounds.top + paddingTop - outline.getBoundingClientRect().top}px`);
|
|
112
|
+
outline.style.setProperty('--navigation-marker-height', `${bounds.height - paddingTop - paddingBottom}px`);
|
|
113
|
+
outline.style.setProperty('--navigation-marker-opacity', '1');
|
|
114
|
+
if (initial) requestAnimationFrame(() => outline.removeAttribute('data-navigation-marker-initial'));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const schedule = () => { frame ??= requestAnimationFrame(update); };
|
|
118
|
+
new MutationObserver(schedule).observe(document.body, {
|
|
119
|
+
subtree: true,
|
|
120
|
+
attributes: true,
|
|
121
|
+
attributeFilter: ['aria-current', 'open', 'hidden'],
|
|
122
|
+
});
|
|
123
|
+
const observer = new ResizeObserver(schedule);
|
|
124
|
+
for (const outline of outlines) observer.observe(outline);
|
|
125
|
+
window.addEventListener('resize', schedule);
|
|
126
|
+
window.addEventListener('pageshow', schedule);
|
|
127
|
+
schedule();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const setupScrollArrival = () => {
|
|
131
|
+
const root = document.documentElement;
|
|
132
|
+
const originalHash = location.hash;
|
|
133
|
+
const scrollBehavior = root.style.scrollBehavior;
|
|
134
|
+
let interrupted = false;
|
|
135
|
+
const interrupt = () => { interrupted = true; };
|
|
136
|
+
for (const event of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
|
|
137
|
+
window.addEventListener(event, interrupt, { once: true, passive: true });
|
|
138
|
+
}
|
|
139
|
+
const ready = async (restoreHistory: boolean) => {
|
|
140
|
+
await document.fonts.ready;
|
|
141
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
142
|
+
if (!restoreHistory && !interrupted && originalHash && location.hash === originalHash) {
|
|
143
|
+
try {
|
|
144
|
+
const target = document.getElementById(decodeURIComponent(originalHash.slice(1)));
|
|
145
|
+
const header = document.querySelector<HTMLElement>('.site-top');
|
|
146
|
+
if (target) {
|
|
147
|
+
const offset = Math.ceil(header?.getBoundingClientRect().height ?? 0);
|
|
148
|
+
window.scrollTo({ top: target.getBoundingClientRect().top + scrollY - offset, behavior: 'instant' });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Leave malformed URL fragments to the browser's native handling.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
root.style.scrollBehavior = scrollBehavior;
|
|
155
|
+
root.setAttribute('data-navigation-motion-ready', '');
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener('pageswap', () => root.removeAttribute('data-navigation-motion-ready'));
|
|
159
|
+
window.addEventListener('pageshow', (event) => {
|
|
160
|
+
if (event.persisted) void ready(true);
|
|
161
|
+
});
|
|
162
|
+
const initialReady = () => {
|
|
163
|
+
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
164
|
+
void ready(navigation?.type === 'back_forward');
|
|
165
|
+
};
|
|
166
|
+
if (document.readyState === 'complete') initialReady();
|
|
167
|
+
else window.addEventListener('load', initialReady, { once: true });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const setupIntentPrefetch = (prefetch: Prefetch) => {
|
|
171
|
+
const basePath = document.documentElement.dataset.navigationContinuity ?? '/';
|
|
172
|
+
let timer: number | undefined;
|
|
173
|
+
let pending: HTMLAnchorElement | null = null;
|
|
174
|
+
const eligible = (target: EventTarget | null) => {
|
|
175
|
+
const link = target instanceof Element ? target.closest<HTMLAnchorElement>('a[href]') : null;
|
|
176
|
+
if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) return null;
|
|
177
|
+
const url = new URL(link.href, location.href);
|
|
178
|
+
if (url.origin !== location.origin || !url.pathname.startsWith(basePath) || url.search) return null;
|
|
179
|
+
if (!url.pathname.endsWith('/') || url.pathname === location.pathname) return null;
|
|
180
|
+
return link;
|
|
181
|
+
};
|
|
182
|
+
const cancel = () => {
|
|
183
|
+
window.clearTimeout(timer);
|
|
184
|
+
pending = null;
|
|
185
|
+
};
|
|
186
|
+
const prepare = (event: Event) => {
|
|
187
|
+
const link = eligible(event.target);
|
|
188
|
+
if (link === pending) return;
|
|
189
|
+
cancel();
|
|
190
|
+
if (!link) return;
|
|
191
|
+
pending = link;
|
|
192
|
+
timer = window.setTimeout(() => {
|
|
193
|
+
prefetch(link.href);
|
|
194
|
+
}, 100);
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener('pointerover', prepare, { passive: true });
|
|
197
|
+
document.addEventListener('focusin', prepare);
|
|
198
|
+
document.addEventListener('pointerout', (event) => {
|
|
199
|
+
if (pending && !(event.relatedTarget instanceof Node && pending.contains(event.relatedTarget))) cancel();
|
|
200
|
+
}, { passive: true });
|
|
201
|
+
document.addEventListener('focusout', cancel);
|
|
202
|
+
window.addEventListener('pagehide', cancel);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const setupNavigationContinuity = (prefetch: Prefetch) => {
|
|
206
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
207
|
+
setupTreeScrollport();
|
|
208
|
+
setupDisclosures(reducedMotion);
|
|
209
|
+
setupOutlineMarkers();
|
|
210
|
+
setupScrollArrival();
|
|
211
|
+
setupIntentPrefetch(prefetch);
|
|
212
|
+
};
|
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
|
|
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
|
+
}
|