@janga/norna 0.7.25 → 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 +3 -2
- package/astro.config.mjs +26 -12
- package/bin/norna-cli.mjs +12 -0
- package/package.json +14 -5
- package/schemas/card-list.schema.json +121 -0
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +306 -32
- 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/dev-local.mjs +24 -2
- package/scripts/generate-schemas.mjs +15 -3
- package/scripts/lib/category-destinations.mjs +48 -0
- package/scripts/lib/code-fence-metadata.mjs +14 -37
- 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/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/norna-markdown-blocks.mjs +32 -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 +2 -173
- package/scripts/lib/schema-definitions.mjs +3 -10
- package/scripts/lib/schema-editor-metadata.mjs +22 -43
- package/scripts/lib/schema-value-definitions.mjs +4 -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/sitemap.mjs +5 -3
- package/scripts/lib/structured-blocks.mjs +108 -0
- package/scripts/lib/table-render-plugin.mjs +46 -3
- 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/CodeBlockCopyScript.astro +47 -5
- package/src/components/ContentBlocks.astro +39 -0
- package/src/components/ContentTabsScript.astro +60 -0
- package/src/components/DisplaySettings.astro +17 -19
- package/src/components/NavigationPageTree.astro +1 -1
- package/src/components/NoteNavigationScript.astro +4 -0
- package/src/components/PageAliasRedirect.astro +5 -4
- package/src/components/SearchNavigationScript.astro +4 -0
- package/src/components/SearchPage.astro +43 -2
- package/src/components/SectionNavigationScript.astro +43 -11
- package/src/components/SiteBreadcrumbs.astro +1 -1
- package/src/components/SiteNavigation.astro +29 -20
- package/src/components/SitePage.astro +40 -15
- package/src/components/SiteSection.astro +5 -81
- package/src/components/SiteTreeNavigation.astro +2 -2
- package/src/components/TableOverflowScript.astro +386 -58
- package/src/components/TopPageMenu.astro +47 -0
- package/src/components/TreeNavigationScript.astro +10 -3
- package/src/layouts/BaseLayout.astro +8 -12
- 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 +20 -90
- package/src/lib/siteNavigation.ts +0 -5
- package/src/lib/sitePages.ts +13 -2
- package/src/lib/table-sort-tooltip.ts +109 -0
- package/src/pages/[...slug].astro +16 -6
- package/src/styles/content.css +394 -132
- package/src/styles/foundations.css +9 -0
- package/src/styles/media.css +5 -0
- package/src/styles/navigation.css +63 -74
- package/src/styles/page-layout.css +7 -0
- package/src/styles/responsive.css +2 -7
|
@@ -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';
|
|
@@ -146,25 +147,20 @@ const marginNoteReadingWidths = [
|
|
|
146
147
|
['wide', `min(80ch, ${visualTheme.images.width})`],
|
|
147
148
|
] as const;
|
|
148
149
|
const marginNoteOuterReserve = '0.5rem';
|
|
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');
|
|
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}"] .site-page-layout:not(.site-page-layout-tree) .section-note {`,
|
|
156
|
+
`:root[data-reading-width="${readingWidth}"] .site-page-layout:not(.site-page-layout-tree) .section-note-paragraph {`,
|
|
161
157
|
marginNoteDeclarations,
|
|
162
158
|
'}',
|
|
163
159
|
'}',
|
|
164
160
|
].join('\n'),
|
|
165
161
|
[
|
|
166
162
|
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
|
|
167
|
-
`:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note {`,
|
|
163
|
+
`:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note-paragraph {`,
|
|
168
164
|
marginNoteDeclarations,
|
|
169
165
|
'}',
|
|
170
166
|
'}',
|
|
@@ -174,7 +170,7 @@ const marginNoteLayoutCss = [
|
|
|
174
170
|
'@media (min-width: 1101px) and (max-width: 1280px) {',
|
|
175
171
|
...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
|
|
176
172
|
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve})) {`,
|
|
177
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note {`,
|
|
173
|
+
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
|
|
178
174
|
marginNoteDeclarations,
|
|
179
175
|
'}',
|
|
180
176
|
'}',
|
|
@@ -184,14 +180,14 @@ const marginNoteLayoutCss = [
|
|
|
184
180
|
...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
|
|
185
181
|
[
|
|
186
182
|
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
|
|
187
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note {`,
|
|
183
|
+
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note-paragraph {`,
|
|
188
184
|
marginNoteDeclarations,
|
|
189
185
|
'}',
|
|
190
186
|
'}',
|
|
191
187
|
].join('\n'),
|
|
192
188
|
[
|
|
193
189
|
`@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
|
|
194
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note {`,
|
|
190
|
+
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
|
|
195
191
|
marginNoteDeclarations,
|
|
196
192
|
'}',
|
|
197
193
|
'}',
|
|
@@ -202,7 +198,7 @@ const marginNoteLayoutCss = [
|
|
|
202
198
|
const { buildInfo, copyrightMessage } = footer ?? {};
|
|
203
199
|
const showBuildInfo = buildInfo === true;
|
|
204
200
|
const buildTime = showBuildInfo
|
|
205
|
-
? new Intl.DateTimeFormat(projectConfig.locale.
|
|
201
|
+
? new Intl.DateTimeFormat(projectConfig.locale.formattingLocale, {
|
|
206
202
|
dateStyle: 'short',
|
|
207
203
|
timeStyle: 'short',
|
|
208
204
|
timeZone: 'UTC',
|
|
@@ -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
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { markdownToHtml } from 'satteri';
|
|
2
|
+
import { groupRenderedTabs } from '../../scripts/lib/content-tabs.mjs';
|
|
2
3
|
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
4
|
import {
|
|
4
5
|
formatHeadingIdentifierIssue,
|
|
@@ -47,15 +48,9 @@ type PageListItem = {
|
|
|
47
48
|
pathname: string;
|
|
48
49
|
description?: string;
|
|
49
50
|
};
|
|
50
|
-
type
|
|
51
|
-
markdown: string;
|
|
52
|
-
number: number;
|
|
53
|
-
id: string;
|
|
54
|
-
referenceId: string;
|
|
55
|
-
html?: string;
|
|
56
|
-
};
|
|
57
|
-
type SectionContentBlock =
|
|
51
|
+
export type SectionContentBlock =
|
|
58
52
|
| { type: 'html'; html: string }
|
|
53
|
+
| { type: 'tabs'; index: number; panels: Array<{ label: string; contentBlocks: SectionContentBlock[] }> }
|
|
59
54
|
| { type: 'image-stack'; images: ManagedImage[] }
|
|
60
55
|
| { type: 'image-carousel'; images: ManagedImage[] }
|
|
61
56
|
| { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] }
|
|
@@ -78,6 +73,11 @@ export type HeadingNavigation = SectionNavigation & {
|
|
|
78
73
|
parentId: string | null;
|
|
79
74
|
};
|
|
80
75
|
|
|
76
|
+
export const flattenContentBlocks = (blocks: SectionContentBlock[]): SectionContentBlock[] =>
|
|
77
|
+
blocks.flatMap((block) => block.type === 'tabs'
|
|
78
|
+
? [block, ...block.panels.flatMap((panel) => flattenContentBlocks(panel.contentBlocks))]
|
|
79
|
+
: [block]);
|
|
80
|
+
|
|
81
81
|
const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
82
82
|
const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
|
|
83
83
|
|
|
@@ -89,43 +89,6 @@ const prepareContentHtml = (html: string) =>
|
|
|
89
89
|
stripImageProvenanceComments(html),
|
|
90
90
|
);
|
|
91
91
|
|
|
92
|
-
const noteDeclarationParagraphRegex = /<p>\s*\{note:\s*[\s\S]*?\}\s*<\/p>/gi;
|
|
93
|
-
const htmlCodeRegionRegex = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/gi;
|
|
94
|
-
|
|
95
|
-
const replaceHtmlOutsideCode = (html: string, transform: (value: string) => string) => {
|
|
96
|
-
let result = '';
|
|
97
|
-
let cursor = 0;
|
|
98
|
-
|
|
99
|
-
for (const match of html.matchAll(htmlCodeRegionRegex)) {
|
|
100
|
-
const start = match.index ?? 0;
|
|
101
|
-
result += transform(html.slice(cursor, start));
|
|
102
|
-
result += match[0];
|
|
103
|
-
cursor = start + match[0].length;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return result + transform(html.slice(cursor));
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const applyInlineNoteMarkup = (html: string, notes: InlineNote[]) => {
|
|
110
|
-
let referenceIndex = 0;
|
|
111
|
-
const withReferences = replaceHtmlOutsideCode(html, (value) => value.replace(/\{note-ref\}/g, () => {
|
|
112
|
-
const note = notes[referenceIndex];
|
|
113
|
-
if (!note) return '{note-ref}';
|
|
114
|
-
|
|
115
|
-
referenceIndex += 1;
|
|
116
|
-
return [
|
|
117
|
-
'\u2060',
|
|
118
|
-
`<sup class="section-note-ref"><a id="${note.referenceId}" href="#${note.id}" aria-label="Note ${note.number}" aria-describedby="${note.id}">${note.number}</a></sup>`,
|
|
119
|
-
`<span class="section-note section-note-margin" id="${note.id}" aria-label="Note ${note.number}" role="note">`,
|
|
120
|
-
`<a class="section-note-number" href="#${note.referenceId}" aria-label="Note ${note.number}">${note.number}</a>`,
|
|
121
|
-
`<span class="section-note-content">${note.html ?? ''}</span>`,
|
|
122
|
-
'</span>',
|
|
123
|
-
].join('');
|
|
124
|
-
}));
|
|
125
|
-
|
|
126
|
-
return withReferences.replace(noteDeclarationParagraphRegex, '');
|
|
127
|
-
};
|
|
128
|
-
|
|
129
92
|
const applyH3HeadingIds = (
|
|
130
93
|
html: string,
|
|
131
94
|
headings: Array<{ id: string | null }>,
|
|
@@ -150,27 +113,6 @@ const applyH3HeadingIds = (
|
|
|
150
113
|
|
|
151
114
|
const getImageSourceKey = (page: SitePage, image: string) => `pages/${page.pageDirectory}/images/${image}`;
|
|
152
115
|
|
|
153
|
-
const renderInlineNoteMarkdown = async (markdown: string) => {
|
|
154
|
-
const result = await markdownToHtml(markdown, {
|
|
155
|
-
features: {
|
|
156
|
-
gfm: true,
|
|
157
|
-
smartPunctuation: true,
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
if (/<img\b/i.test(result.html)) {
|
|
162
|
-
throw new Error('Inline notes cannot contain images. Use a Norna image block with a caption instead.');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const inlineHtml = result.html.trim();
|
|
166
|
-
const paragraph = inlineHtml.match(/^<p>([\s\S]*)<\/p>$/i);
|
|
167
|
-
if (!paragraph) {
|
|
168
|
-
throw new Error('Inline notes support inline Markdown only. Move headings, lists, and separate paragraphs into the page content.');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return paragraph[1] ?? '';
|
|
172
|
-
};
|
|
173
|
-
|
|
174
116
|
const renderHeadingTitleHtml = async (headingSource: string) => {
|
|
175
117
|
const headingMarkdown = headingSource
|
|
176
118
|
.replace(/^#{1,6}[ \t]+/, '')
|
|
@@ -208,14 +150,8 @@ const resolveContentBlocks = async (
|
|
|
208
150
|
blocks: ParsedNornaBlock[],
|
|
209
151
|
page: SitePage,
|
|
210
152
|
childPages: SitePage[],
|
|
211
|
-
inlineNotes: InlineNote[] = [],
|
|
212
153
|
) => {
|
|
213
|
-
const
|
|
214
|
-
...note,
|
|
215
|
-
html: await renderInlineNoteMarkdown(note.markdown),
|
|
216
|
-
})));
|
|
217
|
-
const renderedHtml = applyInlineNoteMarkup(html, renderedNotes);
|
|
218
|
-
const splitBlocks = splitNornaRenderedBlocks(renderedHtml, blocks) as Array<
|
|
154
|
+
const splitBlocks = splitNornaRenderedBlocks(html, blocks) as Array<
|
|
219
155
|
{ type: 'html'; html: string } | ParsedNornaBlock
|
|
220
156
|
>;
|
|
221
157
|
const resolvedBlocks: SectionContentBlock[] = [];
|
|
@@ -273,6 +209,10 @@ export const getSectionsContent = async (
|
|
|
273
209
|
childPages: SitePage[] = [],
|
|
274
210
|
) => {
|
|
275
211
|
const pageDocument = page.markdownDocument;
|
|
212
|
+
const noteError = pageDocument.noteDiagnostics.find((issue) => issue.severity === 'error');
|
|
213
|
+
if (noteError) throw new Error(noteError.message);
|
|
214
|
+
const tabError = pageDocument.diagnostics.find((issue) => issue.code === 'invalid-content-tabs');
|
|
215
|
+
if (tabError) throw new Error(tabError.message);
|
|
276
216
|
const sourceLabel = page.contentLabel;
|
|
277
217
|
const headingIdentifierIssues = pageDocument.headingIssues;
|
|
278
218
|
if (headingIdentifierIssues.length > 0) {
|
|
@@ -280,7 +220,6 @@ export const getSectionsContent = async (
|
|
|
280
220
|
}
|
|
281
221
|
const sections: ResolvedSection[] = [];
|
|
282
222
|
const sectionIds = new Set<string>();
|
|
283
|
-
let nextNoteNumber = 1;
|
|
284
223
|
const pageHeadingCount = pageDocument.pageHeadings.length;
|
|
285
224
|
|
|
286
225
|
if (pageHeadingCount !== 1 || pageDocument.regions[0]?.kind !== 'page-intro') {
|
|
@@ -310,29 +249,20 @@ export const getSectionsContent = async (
|
|
|
310
249
|
if (bodySection.noteErrors.length > 0) {
|
|
311
250
|
throw new Error(bodySection.noteErrors[0].message);
|
|
312
251
|
}
|
|
313
|
-
|
|
314
|
-
|
|
252
|
+
const calloutErrors = bodySection.calloutErrors.filter((error) => error.severity !== 'warning');
|
|
253
|
+
if (calloutErrors.length > 0) {
|
|
254
|
+
throw new Error(calloutErrors[0].message);
|
|
315
255
|
}
|
|
316
256
|
|
|
317
|
-
const inlineNotes = bodySection.notes.map((note) => {
|
|
318
|
-
const number = nextNoteNumber;
|
|
319
|
-
nextNoteNumber += 1;
|
|
320
|
-
const pageKey = page.pageId || 'home';
|
|
321
|
-
const noteKey = `${pageKey}-${id ?? 'page-title'}-${number}`;
|
|
322
|
-
return {
|
|
323
|
-
...note,
|
|
324
|
-
number,
|
|
325
|
-
id: `note-${noteKey}`,
|
|
326
|
-
referenceId: `note-ref-${noteKey}`,
|
|
327
|
-
};
|
|
328
|
-
});
|
|
329
|
-
|
|
330
257
|
sections.push({
|
|
331
258
|
id,
|
|
332
259
|
title,
|
|
333
260
|
titleHtml: await renderHeadingTitleHtml(bodySection.heading.source),
|
|
334
261
|
headingLevel,
|
|
335
|
-
contentBlocks:
|
|
262
|
+
contentBlocks: groupRenderedTabs(
|
|
263
|
+
await resolveContentBlocks(content, bodySection.blocks, page, childPages),
|
|
264
|
+
bodySection.tabGroups,
|
|
265
|
+
),
|
|
336
266
|
});
|
|
337
267
|
}
|
|
338
268
|
|
|
@@ -3,7 +3,6 @@ import type { SiteNode, SitePage } from './sitePages';
|
|
|
3
3
|
import {
|
|
4
4
|
flattenSiteNavigationTree as flattenSiteNavigationTreeShared,
|
|
5
5
|
getDirectChildPages as getDirectChildPagesShared,
|
|
6
|
-
getFirstPageInNavigationNode as getFirstPageInNavigationNodeShared,
|
|
7
6
|
getListedSiteNavigationTree as getListedSiteNavigationTreeShared,
|
|
8
7
|
getSequentialPageNavigation as getSequentialPageNavigationShared,
|
|
9
8
|
} from '../../scripts/lib/site-navigation-tree.mjs';
|
|
@@ -22,10 +21,6 @@ export const getListedSiteNavigationTree = (entries: SiteNavigationEntry[]) => (
|
|
|
22
21
|
getListedSiteNavigationTreeShared(entries) as SiteNavigationNode[]
|
|
23
22
|
);
|
|
24
23
|
|
|
25
|
-
export const getFirstPageInNavigationNode = (node: SiteNavigationNode): SitePage | null => (
|
|
26
|
-
getFirstPageInNavigationNodeShared(node) as SitePage | null
|
|
27
|
-
);
|
|
28
|
-
|
|
29
24
|
export const flattenSiteNavigationTree = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => (
|
|
30
25
|
flattenSiteNavigationTreeShared(nodes) as SiteNavigationNode[]
|
|
31
26
|
);
|
package/src/lib/sitePages.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { decodePageDirectoryPath, parsePageDirectoryPath } from '../../scripts/l
|
|
|
4
4
|
import { parsePageMarkdown } from '../../scripts/lib/page-markdown.mjs';
|
|
5
5
|
import { readSiteFile } from '../../scripts/lib/site-content.mjs';
|
|
6
6
|
import { homePageDirectory } from '../../scripts/lib/site-conventions.mjs';
|
|
7
|
+
import { assertCategoryDestinationModel, createCategoryDestinationModel } from '../../scripts/lib/category-destinations.mjs';
|
|
7
8
|
import { getSiteStructure } from '../../scripts/lib/site-structure.mjs';
|
|
8
9
|
import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
|
|
9
10
|
|
|
@@ -20,6 +21,7 @@ type SiteNodeBase = {
|
|
|
20
21
|
depth: number;
|
|
21
22
|
pageDirectories: string[];
|
|
22
23
|
pathSegment: string;
|
|
24
|
+
pathname: string;
|
|
23
25
|
pageDirectory: string;
|
|
24
26
|
pageId: string;
|
|
25
27
|
pageIds: string[];
|
|
@@ -41,6 +43,7 @@ export type SitePage = SiteNodeBase & {
|
|
|
41
43
|
|
|
42
44
|
export type SiteCategory = SiteNodeBase & {
|
|
43
45
|
kind: 'category';
|
|
46
|
+
categorySourceLabel: string;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
export type SiteNode = SitePage | SiteCategory;
|
|
@@ -88,8 +91,13 @@ const readPageMarkdownDocument = async (entry: SiteEntry) => {
|
|
|
88
91
|
const pageDirectory = getPageDirectory(entry);
|
|
89
92
|
const contentLabel = `${sitePagesLabel}/${pageDirectory}/content.md`;
|
|
90
93
|
const contentPath = path.join(sitePagesDir, pageDirectory, 'content.md');
|
|
91
|
-
const { body } = await readSiteFile(contentPath, contentLabel);
|
|
92
|
-
const markdownDocument = await parsePageMarkdown(body, {
|
|
94
|
+
const { body, frontmatter } = await readSiteFile(contentPath, contentLabel);
|
|
95
|
+
const markdownDocument = await parsePageMarkdown(body, {
|
|
96
|
+
label: contentLabel,
|
|
97
|
+
lineOffset: frontmatter.split('\n').length - 1,
|
|
98
|
+
});
|
|
99
|
+
const detailsError = markdownDocument.diagnostics.find((issue) => issue.code === 'heading-inside-details');
|
|
100
|
+
if (detailsError) throw new Error(`${detailsError.message} ${detailsError.fix}`);
|
|
93
101
|
|
|
94
102
|
if (markdownDocument.pageHeadings.length !== 1 || markdownDocument.regions[0]?.kind !== 'page-intro') {
|
|
95
103
|
throw new Error(`Page entry "${entry.id}" must contain exactly one Markdown H1 page title.`);
|
|
@@ -145,6 +153,8 @@ const createSiteCategory = (category: Awaited<ReturnType<typeof getSiteStructure
|
|
|
145
153
|
order: category.pageOrder,
|
|
146
154
|
},
|
|
147
155
|
pathSegment: category.pagePath,
|
|
156
|
+
pathname: getPagePathname(category.pagePath),
|
|
157
|
+
categorySourceLabel: category.categorySourceLabel,
|
|
148
158
|
pageDirectory: category.pageDirectory,
|
|
149
159
|
pageDirectories: category.pageDirectories,
|
|
150
160
|
pageId: category.pageId,
|
|
@@ -226,6 +236,7 @@ export const getSiteModel = async (entries: SiteEntry[]) => {
|
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
return {
|
|
239
|
+
categoryDestinations: assertCategoryDestinationModel(createCategoryDestinationModel(nodes)),
|
|
229
240
|
categories: nodes.filter((node): node is SiteCategory => node.kind === 'category'),
|
|
230
241
|
nodes,
|
|
231
242
|
pages,
|