@janga/norna 0.7.22 → 0.7.24

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 (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -1,7 +1,12 @@
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 NavigationSnapshot = {
8
+ branchOpen: boolean[];
9
+ sectionOpen: boolean[];
5
10
  scrollTop: number;
6
11
  };
7
12
 
@@ -11,13 +16,34 @@
11
16
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
12
17
  '.navigation-page-disclosure[data-page-path]',
13
18
  ));
14
- let storedState: NavigationState = { openPaths: [], sectionPaths: [], scrollTop: 0 };
19
+ const sectionBranches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
20
+ '.navigation-page-sections-disclosure[data-page-path]',
21
+ ));
22
+ const navigationNodes = Array.from(container.querySelectorAll<HTMLElement>(
23
+ '.navigation-page-node[data-navigation-title]',
24
+ ));
25
+ const getScrollContainer = () => {
26
+ let candidate: HTMLElement | null = container;
27
+
28
+ while (candidate) {
29
+ if (/auto|scroll/.test(getComputedStyle(candidate).overflowY)) return candidate;
30
+ candidate = candidate.parentElement;
31
+ }
32
+
33
+ return container;
34
+ };
35
+ const scrollContainer = getScrollContainer();
36
+ let filterSnapshot: NavigationSnapshot | null = null;
15
37
 
16
38
  try {
17
39
  const parsedState = JSON.parse(sessionStorage.getItem(stateKey) ?? '{}');
18
- storedState = {
40
+ const storedState: NavigationState = {
19
41
  openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
20
- sectionPaths: Array.isArray(parsedState.sectionPaths) ? parsedState.sectionPaths : [],
42
+ sectionOpenByPath: parsedState.sectionOpenByPath
43
+ && typeof parsedState.sectionOpenByPath === 'object'
44
+ ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
45
+ .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
46
+ : {},
21
47
  scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
22
48
  };
23
49
 
@@ -25,15 +51,18 @@
25
51
  const pagePath = branch.dataset.pagePath ?? '';
26
52
  branch.open = branch.dataset.currentBranch === 'true'
27
53
  || storedState.openPaths.includes(pagePath);
28
-
29
- if (storedState.sectionPaths.includes(pagePath)) {
30
- branch.dataset.sectionsOpen = 'true';
31
- }
54
+ });
55
+ sectionBranches.forEach((branch) => {
56
+ const pagePath = branch.dataset.pagePath ?? '';
57
+ const storedOpen = storedState.sectionOpenByPath[pagePath];
58
+ branch.open = typeof storedOpen === 'boolean'
59
+ ? storedOpen
60
+ : branch.dataset.currentPage === 'true';
32
61
  });
33
62
 
34
63
  if (storedState.scrollTop > 0) {
35
64
  requestAnimationFrame(() => {
36
- container.scrollTop = storedState.scrollTop;
65
+ scrollContainer.scrollTop = storedState.scrollTop;
37
66
  });
38
67
  }
39
68
  } catch {
@@ -41,37 +70,189 @@
41
70
  }
42
71
 
43
72
  const saveState = () => {
73
+ if (filterSnapshot) return;
74
+
44
75
  try {
45
76
  const openBranches = branches.filter((branch) => branch.open);
77
+ const sectionOpenByPath = Object.fromEntries(sectionBranches.map((branch) => [
78
+ branch.dataset.pagePath ?? '',
79
+ branch.open,
80
+ ]));
46
81
  const state: NavigationState = {
47
82
  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,
83
+ sectionOpenByPath,
84
+ scrollTop: scrollContainer.scrollTop,
55
85
  };
56
86
  sessionStorage.setItem(stateKey, JSON.stringify(state));
57
87
  } catch {
58
88
  // Navigation state persistence is an optional enhancement.
59
89
  }
60
90
  };
91
+ const controls = container.querySelector<HTMLElement>('[data-tree-controls]');
92
+ const filterInput = controls?.querySelector<HTMLInputElement>('[data-tree-filter]') ?? null;
93
+ const expandButton = controls?.querySelector<HTMLButtonElement>('[data-tree-expand-all]') ?? null;
94
+ const collapseButton = controls?.querySelector<HTMLButtonElement>('[data-tree-collapse-all]') ?? null;
95
+ const locateButton = controls?.querySelector<HTMLButtonElement>('[data-tree-locate-current]') ?? null;
96
+ const emptyMessage = controls?.querySelector<HTMLElement>('[data-tree-filter-empty]') ?? null;
97
+ const status = controls?.querySelector<HTMLElement>('[data-tree-status]') ?? null;
98
+ const currentPageNode = container.querySelector<HTMLElement>(
99
+ '.navigation-page-node[data-navigation-current-page="true"]',
100
+ );
101
+ const labels = {
102
+ collapsed: controls?.dataset.treeLabelCollapsed ?? '',
103
+ expanded: controls?.dataset.treeLabelExpanded ?? '',
104
+ filterMatches: controls?.dataset.treeLabelFilterMatches ?? '{count}',
105
+ located: controls?.dataset.treeLabelLocated ?? '',
106
+ };
107
+ const normalizeTitle = (value: string) => value
108
+ .normalize('NFKD')
109
+ .replace(/\p{Mark}+/gu, '')
110
+ .toLocaleLowerCase(document.documentElement.lang || undefined)
111
+ .trim();
112
+ const setStatus = (message: string) => {
113
+ if (status) status.textContent = message;
114
+ };
115
+ const getSnapshot = (): NavigationSnapshot => ({
116
+ branchOpen: branches.map((branch) => branch.open),
117
+ sectionOpen: sectionBranches.map((branch) => branch.open),
118
+ scrollTop: scrollContainer.scrollTop,
119
+ });
120
+ const revealNodeAndAncestors = (node: HTMLElement) => {
121
+ let currentNode: HTMLElement | null = node;
122
+
123
+ while (currentNode && container.contains(currentNode)) {
124
+ currentNode.hidden = false;
125
+ const parentNode = currentNode.parentElement?.closest<HTMLElement>(
126
+ '.navigation-page-node[data-navigation-title]',
127
+ ) ?? null;
128
+ const parentBranch = parentNode?.querySelector<HTMLDetailsElement>(
129
+ ':scope > .navigation-page-disclosure[data-page-path]',
130
+ );
131
+ if (parentBranch) parentBranch.open = true;
132
+ currentNode = parentNode;
133
+ }
134
+ };
135
+ const restoreFilterSnapshot = () => {
136
+ if (!filterSnapshot) return;
61
137
 
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;
138
+ branches.forEach((branch, index) => { branch.open = filterSnapshot?.branchOpen[index] ?? false; });
139
+ sectionBranches.forEach((branch, index) => { branch.open = filterSnapshot?.sectionOpen[index] ?? false; });
140
+ scrollContainer.scrollTop = filterSnapshot.scrollTop;
141
+ filterSnapshot = null;
142
+ requestAnimationFrame(saveState);
143
+ };
144
+ const clearFilter = () => {
145
+ navigationNodes.forEach((node) => { node.hidden = false; });
146
+ delete container.dataset.treeFilterActive;
147
+ if (emptyMessage) emptyMessage.hidden = true;
148
+ if (expandButton) expandButton.disabled = false;
149
+ if (collapseButton) collapseButton.disabled = false;
150
+ restoreFilterSnapshot();
151
+ setStatus('');
152
+ };
153
+ const applyFilter = () => {
154
+ const query = normalizeTitle(filterInput?.value ?? '');
155
+
156
+ if (!query) {
157
+ clearFilter();
158
+ return;
159
+ }
160
+
161
+ if (!filterSnapshot) filterSnapshot = getSnapshot();
162
+ container.dataset.treeFilterActive = 'true';
163
+ if (expandButton) expandButton.disabled = true;
164
+ if (collapseButton) collapseButton.disabled = true;
165
+ navigationNodes.forEach((node) => { node.hidden = true; });
166
+
167
+ const matches = navigationNodes.filter((node) => normalizeTitle(
168
+ node.dataset.navigationTitle ?? '',
169
+ ).includes(query));
170
+ matches.forEach((node) => {
171
+ revealNodeAndAncestors(node);
172
+
173
+ if (node.dataset.navigationKind === 'category') {
174
+ node.querySelectorAll<HTMLElement>('.navigation-page-node[data-navigation-title]')
175
+ .forEach((descendant) => { descendant.hidden = false; });
176
+ const categoryBranch = node.querySelector<HTMLDetailsElement>(
177
+ ':scope > .navigation-category-disclosure',
178
+ );
179
+ if (categoryBranch) categoryBranch.open = true;
68
180
  }
69
- saveState();
70
- }));
181
+ });
182
+
183
+ if (matches.length === 0 && currentPageNode) revealNodeAndAncestors(currentPageNode);
184
+ if (emptyMessage) emptyMessage.hidden = matches.length > 0;
185
+ setStatus(labels.filterMatches.replace('{count}', String(matches.length)));
186
+ };
187
+ const setAllOpen = (open: boolean) => {
188
+ branches.forEach((branch) => { branch.open = open; });
189
+ sectionBranches.forEach((branch) => { branch.open = open; });
190
+ saveState();
191
+ setStatus(open ? labels.expanded : labels.collapsed);
192
+ };
193
+ const locateCurrentPage = () => {
194
+ if (!currentPageNode) return;
195
+
196
+ if (filterInput?.value) {
197
+ filterInput.value = '';
198
+ clearFilter();
199
+ }
200
+ revealNodeAndAncestors(currentPageNode);
201
+ currentPageNode.querySelectorAll<HTMLDetailsElement>(
202
+ ':scope > .navigation-page-disclosure, :scope > .navigation-page-sections-disclosure',
203
+ ).forEach((branch) => { branch.open = true; });
204
+ saveState();
205
+
206
+ const currentPageLink = currentPageNode.querySelector<HTMLAnchorElement>(
207
+ ':scope > .navigation-page-link, :scope > .navigation-page-open-link',
208
+ );
209
+ if (!currentPageLink) return;
210
+
211
+ currentPageLink.focus({ preventScroll: true });
212
+ const scrollBox = scrollContainer.getBoundingClientRect();
213
+ const linkBox = currentPageLink.getBoundingClientRect();
214
+ scrollContainer.scrollTop += linkBox.top - scrollBox.top
215
+ - Math.max(0, (scrollContainer.clientHeight - linkBox.height) / 2);
216
+ setStatus(labels.located);
217
+ };
218
+ const openLinkedPage = (link: HTMLAnchorElement) => {
219
+ if (filterInput?.value) {
220
+ filterInput.value = '';
221
+ clearFilter();
222
+ }
223
+ const pageNode = link.closest<HTMLElement>('.navigation-page-node');
224
+ const pageDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
225
+ ':scope > .navigation-page-disclosure:not(.navigation-category-disclosure)',
226
+ );
227
+ const sectionDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
228
+ ':scope > .navigation-page-sections-disclosure[data-page-path]',
229
+ );
230
+
231
+ if (pageDisclosure) pageDisclosure.open = true;
232
+ if (sectionDisclosure) sectionDisclosure.open = true;
233
+ saveState();
234
+ };
235
+
236
+ requestAnimationFrame(() => {
237
+ branches.forEach((branch) => branch.addEventListener('toggle', saveState));
238
+ sectionBranches.forEach((branch) => branch.addEventListener('toggle', saveState));
71
239
  });
72
240
 
73
- container.addEventListener('scroll', saveState, { passive: true });
74
- container.querySelectorAll('a').forEach((link) => link.addEventListener('click', saveState));
241
+ if (controls) controls.hidden = false;
242
+ if (locateButton && !currentPageNode) locateButton.disabled = true;
243
+ filterInput?.addEventListener('input', applyFilter);
244
+ filterInput?.addEventListener('keydown', (event) => {
245
+ if (event.key !== 'Escape' || !filterInput.value) return;
246
+ event.preventDefault();
247
+ filterInput.value = '';
248
+ clearFilter();
249
+ });
250
+ expandButton?.addEventListener('click', () => setAllOpen(true));
251
+ collapseButton?.addEventListener('click', () => setAllOpen(false));
252
+ locateButton?.addEventListener('click', locateCurrentPage);
253
+ scrollContainer.addEventListener('scroll', saveState, { passive: true });
254
+ container.querySelectorAll<HTMLAnchorElement>('.navigation-page-link, .navigation-page-open-link')
255
+ .forEach((link) => link.addEventListener('click', () => openLinkedPage(link)));
75
256
  window.addEventListener('pagehide', saveState);
76
257
  };
77
258
 
@@ -1,5 +1,6 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
+ import { z } from 'astro/zod';
3
4
  import { basename } from 'node:path';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import {
@@ -19,7 +20,9 @@ const siteEntryId = `${siteDirLabel
19
20
  .replace(/^[./\\]+/, '')
20
21
  .replace(/[^a-zA-Z0-9-]+/g, '-')
21
22
  .replace(/^-+|-+$/g, '') || 'site'}-content`;
22
- const siteThemeSchema = themeVisualSchema;
23
+ const emptyYamlMapping = (value: unknown) => value ?? {};
24
+ const siteThemeSchema = z.preprocess(emptyYamlMapping, themeVisualSchema);
25
+ const sitewideContentSchema = z.preprocess(emptyYamlMapping, sitewideSchema);
23
26
 
24
27
  const site = defineCollection({
25
28
  loader: glob({
@@ -52,7 +55,7 @@ const sitewide = defineCollection({
52
55
  base: pathToFileURL(siteDir),
53
56
  generateId: () => `${siteEntryId.replace(/-content$/, '')}-sitewide`,
54
57
  }),
55
- schema: sitewideSchema,
58
+ schema: sitewideContentSchema,
56
59
  });
57
60
 
58
61
  export const collections = { site, theme, sitewide };
@@ -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';
@@ -19,7 +19,7 @@ type Presentation = {
19
19
  page: FrameColors;
20
20
  css: Record<string, string>;
21
21
  }>;
22
- colorMode: {
22
+ appearance: {
23
23
  default: 'system' | 'light' | 'dark';
24
24
  };
25
25
  readerPreferences: {
@@ -52,9 +52,10 @@ type VisualTheme = {
52
52
  };
53
53
  };
54
54
  images: {
55
+ presentation: 'prose-aligned' | 'centered-fit';
55
56
  width: string;
56
57
  maxAvailableWidthPercent: { desktop: number; mobile: number };
57
- maxAvailableHeightPercent: { desktop: number; mobile: number };
58
+ maxAvailableHeightPercent?: { desktop: number; mobile: number };
58
59
  };
59
60
  typography: { fontFamily: string };
60
61
  corners: {
@@ -75,7 +76,9 @@ interface Props {
75
76
  copyrightMessage?: string;
76
77
  buildInfo?: boolean;
77
78
  };
78
- treeNavigation?: boolean;
79
+ contentsNavigation?: boolean;
80
+ indexable?: boolean;
81
+ searchable?: boolean;
79
82
  }
80
83
 
81
84
  const {
@@ -86,12 +89,18 @@ const {
86
89
  frameColors,
87
90
  pathname = '/',
88
91
  footer,
89
- treeNavigation = false,
92
+ contentsNavigation = false,
93
+ indexable = true,
94
+ searchable = false,
90
95
  } = Astro.props;
91
96
 
92
97
  const documentTitle = title;
93
98
  const canonicalUrl = new URL(withBasePath(projectConfig.site.basePath, pathname), projectConfig.site.url).href;
94
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;
95
104
  const scrollBehavior = projectConfig.navigation.scrollBehavior === 'smooth' ? 'smooth' : 'auto';
96
105
  const formatPercent = (value: number) => `${value}%`;
97
106
  const formatViewportHeightPercent = (value: number) => `${value}svh`;
@@ -124,9 +133,46 @@ const documentStyle = [
124
133
  `--image-width: ${visualTheme.images.width}`,
125
134
  `--image-max-available-width-desktop: ${formatPercent(visualTheme.images.maxAvailableWidthPercent.desktop)}`,
126
135
  `--image-max-available-width-mobile: ${formatPercent(visualTheme.images.maxAvailableWidthPercent.mobile)}`,
127
- `--image-max-height-desktop: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.desktop)}`,
128
- `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
136
+ ...(visualTheme.images.maxAvailableHeightPercent
137
+ ? [
138
+ `--image-max-height-desktop: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.desktop)}`,
139
+ `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
140
+ ]
141
+ : []),
129
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 vacantContentsTrackWidth = `11rem + ${visualTheme.layout.localNavigationGap}`;
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}"] .section-note {`,
161
+ marginNoteDeclarations,
162
+ '}',
163
+ '}',
164
+ ].join('\n'),
165
+ [
166
+ `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} - ${vacantContentsTrackWidth})) {`,
167
+ `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note,`,
168
+ `:root[data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note {`,
169
+ marginNoteDeclarations,
170
+ '}',
171
+ '}',
172
+ ].join('\n'),
173
+ ]),
174
+ '}',
175
+ ].join('\n');
130
176
  const { buildInfo, copyrightMessage } = footer ?? {};
131
177
  const showBuildInfo = buildInfo === true;
132
178
  const buildTime = showBuildInfo
@@ -154,20 +200,34 @@ const readerPreferencesScript = readerPreferencesEnabled
154
200
  <!doctype html>
155
201
  <html
156
202
  lang={projectConfig.locale.lang}
157
- data-color-mode={presentation.readerPreferences.defaults.appearance}
203
+ data-appearance={presentation.readerPreferences.defaults.appearance}
204
+ data-image-presentation={visualTheme.images.presentation}
158
205
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
159
206
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
160
- data-section-tracking={treeNavigation && projectConfig.navigation.sectionTracking ? 'enabled' : undefined}
207
+ data-section-tracking={contentsNavigation ? 'enabled' : undefined}
208
+ data-pagefind-ignore={searchable ? undefined : 'all'}
161
209
  style={documentStyle}
162
210
  >
163
211
  <head>
164
212
  <meta charset="utf-8" />
165
213
  <meta name="viewport" content="width=device-width, initial-scale=1" />
166
214
  <meta name="format-detection" content="telephone=no" />
215
+ {!indexable && <meta name="robots" content="noindex" />}
167
216
  {description && <meta name="description" content={description} />}
217
+ {indexable && <meta property="og:type" content="website" />}
218
+ {indexable && <meta property="og:title" content={documentTitle} />}
219
+ {indexable && <meta property="og:url" content={canonicalUrl} />}
220
+ {indexable && description && <meta property="og:description" content={description} />}
221
+ {indexable && socialImageUrl && <meta property="og:image" content={socialImageUrl} />}
222
+ {indexable && socialImageAsset && <meta property="og:image:type" content={socialImageAsset.type} />}
223
+ {indexable && <meta name="twitter:card" content={socialImageUrl ? 'summary_large_image' : 'summary'} />}
224
+ {indexable && <meta name="twitter:title" content={documentTitle} />}
225
+ {indexable && description && <meta name="twitter:description" content={description} />}
226
+ {indexable && socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
168
227
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
169
228
  {readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
170
- <link rel="canonical" href={canonicalUrl} />
229
+ <style is:inline set:html={marginNoteLayoutCss} />
230
+ {indexable && <link rel="canonical" href={canonicalUrl} />}
171
231
  <meta name="generator" content={Astro.generator} />
172
232
  {iconLinks.map((iconLink) => (
173
233
  <link
@@ -177,6 +237,7 @@ const readerPreferencesScript = readerPreferencesEnabled
177
237
  href={iconLink.href}
178
238
  />
179
239
  ))}
240
+ <slot name="head" />
180
241
  <title>{documentTitle}</title>
181
242
  </head>
182
243
  <body>
@@ -1,7 +1,7 @@
1
1
  const preferenceDefinitions = Object.freeze({
2
2
  appearance: Object.freeze({
3
3
  allowed: Object.freeze(['system', 'light', 'dark']),
4
- cookieName: 'norna-color-mode',
4
+ cookieName: 'norna-appearance',
5
5
  }),
6
6
  readingWidth: Object.freeze({
7
7
  allowed: Object.freeze(['narrow', 'standard', 'wide']),
@@ -72,7 +72,7 @@ export const getReaderPreferencesScript = ({ controls, defaults, cookiePath }) =
72
72
  const apply = (name, value, persist = false) => {
73
73
  const nextValue = isAllowed(name, value) ? value : defaults[name];
74
74
  values[name] = nextValue;
75
- if (name === 'appearance') root.dataset.colorMode = nextValue;
75
+ if (name === 'appearance') root.dataset.appearance = nextValue;
76
76
  if (name === 'readingWidth') root.dataset.readingWidth = nextValue;
77
77
  if (name === 'focusReading') root.dataset.focusReading = nextValue;
78
78
  if (persist) writeCookie(definitions[name].cookieName, nextValue);
@@ -36,7 +36,16 @@ type ParsedCardListBlock = {
36
36
  width?: CardListWidth;
37
37
  cards: CardListItem[];
38
38
  };
39
- type ParsedNornaBlock = ParsedImageBlock | ParsedCardListBlock;
39
+ type ParsedPageListBlock = {
40
+ type: 'page-list';
41
+ line: number;
42
+ };
43
+ type ParsedNornaBlock = ParsedImageBlock | ParsedCardListBlock | ParsedPageListBlock;
44
+ type PageListItem = {
45
+ title: string;
46
+ pathname: string;
47
+ description?: string;
48
+ };
40
49
  type InlineNote = {
41
50
  markdown: string;
42
51
  number: number;
@@ -48,7 +57,8 @@ type SectionContentBlock =
48
57
  | { type: 'html'; html: string }
49
58
  | { type: 'image-stack'; images: ManagedImage[] }
50
59
  | { type: 'image-carousel'; images: ManagedImage[] }
51
- | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] };
60
+ | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] }
61
+ | { type: 'page-list'; pages: PageListItem[] };
52
62
  export type ResolvedSection = {
53
63
  id: string | null;
54
64
  title: string;
@@ -222,6 +232,7 @@ const resolveContentBlocks = async (
222
232
  html: string,
223
233
  blocks: ParsedNornaBlock[],
224
234
  page: SitePage,
235
+ childPages: SitePage[],
225
236
  inlineNotes: InlineNote[] = [],
226
237
  ) => {
227
238
  const renderedNotes = await Promise.all(inlineNotes.map(async (note) => ({
@@ -252,6 +263,20 @@ const resolveContentBlocks = async (
252
263
  });
253
264
  continue;
254
265
  }
266
+ if (block.type === 'page-list') {
267
+ if (childPages.length === 0) {
268
+ 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.`);
269
+ }
270
+ resolvedBlocks.push({
271
+ type: 'page-list',
272
+ pages: childPages.map((childPage) => ({
273
+ title: childPage.title,
274
+ pathname: childPage.pathname,
275
+ description: childPage.entry.data.page?.description,
276
+ })),
277
+ });
278
+ continue;
279
+ }
255
280
 
256
281
  resolvedBlocks.push({
257
282
  type: block.type,
@@ -268,6 +293,7 @@ const resolveContentBlocks = async (
268
293
  export const getSectionsContent = async (
269
294
  html: string,
270
295
  page: SitePage,
296
+ childPages: SitePage[] = [],
271
297
  ) => {
272
298
  const pageDocument = page.markdownDocument;
273
299
  const sourceLabel = page.contentLabel;
@@ -307,6 +333,9 @@ export const getSectionsContent = async (
307
333
  if (bodySection.noteErrors.length > 0) {
308
334
  throw new Error(bodySection.noteErrors[0].message);
309
335
  }
336
+ if (bodySection.calloutErrors.length > 0) {
337
+ throw new Error(bodySection.calloutErrors[0].message);
338
+ }
310
339
 
311
340
  const inlineNotes = bodySection.notes.map((note) => {
312
341
  const number = nextNoteNumber;
@@ -326,7 +355,7 @@ export const getSectionsContent = async (
326
355
  title,
327
356
  titleHtml: await renderHeadingTitleHtml(bodySection.heading.source),
328
357
  headingLevel,
329
- contentBlocks: await resolveContentBlocks(content, bodySection.blocks, page, inlineNotes),
358
+ contentBlocks: await resolveContentBlocks(content, bodySection.blocks, page, childPages, inlineNotes),
330
359
  });
331
360
  }
332
361
 
@@ -1,5 +1,12 @@
1
1
  import type { HeadingNavigation, SectionNavigation } from './sectionContent';
2
2
  import type { SiteNode, SitePage } from './sitePages';
3
+ import {
4
+ flattenSiteNavigationTree as flattenSiteNavigationTreeShared,
5
+ getDirectChildPages as getDirectChildPagesShared,
6
+ getFirstPageInNavigationNode as getFirstPageInNavigationNodeShared,
7
+ getListedSiteNavigationTree as getListedSiteNavigationTreeShared,
8
+ getSequentialPageNavigation as getSequentialPageNavigationShared,
9
+ } from '../../scripts/lib/site-navigation-tree.mjs';
3
10
 
4
11
  export type SiteNavigationEntry = {
5
12
  node: SiteNode;
@@ -11,57 +18,29 @@ export type SiteNavigationNode = SiteNavigationEntry & {
11
18
  children: SiteNavigationNode[];
12
19
  };
13
20
 
14
- export const getSiteNavigationTree = (entries: SiteNavigationEntry[]) => {
15
- const nodes = new Map<string, SiteNavigationNode>();
16
- const roots: SiteNavigationNode[] = [];
17
-
18
- for (const entry of entries) {
19
- nodes.set(entry.node.pagePath, { ...entry, children: [] });
20
- }
21
-
22
- for (const entry of entries) {
23
- const node = nodes.get(entry.node.pagePath);
24
- if (!node) continue;
25
-
26
- if (entry.node.isHome || entry.node.parentPagePath === null) {
27
- roots.push(node);
28
- continue;
29
- }
30
-
31
- const parent = nodes.get(entry.node.parentPagePath);
32
- if (!parent) {
33
- throw new Error(`Site node "${entry.node.pagePath}" has no parent node "${entry.node.parentPagePath}".`);
34
- }
35
-
36
- parent.children.push(node);
37
- }
38
-
39
- return roots;
40
- };
41
-
42
- export const getListedSiteNavigationTree = (entries: SiteNavigationEntry[]) => {
43
- const filterListed = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => nodes.flatMap((node) => {
44
- if (!node.node.isHome && !node.node.navigation.listed) return [];
45
- const children = filterListed(node.children);
46
- if (node.node.kind === 'category' && children.length === 0) return [];
47
- return [{ ...node, children }];
48
- });
49
-
50
- return filterListed(getSiteNavigationTree(entries));
51
- };
52
-
53
- export const getFirstPageInNavigationNode = (node: SiteNavigationNode): SitePage | null => {
54
- if (node.node.kind === 'page') return node.node;
55
-
56
- for (const child of node.children) {
57
- const page = getFirstPageInNavigationNode(child);
58
- if (page) return page;
21
+ export const getListedSiteNavigationTree = (entries: SiteNavigationEntry[]) => (
22
+ getListedSiteNavigationTreeShared(entries) as SiteNavigationNode[]
23
+ );
24
+
25
+ export const getFirstPageInNavigationNode = (node: SiteNavigationNode): SitePage | null => (
26
+ getFirstPageInNavigationNodeShared(node) as SitePage | null
27
+ );
28
+
29
+ export const flattenSiteNavigationTree = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => (
30
+ flattenSiteNavigationTreeShared(nodes) as SiteNavigationNode[]
31
+ );
32
+
33
+ export const getSequentialPageNavigation = (
34
+ nodes: SiteNavigationNode[],
35
+ currentPagePath: string,
36
+ ): { previous: SitePage | null; next: SitePage | null } => (
37
+ getSequentialPageNavigationShared(nodes, currentPagePath) as {
38
+ previous: SitePage | null;
39
+ next: SitePage | null;
59
40
  }
41
+ );
60
42
 
61
- return null;
62
- };
63
-
64
- export const flattenSiteNavigationTree = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => nodes.flatMap((node) => [
65
- node,
66
- ...flattenSiteNavigationTree(node.children),
67
- ]);
43
+ export const getDirectChildPages = (
44
+ nodes: SiteNavigationNode[],
45
+ currentPagePath: string,
46
+ ): SitePage[] => getDirectChildPagesShared(nodes, currentPagePath) as SitePage[];