@janga/norna 0.7.23 → 0.7.25

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 (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -1,39 +1,109 @@
1
1
  <script>
2
2
  type NavigationState = {
3
3
  openPaths: string[];
4
- sectionPaths: string[];
4
+ sectionOpenByPath: Record<string, boolean>;
5
+ scrollTop: number;
6
+ };
7
+ type SharedNavigationState = Pick<NavigationState, 'openPaths' | 'sectionOpenByPath'>;
8
+ type NavigationSnapshot = {
9
+ branchOpen: boolean[];
10
+ sectionOpen: boolean[];
5
11
  scrollTop: number;
6
12
  };
7
13
 
8
14
  const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
9
15
  const root = container.dataset.navigationRoot ?? '/';
16
+ const sharedRoot = container.dataset.navigationStateRoot ?? root;
10
17
  const stateKey = `norna:tree-navigation:${scope}:${root}`;
18
+ const sharedStateKey = `norna:tree-navigation:shared:${sharedRoot}`;
11
19
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
12
20
  '.navigation-page-disclosure[data-page-path]',
13
21
  ));
14
- let storedState: NavigationState = { openPaths: [], sectionPaths: [], scrollTop: 0 };
22
+ const sectionBranches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
23
+ '.navigation-page-sections-disclosure[data-page-path]',
24
+ ));
25
+ const navigationNodes = Array.from(container.querySelectorAll<HTMLElement>(
26
+ '.navigation-page-node[data-navigation-title]',
27
+ ));
28
+ const getScrollContainer = () => {
29
+ let candidate: HTMLElement | null = container;
15
30
 
16
- try {
17
- const parsedState = JSON.parse(sessionStorage.getItem(stateKey) ?? '{}');
18
- storedState = {
19
- openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
20
- sectionPaths: Array.isArray(parsedState.sectionPaths) ? parsedState.sectionPaths : [],
21
- scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
22
- };
31
+ while (candidate) {
32
+ if (/auto|scroll/.test(getComputedStyle(candidate).overflowY)) return candidate;
33
+ candidate = candidate.parentElement;
34
+ }
23
35
 
36
+ return container;
37
+ };
38
+ const scrollContainer = getScrollContainer();
39
+ let filterSnapshot: NavigationSnapshot | null = null;
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 = (state: SharedNavigationState) => {
24
66
  branches.forEach((branch) => {
25
67
  const pagePath = branch.dataset.pagePath ?? '';
26
68
  branch.open = branch.dataset.currentBranch === 'true'
27
- || storedState.openPaths.includes(pagePath);
28
-
29
- if (storedState.sectionPaths.includes(pagePath)) {
30
- branch.dataset.sectionsOpen = 'true';
31
- }
69
+ || state.openPaths.includes(pagePath);
70
+ });
71
+ sectionBranches.forEach((branch) => {
72
+ const pagePath = branch.dataset.pagePath ?? '';
73
+ const storedOpen = state.sectionOpenByPath[pagePath];
74
+ branch.open = typeof storedOpen === 'boolean'
75
+ ? storedOpen
76
+ : branch.dataset.currentPage === 'true';
32
77
  });
78
+ };
79
+ const mergeSharedState = (state: SharedNavigationState): SharedNavigationState => {
80
+ const existingState = parseState(sessionStorage.getItem(sharedStateKey));
81
+ const knownBranchPaths = new Set(branches.map((branch) => branch.dataset.pagePath ?? ''));
82
+ return {
83
+ openPaths: [
84
+ ...existingState.openPaths.filter((pagePath) => !knownBranchPaths.has(pagePath)),
85
+ ...state.openPaths,
86
+ ],
87
+ sectionOpenByPath: {
88
+ ...existingState.sectionOpenByPath,
89
+ ...state.sectionOpenByPath,
90
+ },
91
+ };
92
+ };
93
+
94
+ try {
95
+ const storedState = parseState(sessionStorage.getItem(stateKey));
96
+ const storedSharedState = sessionStorage.getItem(sharedStateKey);
97
+ applyDisclosureState(storedSharedState === null
98
+ ? storedState
99
+ : parseState(storedSharedState));
100
+ const initialSharedState = mergeSharedState(getDisclosureState());
101
+ lastSharedState = JSON.stringify(initialSharedState);
102
+ sessionStorage.setItem(sharedStateKey, lastSharedState);
33
103
 
34
104
  if (storedState.scrollTop > 0) {
35
105
  requestAnimationFrame(() => {
36
- container.scrollTop = storedState.scrollTop;
106
+ scrollContainer.scrollTop = storedState.scrollTop;
37
107
  });
38
108
  }
39
109
  } catch {
@@ -41,37 +111,204 @@
41
111
  }
42
112
 
43
113
  const saveState = () => {
114
+ if (filterSnapshot) return;
115
+
44
116
  try {
45
- const openBranches = branches.filter((branch) => branch.open);
117
+ const disclosureState = getDisclosureState();
46
118
  const state: NavigationState = {
47
- openPaths: openBranches.map((branch) => branch.dataset.pagePath ?? ''),
48
- sectionPaths: openBranches
49
- .filter((branch) => (
50
- branch.dataset.sectionsOpen === 'true'
51
- || branch.dataset.currentPage === 'true'
52
- ))
53
- .map((branch) => branch.dataset.pagePath ?? ''),
54
- scrollTop: container.scrollTop,
119
+ ...disclosureState,
120
+ scrollTop: scrollContainer.scrollTop,
55
121
  };
56
122
  sessionStorage.setItem(stateKey, JSON.stringify(state));
123
+ const sharedState = mergeSharedState(disclosureState);
124
+ const serializedSharedState = JSON.stringify(sharedState);
125
+ sessionStorage.setItem(sharedStateKey, serializedSharedState);
126
+ if (serializedSharedState !== lastSharedState) {
127
+ lastSharedState = serializedSharedState;
128
+ window.dispatchEvent(new CustomEvent('norna:tree-navigation-state', {
129
+ detail: { root: sharedRoot, state: sharedState },
130
+ }));
131
+ }
57
132
  } catch {
58
133
  // Navigation state persistence is an optional enhancement.
59
134
  }
60
135
  };
136
+ window.addEventListener('norna:tree-navigation-state', (event) => {
137
+ const detail = (event as CustomEvent<{
138
+ root?: string;
139
+ state?: SharedNavigationState;
140
+ }>).detail;
141
+ if (detail?.root !== sharedRoot || !detail.state) return;
142
+ const serializedSharedState = JSON.stringify(detail.state);
143
+ if (serializedSharedState === lastSharedState) return;
144
+ lastSharedState = serializedSharedState;
145
+ applyDisclosureState(detail.state);
146
+ });
147
+ const controls = container.querySelector<HTMLElement>('[data-tree-controls]');
148
+ const filterInput = controls?.querySelector<HTMLInputElement>('[data-tree-filter]') ?? null;
149
+ const expandButton = controls?.querySelector<HTMLButtonElement>('[data-tree-expand-all]') ?? null;
150
+ const collapseButton = controls?.querySelector<HTMLButtonElement>('[data-tree-collapse-all]') ?? null;
151
+ const locateButton = controls?.querySelector<HTMLButtonElement>('[data-tree-locate-current]') ?? null;
152
+ const emptyMessage = controls?.querySelector<HTMLElement>('[data-tree-filter-empty]') ?? null;
153
+ const status = controls?.querySelector<HTMLElement>('[data-tree-status]') ?? null;
154
+ const currentPageNode = container.querySelector<HTMLElement>(
155
+ '.navigation-page-node[data-navigation-current-page="true"]',
156
+ );
157
+ const labels = {
158
+ collapsed: controls?.dataset.treeLabelCollapsed ?? '',
159
+ expanded: controls?.dataset.treeLabelExpanded ?? '',
160
+ filterMatches: controls?.dataset.treeLabelFilterMatches ?? '{count}',
161
+ located: controls?.dataset.treeLabelLocated ?? '',
162
+ };
163
+ const normalizeTitle = (value: string) => value
164
+ .normalize('NFKD')
165
+ .replace(/\p{Mark}+/gu, '')
166
+ .toLocaleLowerCase(document.documentElement.lang || undefined)
167
+ .trim();
168
+ const setStatus = (message: string) => {
169
+ if (status) status.textContent = message;
170
+ };
171
+ const getSnapshot = (): NavigationSnapshot => ({
172
+ branchOpen: branches.map((branch) => branch.open),
173
+ sectionOpen: sectionBranches.map((branch) => branch.open),
174
+ scrollTop: scrollContainer.scrollTop,
175
+ });
176
+ const revealNodeAndAncestors = (node: HTMLElement) => {
177
+ let currentNode: HTMLElement | null = node;
61
178
 
62
- requestAnimationFrame(() => {
63
- branches.forEach((branch) => branch.addEventListener('toggle', () => {
64
- if (branch.open && branch.dataset.hasSections === 'true') {
65
- branch.dataset.sectionsOpen = 'true';
66
- } else if (!branch.open) {
67
- delete branch.dataset.sectionsOpen;
179
+ while (currentNode && container.contains(currentNode)) {
180
+ currentNode.hidden = false;
181
+ const parentNode = currentNode.parentElement?.closest<HTMLElement>(
182
+ '.navigation-page-node[data-navigation-title]',
183
+ ) ?? null;
184
+ const parentBranch = parentNode?.querySelector<HTMLDetailsElement>(
185
+ ':scope > .navigation-page-disclosure[data-page-path]',
186
+ );
187
+ if (parentBranch) parentBranch.open = true;
188
+ currentNode = parentNode;
189
+ }
190
+ };
191
+ const restoreFilterSnapshot = () => {
192
+ if (!filterSnapshot) return;
193
+
194
+ branches.forEach((branch, index) => { branch.open = filterSnapshot?.branchOpen[index] ?? false; });
195
+ sectionBranches.forEach((branch, index) => { branch.open = filterSnapshot?.sectionOpen[index] ?? false; });
196
+ scrollContainer.scrollTop = filterSnapshot.scrollTop;
197
+ filterSnapshot = null;
198
+ requestAnimationFrame(saveState);
199
+ };
200
+ const clearFilter = () => {
201
+ navigationNodes.forEach((node) => { node.hidden = false; });
202
+ delete container.dataset.treeFilterActive;
203
+ if (emptyMessage) emptyMessage.hidden = true;
204
+ if (expandButton) expandButton.disabled = false;
205
+ if (collapseButton) collapseButton.disabled = false;
206
+ restoreFilterSnapshot();
207
+ setStatus('');
208
+ };
209
+ const applyFilter = () => {
210
+ const query = normalizeTitle(filterInput?.value ?? '');
211
+
212
+ if (!query) {
213
+ clearFilter();
214
+ return;
215
+ }
216
+
217
+ if (!filterSnapshot) filterSnapshot = getSnapshot();
218
+ container.dataset.treeFilterActive = 'true';
219
+ if (expandButton) expandButton.disabled = true;
220
+ if (collapseButton) collapseButton.disabled = true;
221
+ navigationNodes.forEach((node) => { node.hidden = true; });
222
+
223
+ const matches = navigationNodes.filter((node) => normalizeTitle(
224
+ node.dataset.navigationTitle ?? '',
225
+ ).includes(query));
226
+ matches.forEach((node) => {
227
+ revealNodeAndAncestors(node);
228
+
229
+ if (node.dataset.navigationKind === 'category') {
230
+ node.querySelectorAll<HTMLElement>('.navigation-page-node[data-navigation-title]')
231
+ .forEach((descendant) => { descendant.hidden = false; });
232
+ const categoryBranch = node.querySelector<HTMLDetailsElement>(
233
+ ':scope > .navigation-category-disclosure',
234
+ );
235
+ if (categoryBranch) categoryBranch.open = true;
68
236
  }
69
- saveState();
70
- }));
237
+ });
238
+
239
+ if (matches.length === 0 && currentPageNode) revealNodeAndAncestors(currentPageNode);
240
+ if (emptyMessage) emptyMessage.hidden = matches.length > 0;
241
+ setStatus(labels.filterMatches.replace('{count}', String(matches.length)));
242
+ };
243
+ const setAllOpen = (open: boolean) => {
244
+ branches.forEach((branch) => { branch.open = open; });
245
+ sectionBranches.forEach((branch) => { branch.open = open; });
246
+ saveState();
247
+ setStatus(open ? labels.expanded : labels.collapsed);
248
+ };
249
+ const locateCurrentPage = () => {
250
+ if (!currentPageNode) return;
251
+
252
+ if (filterInput?.value) {
253
+ filterInput.value = '';
254
+ clearFilter();
255
+ }
256
+ revealNodeAndAncestors(currentPageNode);
257
+ currentPageNode.querySelectorAll<HTMLDetailsElement>(
258
+ ':scope > .navigation-page-disclosure, :scope > .navigation-page-sections-disclosure',
259
+ ).forEach((branch) => { branch.open = true; });
260
+ saveState();
261
+
262
+ const currentPageLink = currentPageNode.querySelector<HTMLAnchorElement>(
263
+ ':scope > .navigation-page-link, :scope > .navigation-page-open-link',
264
+ );
265
+ if (!currentPageLink) return;
266
+
267
+ currentPageLink.focus({ preventScroll: true });
268
+ const scrollBox = scrollContainer.getBoundingClientRect();
269
+ const linkBox = currentPageLink.getBoundingClientRect();
270
+ scrollContainer.scrollTop += linkBox.top - scrollBox.top
271
+ - Math.max(0, (scrollContainer.clientHeight - linkBox.height) / 2);
272
+ setStatus(labels.located);
273
+ };
274
+ const openLinkedPage = (link: HTMLAnchorElement) => {
275
+ if (filterInput?.value) {
276
+ filterInput.value = '';
277
+ clearFilter();
278
+ }
279
+ const pageNode = link.closest<HTMLElement>('.navigation-page-node');
280
+ const pageDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
281
+ ':scope > .navigation-page-disclosure:not(.navigation-category-disclosure)',
282
+ );
283
+ const sectionDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
284
+ ':scope > .navigation-page-sections-disclosure[data-page-path]',
285
+ );
286
+
287
+ if (pageDisclosure) pageDisclosure.open = true;
288
+ if (sectionDisclosure) sectionDisclosure.open = true;
289
+ saveState();
290
+ };
291
+
292
+ requestAnimationFrame(() => {
293
+ branches.forEach((branch) => branch.addEventListener('toggle', saveState));
294
+ sectionBranches.forEach((branch) => branch.addEventListener('toggle', saveState));
71
295
  });
72
296
 
73
- container.addEventListener('scroll', saveState, { passive: true });
74
- container.querySelectorAll('a').forEach((link) => link.addEventListener('click', saveState));
297
+ if (controls) controls.hidden = false;
298
+ if (locateButton && !currentPageNode) locateButton.disabled = true;
299
+ filterInput?.addEventListener('input', applyFilter);
300
+ filterInput?.addEventListener('keydown', (event) => {
301
+ if (event.key !== 'Escape' || !filterInput.value) return;
302
+ event.preventDefault();
303
+ filterInput.value = '';
304
+ clearFilter();
305
+ });
306
+ expandButton?.addEventListener('click', () => setAllOpen(true));
307
+ collapseButton?.addEventListener('click', () => setAllOpen(false));
308
+ locateButton?.addEventListener('click', locateCurrentPage);
309
+ scrollContainer.addEventListener('scroll', saveState, { passive: true });
310
+ container.querySelectorAll<HTMLAnchorElement>('.navigation-page-link, .navigation-page-open-link')
311
+ .forEach((link) => link.addEventListener('click', () => openLinkedPage(link)));
75
312
  window.addEventListener('pagehide', saveState);
76
313
  };
77
314
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs';
4
- import { getIconLinks } from '../lib/sitePublicAssets';
4
+ import { getIconLinks, getSocialImageAsset } from '../lib/sitePublicAssets';
5
5
  import { withBasePath } from '../lib/basePath';
6
6
  import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
7
7
  import '../styles/global.css';
@@ -77,6 +77,8 @@ interface Props {
77
77
  buildInfo?: boolean;
78
78
  };
79
79
  contentsNavigation?: boolean;
80
+ indexable?: boolean;
81
+ searchable?: boolean;
80
82
  }
81
83
 
82
84
  const {
@@ -88,11 +90,17 @@ const {
88
90
  pathname = '/',
89
91
  footer,
90
92
  contentsNavigation = false,
93
+ indexable = true,
94
+ searchable = false,
91
95
  } = Astro.props;
92
96
 
93
97
  const documentTitle = title;
94
98
  const canonicalUrl = new URL(withBasePath(projectConfig.site.basePath, pathname), projectConfig.site.url).href;
95
99
  const iconLinks = getIconLinks();
100
+ const socialImageAsset = getSocialImageAsset();
101
+ const socialImageUrl = socialImageAsset
102
+ ? new URL(withBasePath(projectConfig.site.basePath, socialImageAsset.href), projectConfig.site.url).href
103
+ : null;
96
104
  const scrollBehavior = projectConfig.navigation.scrollBehavior === 'smooth' ? 'smooth' : 'auto';
97
105
  const formatPercent = (value: number) => `${value}%`;
98
106
  const formatViewportHeightPercent = (value: number) => `${value}svh`;
@@ -132,6 +140,65 @@ const documentStyle = [
132
140
  ]
133
141
  : []),
134
142
  ].join('; ');
143
+ const marginNoteReadingWidths = [
144
+ ['narrow', 'min(60ch, 680px)'],
145
+ ['standard', 'min(72ch, 680px)'],
146
+ ['wide', `min(80ch, ${visualTheme.images.width})`],
147
+ ] as const;
148
+ 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');
155
+ const marginNoteLayoutCss = [
156
+ '@media (min-width: 1101px) {',
157
+ ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
158
+ [
159
+ `@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 {`,
161
+ marginNoteDeclarations,
162
+ '}',
163
+ '}',
164
+ ].join('\n'),
165
+ [
166
+ `@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 {`,
168
+ marginNoteDeclarations,
169
+ '}',
170
+ '}',
171
+ ].join('\n'),
172
+ ]),
173
+ '}',
174
+ '@media (min-width: 1101px) and (max-width: 1280px) {',
175
+ ...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
176
+ `@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 {`,
178
+ marginNoteDeclarations,
179
+ '}',
180
+ '}',
181
+ ].join('\n')),
182
+ '}',
183
+ '@media (min-width: 1281px) {',
184
+ ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
185
+ [
186
+ `@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 {`,
188
+ marginNoteDeclarations,
189
+ '}',
190
+ '}',
191
+ ].join('\n'),
192
+ [
193
+ `@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 {`,
195
+ marginNoteDeclarations,
196
+ '}',
197
+ '}',
198
+ ].join('\n'),
199
+ ]),
200
+ '}',
201
+ ].join('\n');
135
202
  const { buildInfo, copyrightMessage } = footer ?? {};
136
203
  const showBuildInfo = buildInfo === true;
137
204
  const buildTime = showBuildInfo
@@ -164,16 +231,29 @@ const readerPreferencesScript = readerPreferencesEnabled
164
231
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
165
232
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
166
233
  data-section-tracking={contentsNavigation ? 'enabled' : undefined}
234
+ data-pagefind-ignore={searchable ? undefined : 'all'}
167
235
  style={documentStyle}
168
236
  >
169
237
  <head>
170
238
  <meta charset="utf-8" />
171
239
  <meta name="viewport" content="width=device-width, initial-scale=1" />
172
240
  <meta name="format-detection" content="telephone=no" />
241
+ {!indexable && <meta name="robots" content="noindex" />}
173
242
  {description && <meta name="description" content={description} />}
243
+ {indexable && <meta property="og:type" content="website" />}
244
+ {indexable && <meta property="og:title" content={documentTitle} />}
245
+ {indexable && <meta property="og:url" content={canonicalUrl} />}
246
+ {indexable && description && <meta property="og:description" content={description} />}
247
+ {indexable && socialImageUrl && <meta property="og:image" content={socialImageUrl} />}
248
+ {indexable && socialImageAsset && <meta property="og:image:type" content={socialImageAsset.type} />}
249
+ {indexable && <meta name="twitter:card" content={socialImageUrl ? 'summary_large_image' : 'summary'} />}
250
+ {indexable && <meta name="twitter:title" content={documentTitle} />}
251
+ {indexable && description && <meta name="twitter:description" content={description} />}
252
+ {indexable && socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
174
253
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
175
254
  {readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
176
- <link rel="canonical" href={canonicalUrl} />
255
+ <style is:inline set:html={marginNoteLayoutCss} />
256
+ {indexable && <link rel="canonical" href={canonicalUrl} />}
177
257
  <meta name="generator" content={Astro.generator} />
178
258
  {iconLinks.map((iconLink) => (
179
259
  <link
@@ -183,6 +263,7 @@ const readerPreferencesScript = readerPreferencesEnabled
183
263
  href={iconLink.href}
184
264
  />
185
265
  ))}
266
+ <slot name="head" />
186
267
  <title>{documentTitle}</title>
187
268
  </head>
188
269
  <body>
@@ -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);
@@ -3,6 +3,7 @@ import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import {
4
4
  formatHeadingIdentifierIssue,
5
5
  } from '../../scripts/lib/heading-ids.mjs';
6
+ import { splitNornaRenderedBlocks } from '../../scripts/lib/norna-markdown-blocks.mjs';
6
7
  import { applyBasePathToHtml } from './basePath';
7
8
  import type { SitePage } from './sitePages';
8
9
 
@@ -36,7 +37,16 @@ type ParsedCardListBlock = {
36
37
  width?: CardListWidth;
37
38
  cards: CardListItem[];
38
39
  };
39
- type ParsedNornaBlock = ParsedImageBlock | ParsedCardListBlock;
40
+ type ParsedPageListBlock = {
41
+ type: 'page-list';
42
+ line: number;
43
+ };
44
+ type ParsedNornaBlock = ParsedImageBlock | ParsedCardListBlock | ParsedPageListBlock;
45
+ type PageListItem = {
46
+ title: string;
47
+ pathname: string;
48
+ description?: string;
49
+ };
40
50
  type InlineNote = {
41
51
  markdown: string;
42
52
  number: number;
@@ -48,7 +58,8 @@ type SectionContentBlock =
48
58
  | { type: 'html'; html: string }
49
59
  | { type: 'image-stack'; images: ManagedImage[] }
50
60
  | { type: 'image-carousel'; images: ManagedImage[] }
51
- | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] };
61
+ | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] }
62
+ | { type: 'page-list'; pages: PageListItem[] };
52
63
  export type ResolvedSection = {
53
64
  id: string | null;
54
65
  title: string;
@@ -192,36 +203,11 @@ const splitRenderedRegions = (html: string, regionCount: number) => {
192
203
  });
193
204
  };
194
205
 
195
- const splitNornaBlockMarkers = (html: string, blocks: ParsedNornaBlock[]) => {
196
- const result: Array<{ type: 'html'; html: string } | ParsedNornaBlock> = [];
197
- const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
198
- const seen = new Set<number>();
199
- let cursor = 0;
200
-
201
- for (const match of html.matchAll(markerRegex)) {
202
- const start = match.index ?? 0;
203
- if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
204
-
205
- const index = Number.parseInt(match[1] ?? '', 10);
206
- const block = blocks[index];
207
- if (!block) throw new Error(`Rendered Norna block ${index + 1} has no matching parsed block.`);
208
- seen.add(index);
209
- result.push(block);
210
- cursor = start + match[0].length;
211
- }
212
-
213
- if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
214
- if (seen.size !== blocks.length) {
215
- throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
216
- }
217
-
218
- return result.filter((block) => block.type !== 'html' || block.html.trim());
219
- };
220
-
221
206
  const resolveContentBlocks = async (
222
207
  html: string,
223
208
  blocks: ParsedNornaBlock[],
224
209
  page: SitePage,
210
+ childPages: SitePage[],
225
211
  inlineNotes: InlineNote[] = [],
226
212
  ) => {
227
213
  const renderedNotes = await Promise.all(inlineNotes.map(async (note) => ({
@@ -229,7 +215,9 @@ const resolveContentBlocks = async (
229
215
  html: await renderInlineNoteMarkdown(note.markdown),
230
216
  })));
231
217
  const renderedHtml = applyInlineNoteMarkup(html, renderedNotes);
232
- const splitBlocks = splitNornaBlockMarkers(renderedHtml, blocks);
218
+ const splitBlocks = splitNornaRenderedBlocks(renderedHtml, blocks) as Array<
219
+ { type: 'html'; html: string } | ParsedNornaBlock
220
+ >;
233
221
  const resolvedBlocks: SectionContentBlock[] = [];
234
222
 
235
223
  for (const block of splitBlocks) {
@@ -252,6 +240,20 @@ const resolveContentBlocks = async (
252
240
  });
253
241
  continue;
254
242
  }
243
+ if (block.type === 'page-list') {
244
+ if (childPages.length === 0) {
245
+ throw new Error(`${page.contentLabel} line ${block.line}: page-list has no listed direct child pages to display. Navigation categories are not pages. Add a listed direct child page or remove the block.`);
246
+ }
247
+ resolvedBlocks.push({
248
+ type: 'page-list',
249
+ pages: childPages.map((childPage) => ({
250
+ title: childPage.title,
251
+ pathname: childPage.pathname,
252
+ description: childPage.entry.data.page?.description,
253
+ })),
254
+ });
255
+ continue;
256
+ }
255
257
 
256
258
  resolvedBlocks.push({
257
259
  type: block.type,
@@ -268,6 +270,7 @@ const resolveContentBlocks = async (
268
270
  export const getSectionsContent = async (
269
271
  html: string,
270
272
  page: SitePage,
273
+ childPages: SitePage[] = [],
271
274
  ) => {
272
275
  const pageDocument = page.markdownDocument;
273
276
  const sourceLabel = page.contentLabel;
@@ -307,6 +310,9 @@ export const getSectionsContent = async (
307
310
  if (bodySection.noteErrors.length > 0) {
308
311
  throw new Error(bodySection.noteErrors[0].message);
309
312
  }
313
+ if (bodySection.calloutErrors.length > 0) {
314
+ throw new Error(bodySection.calloutErrors[0].message);
315
+ }
310
316
 
311
317
  const inlineNotes = bodySection.notes.map((note) => {
312
318
  const number = nextNoteNumber;
@@ -326,7 +332,7 @@ export const getSectionsContent = async (
326
332
  title,
327
333
  titleHtml: await renderHeadingTitleHtml(bodySection.heading.source),
328
334
  headingLevel,
329
- contentBlocks: await resolveContentBlocks(content, bodySection.blocks, page, inlineNotes),
335
+ contentBlocks: await resolveContentBlocks(content, bodySection.blocks, page, childPages, inlineNotes),
330
336
  });
331
337
  }
332
338