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