@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.
Files changed (131) hide show
  1. package/README.md +3 -2
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/dev-local.mjs +24 -2
  16. package/scripts/generate-schemas.mjs +15 -3
  17. package/scripts/lib/category-destinations.mjs +48 -0
  18. package/scripts/lib/code-fence-metadata.mjs +14 -37
  19. package/scripts/lib/content-tabs.mjs +155 -0
  20. package/scripts/lib/details-headings.mjs +58 -0
  21. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  22. package/scripts/lib/editor-language-service.mjs +238 -116
  23. package/scripts/lib/locale-registry.mjs +597 -0
  24. package/scripts/lib/locales/az-Latn.mjs +78 -0
  25. package/scripts/lib/locales/be.mjs +78 -0
  26. package/scripts/lib/locales/bg.mjs +78 -0
  27. package/scripts/lib/locales/bs.mjs +78 -0
  28. package/scripts/lib/locales/ca.mjs +78 -0
  29. package/scripts/lib/locales/cnr.mjs +78 -0
  30. package/scripts/lib/locales/cs.mjs +78 -0
  31. package/scripts/lib/locales/da.mjs +78 -0
  32. package/scripts/lib/locales/de.mjs +78 -0
  33. package/scripts/lib/locales/el.mjs +78 -0
  34. package/scripts/lib/locales/en.mjs +78 -0
  35. package/scripts/lib/locales/es.mjs +78 -0
  36. package/scripts/lib/locales/et.mjs +78 -0
  37. package/scripts/lib/locales/fi.mjs +78 -0
  38. package/scripts/lib/locales/fil.mjs +78 -0
  39. package/scripts/lib/locales/fr.mjs +78 -0
  40. package/scripts/lib/locales/ga.mjs +78 -0
  41. package/scripts/lib/locales/ha.mjs +78 -0
  42. package/scripts/lib/locales/hr.mjs +78 -0
  43. package/scripts/lib/locales/hu.mjs +78 -0
  44. package/scripts/lib/locales/id.mjs +78 -0
  45. package/scripts/lib/locales/is.mjs +78 -0
  46. package/scripts/lib/locales/it.mjs +78 -0
  47. package/scripts/lib/locales/jv.mjs +78 -0
  48. package/scripts/lib/locales/lb.mjs +78 -0
  49. package/scripts/lib/locales/lt.mjs +78 -0
  50. package/scripts/lib/locales/lv.mjs +78 -0
  51. package/scripts/lib/locales/mk.mjs +78 -0
  52. package/scripts/lib/locales/mt.mjs +78 -0
  53. package/scripts/lib/locales/nb.mjs +78 -0
  54. package/scripts/lib/locales/nl.mjs +78 -0
  55. package/scripts/lib/locales/nn.mjs +78 -0
  56. package/scripts/lib/locales/pcm.mjs +78 -0
  57. package/scripts/lib/locales/pl.mjs +78 -0
  58. package/scripts/lib/locales/pt.mjs +78 -0
  59. package/scripts/lib/locales/ro.mjs +78 -0
  60. package/scripts/lib/locales/ru.mjs +78 -0
  61. package/scripts/lib/locales/sk.mjs +78 -0
  62. package/scripts/lib/locales/sl.mjs +78 -0
  63. package/scripts/lib/locales/sq.mjs +78 -0
  64. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  65. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  66. package/scripts/lib/locales/sv.mjs +78 -0
  67. package/scripts/lib/locales/sw.mjs +78 -0
  68. package/scripts/lib/locales/tr.mjs +78 -0
  69. package/scripts/lib/locales/uk.mjs +78 -0
  70. package/scripts/lib/locales/vi.mjs +78 -0
  71. package/scripts/lib/markdown-links.mjs +4 -25
  72. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  73. package/scripts/lib/markdown-notes.mjs +165 -0
  74. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  75. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  76. package/scripts/lib/page-aliases.mjs +12 -1
  77. package/scripts/lib/page-markdown.mjs +63 -16
  78. package/scripts/lib/page-move-plan.mjs +22 -5
  79. package/scripts/lib/pagefind-translations.mjs +620 -0
  80. package/scripts/lib/presentation-contract.mjs +6 -1
  81. package/scripts/lib/presentation.mjs +29 -30
  82. package/scripts/lib/project-config.mjs +2 -173
  83. package/scripts/lib/schema-definitions.mjs +3 -10
  84. package/scripts/lib/schema-editor-metadata.mjs +22 -43
  85. package/scripts/lib/schema-value-definitions.mjs +4 -8
  86. package/scripts/lib/semantic-callouts.mjs +4 -2
  87. package/scripts/lib/site-content.mjs +4 -2
  88. package/scripts/lib/site-link-graph.mjs +16 -6
  89. package/scripts/lib/site-navigation-tree.mjs +0 -11
  90. package/scripts/lib/sitemap.mjs +5 -3
  91. package/scripts/lib/structured-blocks.mjs +108 -0
  92. package/scripts/lib/table-render-plugin.mjs +46 -3
  93. package/scripts/lib/table-row-headers.mjs +218 -0
  94. package/scripts/lib/theme-presets.mjs +3 -10
  95. package/scripts/lib/yaml-config.mjs +2 -6
  96. package/scripts/migrate-check.mjs +19 -0
  97. package/scripts/sync-content-sections.mjs +39 -10
  98. package/scripts/sync-site-public.mjs +7 -1
  99. package/src/components/CodeBlockCopyScript.astro +47 -5
  100. package/src/components/ContentBlocks.astro +39 -0
  101. package/src/components/ContentTabsScript.astro +60 -0
  102. package/src/components/DisplaySettings.astro +17 -19
  103. package/src/components/NavigationPageTree.astro +1 -1
  104. package/src/components/NoteNavigationScript.astro +4 -0
  105. package/src/components/PageAliasRedirect.astro +5 -4
  106. package/src/components/SearchNavigationScript.astro +4 -0
  107. package/src/components/SearchPage.astro +43 -2
  108. package/src/components/SectionNavigationScript.astro +43 -11
  109. package/src/components/SiteBreadcrumbs.astro +1 -1
  110. package/src/components/SiteNavigation.astro +29 -20
  111. package/src/components/SitePage.astro +40 -15
  112. package/src/components/SiteSection.astro +5 -81
  113. package/src/components/SiteTreeNavigation.astro +2 -2
  114. package/src/components/TableOverflowScript.astro +386 -58
  115. package/src/components/TopPageMenu.astro +47 -0
  116. package/src/components/TreeNavigationScript.astro +10 -3
  117. package/src/layouts/BaseLayout.astro +8 -12
  118. package/src/lib/navigationFollowing.ts +142 -0
  119. package/src/lib/noteNavigation.ts +50 -0
  120. package/src/lib/searchNavigation.ts +107 -0
  121. package/src/lib/sectionContent.ts +20 -90
  122. package/src/lib/siteNavigation.ts +0 -5
  123. package/src/lib/sitePages.ts +13 -2
  124. package/src/lib/table-sort-tooltip.ts +109 -0
  125. package/src/pages/[...slug].astro +16 -6
  126. package/src/styles/content.css +394 -132
  127. package/src/styles/foundations.css +9 -0
  128. package/src/styles/media.css +5 -0
  129. package/src/styles/navigation.css +63 -74
  130. package/src/styles/page-layout.css +7 -0
  131. 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.lang, {
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 InlineNote = {
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 renderedNotes = await Promise.all(inlineNotes.map(async (note) => ({
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
- if (bodySection.calloutErrors.length > 0) {
314
- throw new Error(bodySection.calloutErrors[0].message);
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: await resolveContentBlocks(content, bodySection.blocks, page, childPages, inlineNotes),
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
  );
@@ -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, { label: contentLabel });
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,