@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
@@ -23,6 +23,36 @@
23
23
  .filter((heading) => trackedHeadingIds.has(heading.id))
24
24
  : [];
25
25
  let sectionTrackingFrame: number | undefined;
26
+ let sectionTrackingPaused = false;
27
+ let sectionTrackingResumeTimer: number | undefined;
28
+
29
+ const resumeSectionTracking = () => {
30
+ window.clearTimeout(sectionTrackingResumeTimer);
31
+ window.removeEventListener('scrollend', resumeSectionTracking);
32
+ sectionTrackingPaused = false;
33
+ sectionTrackingResumeTimer = undefined;
34
+ };
35
+
36
+ const pauseSectionTrackingForAnchorNavigation = () => {
37
+ if (!sectionTrackingEnabled) return;
38
+
39
+ sectionTrackingPaused = true;
40
+ if (sectionTrackingFrame !== undefined) {
41
+ window.cancelAnimationFrame(sectionTrackingFrame);
42
+ sectionTrackingFrame = undefined;
43
+ }
44
+ window.clearTimeout(sectionTrackingResumeTimer);
45
+ window.removeEventListener('scrollend', resumeSectionTracking);
46
+ if ('onscrollend' in document.documentElement) {
47
+ window.addEventListener('scrollend', resumeSectionTracking, { once: true });
48
+ }
49
+
50
+ const scrollBehavior = getComputedStyle(document.documentElement).scrollBehavior;
51
+ sectionTrackingResumeTimer = window.setTimeout(
52
+ resumeSectionTracking,
53
+ scrollBehavior === 'smooth' ? 2000 : 120,
54
+ );
55
+ };
26
56
 
27
57
  const setAnchorOffset = () => {
28
58
  if (!siteTop) return;
@@ -73,12 +103,35 @@
73
103
 
74
104
  const updateReadingPosition = () => {
75
105
  sectionTrackingFrame = undefined;
76
- const readingLine = Math.ceil(siteTop?.getBoundingClientRect().bottom ?? 0) + 8;
77
- let currentHeadingId: string | undefined;
106
+ if (trackedHeadings.length === 0) return;
107
+
108
+ let currentHeadingId: string | undefined = trackedHeadings[0]?.id;
109
+
110
+ if (window.scrollY > 1) {
111
+ const viewportHeight = window.innerHeight;
112
+ const documentHeight = Math.max(
113
+ document.body.scrollHeight,
114
+ document.body.offsetHeight,
115
+ document.documentElement.clientHeight,
116
+ document.documentElement.scrollHeight,
117
+ document.documentElement.offsetHeight,
118
+ );
119
+ const maximumScroll = Math.max(0, documentHeight - viewportHeight);
120
+ const remainingScroll = Math.max(0, maximumScroll - window.scrollY);
121
+ const normalReadingLine = Math.max(
122
+ Math.ceil(siteTop?.getBoundingClientRect().bottom ?? 0) + 8,
123
+ Math.round(viewportHeight / 2),
124
+ );
125
+ const terminalReadingLine = viewportHeight - remainingScroll;
126
+ const readingLine = Math.min(
127
+ viewportHeight,
128
+ Math.max(normalReadingLine, terminalReadingLine),
129
+ );
78
130
 
79
- for (const heading of trackedHeadings) {
80
- if (heading.getBoundingClientRect().top > readingLine) break;
81
- currentHeadingId = heading.id;
131
+ for (const heading of trackedHeadings) {
132
+ if (heading.getBoundingClientRect().top > readingLine) break;
133
+ currentHeadingId = heading.id;
134
+ }
82
135
  }
83
136
 
84
137
  sectionLinks.forEach((link) => {
@@ -94,7 +147,7 @@
94
147
  };
95
148
 
96
149
  const scheduleReadingPositionUpdate = () => {
97
- if (!sectionTrackingEnabled || sectionTrackingFrame !== undefined) return;
150
+ if (!sectionTrackingEnabled || sectionTrackingPaused || sectionTrackingFrame !== undefined) return;
98
151
  sectionTrackingFrame = window.requestAnimationFrame(updateReadingPosition);
99
152
  };
100
153
 
@@ -154,6 +207,7 @@
154
207
  const target = getSamePageTarget(link);
155
208
  if (!target) return;
156
209
 
210
+ pauseSectionTrackingForAnchorNavigation();
157
211
  if (mobileMenu) mobileMenu.open = false;
158
212
  link.closest<HTMLElement>('.site-nav-item')?.setAttribute('data-submenu-dismissed', 'true');
159
213
 
@@ -172,9 +226,11 @@
172
226
  });
173
227
 
174
228
  window.addEventListener('hashchange', () => {
229
+ pauseSectionTrackingForAnchorNavigation();
175
230
  updateCurrentSection();
176
231
  scheduleReadingPositionUpdate();
177
232
  });
233
+ if (window.location.hash) pauseSectionTrackingForAnchorNavigation();
178
234
  updateCurrentSection();
179
235
  if (sectionTrackingEnabled) {
180
236
  window.addEventListener('scroll', scheduleReadingPositionUpdate, { passive: true });
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { Search } from '@lucide/astro';
2
3
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
4
  import { withBasePath } from '../lib/basePath';
4
5
  import type { SectionNavigation } from '../lib/sectionContent';
@@ -11,6 +12,7 @@ import {
11
12
  import type { SiteNode, SitePage } from '../lib/sitePages';
12
13
  import DisplaySettings from './DisplaySettings.astro';
13
14
  import NavigationPageTree from './NavigationPageTree.astro';
15
+ import NavigationTreeControls from './NavigationTreeControls.astro';
14
16
  import SectionNavigationScript from './SectionNavigationScript.astro';
15
17
 
16
18
  type FrameColors = {
@@ -24,9 +26,11 @@ interface Props {
24
26
  alt: string;
25
27
  height?: string;
26
28
  };
27
- currentPage: SitePage;
29
+ currentPage?: SitePage;
28
30
  navigationEntries: SiteNavigationEntry[];
29
31
  navigationMode: 'sections' | 'top' | 'tree';
32
+ showTopNavigationSubmenus: boolean;
33
+ hasNavigationRailFrame: boolean;
30
34
  frameColors: FrameColors;
31
35
  readerPreferences: {
32
36
  controls: {
@@ -40,9 +44,20 @@ interface Props {
40
44
  focusReading: 'off' | 'on';
41
45
  };
42
46
  };
47
+ searchPage?: boolean;
43
48
  }
44
49
 
45
- const { logo, currentPage, navigationEntries, navigationMode, frameColors, readerPreferences } = Astro.props;
50
+ const {
51
+ logo,
52
+ currentPage,
53
+ navigationEntries,
54
+ navigationMode,
55
+ showTopNavigationSubmenus,
56
+ hasNavigationRailFrame,
57
+ frameColors,
58
+ readerPreferences,
59
+ searchPage = false,
60
+ } = Astro.props;
46
61
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
47
62
  ? 'var(--color-page)'
48
63
  : frameColors.backgroundColor;
@@ -56,10 +71,15 @@ const topLevelNavigation = siteNavigationTree;
56
71
  const showSiteNavigation = topLevelNavigation.some(({ node }) => !node.isHome);
57
72
  const homeNode = navigationEntries.find(({ node }) => node.isHome)?.node;
58
73
  const homePage = homeNode?.kind === 'page' ? homeNode : currentPage;
59
- const currentPageNavigation = navigationEntries.find(({ node }) => node.kind === 'page' && node.pathname === currentPage.pathname);
60
- const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
61
- const isCurrentBranch = (node: SiteNode) => currentPage.pageDirectory === node.pageDirectory
62
- || currentPage.pageDirectory.startsWith(`${node.pageDirectory}/pages/`);
74
+ if (!homePage) throw new Error('Site navigation requires a homepage.');
75
+ const currentPageNavigation = currentPage
76
+ ? navigationEntries.find(({ node }) => node.kind === 'page' && node.pathname === currentPage.pathname)
77
+ : undefined;
78
+ const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
79
+ const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
80
+ currentPage.pageDirectory === node.pageDirectory
81
+ || currentPage.pageDirectory.startsWith(`${node.pageDirectory}/pages/`)
82
+ ));
63
83
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
64
84
  const getNavigationPage = (node: SiteNavigationNode) => getFirstPageInNavigationNode(node);
65
85
  const hasSectionMenu = (pageSections: SectionNavigation[]) => pageSections.length > 1;
@@ -83,7 +103,9 @@ const showPageNavigation = Boolean(
83
103
  const showSinglePageTitleOnly = showSinglePageTopLink && currentPageSectionCount === 0;
84
104
  const showMobileNavigation = showSiteNavigation || hasCurrentPageSectionMenu || hasTreePageContents;
85
105
  const showReaderControls = Object.values(readerPreferences.controls).some(Boolean);
86
- const showDesktopNavigationRow = showSiteNavigation || Boolean(logo) || showReaderControls;
106
+ const showSearch = projectConfig.search.enabled;
107
+ const searchHref = withBasePath(projectConfig.site.basePath, '/search/');
108
+ const showDesktopNavigationRow = showSiteNavigation || Boolean(logo) || showReaderControls || showSearch;
87
109
  const showNavigationRow = showMobileNavigation || showDesktopNavigationRow;
88
110
  const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents || navigationMode === 'tree';
89
111
  ---
@@ -94,6 +116,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
94
116
  class:list={['site-top', {
95
117
  'site-top-has-site-nav': showSiteNavigation,
96
118
  'site-top-has-navigation-row': showDesktopNavigationRow,
119
+ 'site-top-has-navigation-rail-frame': hasNavigationRailFrame,
97
120
  }]}
98
121
  data-navigation-mode={navigationMode}
99
122
  style={navigationStyle}
@@ -118,13 +141,13 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
118
141
  const nodeIsCurrentPage = navigationNode.node.kind === 'page' && isCurrentPage(navigationNode.node);
119
142
  return (
120
143
  <li class:list={['site-nav-item', {
121
- 'site-nav-item-has-submenu': navigationMode !== 'tree' && navigationNode.children.length > 0,
144
+ 'site-nav-item-has-submenu': showTopNavigationSubmenus && navigationNode.children.length > 0,
122
145
  'site-nav-item-current-branch': !nodeIsCurrentPage && isCurrentBranch(navigationNode.node),
123
146
  }]}>
124
147
  <a href={pageHref(destination)} aria-current={nodeIsCurrentPage ? 'page' : undefined}>
125
148
  {navigationNode.node.title}
126
149
  </a>
127
- {navigationMode !== 'tree' && navigationNode.children.length > 0 && (
150
+ {showTopNavigationSubmenus && navigationNode.children.length > 0 && (
128
151
  <div class="site-nav-submenu">
129
152
  <nav aria-label={`${navigationNode.node.title} ${projectConfig.locale.labels.siteNavigation}`}>
130
153
  <NavigationPageTree nodes={[navigationNode]} currentPage={currentPage} variant="submenu" />
@@ -138,6 +161,18 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
138
161
  </nav>
139
162
  )}
140
163
 
164
+ {showSearch && (
165
+ <a
166
+ class="site-search-link"
167
+ href={searchHref}
168
+ aria-current={searchPage ? 'page' : undefined}
169
+ aria-label={projectConfig.locale.labels.search}
170
+ title={projectConfig.locale.labels.search}
171
+ >
172
+ <Search aria-hidden="true" />
173
+ </a>
174
+ )}
175
+
141
176
  {showReaderControls && (
142
177
  <DisplaySettings controls={readerPreferences.controls} defaults={readerPreferences.defaults} />
143
178
  )}
@@ -151,6 +186,10 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
151
186
  {showSiteNavigation && (
152
187
  <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
153
188
  <h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
189
+ <NavigationTreeControls
190
+ idPrefix="mobile-tree-navigation"
191
+ nodes={siteNavigationTree}
192
+ />
154
193
  <NavigationPageTree
155
194
  nodes={siteNavigationTree}
156
195
  currentPage={currentPage}
@@ -160,7 +199,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
160
199
  </nav>
161
200
  )}
162
201
 
163
- {navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && (
202
+ {navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && currentPage && (
164
203
  <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.pageNavigation}>
165
204
  {!showSinglePageTopLink && (
166
205
  <h2 tabindex="-1">{projectConfig.locale.labels.pageNavigation}</h2>
@@ -192,7 +231,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
192
231
  </div>
193
232
  )}
194
233
 
195
- {showPageNavigation && currentPageNavigation && (
234
+ {showPageNavigation && currentPageNavigation && currentPage && (
196
235
  <nav
197
236
  class:list={['page-nav', { 'page-nav-title-only': showSinglePageTitleOnly }]}
198
237
  aria-label={projectConfig.locale.labels.pageNavigation}
@@ -1,20 +1,33 @@
1
1
  ---
2
2
  import { getCollection, type CollectionEntry } from 'astro:content';
3
3
  import BaseLayout from '../layouts/BaseLayout.astro';
4
+ import CodeBlockCopyScript from './CodeBlockCopyScript.astro';
5
+ import EditSourceLink from './EditSourceLink.astro';
4
6
  import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
5
7
  import SiteNavigation from './SiteNavigation.astro';
6
8
  import SiteBanners from './SiteBanners.astro';
9
+ import PageContentsNavigation from './PageContentsNavigation.astro';
10
+ import PageSequenceNavigation from './PageSequenceNavigation.astro';
7
11
  import SiteSection from './SiteSection.astro';
8
12
  import SiteTreeNavigation from './SiteTreeNavigation.astro';
9
13
  import TreeNavigationScript from './TreeNavigationScript.astro';
10
14
  import { getSectionsContent, type ResolvedSection } from '../lib/sectionContent';
11
15
  import { getPageTheme } from '../lib/pageThemes';
12
16
  import { getSiteModel, type SitePage } from '../lib/sitePages';
13
- import { flattenSiteNavigationTree, getListedSiteNavigationTree } from '../lib/siteNavigation';
17
+ import {
18
+ flattenSiteNavigationTree,
19
+ getDirectChildPages,
20
+ getListedSiteNavigationTree,
21
+ getSequentialPageNavigation,
22
+ } from '../lib/siteNavigation';
14
23
  import { getToday, isVisibleToday } from '../lib/visibility';
15
24
  import { getLogoAsset } from '../lib/sitePublicAssets';
16
- import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
25
+ import {
26
+ resolveNavigationModel,
27
+ resolvePageContentsPlacement,
28
+ } from '../../scripts/lib/navigation-model.mjs';
17
29
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
30
+ import { getEditSourceUrl } from '../../scripts/lib/edit-source-link.mjs';
18
31
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
19
32
  import { mergePageThemeConfig } from '../../scripts/lib/theme-presets.mjs';
20
33
 
@@ -53,21 +66,8 @@ const logo = logoAsset
53
66
  height: logoSettings?.height,
54
67
  }
55
68
  : undefined;
56
- const siteHtml = entry.rendered?.html ?? '';
57
- const resolvedSections = await getSectionsContent(siteHtml, currentPage);
58
69
  const today = getToday();
59
70
  const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
60
- const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
61
- block.type === 'image-stack'
62
- || block.type === 'image-carousel'
63
- ));
64
- const firstImageSectionIndex = resolvedSections.findIndex(hasPrioritizableImage);
65
- let contentSectionIndex = 0;
66
- const renderedSections = resolvedSections.map((section, index) => ({
67
- section,
68
- sectionIndex: section.headingLevel === 1 ? 0 : contentSectionIndex++,
69
- prioritizeFirstImage: index === firstImageSectionIndex,
70
- }));
71
71
 
72
72
  const getPageContent = (page: SitePage) =>
73
73
  page.markdownDocument.navigationHeadings.map(({ depth, id, parentId, title }) => ({
@@ -87,7 +87,33 @@ const siteNavigation = siteNodes.map((node) => {
87
87
  .map(({ id, title }) => ({ id, title })),
88
88
  };
89
89
  });
90
- 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;
91
117
  const navigationModel = resolveNavigationModel({
92
118
  mode: projectConfig.navigation.mode,
93
119
  nodes: renderedNavigationEntries.map(({ node, headings }) => ({
@@ -96,7 +122,12 @@ const navigationModel = resolveNavigationModel({
96
122
  isHome: node.isHome,
97
123
  kind: node.kind,
98
124
  listed: node.navigation.listed,
125
+ pagePath: node.pagePath,
99
126
  })),
127
+ currentPage: {
128
+ isHome: currentPage.isHome,
129
+ pagePath: currentPage.pagePath,
130
+ },
100
131
  });
101
132
  const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
102
133
  navigationMode: navigationModel.mode,
@@ -104,6 +135,32 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
104
135
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
105
136
  const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
106
137
  const hasTreeNavigation = navigationModel.mode === 'tree';
138
+ const currentPageNavigation = siteNavigation.find(({ node }) => (
139
+ node.kind === 'page' && node.pathname === currentPage.pathname
140
+ ));
141
+ const pageContentsHeadings = currentPageNavigation?.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [];
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';
159
+ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
160
+ || (
161
+ navigationModel.requestedMode === 'automatic'
162
+ && (navigationModel.hasNestedPages || navigationModel.hasCategories)
163
+ );
107
164
  const showBreadcrumbs = !currentPage.isHome && currentPage.parentPagePath !== null;
108
165
  const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
109
166
 
@@ -117,30 +174,54 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
117
174
  frameColors={frameColors}
118
175
  pathname={currentPage.pathname}
119
176
  footer={sitewideData.footer}
120
- treeNavigation={hasTreeNavigation}
177
+ contentsNavigation={hasContentsNavigation}
178
+ searchable
121
179
  >
122
180
  <SiteNavigation
123
181
  logo={logo}
124
182
  currentPage={currentPage}
125
183
  navigationEntries={siteNavigation}
126
184
  navigationMode={navigationModel.mode}
185
+ showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
186
+ hasNavigationRailFrame={hasNavigationRailFrame}
127
187
  frameColors={frameColors}
128
188
  readerPreferences={pagePresentation.readerPreferences}
129
189
  />
130
190
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
131
191
 
132
- <div class:list={['site-page-layout', { 'site-page-layout-tree': hasTreeNavigation }]}>
192
+ <div class:list={['site-page-layout', {
193
+ 'site-page-layout-tree': hasTreeNavigation,
194
+ 'site-page-layout-contents': showContentsRail,
195
+ }]}
196
+ data-page-contents-placement={hasContentsNavigation ? pageContentsPlacement.placement : undefined}
197
+ >
133
198
  {hasTreeNavigation && (
134
- <SiteTreeNavigation currentPage={currentPage} navigationEntries={siteNavigation} />
199
+ <SiteTreeNavigation
200
+ currentPage={currentPage}
201
+ navigationEntries={siteNavigation}
202
+ integratePageSections={integratePageContentsInTree}
203
+ />
135
204
  )}
136
205
  <main
137
- 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
+ }]}
138
211
  id="main-content"
212
+ data-pagefind-body
139
213
  style={`--page-text-width: ${pageTextWidth}`}
140
214
  >
141
215
  {showBreadcrumbs
142
216
  ? <SiteBreadcrumbs currentPage={currentPage} nodes={siteNodes} />
143
217
  : reserveBreadcrumbSpace && <div class="site-breadcrumbs" aria-hidden="true"></div>}
218
+ {showContentsRail && (
219
+ <PageContentsNavigation
220
+ headings={pageContentsHeadings}
221
+ pageTitle={currentPage.title}
222
+ variant="inline"
223
+ />
224
+ )}
144
225
  {renderedSections.map(({ section, sectionIndex, prioritizeFirstImage }) => (
145
226
  <SiteSection
146
227
  section={section}
@@ -150,7 +231,19 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
150
231
  prioritizeFirstImage={prioritizeFirstImage}
151
232
  />
152
233
  ))}
234
+ {editSourceUrl && (
235
+ <EditSourceLink href={editSourceUrl} label={projectConfig.locale.labels.editSource} />
236
+ )}
237
+ <PageSequenceNavigation previous={pageSequence.previous} next={pageSequence.next} />
153
238
  </main>
239
+ {showContentsRail && (
240
+ <PageContentsNavigation
241
+ headings={pageContentsHeadings}
242
+ pageTitle={currentPage.title}
243
+ variant="rail"
244
+ />
245
+ )}
154
246
  </div>
155
247
  {hasTreeNavigation && <TreeNavigationScript />}
248
+ {hasCodeBlocks && <CodeBlockCopyScript />}
156
249
  </BaseLayout>
@@ -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 };
@@ -33,10 +34,15 @@ type ResolvedPagePresentation = {
33
34
  };
34
35
  type VisualTheme = {
35
36
  images: {
37
+ presentation: 'prose-aligned' | 'centered-fit';
36
38
  maxAvailableWidthPercent: {
37
39
  desktop: number;
38
40
  mobile: number;
39
41
  };
42
+ maxAvailableHeightPercent?: {
43
+ desktop: number;
44
+ mobile: number;
45
+ };
40
46
  };
41
47
  };
42
48
 
@@ -108,6 +114,7 @@ const isStructuredBlock = (block: ResolvedSection['contentBlocks'][number]) => (
108
114
  block.type === 'image-stack'
109
115
  || block.type === 'image-carousel'
110
116
  || block.type === 'card-list'
117
+ || block.type === 'page-list'
111
118
  );
112
119
  const hasMedia = section.contentBlocks.some((block) => (
113
120
  block.type === 'image-stack'
@@ -116,7 +123,7 @@ const hasMedia = section.contentBlocks.some((block) => (
116
123
  ));
117
124
  const contentGroups: Array<
118
125
  | { type: 'reading'; blocks: ResolvedSection['contentBlocks'] }
119
- | { 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' }> }
120
127
  > = [];
121
128
  let readingBlocks: ResolvedSection['contentBlocks'] = [];
122
129
  const flushReadingBlocks = () => {
@@ -128,14 +135,17 @@ const flushReadingBlocks = () => {
128
135
  for (const block of section.contentBlocks) {
129
136
  if (isStructuredBlock(block)) {
130
137
  flushReadingBlocks();
131
- contentGroups.push({ type: 'media', block });
138
+ contentGroups.push({ type: 'structured', block });
132
139
  } else {
133
140
  readingBlocks = [...readingBlocks, block];
134
141
  }
135
142
  }
136
143
 
137
144
  flushReadingBlocks();
138
- 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
+ ));
139
149
  const markdownHeadingStyle = (level: 'h3' | 'h4') => {
140
150
  const headingConfig = markdownHeadings[level];
141
151
  const fontSize = getHeadingTypographySizeValue(level, headingConfig.size);
@@ -235,6 +245,9 @@ const sectionStyle = [
235
245
  />
236
246
  );
237
247
  }
248
+ if (block.type === 'page-list') {
249
+ return <PageList pages={block.pages} />;
250
+ }
238
251
 
239
252
  return (
240
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,12 +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"
32
- integratePageSections
38
+ integratePageSections={integratePageSections}
33
39
  />
34
40
  </nav>
35
41
  </aside>