@janga/norna 0.7.25 → 0.7.27

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 (137) hide show
  1. package/README.md +18 -10
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +14 -2
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +4 -4
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +8 -8
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/add-site-node.mjs +1 -1
  16. package/scripts/dev-local.mjs +24 -2
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/lib/category-destinations.mjs +48 -0
  19. package/scripts/lib/code-fence-metadata.mjs +14 -37
  20. package/scripts/lib/content-tabs.mjs +155 -0
  21. package/scripts/lib/details-headings.mjs +58 -0
  22. package/scripts/lib/documentation-links.mjs +33 -2
  23. package/scripts/lib/documentation-routes.json +222 -0
  24. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  25. package/scripts/lib/editor-language-service.mjs +238 -116
  26. package/scripts/lib/locale-registry.mjs +597 -0
  27. package/scripts/lib/locales/az-Latn.mjs +78 -0
  28. package/scripts/lib/locales/be.mjs +78 -0
  29. package/scripts/lib/locales/bg.mjs +78 -0
  30. package/scripts/lib/locales/bs.mjs +78 -0
  31. package/scripts/lib/locales/ca.mjs +78 -0
  32. package/scripts/lib/locales/cnr.mjs +78 -0
  33. package/scripts/lib/locales/cs.mjs +78 -0
  34. package/scripts/lib/locales/da.mjs +78 -0
  35. package/scripts/lib/locales/de.mjs +78 -0
  36. package/scripts/lib/locales/el.mjs +78 -0
  37. package/scripts/lib/locales/en.mjs +78 -0
  38. package/scripts/lib/locales/es.mjs +78 -0
  39. package/scripts/lib/locales/et.mjs +78 -0
  40. package/scripts/lib/locales/fi.mjs +78 -0
  41. package/scripts/lib/locales/fil.mjs +78 -0
  42. package/scripts/lib/locales/fr.mjs +78 -0
  43. package/scripts/lib/locales/ga.mjs +78 -0
  44. package/scripts/lib/locales/ha.mjs +78 -0
  45. package/scripts/lib/locales/hr.mjs +78 -0
  46. package/scripts/lib/locales/hu.mjs +78 -0
  47. package/scripts/lib/locales/id.mjs +78 -0
  48. package/scripts/lib/locales/is.mjs +78 -0
  49. package/scripts/lib/locales/it.mjs +78 -0
  50. package/scripts/lib/locales/jv.mjs +78 -0
  51. package/scripts/lib/locales/lb.mjs +78 -0
  52. package/scripts/lib/locales/lt.mjs +78 -0
  53. package/scripts/lib/locales/lv.mjs +78 -0
  54. package/scripts/lib/locales/mk.mjs +78 -0
  55. package/scripts/lib/locales/mt.mjs +78 -0
  56. package/scripts/lib/locales/nb.mjs +78 -0
  57. package/scripts/lib/locales/nl.mjs +78 -0
  58. package/scripts/lib/locales/nn.mjs +78 -0
  59. package/scripts/lib/locales/pcm.mjs +78 -0
  60. package/scripts/lib/locales/pl.mjs +78 -0
  61. package/scripts/lib/locales/pt.mjs +78 -0
  62. package/scripts/lib/locales/ro.mjs +78 -0
  63. package/scripts/lib/locales/ru.mjs +78 -0
  64. package/scripts/lib/locales/sk.mjs +78 -0
  65. package/scripts/lib/locales/sl.mjs +78 -0
  66. package/scripts/lib/locales/sq.mjs +78 -0
  67. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  68. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  69. package/scripts/lib/locales/sv.mjs +78 -0
  70. package/scripts/lib/locales/sw.mjs +78 -0
  71. package/scripts/lib/locales/tr.mjs +78 -0
  72. package/scripts/lib/locales/uk.mjs +78 -0
  73. package/scripts/lib/locales/vi.mjs +78 -0
  74. package/scripts/lib/markdown-links.mjs +4 -25
  75. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  76. package/scripts/lib/markdown-notes.mjs +165 -0
  77. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  78. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  79. package/scripts/lib/page-aliases.mjs +12 -1
  80. package/scripts/lib/page-markdown.mjs +63 -16
  81. package/scripts/lib/page-model.mjs +22 -0
  82. package/scripts/lib/page-move-plan.mjs +22 -5
  83. package/scripts/lib/pagefind-translations.mjs +620 -0
  84. package/scripts/lib/presentation-contract.mjs +6 -1
  85. package/scripts/lib/presentation.mjs +29 -30
  86. package/scripts/lib/project-config.mjs +2 -173
  87. package/scripts/lib/schema-definitions.mjs +6 -13
  88. package/scripts/lib/schema-editor-metadata.mjs +23 -44
  89. package/scripts/lib/schema-value-definitions.mjs +4 -8
  90. package/scripts/lib/semantic-callouts.mjs +4 -2
  91. package/scripts/lib/site-content.mjs +4 -2
  92. package/scripts/lib/site-link-graph.mjs +16 -6
  93. package/scripts/lib/site-navigation-tree.mjs +0 -11
  94. package/scripts/lib/sitemap.mjs +5 -3
  95. package/scripts/lib/structured-blocks.mjs +108 -0
  96. package/scripts/lib/table-render-plugin.mjs +46 -3
  97. package/scripts/lib/table-row-headers.mjs +218 -0
  98. package/scripts/lib/theme-presets.mjs +3 -10
  99. package/scripts/lib/yaml-config.mjs +2 -6
  100. package/scripts/migrate-check.mjs +19 -0
  101. package/scripts/sync-content-sections.mjs +39 -10
  102. package/scripts/sync-site-public.mjs +7 -1
  103. package/src/components/CodeBlockCopyScript.astro +47 -5
  104. package/src/components/ContentBlocks.astro +39 -0
  105. package/src/components/ContentTabsScript.astro +60 -0
  106. package/src/components/DisplaySettings.astro +17 -19
  107. package/src/components/NavigationPageTree.astro +1 -1
  108. package/src/components/NoteNavigationScript.astro +4 -0
  109. package/src/components/PageAliasRedirect.astro +49 -7
  110. package/src/components/SearchNavigationScript.astro +4 -0
  111. package/src/components/SearchPage.astro +43 -2
  112. package/src/components/SectionNavigationScript.astro +43 -11
  113. package/src/components/SiteBreadcrumbs.astro +1 -1
  114. package/src/components/SiteNavigation.astro +29 -20
  115. package/src/components/SitePage.astro +40 -15
  116. package/src/components/SiteSection.astro +5 -81
  117. package/src/components/SiteTreeNavigation.astro +2 -2
  118. package/src/components/TableOverflowScript.astro +386 -58
  119. package/src/components/TopPageMenu.astro +47 -0
  120. package/src/components/TreeNavigationScript.astro +10 -3
  121. package/src/content.config.ts +5 -8
  122. package/src/layouts/BaseLayout.astro +8 -12
  123. package/src/lib/navigationFollowing.ts +142 -0
  124. package/src/lib/noteNavigation.ts +50 -0
  125. package/src/lib/searchNavigation.ts +107 -0
  126. package/src/lib/sectionContent.ts +20 -90
  127. package/src/lib/siteNavigation.ts +0 -5
  128. package/src/lib/sitePages.ts +17 -20
  129. package/src/lib/table-sort-tooltip.ts +109 -0
  130. package/src/pages/[...slug].astro +16 -6
  131. package/src/styles/content.css +397 -134
  132. package/src/styles/foundations.css +9 -0
  133. package/src/styles/media.css +5 -0
  134. package/src/styles/navigation.css +63 -74
  135. package/src/styles/page-layout.css +7 -0
  136. package/src/styles/responsive.css +2 -7
  137. package/starters/basic/README.md +1 -1
@@ -9,6 +9,8 @@ import {
9
9
  sitePublicLabel,
10
10
  } from './lib/site-paths.mjs';
11
11
  import { getSiteStructure } from './lib/site-structure.mjs';
12
+ import { getSiteLinkGraph } from './lib/site-link-graph.mjs';
13
+ import { assertCategoryDestinationModel } from './lib/category-destinations.mjs';
12
14
 
13
15
  const keepAstroPublicEntries = new Set([
14
16
  'images',
@@ -53,9 +55,12 @@ for (const generatedFile of generatedPublicFiles) {
53
55
  }
54
56
 
55
57
  const siteStructure = await getSiteStructure();
58
+ const { categoryModel } = await getSiteLinkGraph({ siteStructure });
59
+ const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
56
60
  const sitemapXml = createSitemapXml({
57
61
  siteStructure,
58
62
  siteUrl: projectConfig.site.url,
63
+ categoryDestinations,
59
64
  });
60
65
 
61
66
  await mkdir(astroPublicDir, { recursive: true });
@@ -79,4 +84,5 @@ for (const entry of sourceEntries) {
79
84
  await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
80
85
 
81
86
  console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
82
- console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
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'}.`);
@@ -14,7 +14,7 @@ const labels = {
14
14
  <Copy aria-hidden="true" data-code-copy-icon="copy" size={18} stroke-width={2} />
15
15
  <Check aria-hidden="true" data-code-copy-icon="copied" hidden size={18} stroke-width={2} />
16
16
  <X aria-hidden="true" data-code-copy-icon="failed" hidden size={18} stroke-width={2} />
17
- <span class="visually-hidden" aria-live="polite" data-code-copy-status></span>
17
+ <span class="code-block-copy-status" aria-live="polite" data-code-copy-status></span>
18
18
  </button>
19
19
  </template>
20
20
 
@@ -24,6 +24,39 @@ const labels = {
24
24
  if (!(template instanceof HTMLTemplateElement)) return;
25
25
 
26
26
  const resetTimers = new WeakMap();
27
+ const layoutUpdaters = [];
28
+ const prepareLayout = (wrapper, pre) => {
29
+ const topLevel = wrapper.parentElement?.classList.contains('section-markdown');
30
+ let scheduled = false;
31
+ const update = () => {
32
+ scheduled = false;
33
+ if (topLevel) {
34
+ const layout = ['prose', 'end', 'canvas'].find((candidate) => {
35
+ wrapper.dataset.codeLayout = candidate;
36
+ return pre.scrollWidth <= pre.clientWidth + 1;
37
+ }) ?? 'canvas';
38
+ wrapper.dataset.codeLayout = layout;
39
+ }
40
+ const overflow = pre.scrollWidth > pre.clientWidth + 1;
41
+ wrapper.dataset.codeOverflow = String(overflow);
42
+ if (overflow) pre.setAttribute('tabindex', '0');
43
+ else pre.removeAttribute('tabindex');
44
+ };
45
+ const schedule = () => {
46
+ if (scheduled) return;
47
+ scheduled = true;
48
+ requestAnimationFrame(update);
49
+ };
50
+ layoutUpdaters.push(schedule);
51
+ if ('ResizeObserver' in window) {
52
+ const observer = new ResizeObserver(schedule);
53
+ observer.observe(wrapper.parentElement);
54
+ const body = wrapper.closest('.section-body');
55
+ if (body) observer.observe(body);
56
+ }
57
+ window.addEventListener('resize', schedule, { passive: true });
58
+ update();
59
+ };
27
60
  const copyText = async (text) => {
28
61
  if (navigator.clipboard?.writeText) {
29
62
  try {
@@ -45,7 +78,7 @@ const labels = {
45
78
 
46
79
  const showState = (button, state) => {
47
80
  button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
48
- icon.hidden = icon.getAttribute('data-code-copy-icon') !== state;
81
+ icon.toggleAttribute('hidden', icon.getAttribute('data-code-copy-icon') !== state);
49
82
  });
50
83
  const status = button.querySelector('[data-code-copy-status]');
51
84
  if (status) status.textContent = state === 'copied' ? labels.copied : labels.failed;
@@ -54,11 +87,11 @@ const labels = {
54
87
  clearTimeout(resetTimers.get(button));
55
88
  resetTimers.set(button, setTimeout(() => {
56
89
  button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
57
- icon.hidden = icon.getAttribute('data-code-copy-icon') !== 'copy';
90
+ icon.toggleAttribute('hidden', icon.getAttribute('data-code-copy-icon') !== 'copy');
58
91
  });
59
- if (status) status.textContent = '';
92
+ // Keep the live announcement available after the visual feedback ends.
60
93
  delete button.dataset.copyState;
61
- }, 2000));
94
+ }, state === 'copied' ? 1000 : 2000));
62
95
  };
63
96
 
64
97
  document.querySelectorAll('#main-content .section-markdown pre > code').forEach((code) => {
@@ -79,9 +112,12 @@ const labels = {
79
112
  if (!(button instanceof HTMLButtonElement)) return;
80
113
  const title = codeExample?.querySelector(':scope > .norna-code-title');
81
114
  (title ?? wrapper).append(button);
115
+ prepareLayout(wrapper, pre);
82
116
  button.addEventListener('click', async () => {
83
117
  if (button.dataset.copyBusy === 'true') return;
84
118
  button.dataset.copyBusy = 'true';
119
+ const status = button.querySelector('[data-code-copy-status]');
120
+ if (status) status.textContent = '';
85
121
  try {
86
122
  await copyText(code.textContent ?? '');
87
123
  showState(button, 'copied');
@@ -92,6 +128,12 @@ const labels = {
92
128
  }
93
129
  });
94
130
  });
131
+ const preferences = new MutationObserver(() => layoutUpdaters.forEach((update) => update()));
132
+ preferences.observe(document.documentElement, {
133
+ attributes: true,
134
+ attributeFilter: ['data-reading-width', 'data-focus-reading', 'dir'],
135
+ });
136
+ document.fonts?.ready.then(() => layoutUpdaters.forEach((update) => update()));
95
137
 
96
138
  template.remove();
97
139
  })();
@@ -0,0 +1,39 @@
1
+ ---
2
+ import CardList from './CardList.astro';
3
+ import ImageCarousel from './ImageCarousel.astro';
4
+ import ImageStack from './ImageStack.astro';
5
+ import PageList from './PageList.astro';
6
+ import type { SectionContentBlock } from '../lib/sectionContent';
7
+
8
+ interface Props {
9
+ blocks: SectionContentBlock[];
10
+ title: string;
11
+ images: {
12
+ maxAvailableWidthPercent: { desktop: number; mobile: number };
13
+ maxAvailableHeightPercent?: { desktop: number; mobile: number };
14
+ };
15
+ cardWidth: 'text' | 'narrow' | 'normal' | 'wide';
16
+ prioritizeFirstImage?: boolean;
17
+ }
18
+ const { blocks, title, images, cardWidth, prioritizeFirstImage = false } = Astro.props;
19
+ const firstMediaIndex = blocks.findIndex((block) => block.type === 'image-stack' || block.type === 'image-carousel');
20
+ ---
21
+
22
+ {blocks.map((block, index) => {
23
+ if (block.type === 'html') return <div class="section-markdown" set:html={block.html} />;
24
+ if (block.type === 'tabs') return (
25
+ <div class="content-tabs content-block-note-lane-boundary" data-content-tabs>
26
+ <div class="content-tabs-controls" data-tabs-controls hidden></div>
27
+ {block.panels.map((panel, panelIndex) => (
28
+ <div class="content-tab-panel" data-tab-panel role="group" aria-labelledby={`norna-tabs-label-${block.index}-${panelIndex}`}>
29
+ <div class="content-tab-label" data-tab-label id={`norna-tabs-label-${block.index}-${panelIndex}`}>{panel.label}</div>
30
+ <Astro.self blocks={panel.contentBlocks} title={title} images={images} cardWidth={cardWidth} />
31
+ </div>
32
+ ))}
33
+ </div>
34
+ );
35
+ if (block.type === 'page-list') return <PageList pages={block.pages} />;
36
+ if (block.type === 'card-list') return <CardList cards={block.cards} layout={block.layout} flow={block.flow} size={block.size} width={block.width ?? cardWidth} />;
37
+ if (block.type === 'image-carousel') return <ImageCarousel title={title} images={block.images} maxAvailableWidthPercent={images.maxAvailableWidthPercent} maxAvailableHeightPercent={images.maxAvailableHeightPercent} priorityFirstImage={prioritizeFirstImage && index === firstMediaIndex} />;
38
+ return <ImageStack title={title} images={block.images} maxAvailableWidthPercent={images.maxAvailableWidthPercent} priorityFirstImage={prioritizeFirstImage && index === firstMediaIndex} />;
39
+ })}
@@ -0,0 +1,60 @@
1
+ <script>
2
+ document.querySelectorAll<HTMLElement>('[data-content-tabs]').forEach((group, groupIndex) => {
3
+ const controls = group.querySelector<HTMLElement>('[data-tabs-controls]')!;
4
+ const panels = Array.from(group.querySelectorAll<HTMLElement>(':scope > [data-tab-panel]'));
5
+ const labels = panels.map((panel) => panel.querySelector<HTMLElement>('[data-tab-label]')!);
6
+ const buttons = panels.map((panel, index) => {
7
+ const button = document.createElement('button');
8
+ const id = `norna-tabs-${groupIndex}-${index}`;
9
+ button.type = 'button';
10
+ button.id = `${id}-tab`;
11
+ button.textContent = labels[index].textContent;
12
+ button.setAttribute('role', 'tab');
13
+ button.setAttribute('aria-controls', id);
14
+ panel.id = id;
15
+ panel.setAttribute('role', 'tabpanel');
16
+ panel.setAttribute('aria-labelledby', button.id);
17
+ panel.tabIndex = 0;
18
+ controls.append(button);
19
+ return button;
20
+ });
21
+ const heading = group.closest('section')?.querySelector<HTMLElement>('h1, h2');
22
+ controls.setAttribute('role', 'tablist');
23
+ if (heading?.id) controls.setAttribute('aria-labelledby', heading.id);
24
+ const select = (selected: number, focus = false) => {
25
+ buttons.forEach((button, index) => {
26
+ button.setAttribute('aria-selected', String(index === selected));
27
+ button.tabIndex = index === selected ? 0 : -1;
28
+ panels[index].hidden = index !== selected;
29
+ });
30
+ if (focus) buttons[selected].focus({ preventScroll: true });
31
+ group.dispatchEvent(new CustomEvent('norna:tab-change', { bubbles: true }));
32
+ window.dispatchEvent(new Event('resize'));
33
+ };
34
+ buttons.forEach((button, index) => {
35
+ button.addEventListener('click', () => select(index));
36
+ button.addEventListener('keydown', (event) => {
37
+ let selected = index;
38
+ if (event.key === 'ArrowRight') selected = (index + 1) % buttons.length;
39
+ else if (event.key === 'ArrowLeft') selected = (index - 1 + buttons.length) % buttons.length;
40
+ else if (event.key === 'Home') selected = 0;
41
+ else if (event.key === 'End') selected = buttons.length - 1;
42
+ else return;
43
+ event.preventDefault();
44
+ select(selected, true);
45
+ });
46
+ });
47
+ controls.hidden = false;
48
+ group.dataset.tabsReady = 'true';
49
+ select(0);
50
+ const revealTarget = () => {
51
+ let id: string;
52
+ try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; }
53
+ const target = document.getElementById(id);
54
+ const index = panels.findIndex((panel) => target && panel.contains(target));
55
+ if (index >= 0) { select(index); target?.scrollIntoView(); }
56
+ };
57
+ window.addEventListener('hashchange', revealTarget);
58
+ revealTarget();
59
+ });
60
+ </script>
@@ -49,25 +49,23 @@ const widthOptions = [
49
49
  <form class="display-settings-panel">
50
50
  <p class="display-settings-title">{labels.displaySettings}</p>
51
51
 
52
- {controls.appearance && (
53
- <fieldset>
54
- <legend>{labels.appearance}</legend>
55
- <div class="display-settings-segmented">
56
- {appearanceOptions.map((option) => (
57
- <label>
58
- <input
59
- type="radio"
60
- name="appearance"
61
- value={option.value}
62
- checked={defaults.appearance === option.value}
63
- data-reader-appearance
64
- />
65
- <span>{option.label}</span>
66
- </label>
67
- ))}
68
- </div>
69
- </fieldset>
70
- )}
52
+ <fieldset>
53
+ <legend>{labels.appearance}</legend>
54
+ <div class="display-settings-segmented">
55
+ {appearanceOptions.map((option) => (
56
+ <label>
57
+ <input
58
+ type="radio"
59
+ name="appearance"
60
+ value={option.value}
61
+ checked={defaults.appearance === option.value}
62
+ data-reader-appearance
63
+ />
64
+ <span>{option.label}</span>
65
+ </label>
66
+ ))}
67
+ </div>
68
+ </fieldset>
71
69
 
72
70
  {controls.readingWidth && (
73
71
  <fieldset>
@@ -7,7 +7,7 @@ import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
- currentPage?: SitePage;
10
+ currentPage?: SiteNode;
11
11
  variant: 'mobile' | 'sidebar' | 'submenu';
12
12
  integratePageSections?: boolean;
13
13
  }
@@ -0,0 +1,4 @@
1
+ <script>
2
+ import { installNoteNavigation } from '../lib/noteNavigation';
3
+ installNoteNavigation();
4
+ </script>
@@ -1,34 +1,70 @@
1
1
  ---
2
+ import { getCollection } from 'astro:content';
3
+ import { getPresentationCssVariables, resolveThemePresentation } from '../../scripts/lib/presentation.mjs';
2
4
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
5
  import { getAbsolutePageUrl } from '../../scripts/lib/site-page-urls.mjs';
6
+ import { siteThemeLabel } from '../../scripts/lib/site-paths.mjs';
4
7
  import { withBasePath } from '../lib/basePath';
8
+ import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
5
9
 
6
10
  interface Props {
7
11
  targetPathname: string;
8
12
  targetTitle: string;
13
+ categoryTitle?: string;
9
14
  }
10
15
 
11
- const { targetPathname, targetTitle } = Astro.props;
16
+ const { targetPathname, targetTitle, categoryTitle } = Astro.props;
12
17
  const { labels, lang } = projectConfig.locale;
13
18
  const targetHref = withBasePath(projectConfig.site.basePath, targetPathname);
14
19
  const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
20
+ const [siteTheme] = await getCollection('theme');
21
+ if (!siteTheme) throw new Error(`${siteThemeLabel} is required. Add a root theme before building the site.`);
22
+ const presentation = resolveThemePresentation(siteTheme.data, siteThemeLabel);
23
+ const documentStyle = Object.entries(getPresentationCssVariables(presentation))
24
+ .map(([name, value]) => `${name}: ${value}`).join('; ');
25
+ const readerPreferencesScript = getReaderPreferencesScript({
26
+ ...presentation.readerPreferences,
27
+ cookiePath: projectConfig.site.basePath,
28
+ });
15
29
  ---
16
30
 
17
31
  <!doctype html>
18
- <html lang={lang} data-pagefind-ignore="all">
32
+ <html lang={lang} data-pagefind-ignore="all" data-appearance={presentation.appearance.default} style={documentStyle}>
19
33
  <head>
20
34
  <meta charset="utf-8" />
21
35
  <meta name="viewport" content="width=device-width" />
22
36
  <meta name="robots" content="noindex, follow" />
23
- <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
24
37
  <link rel="canonical" href={canonicalUrl} />
25
- <title>{labels.pageMoved}: {targetTitle}</title>
38
+ <title>{categoryTitle ?? labels.pageMoved}: {targetTitle}</title>
39
+ <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
26
40
  <style is:inline>
27
41
  :root {
28
- color-scheme: light dark;
42
+ --color-page: var(--palette-light-page-background);
43
+ --color-primary-text: var(--palette-light-primary-text);
44
+ --color-link-text: var(--palette-light-link-text);
45
+ color-scheme: light;
46
+ min-height: 100%;
47
+ background: var(--color-page);
48
+ color: var(--color-primary-text);
29
49
  font-family: system-ui, sans-serif;
30
50
  }
31
51
 
52
+ :root[data-appearance='dark'] {
53
+ --color-page: var(--palette-dark-page-background);
54
+ --color-primary-text: var(--palette-dark-primary-text);
55
+ --color-link-text: var(--palette-dark-link-text);
56
+ color-scheme: dark;
57
+ }
58
+
59
+ @media (prefers-color-scheme: dark) {
60
+ :root[data-appearance='system'] {
61
+ --color-page: var(--palette-dark-page-background);
62
+ --color-primary-text: var(--palette-dark-primary-text);
63
+ --color-link-text: var(--palette-dark-link-text);
64
+ color-scheme: dark;
65
+ }
66
+ }
67
+
32
68
  body {
33
69
  margin: 0;
34
70
  padding: 2rem;
@@ -38,12 +74,18 @@ const canonicalUrl = getAbsolutePageUrl(projectConfig.site.url, targetPathname);
38
74
  max-width: 42rem;
39
75
  margin-inline: auto;
40
76
  }
77
+
78
+ a {
79
+ color: var(--color-link-text);
80
+ }
41
81
  </style>
82
+ <script is:inline set:html={readerPreferencesScript} />
83
+ <meta http-equiv="refresh" content={`0; url=${targetHref}`} />
42
84
  </head>
43
85
  <body>
44
86
  <main>
45
- <h1>{labels.pageMoved}</h1>
46
- <p>{labels.pageMovedText} <a href={targetHref}>{targetTitle}</a>.</p>
87
+ <h1>{categoryTitle ?? labels.pageMoved}</h1>
88
+ <p>{!categoryTitle && labels.pageMovedText} <a href={targetHref}>{targetTitle}</a>.</p>
47
89
  </main>
48
90
  </body>
49
91
  </html>
@@ -0,0 +1,4 @@
1
+ <script>
2
+ import { setupSearchNavigation } from '../lib/searchNavigation';
3
+ setupSearchNavigation();
4
+ </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
+ import { ArrowLeft } from '@lucide/astro';
3
4
  import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
4
5
  import { getTextWidthCssValue, resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
5
6
  import { projectConfig, resolveThemeVisualConfig } from '../../scripts/lib/project-config.mjs';
@@ -82,6 +83,9 @@ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
82
83
  && (navigationModel.hasNestedPages || navigationModel.hasCategories)
83
84
  );
84
85
  const bundlePath = withBasePath(projectConfig.site.basePath, '/pagefind/');
86
+ const pagefindTranslationOption = projectConfig.locale.pagefindTranslations
87
+ ? `,\n\t\t\ttranslations: ${JSON.stringify(projectConfig.locale.pagefindTranslations)}`
88
+ : '';
85
89
  const searchScript = `(() => {
86
90
  const root = document.querySelector('#norna-search');
87
91
  const status = root?.querySelector('[data-search-status]');
@@ -95,7 +99,7 @@ const searchScript = `(() => {
95
99
  bundlePath: ${JSON.stringify(bundlePath)},
96
100
  baseUrl: ${JSON.stringify(projectConfig.site.basePath)},
97
101
  showImages: false,
98
- showSubResults: true
102
+ showSubResults: true${pagefindTranslationOption}
99
103
  });
100
104
  const labelInput = () => {
101
105
  const input = root.querySelector('.pagefind-ui__search-input');
@@ -145,7 +149,44 @@ const searchScript = `(() => {
145
149
  pagePresentation={pagePresentation}
146
150
  visualTheme={visualTheme}
147
151
  sectionIndex={0}
148
- />
152
+ >
153
+ <div slot="before-heading" class="search-return">
154
+ <a href={projectConfig.site.basePath} data-search-return data-search-return-label={labels.searchReturn}>
155
+ <ArrowLeft aria-hidden="true" />
156
+ <span>{labels.returnHome}</span>
157
+ </a>
158
+ </div>
159
+ </SiteSection>
149
160
  </main>
150
161
  <script is:inline set:html={searchScript} />
151
162
  </BaseLayout>
163
+
164
+ <style>
165
+ .search-return {
166
+ width: var(--layout-heading-width);
167
+ margin-inline: var(--layout-inline-start-margin) var(--layout-inline-end-margin);
168
+ margin-bottom: 1rem;
169
+ }
170
+
171
+ .search-return a {
172
+ display: inline-flex;
173
+ align-items: center;
174
+ gap: 0.5rem;
175
+ min-height: 2.75rem;
176
+ max-width: 100%;
177
+ color: var(--color-link-text);
178
+ font-size: 0.9375rem;
179
+ line-height: 1.5;
180
+ text-underline-offset: 0.2em;
181
+ }
182
+
183
+ .search-return svg {
184
+ width: 1.125rem;
185
+ height: 1.125rem;
186
+ flex-shrink: 0;
187
+ }
188
+
189
+ .search-return span {
190
+ overflow-wrap: anywhere;
191
+ }
192
+ </style>
@@ -1,15 +1,18 @@
1
1
  <script>
2
+ import { setupNavigationFollowing } from '../lib/navigationFollowing';
3
+
2
4
  const siteTop = document.querySelector<HTMLElement>('.site-top');
3
5
  const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-nav-menu');
4
6
  const sectionLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>(
5
- '.mobile-nav-sections a, .page-contents-links a, .page-nav a',
7
+ '.mobile-nav-sections a, .page-contents-links a, .page-nav a, .top-page-sections a',
6
8
  ));
7
- const submenuItems = Array.from(document.querySelectorAll<HTMLElement>('.site-nav-item-has-submenu'));
9
+ const topPageMenus = Array.from(document.querySelectorAll<HTMLDetailsElement>('.top-page-menu'));
8
10
  const mobileDestinationLinks = Array.from(mobileMenu?.querySelectorAll<HTMLAnchorElement>('.mobile-nav-destination') ?? []);
9
11
  const mobileMenuSummary = mobileMenu?.querySelector<HTMLElement>(':scope > summary');
10
12
  const mobileMenuPanel = mobileMenu?.querySelector<HTMLElement>('[data-compact-navigation-panel]');
11
13
  const mobileMenuClose = mobileMenu?.querySelector<HTMLButtonElement>('[data-compact-navigation-close]');
12
14
  const sectionTrackingEnabled = document.documentElement.dataset.sectionTracking === 'enabled';
15
+ const navigationFollowing = sectionTrackingEnabled ? setupNavigationFollowing() : undefined;
13
16
  const trackedHeadingIds = new Set(sectionLinks.flatMap((link) => {
14
17
  const linkUrl = new URL(link.href, window.location.href);
15
18
  if (linkUrl.pathname !== window.location.pathname || !linkUrl.hash) return [];
@@ -101,6 +104,7 @@
101
104
  link.removeAttribute('aria-current');
102
105
  }
103
106
  });
107
+ navigationFollowing?.update();
104
108
  };
105
109
 
106
110
  const updateReadingPosition = () => {
@@ -146,6 +150,7 @@
146
150
  link.removeAttribute('aria-current');
147
151
  }
148
152
  });
153
+ navigationFollowing?.update();
149
154
  };
150
155
 
151
156
  const scheduleReadingPositionUpdate = () => {
@@ -153,11 +158,38 @@
153
158
  sectionTrackingFrame = window.requestAnimationFrame(updateReadingPosition);
154
159
  };
155
160
 
156
- submenuItems.forEach((item) => {
157
- const resetDismissal = () => delete item.dataset.submenuDismissed;
158
- item.addEventListener('pointerleave', resetDismissal);
159
- item.querySelector<HTMLAnchorElement>(':scope > a')?.addEventListener('focus', resetDismissal);
161
+ topPageMenus.forEach((menu) => {
162
+ menu.addEventListener('toggle', () => {
163
+ if (!menu.open) return;
164
+ topPageMenus.forEach((other) => { if (other !== menu) other.open = false; });
165
+ const panel = menu.querySelector<HTMLElement>('.top-page-panel');
166
+ if (!panel) return;
167
+ panel.style.removeProperty('--top-page-panel-shift');
168
+ const rectangle = panel.getBoundingClientRect();
169
+ const shift = Math.max(0, 16 - rectangle.left)
170
+ - Math.max(0, rectangle.right - document.documentElement.clientWidth + 16);
171
+ panel.style.setProperty('--top-page-panel-shift', `${shift}px`);
172
+ });
173
+ menu.addEventListener('keydown', (event) => {
174
+ if (event.key !== 'Escape' || !menu.open) return;
175
+ event.preventDefault();
176
+ menu.open = false;
177
+ menu.querySelector('summary')?.focus();
178
+ });
179
+ menu.addEventListener('focusout', (event) => {
180
+ if (event.relatedTarget instanceof Node && !menu.contains(event.relatedTarget)) menu.open = false;
181
+ });
182
+ menu.querySelectorAll('a').forEach((link) => {
183
+ link.addEventListener('click', () => { menu.open = false; });
184
+ });
160
185
  });
186
+ if (topPageMenus.length > 0) {
187
+ document.addEventListener('pointerdown', (event) => {
188
+ topPageMenus.forEach((menu) => {
189
+ if (event.target instanceof Node && !menu.contains(event.target)) menu.open = false;
190
+ });
191
+ });
192
+ }
161
193
 
162
194
  if (mobileMenu) {
163
195
  const siteHeader = mobileMenu.closest<HTMLElement>('.site-top');
@@ -192,7 +224,7 @@
192
224
  };
193
225
  const closeMenu = (restoreFocus: boolean) => {
194
226
  mobileMenu.open = false;
195
- if (restoreFocus) mobileMenuSummary?.focus();
227
+ if (restoreFocus) mobileMenuSummary?.focus({ preventScroll: true });
196
228
  };
197
229
 
198
230
  mobileDestinationLinks.forEach((link) => {
@@ -206,9 +238,10 @@
206
238
  document.documentElement.classList.toggle('mobile-navigation-open', mobileMenu.open);
207
239
  setBackgroundInert(mobileMenu.open);
208
240
  if (mobileMenu.open) {
209
- window.requestAnimationFrame(() => (
210
- mobileMenuClose?.focus()
211
- ));
241
+ window.requestAnimationFrame(() => {
242
+ mobileMenuClose?.focus({ preventScroll: true });
243
+ navigationFollowing?.revealCompact();
244
+ });
212
245
  }
213
246
  });
214
247
 
@@ -247,7 +280,6 @@
247
280
 
248
281
  pauseSectionTrackingForAnchorNavigation();
249
282
  if (mobileMenu) mobileMenu.open = false;
250
- link.closest<HTMLElement>('.site-nav-item')?.setAttribute('data-submenu-dismissed', 'true');
251
283
 
252
284
  window.requestAnimationFrame(() => {
253
285
  updateCurrentSection();
@@ -4,7 +4,7 @@ import { withBasePath } from '../lib/basePath';
4
4
  import type { SiteNode, SitePage } from '../lib/sitePages';
5
5
 
6
6
  interface Props {
7
- currentPage: SitePage;
7
+ currentPage: SiteNode;
8
8
  nodes: SiteNode[];
9
9
  }
10
10