@janga/norna 0.7.23 → 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 (71) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +12 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +13 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +45 -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 +12 -1
  13. package/scripts/generate-search-index.mjs +57 -0
  14. package/scripts/init-site.mjs +1 -0
  15. package/scripts/lib/code-fence-metadata.mjs +223 -0
  16. package/scripts/lib/edit-source-link.mjs +49 -0
  17. package/scripts/lib/editor-language-service.mjs +32 -12
  18. package/scripts/lib/image-presentation.mjs +4 -0
  19. package/scripts/lib/navigation-model.mjs +34 -13
  20. package/scripts/lib/navigation-review.mjs +396 -0
  21. package/scripts/lib/norna-markdown-blocks.mjs +73 -26
  22. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  23. package/scripts/lib/page-aliases.mjs +21 -1
  24. package/scripts/lib/page-markdown.mjs +32 -1
  25. package/scripts/lib/page-move-plan.mjs +659 -0
  26. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  27. package/scripts/lib/presentation.mjs +1 -10
  28. package/scripts/lib/project-config.mjs +111 -5
  29. package/scripts/lib/public-asset-conventions.mjs +36 -2
  30. package/scripts/lib/schema-definitions.mjs +22 -5
  31. package/scripts/lib/schema-editor-metadata.mjs +19 -4
  32. package/scripts/lib/schema-value-definitions.mjs +1 -1
  33. package/scripts/lib/semantic-callouts.mjs +128 -0
  34. package/scripts/lib/site-content.mjs +2 -1
  35. package/scripts/lib/site-link-graph.mjs +33 -2
  36. package/scripts/lib/site-navigation-tree.mjs +85 -0
  37. package/scripts/lib/social-image-assets.mjs +30 -0
  38. package/scripts/lib/theme-presets.mjs +2 -2
  39. package/scripts/lib/theme-profiles.mjs +0 -4
  40. package/scripts/move-site-page.mjs +256 -0
  41. package/scripts/review-navigation.mjs +32 -0
  42. package/scripts/sync-content-sections.mjs +67 -4
  43. package/scripts/sync-site-public.mjs +24 -5
  44. package/src/components/CodeBlockCopyScript.astro +9 -4
  45. package/src/components/EditSourceLink.astro +17 -0
  46. package/src/components/ImageCarousel.astro +5 -6
  47. package/src/components/NavigationPageTree.astro +66 -55
  48. package/src/components/NavigationTreeControls.astro +77 -0
  49. package/src/components/PageAliasRedirect.astro +1 -1
  50. package/src/components/PageList.astro +33 -0
  51. package/src/components/PageSequenceNavigation.astro +37 -0
  52. package/src/components/SearchPage.astro +151 -0
  53. package/src/components/SiteNavigation.astro +35 -8
  54. package/src/components/SitePage.astro +79 -26
  55. package/src/components/SiteSection.astro +11 -3
  56. package/src/components/SiteTreeNavigation.astro +8 -1
  57. package/src/components/TreeNavigationScript.astro +207 -26
  58. package/src/layouts/BaseLayout.astro +57 -2
  59. package/src/lib/sectionContent.ts +32 -3
  60. package/src/lib/siteNavigation.ts +31 -52
  61. package/src/lib/sitePublicAssets.ts +1 -0
  62. package/src/pages/404.astro +110 -0
  63. package/src/pages/[...slug].astro +15 -5
  64. package/src/styles/content.css +242 -18
  65. package/src/styles/media.css +0 -5
  66. package/src/styles/navigation.css +31 -0
  67. package/src/styles/page-layout.css +438 -89
  68. package/src/styles/responsive.css +77 -8
  69. package/starters/basic/README.md +6 -19
  70. package/starters/basic/package.json +1 -0
  71. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -2,21 +2,32 @@
2
2
  import { getCollection, type CollectionEntry } from 'astro:content';
3
3
  import BaseLayout from '../layouts/BaseLayout.astro';
4
4
  import CodeBlockCopyScript from './CodeBlockCopyScript.astro';
5
+ import EditSourceLink from './EditSourceLink.astro';
5
6
  import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
6
7
  import SiteNavigation from './SiteNavigation.astro';
7
8
  import SiteBanners from './SiteBanners.astro';
8
9
  import PageContentsNavigation from './PageContentsNavigation.astro';
10
+ import PageSequenceNavigation from './PageSequenceNavigation.astro';
9
11
  import SiteSection from './SiteSection.astro';
10
12
  import SiteTreeNavigation from './SiteTreeNavigation.astro';
11
13
  import TreeNavigationScript from './TreeNavigationScript.astro';
12
14
  import { getSectionsContent, type ResolvedSection } from '../lib/sectionContent';
13
15
  import { getPageTheme } from '../lib/pageThemes';
14
16
  import { getSiteModel, type SitePage } from '../lib/sitePages';
15
- import { flattenSiteNavigationTree, getListedSiteNavigationTree } from '../lib/siteNavigation';
17
+ import {
18
+ flattenSiteNavigationTree,
19
+ getDirectChildPages,
20
+ getListedSiteNavigationTree,
21
+ getSequentialPageNavigation,
22
+ } from '../lib/siteNavigation';
16
23
  import { getToday, isVisibleToday } from '../lib/visibility';
17
24
  import { getLogoAsset } from '../lib/sitePublicAssets';
18
- import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
25
+ import {
26
+ resolveNavigationModel,
27
+ resolvePageContentsPlacement,
28
+ } from '../../scripts/lib/navigation-model.mjs';
19
29
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
30
+ import { getEditSourceUrl } from '../../scripts/lib/edit-source-link.mjs';
20
31
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
21
32
  import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
22
33
 
@@ -55,24 +66,8 @@ const logo = logoAsset
55
66
  height: logoSettings?.height,
56
67
  }
57
68
  : undefined;
58
- const siteHtml = entry.rendered?.html ?? '';
59
- const resolvedSections = await getSectionsContent(siteHtml, currentPage);
60
- const hasCodeBlocks = resolvedSections.some((section) => section.contentBlocks.some((block) => (
61
- block.type === 'html' && /<pre\b[^>]*>[\s\S]*?<code\b/i.test(block.html)
62
- )));
63
69
  const today = getToday();
64
70
  const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
65
- const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
66
- block.type === 'image-stack'
67
- || block.type === 'image-carousel'
68
- ));
69
- const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
70
- let contentSectionIndex = 0;
71
- const renderedSections = resolvedSections.map((section, index) => ({
72
- section,
73
- sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
74
- prioritizeFirstImage: index === firstImageSectionIndex,
75
- }));
76
71
 
77
72
  const getPageContent = (page: SitePage) =>
78
73
  page.markdownDocument.navigationHeadings.map(({ depth, id, parentId, title }) => ({
@@ -92,7 +87,33 @@ const siteNavigation = siteNodes.map((node) => {
92
87
  .map(({ id, title }) => ({ id, title })),
93
88
  };
94
89
  });
95
- const renderedNavigationEntries = flattenSiteNavigationTree(getListedSiteNavigationTree(siteNavigation));
90
+ const listedNavigationTree = getListedSiteNavigationTree(siteNavigation);
91
+ const renderedNavigationEntries = flattenSiteNavigationTree(listedNavigationTree);
92
+ const pageSequence = getSequentialPageNavigation(listedNavigationTree, currentPage.pagePath);
93
+ const childPages = getDirectChildPages(listedNavigationTree, currentPage.pagePath);
94
+ const siteHtml = entry.rendered?.html ?? '';
95
+ const resolvedSections = await getSectionsContent(siteHtml, currentPage, childPages);
96
+ const hasCodeBlocks = resolvedSections.some((section) => section.contentBlocks.some((block) => (
97
+ block.type === 'html' && /<pre\b[^>]*>[\s\S]*?<code\b/i.test(block.html)
98
+ )));
99
+ const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
100
+ block.type === 'image-stack'
101
+ || block.type === 'image-carousel'
102
+ ));
103
+ const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
104
+ let contentSectionIndex = 0;
105
+ const renderedSections = resolvedSections.map((section, index) => ({
106
+ section,
107
+ sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
108
+ prioritizeFirstImage: index === firstImageSectionIndex,
109
+ }));
110
+ const hasPageSequence = Boolean(pageSequence.previous || pageSequence.next);
111
+ const editSourceUrl = projectConfig.editLink
112
+ ? getEditSourceUrl({
113
+ baseUrl: projectConfig.editLink.baseUrl,
114
+ sourcePath: currentPage.contentLabel,
115
+ })
116
+ : null;
96
117
  const navigationModel = resolveNavigationModel({
97
118
  mode: projectConfig.navigation.mode,
98
119
  nodes: renderedNavigationEntries.map(({ node, headings }) => ({
@@ -118,7 +139,23 @@ const currentPageNavigation = siteNavigation.find(({ node }) => (
118
139
  node.kind === 'page' && node.pathname === currentPage.pathname
119
140
  ));
120
141
  const pageContentsHeadings = currentPageNavigation?.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [];
121
- const hasContentsNavigation = hasTreeNavigation && pageContentsHeadings.length > 1;
142
+ const pageContentsPlacement = resolvePageContentsPlacement({
143
+ navigationMode: navigationModel.mode,
144
+ nodes: renderedNavigationEntries.map(({ node }) => ({
145
+ depth: Math.max(1, node.depth),
146
+ isHome: node.isHome,
147
+ listed: node.navigation.listed,
148
+ pagePath: node.pagePath,
149
+ })),
150
+ currentPage: {
151
+ isHome: currentPage.isHome,
152
+ pagePath: currentPage.pagePath,
153
+ },
154
+ headingCount: pageContentsHeadings.length,
155
+ });
156
+ const hasContentsNavigation = pageContentsPlacement.placement !== 'none';
157
+ const integratePageContentsInTree = pageContentsPlacement.placement === 'page-tree';
158
+ const showContentsRail = pageContentsPlacement.placement === 'contents-rail';
122
159
  const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
123
160
  || (
124
161
  navigationModel.requestedMode === 'automatic'
@@ -138,6 +175,7 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
138
175
  pathname={currentPage.pathname}
139
176
  footer={sitewideData.footer}
140
177
  contentsNavigation={hasContentsNavigation}
178
+ searchable
141
179
  >
142
180
  <SiteNavigation
143
181
  logo={logo}
@@ -153,20 +191,31 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
153
191
 
154
192
  <div class:list={['site-page-layout', {
155
193
  'site-page-layout-tree': hasTreeNavigation,
156
- 'site-page-layout-contents': hasContentsNavigation,
157
- }]}>
194
+ 'site-page-layout-contents': showContentsRail,
195
+ }]}
196
+ data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
197
+ >
158
198
  {hasTreeNavigation && (
159
- <SiteTreeNavigation currentPage={currentPage} navigationEntries={siteNavigation} />
199
+ <SiteTreeNavigation
200
+ currentPage={currentPage}
201
+ navigationEntries={siteNavigation}
202
+ integratePageSections={integratePageContentsInTree}
203
+ />
160
204
  )}
161
205
  <main
162
- class:list={['site-content', { 'site-content-has-breadcrumbs': reserveBreadcrumbSpace }]}
206
+ class:list={['site-content', {
207
+ 'site-content-has-breadcrumbs': reserveBreadcrumbSpace,
208
+ 'site-content-has-edit-source': Boolean(editSourceUrl),
209
+ 'site-content-has-page-sequence': hasPageSequence,
210
+ }]}
163
211
  id="main-content"
212
+ data-pagefind-body
164
213
  style={`--page-text-width: ${pageTextWidth}`}
165
214
  >
166
215
  {showBreadcrumbs
167
216
  ? <SiteBreadcrumbs currentPage={currentPage} nodes={siteNodes} />
168
217
  : reserveBreadcrumbSpace && <div class="site-breadcrumbs" aria-hidden="true"></div>}
169
- {hasContentsNavigation && (
218
+ {showContentsRail && (
170
219
  <PageContentsNavigation
171
220
  headings={pageContentsHeadings}
172
221
  pageTitle={currentPage.title}
@@ -182,8 +231,12 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
182
231
  prioritizeFirstImage={prioritizeFirstImage}
183
232
  />
184
233
  ))}
234
+ {editSourceUrl && (
235
+ <EditSourceLink href={editSourceUrl} label={projectConfig.locale.labels.editSource} />
236
+ )}
237
+ <PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
185
238
  </main>
186
- {hasContentsNavigation && (
239
+ {showContentsRail && (
187
240
  <PageContentsNavigation
188
241
  headings={pageContentsHeadings}
189
242
  pageTitle={currentPage.title}
@@ -9,6 +9,7 @@ import {
9
9
  import CardList from './CardList.astro';
10
10
  import ImageCarousel from './ImageCarousel.astro';
11
11
  import ImageStack from './ImageStack.astro';
12
+ import PageList from './PageList.astro';
12
13
  import type { ResolvedSection } from '../lib/sectionContent';
13
14
 
14
15
  type ResponsiveValue<T> = { desktop: T; mobile: T };
@@ -113,6 +114,7 @@ const isStructuredBlock = (block: ResolvedSection['contentBlocks'][number]) => (
113
114
  block.type === 'image-stack'
114
115
  || block.type === 'image-carousel'
115
116
  || block.type === 'card-list'
117
+ || block.type === 'page-list'
116
118
  );
117
119
  const hasMedia = section.contentBlocks.some((block) => (
118
120
  block.type === 'image-stack'
@@ -121,7 +123,7 @@ const hasMedia = section.contentBlocks.some((block) => (
121
123
  ));
122
124
  const contentGroups: Array<
123
125
  | { type: 'reading'; blocks: ResolvedSection['contentBlocks'] }
124
- | { type: 'media'; block: Extract<ResolvedSection['contentBlocks'][number], { type: 'image-stack' | 'image-carousel' | 'card-list' }> }
126
+ | { type: 'structured'; block: Extract<ResolvedSection['contentBlocks'][number], { type: 'image-stack' | 'image-carousel' | 'card-list' | 'page-list' }> }
125
127
  > = [];
126
128
  let readingBlocks: ResolvedSection['contentBlocks'] = [];
127
129
  const flushReadingBlocks = () => {
@@ -133,14 +135,17 @@ const flushReadingBlocks = () => {
133
135
  for (const block of section.contentBlocks) {
134
136
  if (isStructuredBlock(block)) {
135
137
  flushReadingBlocks();
136
- contentGroups.push({ type: 'media', block });
138
+ contentGroups.push({ type: 'structured', block });
137
139
  } else {
138
140
  readingBlocks = [...readingBlocks, block];
139
141
  }
140
142
  }
141
143
 
142
144
  flushReadingBlocks();
143
- const firstMediaGroupIndex = contentGroups.findIndex((group) => group.type === 'media');
145
+ const firstMediaGroupIndex = contentGroups.findIndex((group) => (
146
+ group.type === 'structured'
147
+ && (group.block.type === 'image-stack' || group.block.type === 'image-carousel')
148
+ ));
144
149
  const markdownHeadingStyle = (level: 'h3' | 'h4') => {
145
150
  const headingConfig = markdownHeadings[level];
146
151
  const fontSize = getHeadingTypographySizeValue(level, headingConfig.size);
@@ -240,6 +245,9 @@ const sectionStyle = [
240
245
  />
241
246
  );
242
247
  }
248
+ if (block.type === 'page-list') {
249
+ return <PageList pages={block.pages} />;
250
+ }
243
251
 
244
252
  return (
245
253
  <ImageStack
@@ -3,13 +3,15 @@ import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { getListedSiteNavigationTree, type SiteNavigationEntry } from '../lib/siteNavigation';
4
4
  import type { SitePage } from '../lib/sitePages';
5
5
  import NavigationPageTree from './NavigationPageTree.astro';
6
+ import NavigationTreeControls from './NavigationTreeControls.astro';
6
7
 
7
8
  interface Props {
8
9
  currentPage: SitePage;
9
10
  navigationEntries: SiteNavigationEntry[];
11
+ integratePageSections?: boolean;
10
12
  }
11
13
 
12
- const { currentPage, navigationEntries } = Astro.props;
14
+ const { currentPage, navigationEntries, integratePageSections = false } = Astro.props;
13
15
  const roots = getListedSiteNavigationTree(navigationEntries);
14
16
  const activeRoot = roots.find((node) => (
15
17
  currentPage.pageDirectory === node.node.pageDirectory
@@ -24,11 +26,16 @@ const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}
24
26
  class="tree-local-navigation"
25
27
  data-navigation-root={navigationRoot}
26
28
  >
29
+ <NavigationTreeControls
30
+ idPrefix="desktop-tree-navigation"
31
+ nodes={[activeRoot]}
32
+ />
27
33
  <nav aria-label={`${activeRoot.node.title}: ${projectConfig.locale.labels.siteNavigation}`}>
28
34
  <NavigationPageTree
29
35
  nodes={[activeRoot]}
30
36
  currentPage={currentPage}
31
37
  variant="sidebar"
38
+ integratePageSections={integratePageSections}
32
39
  />
33
40
  </nav>
34
41
  </aside>
@@ -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,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,39 @@ 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 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');
135
176
  const { buildInfo, copyrightMessage } = footer ?? {};
136
177
  const showBuildInfo = buildInfo === true;
137
178
  const buildTime = showBuildInfo
@@ -164,16 +205,29 @@ const readerPreferencesScript = readerPreferencesEnabled
164
205
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
165
206
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
166
207
  data-section-tracking={contentsNavigation ? 'enabled' : undefined}
208
+ data-pagefind-ignore={searchable ? undefined : 'all'}
167
209
  style={documentStyle}
168
210
  >
169
211
  <head>
170
212
  <meta charset="utf-8" />
171
213
  <meta name="viewport" content="width=device-width, initial-scale=1" />
172
214
  <meta name="format-detection" content="telephone=no" />
215
+ {!indexable && <meta name="robots" content="noindex" />}
173
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} />}
174
227
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
175
228
  {readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
176
- <link rel="canonical" href={canonicalUrl} />
229
+ <style is:inline set:html={marginNoteLayoutCss} />
230
+ {indexable && <link rel="canonical" href={canonicalUrl} />}
177
231
  <meta name="generator" content={Astro.generator} />
178
232
  {iconLinks.map((iconLink) => (
179
233
  <link
@@ -183,6 +237,7 @@ const readerPreferencesScript = readerPreferencesEnabled
183
237
  href={iconLink.href}
184
238
  />
185
239
  ))}
240
+ <slot name="head" />
186
241
  <title>{documentTitle}</title>
187
242
  </head>
188
243
  <body>