@janga/norna 0.7.22 → 0.7.23

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 (60) hide show
  1. package/astro.config.mjs +3 -0
  2. package/bin/norna-cli.mjs +2 -2
  3. package/package.json +17 -3
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +22 -24
  6. package/schemas/content-frontmatter.schema.json +25 -5
  7. package/schemas/page-theme.schema.json +53 -25
  8. package/schemas/sitewide-content.schema.json +19 -18
  9. package/schemas/theme.schema.json +383 -250
  10. package/scripts/check-config.mjs +24 -4
  11. package/scripts/deploy-site.mjs +0 -1
  12. package/scripts/dev-local.mjs +175 -112
  13. package/scripts/lib/content-sync-apply.mjs +34 -0
  14. package/scripts/lib/content-sync-plan.mjs +179 -0
  15. package/scripts/lib/editor-language-service.mjs +128 -0
  16. package/scripts/lib/heading-ids.mjs +1 -1
  17. package/scripts/lib/image-presentation.mjs +2 -0
  18. package/scripts/lib/markdown-links.mjs +182 -0
  19. package/scripts/lib/navigation-model.mjs +25 -14
  20. package/scripts/lib/norna-markdown-blocks.mjs +1 -0
  21. package/scripts/lib/page-aliases.mjs +164 -0
  22. package/scripts/lib/page-markdown.mjs +56 -6
  23. package/scripts/lib/presentation.mjs +19 -37
  24. package/scripts/lib/project-config.mjs +50 -29
  25. package/scripts/lib/schema-definitions.mjs +24 -13
  26. package/scripts/lib/schema-editor-metadata.mjs +43 -30
  27. package/scripts/lib/schema-value-definitions.mjs +6 -2
  28. package/scripts/lib/site-content.mjs +26 -4
  29. package/scripts/lib/site-link-graph.mjs +326 -0
  30. package/scripts/lib/site-page-urls.mjs +10 -0
  31. package/scripts/lib/sitemap.mjs +34 -0
  32. package/scripts/lib/theme-presets.mjs +51 -23
  33. package/scripts/lib/theme-profiles.mjs +7 -5
  34. package/scripts/lib/yaml-config.mjs +6 -2
  35. package/scripts/sync-content-sections.mjs +116 -213
  36. package/scripts/sync-site-public.mjs +23 -2
  37. package/src/components/CodeBlockCopyScript.astro +92 -0
  38. package/src/components/DisplaySettings.astro +4 -4
  39. package/src/components/ImageCarousel.astro +7 -3
  40. package/src/components/NavigationPageTree.astro +44 -60
  41. package/src/components/PageAliasRedirect.astro +49 -0
  42. package/src/components/PageContentsNavigation.astro +21 -0
  43. package/src/components/SectionNavigationScript.astro +62 -6
  44. package/src/components/SiteNavigation.astro +15 -3
  45. package/src/components/SitePage.astro +42 -2
  46. package/src/components/SiteSection.astro +5 -0
  47. package/src/components/SiteTreeNavigation.astro +0 -1
  48. package/src/content.config.ts +5 -2
  49. package/src/layouts/BaseLayout.astro +14 -8
  50. package/src/lib/readerPreferencesScript.mjs +2 -2
  51. package/src/lib/sitePages.ts +3 -2
  52. package/src/pages/[...slug].astro +35 -8
  53. package/src/styles/content.css +494 -0
  54. package/src/styles/foundations.css +284 -0
  55. package/src/styles/global.css +6 -2510
  56. package/src/styles/media.css +523 -0
  57. package/src/styles/navigation.css +495 -0
  58. package/src/styles/page-layout.css +437 -0
  59. package/src/styles/responsive.css +525 -0
  60. package/starters/basic/README.md +1 -1
@@ -30,6 +30,11 @@ const hasPageSections = (node: SiteNavigationNode) => (
30
30
  node.node.kind === 'page' && node.headings.some((heading) => heading.depth === 2)
31
31
  );
32
32
  const hasPageDisclosure = (node: SiteNavigationNode) => hasBranch(node) || hasPageSections(node);
33
+ const usesPageDisclosure = (node: SiteNavigationNode) => (
34
+ variant === 'sidebar'
35
+ ? hasBranch(node)
36
+ : integratePageSections && hasPageDisclosure(node)
37
+ );
33
38
  ---
34
39
 
35
40
  <ul class:list={['navigation-page-tree', `navigation-page-tree-${variant}`]}>
@@ -69,62 +74,52 @@ const hasPageDisclosure = (node: SiteNavigationNode) => hasBranch(node) || hasPa
69
74
  />
70
75
  </div>
71
76
  </details>
72
- ) : variant !== 'submenu' && integratePageSections ? (
73
- hasPageDisclosure(node) ? (
74
- <details
75
- class:list={['navigation-page-branch', 'navigation-page-disclosure', `navigation-page-disclosure-${variant}`]}
76
- data-page-path={node.node.pagePath || '/'}
77
- data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
78
- data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
79
- data-has-sections={hasPageSections(node) ? 'true' : undefined}
80
- open={isCurrentBranch(node.node)}
77
+ ) : variant !== 'submenu' && usesPageDisclosure(node) ? (
78
+ <details
79
+ class:list={['navigation-page-branch', 'navigation-page-disclosure', `navigation-page-disclosure-${variant}`]}
80
+ data-page-path={node.node.pagePath || '/'}
81
+ data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
82
+ data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
83
+ data-has-sections={hasPageSections(node) ? 'true' : undefined}
84
+ open={isCurrentBranch(node.node)}
85
+ >
86
+ <summary
87
+ class="navigation-page-summary"
88
+ aria-controls={getBranchId(node.node)}
81
89
  >
82
- <summary
83
- class="navigation-page-summary"
84
- aria-controls={getBranchId(node.node)}
85
- >
86
- <span class="navigation-page-summary-title">{node.node.title}</span>
87
- <span class="navigation-page-chevron" aria-hidden="true"></span>
88
- </summary>
89
- <a
90
- class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
91
- href={pageHref(node.node)}
92
- aria-current={isCurrentPage(node.node) ? 'page' : undefined}
93
- >
94
- {node.node.title}
95
- </a>
96
- <div id={getBranchId(node.node)} class="navigation-page-branch-content">
97
- {hasPageSections(node) && (
98
- <nav
99
- class="navigation-page-sections"
100
- aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
101
- >
102
- <PageContentsLinks
103
- headings={node.headings}
104
- variant={variant === 'mobile' ? 'mobile' : 'desktop'}
105
- pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
106
- />
107
- </nav>
108
- )}
109
- {hasBranch(node) && (
110
- <Astro.self
111
- nodes={node.children}
112
- currentPage={currentPage}
113
- variant={variant}
114
- integratePageSections={integratePageSections}
115
- />
116
- )}
117
- </div>
118
- </details>
119
- ) : (
90
+ <span class="navigation-page-summary-title">{node.node.title}</span>
91
+ <span class="navigation-page-chevron" aria-hidden="true"></span>
92
+ </summary>
120
93
  <a
121
- class:list={['navigation-page-link', { 'mobile-nav-destination': variant === 'mobile' }]}
94
+ class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
122
95
  href={pageHref(node.node)}
123
96
  aria-current={isCurrentPage(node.node) ? 'page' : undefined}
124
97
  >
125
98
  {node.node.title}
126
99
  </a>
127
- )
100
+ <div id={getBranchId(node.node)} class="navigation-page-branch-content">
101
+ {integratePageSections && hasPageSections(node) && (
102
+ <nav
103
+ class="navigation-page-sections"
104
+ aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
105
+ >
106
+ <PageContentsLinks
107
+ headings={node.headings}
108
+ variant={variant === 'mobile' ? 'mobile' : 'desktop'}
109
+ pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
110
+ />
111
+ </nav>
112
+ )}
113
+ {hasBranch(node) && (
114
+ <Astro.self
115
+ nodes={node.children}
116
+ currentPage={currentPage}
117
+ variant={variant}
118
+ integratePageSections={integratePageSections}
119
+ />
120
+ )}
121
+ </div>
122
+ </details>
128
123
  ) : variant !== 'submenu' ? (
129
124
  <>
130
125
  <a
@@ -134,17 +129,6 @@ const hasPageDisclosure = (node: SiteNavigationNode) => hasBranch(node) || hasPa
134
129
  >
135
130
  {node.node.title}
136
131
  </a>
137
- {variant === 'sidebar' && isCurrentPage(node.node) && hasPageSections(node) && (
138
- <nav
139
- class="navigation-page-sections"
140
- aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
141
- >
142
- <PageContentsLinks
143
- headings={node.headings}
144
- variant="desktop"
145
- />
146
- </nav>
147
- )}
148
132
  {hasBranch(node) && (
149
133
  <details
150
134
  class="navigation-page-branch"
@@ -0,0 +1,49 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { getAbsolutePageUrl } from '../../scripts/lib/site-page-urls.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+
6
+ interface Props {
7
+ targetPathname: string;
8
+ targetTitle: string;
9
+ }
10
+
11
+ const { targetPathname, targetTitle } = Astro.props;
12
+ const { labels, lang } = projectConfig.locale;
13
+ const targetHref = withBasePath(projectConfig.site.basePath, targetPathname);
14
+ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
15
+ ---
16
+
17
+ <!doctype html>
18
+ <html lang={lang}>
19
+ <head>
20
+ <meta charset="utf-8" />
21
+ <meta name="viewport" content="width=device-width" />
22
+ <meta name="robots" content="noindex, follow" />
23
+ <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
24
+ <link rel="canonical" href={canonicalUrl} />
25
+ <title>{labels.pageMoved}: {targetTitle}</title>
26
+ <style is:inline>
27
+ :root {
28
+ color-scheme: light dark;
29
+ font-family: system-ui, sans-serif;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ padding: 2rem;
35
+ }
36
+
37
+ main {
38
+ max-width: 42rem;
39
+ margin-inline: auto;
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <main>
45
+ <h1>{labels.pageMoved}</h1>
46
+ <p>{labels.pageMovedText} <a href={targetHref}>{targetTitle}</a>.</p>
47
+ </main>
48
+ </body>
49
+ </html>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import type { HeadingNavigation } from '../lib/sectionContent';
4
+ import PageContentsLinks from './PageContentsLinks.astro';
5
+
6
+ interface Props {
7
+ headings: HeadingNavigation[];
8
+ pageTitle: string;
9
+ variant: 'inline' | 'rail';
10
+ }
11
+
12
+ const { headings, pageTitle, variant } = Astro.props;
13
+ const label = `${projectConfig.locale.labels.pageNavigation}: ${pageTitle}`;
14
+ ---
15
+
16
+ <aside class={`page-contents-navigation page-contents-navigation-${variant}`}>
17
+ <nav aria-label={label}>
18
+ <p class="page-contents-navigation-label">{projectConfig.locale.labels.pageNavigation}</p>
19
+ <PageContentsLinks headings={headings} variant="desktop" />
20
+ </nav>
21
+ </aside>
@@ -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 });
@@ -27,6 +27,8 @@ interface Props {
27
27
  currentPage: SitePage;
28
28
  navigationEntries: SiteNavigationEntry[];
29
29
  navigationMode: 'sections' | 'top' | 'tree';
30
+ showTopNavigationSubmenus: boolean;
31
+ hasNavigationRailFrame: boolean;
30
32
  frameColors: FrameColors;
31
33
  readerPreferences: {
32
34
  controls: {
@@ -42,7 +44,16 @@ interface Props {
42
44
  };
43
45
  }
44
46
 
45
- const { logo, currentPage, navigationEntries, navigationMode, frameColors, readerPreferences } = Astro.props;
47
+ const {
48
+ logo,
49
+ currentPage,
50
+ navigationEntries,
51
+ navigationMode,
52
+ showTopNavigationSubmenus,
53
+ hasNavigationRailFrame,
54
+ frameColors,
55
+ readerPreferences,
56
+ } = Astro.props;
46
57
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
47
58
  ? 'var(--color-page)'
48
59
  : frameColors.backgroundColor;
@@ -94,6 +105,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
94
105
  class:list={['site-top', {
95
106
  'site-top-has-site-nav': showSiteNavigation,
96
107
  'site-top-has-navigation-row': showDesktopNavigationRow,
108
+ 'site-top-has-navigation-rail-frame': hasNavigationRailFrame,
97
109
  }]}
98
110
  data-navigation-mode={navigationMode}
99
111
  style={navigationStyle}
@@ -118,13 +130,13 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
118
130
  const nodeIsCurrentPage = navigationNode.node.kind === 'page' && isCurrentPage(navigationNode.node);
119
131
  return (
120
132
  <li class:list={['site-nav-item', {
121
- 'site-nav-item-has-submenu': navigationMode !== 'tree' && navigationNode.children.length > 0,
133
+ 'site-nav-item-has-submenu': showTopNavigationSubmenus && navigationNode.children.length > 0,
122
134
  'site-nav-item-current-branch': !nodeIsCurrentPage && isCurrentBranch(navigationNode.node),
123
135
  }]}>
124
136
  <a href={pageHref(destination)} aria-current={nodeIsCurrentPage ? 'page' : undefined}>
125
137
  {navigationNode.node.title}
126
138
  </a>
127
- {navigationMode !== 'tree' && navigationNode.children.length > 0 && (
139
+ {showTopNavigationSubmenus && navigationNode.children.length > 0 && (
128
140
  <div class="site-nav-submenu">
129
141
  <nav aria-label={`${navigationNode.node.title} ${projectConfig.locale.labels.siteNavigation}`}>
130
142
  <NavigationPageTree nodes={[navigationNode]} currentPage={currentPage} variant="submenu" />
@@ -1,9 +1,11 @@
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';
4
5
  import SiteBreadcrumbs from './SiteBreadcrumbs.astro';
5
6
  import SiteNavigation from './SiteNavigation.astro';
6
7
  import SiteBanners from './SiteBanners.astro';
8
+ import PageContentsNavigation from './PageContentsNavigation.astro';
7
9
  import SiteSection from './SiteSection.astro';
8
10
  import SiteTreeNavigation from './SiteTreeNavigation.astro';
9
11
  import TreeNavigationScript from './TreeNavigationScript.astro';
@@ -55,6 +57,9 @@ const logo = logoAsset
55
57
  : undefined;
56
58
  const siteHtml = entry.rendered?.html ?? '';
57
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
+ )));
58
63
  const today = getToday();
59
64
  const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
60
65
  const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
@@ -96,7 +101,12 @@ const navigationModel = resolveNavigationModel({
96
101
  isHome: node.isHome,
97
102
  kind: node.kind,
98
103
  listed: node.navigation.listed,
104
+ pagePath: node.pagePath,
99
105
  })),
106
+ currentPage: {
107
+ isHome: currentPage.isHome,
108
+ pagePath: currentPage.pagePath,
109
+ },
100
110
  });
101
111
  const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
102
112
  navigationMode: navigationModel.mode,
@@ -104,6 +114,16 @@ const pagePresentation = resolvePagePresentation(themeData, themeLabel, {
104
114
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
105
115
  const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: `${siteDirectory || 'site'}/${siteTheme.id}` });
106
116
  const hasTreeNavigation = navigationModel.mode === 'tree';
117
+ const currentPageNavigation = siteNavigation.find(({ node }) => (
118
+ node.kind === 'page' && node.pathname === currentPage.pathname
119
+ ));
120
+ const pageContentsHeadings = currentPageNavigation?.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [];
121
+ const hasContentsNavigation = hasTreeNavigation && pageContentsHeadings.length > 1;
122
+ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
123
+ || (
124
+ navigationModel.requestedMode === 'automatic'
125
+ && (navigationModel.hasNestedPages || navigationModel.hasCategories)
126
+ );
107
127
  const showBreadcrumbs = !currentPage.isHome && currentPage.parentPagePath !== null;
108
128
  const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
109
129
 
@@ -117,19 +137,24 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
117
137
  frameColors={frameColors}
118
138
  pathname={currentPage.pathname}
119
139
  footer={sitewideData.footer}
120
- treeNavigation={hasTreeNavigation}
140
+ contentsNavigation={hasContentsNavigation}
121
141
  >
122
142
  <SiteNavigation
123
143
  logo={logo}
124
144
  currentPage={currentPage}
125
145
  navigationEntries={siteNavigation}
126
146
  navigationMode={navigationModel.mode}
147
+ showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
148
+ hasNavigationRailFrame={hasNavigationRailFrame}
127
149
  frameColors={frameColors}
128
150
  readerPreferences={pagePresentation.readerPreferences}
129
151
  />
130
152
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
131
153
 
132
- <div class:list={['site-page-layout', { 'site-page-layout-tree': hasTreeNavigation }]}>
154
+ <div class:list={['site-page-layout', {
155
+ 'site-page-layout-tree': hasTreeNavigation,
156
+ 'site-page-layout-contents': hasContentsNavigation,
157
+ }]}>
133
158
  {hasTreeNavigation && (
134
159
  <SiteTreeNavigation currentPage={currentPage} navigationEntries={siteNavigation} />
135
160
  )}
@@ -141,6 +166,13 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
141
166
  {showBreadcrumbs
142
167
  ? <SiteBreadcrumbs currentPage={currentPage} nodes={siteNodes} />
143
168
  : reserveBreadcrumbSpace && <div class="site-breadcrumbs" aria-hidden="true"></div>}
169
+ {hasContentsNavigation && (
170
+ <PageContentsNavigation
171
+ headings={pageContentsHeadings}
172
+ pageTitle={currentPage.title}
173
+ variant="inline"
174
+ />
175
+ )}
144
176
  {renderedSections.map(({ section, sectionIndex, prioritizeFirstImage }) => (
145
177
  <SiteSection
146
178
  section={section}
@@ -151,6 +183,14 @@ const reserveBreadcrumbSpace = hasTreeNavigation || showBreadcrumbs;
151
183
  />
152
184
  ))}
153
185
  </main>
186
+ {hasContentsNavigation && (
187
+ <PageContentsNavigation
188
+ headings={pageContentsHeadings}
189
+ pageTitle={currentPage.title}
190
+ variant="rail"
191
+ />
192
+ )}
154
193
  </div>
155
194
  {hasTreeNavigation && <TreeNavigationScript />}
195
+ {hasCodeBlocks && <CodeBlockCopyScript />}
156
196
  </BaseLayout>
@@ -33,10 +33,15 @@ type ResolvedPagePresentation = {
33
33
  };
34
34
  type VisualTheme = {
35
35
  images: {
36
+ presentation: 'prose-aligned' | 'centered-fit';
36
37
  maxAvailableWidthPercent: {
37
38
  desktop: number;
38
39
  mobile: number;
39
40
  };
41
+ maxAvailableHeightPercent?: {
42
+ desktop: number;
43
+ mobile: number;
44
+ };
40
45
  };
41
46
  };
42
47
 
@@ -29,7 +29,6 @@ const navigationRoot = activeRoot?.node.pagePath ? `/${activeRoot.node.pagePath}
29
29
  nodes={[activeRoot]}
30
30
  currentPage={currentPage}
31
31
  variant="sidebar"
32
- integratePageSections
33
32
  />
34
33
  </nav>
35
34
  </aside>
@@ -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 };
@@ -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,7 @@ interface Props {
75
76
  copyrightMessage?: string;
76
77
  buildInfo?: boolean;
77
78
  };
78
- treeNavigation?: boolean;
79
+ contentsNavigation?: boolean;
79
80
  }
80
81
 
81
82
  const {
@@ -86,7 +87,7 @@ const {
86
87
  frameColors,
87
88
  pathname = '/',
88
89
  footer,
89
- treeNavigation = false,
90
+ contentsNavigation = false,
90
91
  } = Astro.props;
91
92
 
92
93
  const documentTitle = title;
@@ -124,8 +125,12 @@ const documentStyle = [
124
125
  `--image-width: ${visualTheme.images.width}`,
125
126
  `--image-max-available-width-desktop: ${formatPercent(visualTheme.images.maxAvailableWidthPercent.desktop)}`,
126
127
  `--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)}`,
128
+ ...(visualTheme.images.maxAvailableHeightPercent
129
+ ? [
130
+ `--image-max-height-desktop: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.desktop)}`,
131
+ `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
132
+ ]
133
+ : []),
129
134
  ].join('; ');
130
135
  const { buildInfo, copyrightMessage } = footer ?? {};
131
136
  const showBuildInfo = buildInfo === true;
@@ -154,10 +159,11 @@ const readerPreferencesScript = readerPreferencesEnabled
154
159
  <!doctype html>
155
160
  <html
156
161
  lang={projectConfig.locale.lang}
157
- data-color-mode={presentation.readerPreferences.defaults.appearance}
162
+ data-appearance={presentation.readerPreferences.defaults.appearance}
163
+ data-image-presentation={visualTheme.images.presentation}
158
164
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
159
165
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
160
- data-section-tracking={treeNavigation && projectConfig.navigation.sectionTracking ? 'enabled' : undefined}
166
+ data-section-tracking={contentsNavigation ? 'enabled' : undefined}
161
167
  style={documentStyle}
162
168
  >
163
169
  <head>
@@ -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);
@@ -10,6 +10,7 @@ import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
10
10
  type SiteEntry = CollectionEntry<'site'>;
11
11
 
12
12
  type SiteNodeBase = {
13
+ aliases: string[];
13
14
  kind: 'category' | 'page';
14
15
  isHome: boolean;
15
16
  navigation: {
@@ -106,6 +107,7 @@ const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
106
107
  const navigation = entry.data.navigation ?? {};
107
108
 
108
109
  return {
110
+ aliases: entry.data.page?.aliases ?? [],
109
111
  kind: 'page',
110
112
  entry,
111
113
  isHome,
@@ -131,6 +133,7 @@ const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
131
133
  };
132
134
 
133
135
  const createSiteCategory = (category: Awaited<ReturnType<typeof getSiteStructure>>['categories'][number]): SiteCategory => ({
136
+ aliases: [],
134
137
  kind: 'category',
135
138
  isHome: false,
136
139
  depth: category.depth,
@@ -226,5 +229,3 @@ export const getSiteModel = async (entries: SiteEntry[]) => {
226
229
  warnings: structure.warnings,
227
230
  };
228
231
  };
229
-
230
- export const getSitePages = async (entries: SiteEntry[]) => (await getSiteModel(entries)).pages;