@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
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { PencilLine } from '@lucide/astro';
3
+
4
+ interface Props {
5
+ href: string;
6
+ label: string;
7
+ }
8
+
9
+ const { href, label } = Astro.props;
10
+ ---
11
+
12
+ <p class="edit-source-link" data-pagefind-ignore="all">
13
+ <a href={href}>
14
+ <PencilLine aria-hidden="true" />
15
+ <span>{label}</span>
16
+ </a>
17
+ </p>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { defaultImageMaxAvailableHeightPercent } from '../../scripts/lib/image-presentation.mjs';
3
4
  import { getImageAttributes } from '../lib/generatedImages';
4
5
  import {
5
6
  getCaptionId,
@@ -33,15 +34,13 @@ const {
33
34
  } = Astro.props;
34
35
  const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
35
36
  const carouselAspectRatio = getCarouselAspectRatio(images);
37
+ const carouselMaxAvailableHeightPercent = maxAvailableHeightPercent
38
+ ?? defaultImageMaxAvailableHeightPercent;
36
39
  const carouselStyle = carouselAspectRatio
37
40
  ? [
38
41
  `--image-carousel-aspect-ratio: ${carouselAspectRatio}`,
39
- ...(maxAvailableHeightPercent
40
- ? [
41
- `--image-carousel-width-from-height-desktop: ${carouselAspectRatio * maxAvailableHeightPercent.desktop}svh`,
42
- `--image-carousel-width-from-height-mobile: ${carouselAspectRatio * maxAvailableHeightPercent.mobile}svh`,
43
- ]
44
- : []),
42
+ `--image-carousel-width-from-height-desktop: ${carouselAspectRatio * carouselMaxAvailableHeightPercent.desktop}svh`,
43
+ `--image-carousel-width-from-height-mobile: ${carouselAspectRatio * carouselMaxAvailableHeightPercent.mobile}svh`,
45
44
  ].join('; ')
46
45
  : undefined;
47
46
  ---
@@ -7,7 +7,7 @@ import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
- currentPage: SitePage;
10
+ currentPage?: SitePage;
11
11
  variant: 'mobile' | 'sidebar' | 'submenu';
12
12
  integratePageSections?: boolean;
13
13
  }
@@ -15,26 +15,31 @@ interface Props {
15
15
  const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
16
16
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
17
17
  const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
18
- const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
19
- const isCurrentBranch = (node: SiteNode) => (
18
+ const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
19
+ const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
20
20
  currentPage.pageDirectory === node.pageDirectory
21
21
  || currentPage.pageDirectory.startsWith(`${node.pageDirectory}/pages/`)
22
- );
22
+ ));
23
23
  const getBranchId = (node: SiteNode) => [
24
24
  'navigation',
25
25
  variant,
26
26
  node.pagePath || 'home',
27
27
  'children',
28
28
  ].join('-').replace(/[^a-z0-9-]+/gi, '-');
29
+ const getSectionsId = (node: SiteNode) => [
30
+ 'navigation',
31
+ variant,
32
+ node.pagePath || 'home',
33
+ 'sections',
34
+ ].join('-').replace(/[^a-z0-9-]+/gi, '-');
29
35
  const hasPageSections = (node: SiteNavigationNode) => (
30
36
  node.node.kind === 'page' && node.headings.some((heading) => heading.depth === 2)
31
37
  );
32
- const hasPageDisclosure = (node: SiteNavigationNode) => hasBranch(node) || hasPageSections(node);
33
- const usesPageDisclosure = (node: SiteNavigationNode) => (
34
- variant === 'sidebar'
35
- ? hasBranch(node)
36
- : integratePageSections && hasPageDisclosure(node)
38
+ const showsPageSections = (node: SiteNavigationNode) => (
39
+ integratePageSections
40
+ && hasPageSections(node)
37
41
  );
42
+ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' && hasBranch(node);
38
43
  ---
39
44
 
40
45
  <ul class:list={['navigation-page-tree', `navigation-page-tree-${variant}`]}>
@@ -42,9 +47,13 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
42
47
  <li
43
48
  class:list={['navigation-page-node', {
44
49
  'navigation-page-node-category': node.node.kind === 'category',
50
+ 'navigation-page-node-page-disclosure': node.node.kind === 'page' && usesPageDisclosure(node),
45
51
  'navigation-page-node-current': node.node.kind === 'page' && isCurrentPage(node.node),
46
52
  'navigation-page-node-current-branch': isCurrentBranch(node.node),
47
53
  }]}
54
+ data-navigation-title={node.node.title}
55
+ data-navigation-kind={node.node.kind}
56
+ data-navigation-current-page={node.node.kind === 'page' && isCurrentPage(node.node) ? 'true' : undefined}
48
57
  >
49
58
  {node.node.kind === 'category' ? (
50
59
  <details
@@ -80,26 +89,69 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
80
89
  data-page-path={node.node.pagePath || '/'}
81
90
  data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
82
91
  data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
83
- data-has-sections={hasPageSections(node) ? 'true' : undefined}
84
92
  open={isCurrentBranch(node.node)}
85
93
  >
86
94
  <summary
87
95
  class="navigation-page-summary"
88
96
  aria-controls={getBranchId(node.node)}
97
+ aria-label={`${projectConfig.locale.labels.navigationChildren}: ${node.node.title}`}
89
98
  >
90
- <span class="navigation-page-summary-title">{node.node.title}</span>
91
99
  <span class="navigation-page-chevron" aria-hidden="true"></span>
92
100
  </summary>
101
+ </details>
102
+ <a
103
+ class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
104
+ href={pageHref(node.node)}
105
+ aria-current={isCurrentPage(node.node) ? 'page' : undefined}
106
+ >
107
+ {node.node.title}
108
+ </a>
109
+ <div id={getBranchId(node.node)} class="navigation-page-branch-content">
110
+ {showsPageSections(node) && (
111
+ <nav
112
+ class="navigation-page-sections"
113
+ aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
114
+ >
115
+ <PageContentsLinks
116
+ headings={node.headings}
117
+ variant={variant === 'mobile' ? 'mobile' : 'desktop'}
118
+ pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
119
+ />
120
+ </nav>
121
+ )}
122
+ {hasBranch(node) && (
123
+ <Astro.self
124
+ nodes={node.children}
125
+ currentPage={currentPage}
126
+ variant={variant}
127
+ integratePageSections={integratePageSections}
128
+ />
129
+ )}
130
+ </div>
131
+ ) : variant !== 'submenu' ? (
132
+ <>
93
133
  <a
94
- class:list={['navigation-page-open-link', { 'mobile-nav-destination': variant === 'mobile' }]}
134
+ class:list={['navigation-page-link', { 'mobile-nav-destination': variant === 'mobile' }]}
95
135
  href={pageHref(node.node)}
96
136
  aria-current={isCurrentPage(node.node) ? 'page' : undefined}
97
137
  >
98
138
  {node.node.title}
99
139
  </a>
100
- <div id={getBranchId(node.node)} class="navigation-page-branch-content">
101
- {integratePageSections && hasPageSections(node) && (
140
+ {showsPageSections(node) && (
141
+ <details
142
+ class="navigation-page-sections-disclosure"
143
+ data-page-path={node.node.pagePath || '/'}
144
+ data-current-page={isCurrentPage(node.node) ? 'true' : undefined}
145
+ open={isCurrentPage(node.node)}
146
+ >
147
+ <summary
148
+ aria-controls={getSectionsId(node.node)}
149
+ aria-label={`${projectConfig.locale.labels.pageSections}: ${node.node.title}`}
150
+ >
151
+ <span class="navigation-page-chevron" aria-hidden="true"></span>
152
+ </summary>
102
153
  <nav
154
+ id={getSectionsId(node.node)}
103
155
  class="navigation-page-sections"
104
156
  aria-label={`${projectConfig.locale.labels.pageNavigation}: ${node.node.title}`}
105
157
  >
@@ -109,47 +161,6 @@ const usesPageDisclosure = (node: SiteNavigationNode) => (
109
161
  pageHref={isCurrentPage(node.node) ? undefined : pageHref(node.node)}
110
162
  />
111
163
  </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>
123
- ) : variant !== 'submenu' ? (
124
- <>
125
- <a
126
- class:list={['navigation-page-link', { 'mobile-nav-destination': variant === 'mobile' }]}
127
- href={pageHref(node.node)}
128
- aria-current={isCurrentPage(node.node) ? 'page' : undefined}
129
- >
130
- {node.node.title}
131
- </a>
132
- {hasBranch(node) && (
133
- <details
134
- class="navigation-page-branch"
135
- data-page-path={node.node.pagePath || '/'}
136
- data-current-branch={isCurrentBranch(node.node) ? 'true' : undefined}
137
- open={isCurrentBranch(node.node)}
138
- >
139
- <summary aria-controls={getBranchId(node.node)}>
140
- <span class="visually-hidden">
141
- {projectConfig.locale.labels.navigationChildren}: {node.node.title}
142
- </span>
143
- <span aria-hidden="true"></span>
144
- </summary>
145
- <div id={getBranchId(node.node)} class="navigation-page-branch-content">
146
- <Astro.self
147
- nodes={node.children}
148
- currentPage={currentPage}
149
- variant={variant}
150
- integratePageSections={integratePageSections}
151
- />
152
- </div>
153
164
  </details>
154
165
  )}
155
166
  </>
@@ -0,0 +1,77 @@
1
+ ---
2
+ import { ChevronsDownUp, ChevronsUpDown, ListFilter, LocateFixed } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import type { SiteNavigationNode } from '../lib/siteNavigation';
5
+
6
+ interface Props {
7
+ idPrefix: string;
8
+ nodes: SiteNavigationNode[];
9
+ }
10
+
11
+ const { idPrefix, nodes } = Astro.props;
12
+ const countNodes = (tree: SiteNavigationNode[]): number => tree.reduce(
13
+ (total, node) => total + 1 + countNodes(node.children),
14
+ 0,
15
+ );
16
+ const minimumUsefulNodeCount = 12;
17
+ const showControls = countNodes(nodes) >= minimumUsefulNodeCount;
18
+ const labels = projectConfig.locale.labels;
19
+ const filterId = `${idPrefix}-filter`;
20
+ const statusId = `${idPrefix}-status`;
21
+ ---
22
+
23
+ {showControls && (
24
+ <div
25
+ class="navigation-tree-controls"
26
+ data-tree-controls
27
+ data-tree-label-collapsed={labels.navigationCollapsedAll}
28
+ data-tree-label-expanded={labels.navigationExpandedAll}
29
+ data-tree-label-filter-matches={labels.navigationFilterMatches}
30
+ data-tree-label-located={labels.navigationLocatedCurrent}
31
+ hidden
32
+ >
33
+ <label class="navigation-tree-filter" for={filterId}>
34
+ <ListFilter aria-hidden="true" />
35
+ <span class="visually-hidden">{labels.navigationFilter}</span>
36
+ <input
37
+ id={filterId}
38
+ type="search"
39
+ autocomplete="off"
40
+ spellcheck="false"
41
+ placeholder={labels.navigationFilter}
42
+ aria-describedby={statusId}
43
+ data-tree-filter
44
+ />
45
+ </label>
46
+ <div class="navigation-tree-commands" role="group" aria-label={labels.navigationControls}>
47
+ <button
48
+ type="button"
49
+ aria-label={labels.navigationExpandAll}
50
+ title={labels.navigationExpandAll}
51
+ data-tree-expand-all
52
+ >
53
+ <ChevronsUpDown aria-hidden="true" />
54
+ </button>
55
+ <button
56
+ type="button"
57
+ aria-label={labels.navigationCollapseAll}
58
+ title={labels.navigationCollapseAll}
59
+ data-tree-collapse-all
60
+ >
61
+ <ChevronsDownUp aria-hidden="true" />
62
+ </button>
63
+ <button
64
+ type="button"
65
+ aria-label={labels.navigationLocateCurrent}
66
+ title={labels.navigationLocateCurrent}
67
+ data-tree-locate-current
68
+ >
69
+ <LocateFixed aria-hidden="true" />
70
+ </button>
71
+ </div>
72
+ <p class="navigation-tree-filter-empty" data-tree-filter-empty hidden>
73
+ {labels.navigationFilterEmpty}
74
+ </p>
75
+ <p id={statusId} class="visually-hidden" role="status" aria-live="polite" data-tree-status></p>
76
+ </div>
77
+ )}
@@ -15,7 +15,7 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
15
15
  ---
16
16
 
17
17
  <!doctype html>
18
- <html lang={lang}>
18
+ <html lang={lang} data-pagefind-ignore="all">
19
19
  <head>
20
20
  <meta charset="utf-8" />
21
21
  <meta name="viewport" content="width=device-width" />
@@ -0,0 +1,33 @@
1
+ ---
2
+ import { ArrowRight } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+
6
+ type PageListItem = {
7
+ title: string;
8
+ pathname: string;
9
+ description?: string;
10
+ };
11
+
12
+ interface Props {
13
+ pages: PageListItem[];
14
+ }
15
+
16
+ const { pages } = Astro.props;
17
+ ---
18
+
19
+ <nav class="child-page-list" aria-label={projectConfig.locale.labels.navigationChildren}>
20
+ <ul>
21
+ {pages.map((page) => (
22
+ <li>
23
+ <a href={withBasePath(projectConfig.site.basePath, page.pathname)}>
24
+ <span class="child-page-list-copy">
25
+ <strong>{page.title}</strong>
26
+ {page.description && <span>{page.description}</span>}
27
+ </span>
28
+ <ArrowRight aria-hidden="true" />
29
+ </a>
30
+ </li>
31
+ ))}
32
+ </ul>
33
+ </nav>
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { ArrowLeft, ArrowRight } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+ import type { SitePage } from '../lib/sitePages';
6
+
7
+ interface Props {
8
+ previous: SitePage | null;
9
+ next: SitePage | null;
10
+ }
11
+
12
+ const { previous, next } = Astro.props;
13
+ const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
14
+ ---
15
+
16
+ {(previous || next) && (
17
+ <nav class="page-sequence-navigation" aria-label={projectConfig.locale.labels.pageSequence}>
18
+ {previous ? (
19
+ <a class="page-sequence-link page-sequence-previous" href={pageHref(previous)} rel="prev">
20
+ <ArrowLeft aria-hidden="true" />
21
+ <span>
22
+ <span class="page-sequence-direction">{projectConfig.locale.labels.previousPage}</span>
23
+ <span class="page-sequence-title">{previous.title}</span>
24
+ </span>
25
+ </a>
26
+ ) : <span class="page-sequence-empty" aria-hidden="true"></span>}
27
+ {next ? (
28
+ <a class="page-sequence-link page-sequence-next" href={pageHref(next)} rel="next">
29
+ <span>
30
+ <span class="page-sequence-direction">{projectConfig.locale.labels.nextPage}</span>
31
+ <span class="page-sequence-title">{next.title}</span>
32
+ </span>
33
+ <ArrowRight aria-hidden="true" />
34
+ </a>
35
+ ) : <span class="page-sequence-empty" aria-hidden="true"></span>}
36
+ </nav>
37
+ )}
@@ -0,0 +1,151 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
4
+ import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
5
+ import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
6
+ import SiteBanners from './SiteBanners.astro';
7
+ import SiteNavigation from './SiteNavigation.astro';
8
+ import SiteSection from './SiteSection.astro';
9
+ import BaseLayout from '../layouts/BaseLayout.astro';
10
+ import type { ResolvedSection } from '../lib/sectionContent';
11
+ import { flattenSiteNavigationTree, getListedSiteNavigationTree } from '../lib/siteNavigation';
12
+ import { getSiteModel, type SitePage } from '../lib/sitePages';
13
+ import { getLogoAsset } from '../lib/sitePublicAssets';
14
+ import { getToday, isVisibleToday } from '../lib/visibility';
15
+ import { withBasePath } from '../lib/basePath';
16
+
17
+ const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
18
+ const siteModel = await getSiteModel(await getCollection('site'));
19
+ const { nodes: siteNodes, pages: sitePages } = siteModel;
20
+ const homePage = sitePages.find((page) => page.isHome);
21
+ if (!homePage) throw new Error('The required homepage is missing from the site page model.');
22
+
23
+ const [siteTheme] = await getCollection('theme');
24
+ if (!siteTheme) throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
25
+ const [sitewide] = await getCollection('sitewide');
26
+ const sitewideData = sitewide?.data ?? {};
27
+ const siteThemeData = siteTheme.data;
28
+ const themeLabel = `${siteDirectory || 'site'}/${siteTheme.id}`;
29
+ const visualTheme = resolveThemeVisualConfig(siteThemeData, themeLabel);
30
+ const getPageContent = (page: SitePage) => page.markdownDocument.navigationHeadings.map((heading) => ({ ...heading }));
31
+ const siteNavigation = siteNodes.map((node) => {
32
+ const headings = node.kind === 'page' ? getPageContent(node) : [];
33
+ return {
34
+ node,
35
+ headings,
36
+ sections: headings
37
+ .filter((heading) => heading.depth === 2)
38
+ .map(({ id, title }) => ({ id, title })),
39
+ };
40
+ });
41
+ const renderedNavigationEntries = flattenSiteNavigationTree(getListedSiteNavigationTree(siteNavigation));
42
+ const navigationModel = resolveNavigationModel({
43
+ mode: projectConfig.navigation.mode,
44
+ nodes: renderedNavigationEntries.map(({ node, headings }) => ({
45
+ depth: Math.max(1, node.depth),
46
+ headings,
47
+ isHome: node.isHome,
48
+ kind: node.kind,
49
+ listed: node.navigation.listed,
50
+ pagePath: node.pagePath,
51
+ })),
52
+ });
53
+ const pagePresentation = resolvePagePresentation(siteThemeData, themeLabel, {
54
+ navigationMode: navigationModel.mode,
55
+ });
56
+ const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
57
+ const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: themeLabel });
58
+ const logoAsset = getLogoAsset();
59
+ const logo = logoAsset
60
+ ? {
61
+ src: logoAsset.href,
62
+ alt: homePage.title,
63
+ height: sitewideData.logo?.height,
64
+ }
65
+ : undefined;
66
+ const today = getToday();
67
+ const siteBanners = (sitewideData.banners ?? []).filter((banner) => isVisibleToday(banner.visible, today));
68
+ const labels = projectConfig.locale.labels;
69
+ const searchSection: ResolvedSection = {
70
+ id: null,
71
+ title: labels.search,
72
+ titleHtml: labels.search,
73
+ headingLevel: 1,
74
+ contentBlocks: [{
75
+ type: 'html',
76
+ html: `<p>${labels.searchDescription}</p><div class="norna-search" id="norna-search"><p data-search-status>${labels.searchLoading}</p><noscript><p>${labels.searchNoScript}</p></noscript></div>`,
77
+ }],
78
+ };
79
+ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
80
+ || (
81
+ navigationModel.requestedMode === 'automatic'
82
+ && (navigationModel.hasNestedPages || navigationModel.hasCategories)
83
+ );
84
+ const bundlePath = withBasePath(projectConfig.site.basePath, '/pagefind/');
85
+ const searchScript = `(() => {
86
+ const root = document.querySelector('#norna-search');
87
+ const status = root?.querySelector('[data-search-status]');
88
+ if (!root) return;
89
+ const script = document.createElement('script');
90
+ script.src = ${JSON.stringify(`${bundlePath}pagefind-ui.js`)};
91
+ script.onload = () => {
92
+ status?.remove();
93
+ new window.PagefindUI({
94
+ element: '#norna-search',
95
+ bundlePath: ${JSON.stringify(bundlePath)},
96
+ baseUrl: ${JSON.stringify(projectConfig.site.basePath)},
97
+ showImages: false,
98
+ showSubResults: true
99
+ });
100
+ const labelInput = () => {
101
+ const input = root.querySelector('.pagefind-ui__search-input');
102
+ if (!input) return false;
103
+ input.setAttribute('aria-label', ${JSON.stringify(labels.search)});
104
+ return true;
105
+ };
106
+ if (!labelInput()) {
107
+ const observer = new MutationObserver(() => {
108
+ if (labelInput()) observer.disconnect();
109
+ });
110
+ observer.observe(root, { childList: true, subtree: true });
111
+ }
112
+ };
113
+ script.onerror = () => {
114
+ if (status) status.textContent = ${JSON.stringify(labels.searchUnavailable)};
115
+ };
116
+ document.head.append(script);
117
+ })();`;
118
+ ---
119
+
120
+ <BaseLayout
121
+ title={labels.search}
122
+ description={labels.searchDescription}
123
+ presentation={pagePresentation}
124
+ visualTheme={visualTheme}
125
+ frameColors={frameColors}
126
+ pathname="/search/"
127
+ footer={sitewideData.footer}
128
+ indexable={false}
129
+ >
130
+ <link slot="head" rel="stylesheet" href={`${bundlePath}pagefind-ui.css`} />
131
+ <SiteNavigation
132
+ logo={logo}
133
+ navigationEntries={siteNavigation}
134
+ navigationMode={navigationModel.mode}
135
+ showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
136
+ hasNavigationRailFrame={hasNavigationRailFrame}
137
+ frameColors={frameColors}
138
+ readerPreferences={pagePresentation.readerPreferences}
139
+ searchPage
140
+ />
141
+ {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
142
+ <main class="site-content search-page-content" id="main-content" style={`--page-text-width: ${pageTextWidth}`}>
143
+ <SiteSection
144
+ section={searchSection}
145
+ pagePresentation={pagePresentation}
146
+ visualTheme={visualTheme}
147
+ sectionIndex={0}
148
+ />
149
+ </main>
150
+ <script is:inline set:html={searchScript} />
151
+ </BaseLayout>
@@ -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,7 +26,7 @@ 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';
30
32
  showTopNavigationSubmenus: boolean;
@@ -42,6 +44,7 @@ interface Props {
42
44
  focusReading: 'off' | 'on';
43
45
  };
44
46
  };
47
+ searchPage?: boolean;
45
48
  }
46
49
 
47
50
  const {
@@ -53,6 +56,7 @@ const {
53
56
  hasNavigationRailFrame,
54
57
  frameColors,
55
58
  readerPreferences,
59
+ searchPage = false,
56
60
  } = Astro.props;
57
61
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
58
62
  ? 'var(--color-page)'
@@ -67,10 +71,15 @@ const topLevelNavigation = siteNavigationTree;
67
71
  const showSiteNavigation = topLevelNavigation.some(({ node }) => !node.isHome);
68
72
  const homeNode = navigationEntries.find(({ node }) => node.isHome)?.node;
69
73
  const homePage = homeNode?.kind === 'page' ? homeNode : currentPage;
70
- const currentPageNavigation = navigationEntries.find(({ node }) => node.kind === 'page' && node.pathname === currentPage.pathname);
71
- const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
72
- const isCurrentBranch = (node: SiteNode) => currentPage.pageDirectory === node.pageDirectory
73
- || 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
+ ));
74
83
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
75
84
  const getNavigationPage = (node: SiteNavigationNode) => getFirstPageInNavigationNode(node);
76
85
  const hasSectionMenu = (pageSections: SectionNavigation[]) => pageSections.length > 1;
@@ -94,7 +103,9 @@ const showPageNavigation = Boolean(
94
103
  const showSinglePageTitleOnly = showSinglePageTopLink && currentPageSectionCount === 0;
95
104
  const showMobileNavigation = showSiteNavigation || hasCurrentPageSectionMenu || hasTreePageContents;
96
105
  const showReaderControls = Object.values(readerPreferences.controls).some(Boolean);
97
- 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;
98
109
  const showNavigationRow = showMobileNavigation || showDesktopNavigationRow;
99
110
  const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents || navigationMode === 'tree';
100
111
  ---
@@ -150,6 +161,18 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
150
161
  </nav>
151
162
  )}
152
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
+
153
176
  {showReaderControls && (
154
177
  <DisplaySettings controls={readerPreferences.controls} defaults={readerPreferences.defaults} />
155
178
  )}
@@ -163,6 +186,10 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
163
186
  {showSiteNavigation && (
164
187
  <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.siteNavigation}>
165
188
  <h2 tabindex="-1">{projectConfig.locale.labels.siteNavigation}</h2>
189
+ <NavigationTreeControls
190
+ idPrefix="mobile-tree-navigation"
191
+ nodes={siteNavigationTree}
192
+ />
166
193
  <NavigationPageTree
167
194
  nodes={siteNavigationTree}
168
195
  currentPage={currentPage}
@@ -172,7 +199,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
172
199
  </nav>
173
200
  )}
174
201
 
175
- {navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && (
202
+ {navigationMode !== 'tree' && hasCurrentPageSectionMenu && currentPageNavigation && currentPage && (
176
203
  <nav class="mobile-nav-view" aria-label={projectConfig.locale.labels.pageNavigation}>
177
204
  {!showSinglePageTopLink && (
178
205
  <h2 tabindex="-1">{projectConfig.locale.labels.pageNavigation}</h2>
@@ -204,7 +231,7 @@ const needsNavigationScript = hasCurrentPageSectionMenu || hasTreePageContents |
204
231
  </div>
205
232
  )}
206
233
 
207
- {showPageNavigation && currentPageNavigation && (
234
+ {showPageNavigation && currentPageNavigation && currentPage && (
208
235
  <nav
209
236
  class:list={['page-nav', { 'page-nav-title-only': showSinglePageTitleOnly }]}
210
237
  aria-label={projectConfig.locale.labels.pageNavigation}