@janga/norna 0.7.27 → 0.7.29

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 (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -5,84 +5,62 @@ import { createSitemapXml, sitemapFilename } from './lib/sitemap.mjs';
5
5
  import {
6
6
  astroPublicDir,
7
7
  astroPublicLabel,
8
- sitePublicDir,
9
8
  sitePublicLabel,
10
9
  } from './lib/site-paths.mjs';
11
10
  import { getSiteStructure } from './lib/site-structure.mjs';
12
11
  import { getSiteLinkGraph } from './lib/site-link-graph.mjs';
13
12
  import { assertCategoryDestinationModel } from './lib/category-destinations.mjs';
14
13
 
15
- const keepAstroPublicEntries = new Set([
16
- 'images',
17
- ...(projectConfig.search.enabled ? ['pagefind'] : []),
18
- ]);
14
+ const main = async () => {
15
+ const readDirectory = async (directory) => {
16
+ try {
17
+ return await readdir(directory, { withFileTypes: true });
18
+ } catch (error) {
19
+ if (error?.code === 'ENOENT') {
20
+ return [];
21
+ }
19
22
 
20
- const readDirectory = async (directory) => {
21
- try {
22
- return await readdir(directory, { withFileTypes: true });
23
- } catch (error) {
24
- if (error?.code === 'ENOENT') {
25
- return [];
23
+ throw error;
26
24
  }
25
+ };
27
26
 
28
- throw error;
29
- }
30
- };
31
-
32
- const sourceEntries = await readDirectory(sitePublicDir);
33
- const generatedPublicFiles = [
34
- {
35
- filename: sitemapFilename,
36
- explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
37
- },
38
- {
39
- filename: '404.html',
40
- explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
41
- },
42
- ...(projectConfig.search.enabled ? [{
43
- filename: 'pagefind',
44
- explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
45
- }] : []),
46
- ];
47
- for (const generatedFile of generatedPublicFiles) {
48
- const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
49
- if (!conflict) continue;
50
-
51
- throw new Error([
52
- `${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
53
- `Remove that source file. ${generatedFile.explanation}`,
54
- ].join('\n'));
55
- }
56
-
57
- const siteStructure = await getSiteStructure();
58
- const { categoryModel } = await getSiteLinkGraph({ siteStructure });
59
- const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
60
- const sitemapXml = createSitemapXml({
61
- siteStructure,
62
- siteUrl: projectConfig.site.url,
63
- categoryDestinations,
64
- });
27
+ const siteStructure = await getSiteStructure();
28
+ const { categoryModel, attachments, diagnostics, publicFilesByPathname } = await getSiteLinkGraph({ siteStructure });
29
+ const outputErrors = diagnostics.filter(issue => !issue.reference && issue.severity === 'error');
30
+ if (outputErrors.length) throw new Error(outputErrors.map(issue => issue.message).join('\n'));
31
+ const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
32
+ const sitemapXml = createSitemapXml({
33
+ siteStructure,
34
+ siteUrl: projectConfig.site.url,
35
+ categoryDestinations,
36
+ });
65
37
 
66
- await mkdir(astroPublicDir, { recursive: true });
38
+ await mkdir(astroPublicDir, { recursive: true });
67
39
 
68
- for (const entry of await readDirectory(astroPublicDir)) {
69
- if (keepAstroPublicEntries.has(entry.name)) {
70
- continue;
40
+ for (const entry of await readDirectory(astroPublicDir)) {
41
+ if (entry.name !== '_norna') await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
42
+ }
43
+ const resources = path.join(astroPublicDir, '_norna');
44
+ for (const entry of await readDirectory(resources)) {
45
+ if (entry.name === 'images' || entry.name === 'search' && projectConfig.search.enabled) continue;
46
+ await rm(path.join(resources, entry.name), { force: true, recursive: true });
47
+ }
48
+ for (const file of new Set(publicFilesByPathname.values())) {
49
+ const destination = path.join(astroPublicDir, file.pathname.slice(1));
50
+ await mkdir(path.dirname(destination), { recursive: true });
51
+ await cp(file.filePath, destination, { force: false, errorOnExist: true });
71
52
  }
72
53
 
73
- await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
74
- }
75
-
76
- for (const entry of sourceEntries) {
77
- await cp(
78
- path.join(sitePublicDir, entry.name),
79
- path.join(astroPublicDir, entry.name),
80
- { force: true, recursive: true },
81
- );
82
- }
54
+ for (const file of attachments.files) {
55
+ const destination = path.join(astroPublicDir, file.pathname.slice(1));
56
+ await mkdir(path.dirname(destination), { recursive: true });
57
+ await cp(file.filePath, destination, { force: false, errorOnExist: true });
58
+ }
59
+ await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
83
60
 
84
- await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
61
+ console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
62
+ const pageCount = siteStructure.contentFiles.length + categoryDestinations.filter(({ kind }) => kind === 'listing').length;
63
+ console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${pageCount} public page${pageCount === 1 ? '' : 's'}.`);
64
+ };
85
65
 
86
- console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
87
- const pageCount = siteStructure.contentFiles.length + categoryDestinations.filter(({ kind }) => kind === 'listing').length;
88
- console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${pageCount} public page${pageCount === 1 ? '' : 's'}.`);
66
+ await main().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,17 @@
1
+ import { siteDir } from './lib/site-paths.mjs';
2
+ import { planSiteUpgrade } from './lib/site-upgrade.mjs';
3
+
4
+ const usage = 'Usage: norna site:upgrade [--apply]\nCheck the root/ source layout. Former layouts require manual conversion; --apply is retained but does not convert old sources.';
5
+ const args = process.argv.slice(2);
6
+ try {
7
+ if (args.includes('--help') || args.includes('-h')) {
8
+ console.log(usage);
9
+ } else {
10
+ if (args.length > 1 || args.some((arg) => arg !== '--apply')) throw new Error(usage);
11
+ await planSiteUpgrade(siteDir);
12
+ console.log('The site already uses the current configuration and homepage layout. No files changed.');
13
+ }
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
4
+ import type { SiteNode } from '../lib/sitePages';
5
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
6
+
7
+ interface Props {
8
+ node: SiteNavigationNode;
9
+ currentPage?: SiteNode;
10
+ categoryDestinations?: CategoryDestinationMap;
11
+ }
12
+ const { node, currentPage, categoryDestinations } = Astro.props;
13
+ const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}`;
14
+ const panelId = `area-menu-${encodeURIComponent(node.node.pagePath)}`;
15
+ ---
16
+
17
+ {node.children.length > 0 && (
18
+ <details class="top-page-menu area-navigation-menu" data-area-menu={node.node.pagePath}>
19
+ <summary aria-label={label} aria-controls={panelId}>
20
+ <span class="top-page-menu-title">{node.node.title}</span>
21
+ </summary>
22
+ <AreaNavigationPanel id={panelId} node={node} currentPage={currentPage} categoryDestinations={categoryDestinations} />
23
+ </details>
24
+ )}
@@ -0,0 +1,55 @@
1
+ ---
2
+ import { Check } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
6
+ import { getAreaMenuGroups, resolveAreaNavigation } from '../lib/areaNavigation';
7
+ import type { SiteNode } from '../lib/sitePages';
8
+
9
+ interface Props {
10
+ id: string;
11
+ node?: SiteNavigationNode;
12
+ currentPage?: SiteNode;
13
+ categoryDestinations?: CategoryDestinationMap;
14
+ otherRoots?: SiteNavigationNode[];
15
+ }
16
+ const { id, node, currentPage, categoryDestinations, otherRoots = [] } = Astro.props;
17
+ const { localRoot } = resolveAreaNavigation(node ? [node] : [], currentPage);
18
+ const { branches, pages } = node ? getAreaMenuGroups(node) : { branches: [], pages: [] };
19
+ const columnsFor = (count: number, maximum: number) => count === 0 ? 0 : Math.ceil(count / Math.ceil(count / maximum));
20
+ const branchColumns = columnsFor(branches.length, pages.length > 0 ? 2 : 3);
21
+ const pageColumns = branches.length === 0 ? columnsFor(pages.length, 3) : pages.length > 0 ? 1 : 0;
22
+ const groups = [
23
+ { kind: 'branches', choices: branches, columns: branchColumns },
24
+ { kind: 'pages', choices: pages, columns: pageColumns },
25
+ ].filter(({ choices }) => choices.length > 0);
26
+ ---
27
+
28
+ <nav id={id} class="top-page-panel area-navigation-panel"
29
+ style={`--area-menu-columns: ${branchColumns + pageColumns || 2}`}
30
+ aria-label={node ? `${node.node.title}: ${projectConfig.locale.labels.navigationChildren}` : projectConfig.locale.labels.siteNavigation}>
31
+ {groups.map((group) => <ul class="area-navigation-choices" data-area-choice-group={group.kind}
32
+ style={`--area-group-columns: ${group.columns}; --area-group-rows: ${Math.ceil(group.choices.length / group.columns)}`}>
33
+ {group.choices.map((child) => {
34
+ const destination = getNavigationDestination(child.node, categoryDestinations);
35
+ const selected = localRoot === child || currentPage?.pagePath === child.node.pagePath;
36
+ const description = child.node.kind === 'page'
37
+ ? child.node.entry.data.page?.description
38
+ : child.node.description;
39
+ return <li>
40
+ <a href={withBasePath(projectConfig.site.basePath, destination.pathname)}
41
+ aria-current={selected ? 'true' : undefined} data-area-choice={child.node.pagePath}>
42
+ <span class="area-navigation-title">{child.node.title}{selected && <Check aria-hidden="true" />}</span>
43
+ {group.kind === 'branches' && description && <span class="area-navigation-description">{description}</span>}
44
+ </a>
45
+ </li>;
46
+ })}
47
+ </ul>)}
48
+ {otherRoots.length > 0 && <ul class="area-navigation-other-roots" aria-label={projectConfig.locale.labels.siteNavigation}>
49
+ {otherRoots.map(({ node: root }) => <li>
50
+ <a href={withBasePath(projectConfig.site.basePath, getNavigationDestination(root, categoryDestinations).pathname)}>
51
+ {root.isHome ? projectConfig.locale.labels.returnHome : root.title}
52
+ </a>
53
+ </li>)}
54
+ </ul>}
55
+ </nav>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { Menu } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { hasLargeAreaMenu, resolveAreaNavigation } from '../lib/areaNavigation';
5
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
6
+ import type { SiteNode } from '../lib/sitePages';
7
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
8
+
9
+ interface Props {
10
+ nodes: SiteNavigationNode[];
11
+ currentPage?: SiteNode;
12
+ categoryDestinations?: CategoryDestinationMap;
13
+ label?: string;
14
+ }
15
+
16
+ const { nodes, currentPage, categoryDestinations, label } = Astro.props;
17
+ const { globalRoot } = resolveAreaNavigation(nodes, currentPage);
18
+ const currentLabel = label ?? globalRoot?.node.title ?? projectConfig.locale.labels.navigationMenu;
19
+ const menuRoot = globalRoot && globalRoot.children.length > 0 && !hasLargeAreaMenu(globalRoot) ? globalRoot : undefined;
20
+ const otherRoots = nodes.filter((root) => root !== menuRoot);
21
+ const menuLabel = `${projectConfig.locale.labels.navigationMenu}: ${currentLabel}`;
22
+ ---
23
+
24
+ <details class="top-page-menu area-navigation-switcher" data-area-switcher>
25
+ <summary aria-label={menuLabel} title={menuLabel} aria-controls="area-switcher-panel">
26
+ <Menu aria-hidden="true" />
27
+ </summary>
28
+ <AreaNavigationPanel id="area-switcher-panel" node={menuRoot} currentPage={currentPage}
29
+ categoryDestinations={categoryDestinations} otherRoots={otherRoots} />
30
+ </details>
@@ -4,6 +4,7 @@ import ImageCarousel from './ImageCarousel.astro';
4
4
  import ImageStack from './ImageStack.astro';
5
5
  import PageList from './PageList.astro';
6
6
  import type { SectionContentBlock } from '../lib/sectionContent';
7
+ import { renderNestedMarkdownImages } from '../lib/markdownImages';
7
8
 
8
9
  interface Props {
9
10
  blocks: SectionContentBlock[];
@@ -20,7 +21,7 @@ const firstMediaIndex = blocks.findIndex((block) => block.type === 'image-stack'
20
21
  ---
21
22
 
22
23
  {blocks.map((block, index) => {
23
- if (block.type === 'html') return <div class="section-markdown" set:html={block.html} />;
24
+ if (block.type === 'html') return <div class="section-markdown" set:html={renderNestedMarkdownImages(block.html, title, images.maxAvailableWidthPercent)} />;
24
25
  if (block.type === 'tabs') return (
25
26
  <div class="content-tabs content-block-note-lane-boundary" data-content-tabs>
26
27
  <div class="content-tabs-controls" data-tabs-controls hidden></div>
@@ -43,10 +43,11 @@ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
43
43
  <div class="managed-image-frame">
44
44
  <a
45
45
  class="managed-image-inspection-link"
46
- href={inspection.href}
47
- aria-label={inspectionLabel}
46
+ href={image.href ?? inspection.href}
47
+ title={image.linkTitle}
48
+ aria-label={image.href === undefined ? inspectionLabel : undefined}
48
49
  aria-describedby={captionId}
49
- data-image-inspection-trigger
50
+ data-image-inspection-trigger={image.href === undefined ? '' : undefined}
50
51
  data-image-intrinsic-width={inspection.width}
51
52
  data-image-intrinsic-height={inspection.height}
52
53
  data-image-inspection-scalable={inspection.scalable ? 'true' : undefined}
@@ -56,12 +57,13 @@ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
56
57
  {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
57
58
  class:list={['managed-image', getImageClass(image)]}
58
59
  alt={image.alt ?? ''}
60
+ title={image.title}
59
61
  aria-describedby={captionId}
60
62
  {...getImageLoadingProps(isPriorityItem)}
61
63
  />
62
- <span class="managed-image-inspection-affordance" aria-hidden="true">
64
+ {image.href === undefined && <span class="managed-image-inspection-affordance" aria-hidden="true">
63
65
  <Maximize2 size={18} stroke-width={2} />
64
- </span>
66
+ </span>}
65
67
  </a>
66
68
  </div>
67
69
  {image.caption && (
@@ -104,13 +104,6 @@ const labels = projectConfig.locale.labels;
104
104
  const imageBounds = image.getBoundingClientRect();
105
105
  if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
106
106
 
107
- const visibleContentsRail = Array.from(
108
- layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
109
- ).some(isVisible);
110
- const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
111
- && document.documentElement.dataset.focusReading !== 'on';
112
- if (visibleContentsRail || reservesContentsLane) return;
113
-
114
107
  const frameBounds = frame.getBoundingClientRect();
115
108
  const figureBounds = figure.getBoundingClientRect();
116
109
  const captionBounds = caption.getBoundingClientRect();
@@ -0,0 +1,26 @@
1
+ ---
2
+ import styles from '../styles/navigation-continuity.css?inline';
3
+ ---
4
+
5
+ <style is:inline set:html={styles} />
6
+ <script is:inline>
7
+ // A cold history arrival can restore its position at pagereveal, then lose
8
+ // it to native fragment scrolling when deferred modules finish parsing.
9
+ // Keep the browser's restored position until that initial load completes.
10
+ window.addEventListener('pagereveal', () => {
11
+ if (!location.hash || document.readyState === 'complete'
12
+ || performance.getEntriesByType('navigation')[0]?.type !== 'back_forward'
13
+ || history.scrollRestoration !== 'auto') return;
14
+
15
+ history.scrollRestoration = 'manual';
16
+ const release = () => {
17
+ history.scrollRestoration = 'auto';
18
+ window.removeEventListener('load', release);
19
+ window.removeEventListener('pagehide', release);
20
+ };
21
+ window.addEventListener('load', release);
22
+ window.addEventListener('pagehide', release);
23
+ }, { once: true });
24
+ </script>
25
+ <!-- A streamed response must finish the page before its transition snapshot. -->
26
+ <link rel="expect" blocking="render" href="#norna-navigation-ready" />
@@ -1,19 +1,23 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
- import type { SiteNavigationNode } from '../lib/siteNavigation';
4
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
5
5
  import type { SiteNode, SitePage } from '../lib/sitePages';
6
6
  import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
10
  currentPage?: SiteNode;
11
+ categoryDestinations?: CategoryDestinationMap;
11
12
  variant: 'mobile' | 'sidebar' | 'submenu';
12
13
  integratePageSections?: boolean;
13
14
  }
14
15
 
15
- const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
16
+ const { nodes, currentPage, categoryDestinations, variant, integratePageSections = false } = Astro.props;
16
17
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
18
+ const categoryHref = (node: SiteNavigationNode) => withBasePath(
19
+ projectConfig.site.basePath, getNavigationDestination(node.node, categoryDestinations).pathname,
20
+ );
17
21
  const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
18
22
  const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
19
23
  const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
@@ -71,13 +75,17 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
71
75
  class="navigation-page-summary"
72
76
  aria-controls={getBranchId(node.node)}
73
77
  >
74
- <span class="navigation-page-summary-title">{node.node.title}</span>
78
+ <a class:list={['navigation-page-summary-title', 'navigation-category-link', {
79
+ 'mobile-nav-destination': variant === 'mobile',
80
+ }]}
81
+ href={categoryHref(node)}>{node.node.title}</a>
75
82
  <span class="navigation-page-chevron" aria-hidden="true"></span>
76
83
  </summary>
77
84
  <div id={getBranchId(node.node)} class="navigation-page-branch-content">
78
85
  <Astro.self
79
86
  nodes={node.children}
80
87
  currentPage={currentPage}
88
+ categoryDestinations={categoryDestinations}
81
89
  variant={variant}
82
90
  integratePageSections={integratePageSections}
83
91
  />
@@ -123,6 +131,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
123
131
  <Astro.self
124
132
  nodes={node.children}
125
133
  currentPage={currentPage}
134
+ categoryDestinations={categoryDestinations}
126
135
  variant={variant}
127
136
  integratePageSections={integratePageSections}
128
137
  />
@@ -174,7 +183,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
174
183
  {node.node.title}
175
184
  </a>
176
185
  {node.children.length > 0 && (
177
- <Astro.self nodes={node.children} currentPage={currentPage} variant={variant} />
186
+ <Astro.self nodes={node.children} currentPage={currentPage} categoryDestinations={categoryDestinations} variant={variant} />
178
187
  )}
179
188
  </>
180
189
  )}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { getNavigationStateScript } from '../lib/navigationStateScript.mjs';
3
+
4
+ interface Props {
5
+ scope: 'desktop' | 'mobile';
6
+ }
7
+
8
+ const { scope } = Astro.props;
9
+ ---
10
+
11
+ <script is:inline data-navigation-state-scope={scope} set:html={getNavigationStateScript()} />
@@ -6,15 +6,16 @@ import type { SiteNavigationNode } from '../lib/siteNavigation';
6
6
  interface Props {
7
7
  idPrefix: string;
8
8
  nodes: SiteNavigationNode[];
9
+ forceVisible?: boolean;
9
10
  }
10
11
 
11
- const { idPrefix, nodes } = Astro.props;
12
+ const { idPrefix, nodes, forceVisible = false } = Astro.props;
12
13
  const countNodes = (tree: SiteNavigationNode[]): number => tree.reduce(
13
14
  (total, node) => total + 1 + countNodes(node.children),
14
15
  0,
15
16
  );
16
17
  const minimumUsefulNodeCount = 12;
17
- const showControls = countNodes(nodes) >= minimumUsefulNodeCount;
18
+ const showControls = forceVisible || countNodes(nodes) >= minimumUsefulNodeCount;
18
19
  const labels = projectConfig.locale.labels;
19
20
  const filterId = `${idPrefix}-filter`;
20
21
  const statusId = `${idPrefix}-status`;
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { searchOutputDirectory } from '../../scripts/lib/publication-paths.mjs';
2
3
  import { getCollection } from 'astro:content';
3
4
  import { ArrowLeft } from '@lucide/astro';
4
5
  import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
@@ -17,12 +18,13 @@ import { withBasePath } from '../lib/basePath';
17
18
 
18
19
  const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
19
20
  const siteModel = await getSiteModel(await getCollection('site'));
21
+ const categoryDestinations = siteModel.categoryDestinations.byPathname;
20
22
  const { nodes: siteNodes, pages: sitePages } = siteModel;
21
23
  const homePage = sitePages.find((page) => page.isHome);
22
24
  if (!homePage) throw new Error('The required homepage is missing from the site page model.');
23
25
 
24
26
  const [siteTheme] = await getCollection('theme');
25
- if (!siteTheme) throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
27
+ if (!siteTheme) throw new Error('site/root/tree-theme.yaml is required. Add the shared site theme before building the site.');
26
28
  const [sitewide] = await getCollection('sitewide');
27
29
  const sitewideData = sitewide?.data ?? {};
28
30
  const siteThemeData = siteTheme.data;
@@ -55,6 +57,7 @@ const pagePresentation = resolvePagePresentation(siteThemeData, themeLabel, {
55
57
  navigationMode: navigationModel.mode,
56
58
  });
57
59
  const pageTextWidth = getTextWidthCssValue(pagePresentation.typography.values.body.width);
60
+ const areaReadingFrame = navigationModel.mode === 'tree';
58
61
  const frameColors = resolveFrameColors({ theme: siteThemeData, sourceLabel: themeLabel });
59
62
  const logoAsset = getLogoAsset();
60
63
  const logo = logoAsset
@@ -82,7 +85,7 @@ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
82
85
  navigationModel.requestedMode === 'automatic'
83
86
  && (navigationModel.hasNestedPages || navigationModel.hasCategories)
84
87
  );
85
- const bundlePath = withBasePath(projectConfig.site.basePath, '/pagefind/');
88
+ const bundlePath = withBasePath(projectConfig.site.basePath, `/${searchOutputDirectory}/`);
86
89
  const pagefindTranslationOption = projectConfig.locale.pagefindTranslations
87
90
  ? `,\n\t\t\ttranslations: ${JSON.stringify(projectConfig.locale.pagefindTranslations)}`
88
91
  : '';
@@ -130,19 +133,24 @@ const searchScript = `(() => {
130
133
  pathname="/search/"
131
134
  footer={sitewideData.footer}
132
135
  indexable={false}
136
+ areaNavigation={navigationModel.mode === 'tree'}
137
+ areaReadingFrame={areaReadingFrame}
133
138
  >
134
139
  <link slot="head" rel="stylesheet" href={`${bundlePath}pagefind-ui.css`} />
135
140
  <SiteNavigation
136
141
  logo={logo}
137
142
  navigationEntries={siteNavigation}
143
+ categoryDestinations={categoryDestinations}
138
144
  navigationMode={navigationModel.mode}
139
145
  showTopNavigationSubmenus={navigationModel.requestedMode !== 'automatic' && navigationModel.mode !== 'tree'}
140
146
  hasNavigationRailFrame={hasNavigationRailFrame}
141
147
  frameColors={frameColors}
142
148
  readerPreferences={pagePresentation.readerPreferences}
143
149
  searchPage
150
+ areaReadingFrame={areaReadingFrame}
144
151
  />
145
152
  {siteBanners.length > 0 && <SiteBanners banners={siteBanners} />}
153
+ <div class:list={['site-page-layout', { 'site-page-layout-tree': areaReadingFrame }]}>
146
154
  <main class="site-content search-page-content" id="main-content" style={`--page-text-width: ${pageTextWidth}`}>
147
155
  <SiteSection
148
156
  section={searchSection}
@@ -158,6 +166,7 @@ const searchScript = `(() => {
158
166
  </div>
159
167
  </SiteSection>
160
168
  </main>
169
+ </div>
161
170
  <script is:inline set:html={searchScript} />
162
171
  </BaseLayout>
163
172