@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
@@ -1,4 +1,6 @@
1
1
  <script>
2
+ import { createNavigationStateHelpers } from '../lib/navigationState.mjs';
3
+
2
4
  type NavigationState = {
3
5
  openPaths: string[];
4
6
  sectionOpenByPath: Record<string, boolean>;
@@ -10,12 +12,12 @@
10
12
  sectionOpen: boolean[];
11
13
  scrollTop: number;
12
14
  };
15
+ const { storageKeys, parseState, initialState, arrivalPolicy, readEntryState, saveEntryState, pageIsOpen, sectionsAreOpen } = createNavigationStateHelpers();
13
16
 
14
17
  const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
15
18
  const root = container.dataset.navigationRoot ?? '/';
16
19
  const sharedRoot = container.dataset.navigationStateRoot ?? root;
17
- const stateKey = `norna:tree-navigation:${scope}:${root}`;
18
- const sharedStateKey = `norna:tree-navigation:shared:${sharedRoot}`;
20
+ const { stateKey, sharedStateKey } = storageKeys(scope, root, sharedRoot);
19
21
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
20
22
  '.navigation-page-disclosure[data-page-path]',
21
23
  ));
@@ -38,22 +40,7 @@
38
40
  const scrollContainer = getScrollContainer();
39
41
  let filterSnapshot: NavigationSnapshot | null = null;
40
42
  let lastSharedState = '';
41
- const parseState = (value: string | null): NavigationState => {
42
- try {
43
- const parsedState = JSON.parse(value ?? '{}');
44
- return {
45
- openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
46
- sectionOpenByPath: parsedState.sectionOpenByPath
47
- && typeof parsedState.sectionOpenByPath === 'object'
48
- ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
49
- .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
50
- : {},
51
- scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
52
- };
53
- } catch {
54
- return { openPaths: [], sectionOpenByPath: {}, scrollTop: 0 };
55
- }
56
- };
43
+ let preserveOutgoingEntry = false;
57
44
  const getDisclosureState = (): SharedNavigationState => ({
58
45
  openPaths: branches.filter((branch) => branch.open)
59
46
  .map((branch) => branch.dataset.pagePath ?? ''),
@@ -64,23 +51,13 @@
64
51
  });
65
52
  const applyDisclosureState = (
66
53
  state: SharedNavigationState,
67
- openCurrentPageOnArrival = false,
54
+ policy: { onArrival?: boolean; preserveCurrentPage?: boolean; preserveCurrentOutline?: boolean } = {},
68
55
  ) => {
69
56
  branches.forEach((branch) => {
70
- const pagePath = branch.dataset.pagePath ?? '';
71
- branch.open = (openCurrentPageOnArrival && branch.dataset.currentBranch === 'true')
72
- || state.openPaths.includes(pagePath);
57
+ branch.open = pageIsOpen(state, branch.dataset, policy);
73
58
  });
74
59
  sectionBranches.forEach((branch) => {
75
- const pagePath = branch.dataset.pagePath ?? '';
76
- const storedOpen = state.sectionOpenByPath[pagePath];
77
- if (openCurrentPageOnArrival && branch.dataset.currentPage === 'true') {
78
- branch.open = true;
79
- return;
80
- }
81
- branch.open = typeof storedOpen === 'boolean'
82
- ? storedOpen
83
- : branch.dataset.currentPage === 'true';
60
+ branch.open = sectionsAreOpen(state, branch.dataset, policy);
84
61
  });
85
62
  };
86
63
  const mergeSharedState = (state: SharedNavigationState): SharedNavigationState => {
@@ -99,18 +76,16 @@
99
76
  };
100
77
 
101
78
  try {
102
- const storedState = parseState(sessionStorage.getItem(stateKey));
103
- const storedSharedState = sessionStorage.getItem(sharedStateKey);
104
- applyDisclosureState(storedSharedState === null
105
- ? storedState
106
- : parseState(storedSharedState), true);
79
+ const state = initialState(sessionStorage.getItem(stateKey), sessionStorage.getItem(sharedStateKey),
80
+ readEntryState(stateKey));
81
+ applyDisclosureState(state.disclosure, arrivalPolicy(state));
107
82
  const initialSharedState = mergeSharedState(getDisclosureState());
108
83
  lastSharedState = JSON.stringify(initialSharedState);
109
84
  sessionStorage.setItem(sharedStateKey, lastSharedState);
110
85
 
111
- if (storedState.scrollTop > 0) {
86
+ if (state.local.scrollTop > 0 || state.fromEntry) {
112
87
  requestAnimationFrame(() => {
113
- scrollContainer.scrollTop = storedState.scrollTop;
88
+ scrollContainer.scrollTop = Math.max(0, state.local.scrollTop);
114
89
  });
115
90
  }
116
91
  } catch {
@@ -127,6 +102,7 @@
127
102
  scrollTop: scrollContainer.scrollTop,
128
103
  };
129
104
  sessionStorage.setItem(stateKey, JSON.stringify(state));
105
+ if (!preserveOutgoingEntry) saveEntryState(stateKey, state);
130
106
  const sharedState = mergeSharedState(disclosureState);
131
107
  const serializedSharedState = JSON.stringify(sharedState);
132
108
  sessionStorage.setItem(sharedStateKey, serializedSharedState);
@@ -278,23 +254,6 @@
278
254
  - Math.max(0, (scrollContainer.clientHeight - linkBox.height) / 2);
279
255
  setStatus(labels.located);
280
256
  };
281
- const openLinkedPage = (link: HTMLAnchorElement) => {
282
- if (filterInput?.value) {
283
- filterInput.value = '';
284
- clearFilter();
285
- }
286
- const pageNode = link.closest<HTMLElement>('.navigation-page-node');
287
- const pageDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
288
- ':scope > .navigation-page-disclosure:not(.navigation-category-disclosure)',
289
- );
290
- const sectionDisclosure = pageNode?.querySelector<HTMLDetailsElement>(
291
- ':scope > .navigation-page-sections-disclosure[data-page-path]',
292
- );
293
-
294
- if (pageDisclosure) pageDisclosure.open = true;
295
- if (sectionDisclosure) sectionDisclosure.open = true;
296
- saveState();
297
- };
298
257
 
299
258
  requestAnimationFrame(() => {
300
259
  branches.forEach((branch) => branch.addEventListener('toggle', saveState));
@@ -314,8 +273,42 @@
314
273
  collapseButton?.addEventListener('click', () => setAllOpen(false));
315
274
  locateButton?.addEventListener('click', locateCurrentPage);
316
275
  scrollContainer.addEventListener('scroll', saveState, { passive: true });
317
- container.querySelectorAll<HTMLAnchorElement>('.navigation-page-link, .navigation-page-open-link')
318
- .forEach((link) => link.addEventListener('click', () => openLinkedPage(link)));
276
+ // Cross-document arrivals open their current branch in the early
277
+ // restore. Do not expand the departing tree and paint an extra frame.
278
+ document.addEventListener('click', (event) => {
279
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
280
+ const link = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null;
281
+ if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
282
+ const url = new URL(link.href, location.href);
283
+ if (url.origin !== location.origin || url.pathname !== location.pathname || url.search !== location.search || !currentPageNode) return;
284
+ // A same-document fragment creates a new history entry after this
285
+ // click. Keep the old entry's disclosure snapshot until then.
286
+ preserveOutgoingEntry = url.href !== location.href;
287
+ if (filterInput?.value) { filterInput.value = ''; clearFilter(); }
288
+ const scrollTop = scrollContainer.scrollTop;
289
+ revealNodeAndAncestors(currentPageNode);
290
+ currentPageNode.querySelectorAll<HTMLDetailsElement>(
291
+ ':scope > .navigation-page-disclosure, :scope > .navigation-page-sections-disclosure',
292
+ ).forEach((branch) => { branch.open = true; });
293
+ scrollContainer.scrollTop = scrollTop;
294
+ saveState();
295
+ });
296
+ const restoreEntry = () => {
297
+ preserveOutgoingEntry = false;
298
+ try {
299
+ const source = readEntryState(stateKey);
300
+ if (source === null) return;
301
+ const state = parseState(source);
302
+ applyDisclosureState(state);
303
+ scrollContainer.scrollTop = Math.max(0, state.scrollTop);
304
+ saveState();
305
+ } catch { /* Native links work when storage is unavailable. */ }
306
+ };
307
+ window.addEventListener('pageshow', (event) => { if (event.persisted) restoreEntry(); });
308
+ window.addEventListener('popstate', restoreEntry);
309
+ window.addEventListener('hashchange', () => { preserveOutgoingEntry = false; saveState(); });
310
+ requestAnimationFrame(saveState);
311
+
319
312
  window.addEventListener('pagehide', saveState);
320
313
  };
321
314
 
@@ -1,36 +1,35 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import { z } from 'astro/zod';
4
- import { basename } from 'node:path';
4
+ import { basename, dirname } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import {
7
- siteDir,
7
+ siteHomePageDir,
8
8
  siteDirLabel,
9
9
  siteThemePath,
10
10
  sitewideContentPath,
11
11
  } from '../scripts/lib/site-paths.mjs';
12
12
  import { encodePageEntryId, getSiteEntryPrefix, parsePageDirectoryPath } from '../scripts/lib/page-model.mjs';
13
+ import { homePageDirectory } from '../scripts/lib/site-conventions.mjs';
13
14
  import {
14
15
  siteSchema,
15
16
  sitewideSchema,
16
- themeVisualSchema,
17
+ rootThemeSchema,
17
18
  } from '../scripts/lib/schema-definitions.mjs';
18
19
 
19
20
  const siteEntryPrefix = getSiteEntryPrefix(siteDirLabel);
20
21
  const emptyYamlMapping = (value: unknown) => value ?? {};
21
- const siteThemeSchema = z.preprocess(emptyYamlMapping, themeVisualSchema);
22
+ const siteThemeSchema = z.preprocess(emptyYamlMapping, rootThemeSchema);
22
23
  const sitewideContentSchema = z.preprocess(emptyYamlMapping, sitewideSchema);
23
24
 
24
25
  const site = defineCollection({
25
26
  loader: glob({
26
- pattern: 'pages/**/content.md',
27
- base: pathToFileURL(siteDir),
27
+ pattern: ['content.md', 'pages/**/content.md'],
28
+ base: pathToFileURL(siteHomePageDir),
28
29
  generateId: ({ entry }) => {
30
+ if (entry === 'content.md') return encodePageEntryId(siteDirLabel, homePageDirectory);
29
31
  const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
30
- const pageDirectory = pageEntryDirectory
31
- .split('/')
32
- .filter((segment) => segment !== 'pages')
33
- .join('/pages/');
32
+ const pageDirectory = pageEntryDirectory;
34
33
  return encodePageEntryId(siteDirLabel, parsePageDirectoryPath(pageDirectory, `page directory pages/${pageEntryDirectory}`).pageDirectory);
35
34
  },
36
35
  }),
@@ -40,7 +39,7 @@ const site = defineCollection({
40
39
  const theme = defineCollection({
41
40
  loader: glob({
42
41
  pattern: basename(siteThemePath),
43
- base: pathToFileURL(siteDir),
42
+ base: pathToFileURL(dirname(siteThemePath)),
44
43
  generateId: () => `${siteEntryPrefix}-theme`,
45
44
  }),
46
45
  schema: siteThemeSchema,
@@ -49,7 +48,7 @@ const theme = defineCollection({
49
48
  const sitewide = defineCollection({
50
49
  loader: glob({
51
50
  pattern: basename(sitewideContentPath),
52
- base: pathToFileURL(siteDir),
51
+ base: pathToFileURL(dirname(sitewideContentPath)),
53
52
  generateId: () => `${siteEntryPrefix}-sitewide`,
54
53
  }),
55
54
  schema: sitewideContentSchema,
@@ -5,6 +5,8 @@ import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs'
5
5
  import { getIconLinks, getSocialImageAsset } from '../lib/sitePublicAssets';
6
6
  import { withBasePath } from '../lib/basePath';
7
7
  import { getReaderPreferencesScript } from '../lib/readerPreferencesScript.mjs';
8
+ import NavigationContinuity from '../components/NavigationContinuity.astro';
9
+ import areaNavigationStyles from '../styles/area-navigation.css?inline';
8
10
  import '../styles/global.css';
9
11
 
10
12
  type FrameColors = {
@@ -80,6 +82,8 @@ interface Props {
80
82
  contentsNavigation?: boolean;
81
83
  indexable?: boolean;
82
84
  searchable?: boolean;
85
+ areaNavigation?: boolean;
86
+ areaReadingFrame?: boolean;
83
87
  }
84
88
 
85
89
  const {
@@ -93,6 +97,8 @@ const {
93
97
  contentsNavigation = false,
94
98
  indexable = true,
95
99
  searchable = false,
100
+ areaNavigation = false,
101
+ areaReadingFrame = false,
96
102
  } = Astro.props;
97
103
 
98
104
  const documentTitle = title;
@@ -150,49 +156,13 @@ const marginNoteOuterReserve = '0.5rem';
150
156
  const marginNoteDeclarations = marginNoteLayoutDeclarations;
151
157
  const marginNoteLayoutCss = [
152
158
  '@media (min-width: 1101px) {',
153
- ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
154
- [
155
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
156
- `:root[data-reading-width="${readingWidth}"] .site-page-layout:not(.site-page-layout-tree) .section-note-paragraph {`,
157
- marginNoteDeclarations,
158
- '}',
159
- '}',
160
- ].join('\n'),
161
- [
162
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
163
- `:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note-paragraph {`,
164
- marginNoteDeclarations,
165
- '}',
166
- '}',
167
- ].join('\n'),
168
- ]),
169
- '}',
170
- '@media (min-width: 1101px) and (max-width: 1280px) {',
171
159
  ...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
172
160
  `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve})) {`,
173
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
174
- marginNoteDeclarations,
175
- '}',
176
- '}',
177
- ].join('\n')),
178
- '}',
179
- '@media (min-width: 1281px) {',
180
- ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
181
- [
182
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
183
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note-paragraph {`,
184
- marginNoteDeclarations,
185
- '}',
161
+ `:root[data-reading-width="${readingWidth}"] .section-note-paragraph {`,
162
+ marginNoteDeclarations,
186
163
  '}',
187
- ].join('\n'),
188
- [
189
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
190
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note-paragraph {`,
191
- marginNoteDeclarations,
192
- '}',
193
164
  '}',
194
- ].join('\n'),
195
- ]),
165
+ ].join('\n')),
196
166
  '}',
197
167
  ].join('\n');
198
168
  const { buildInfo, copyrightMessage } = footer ?? {};
@@ -227,6 +197,9 @@ const readerPreferencesScript = readerPreferencesEnabled
227
197
  data-reading-width={presentation.readerPreferences.defaults.readingWidth}
228
198
  data-focus-reading={presentation.readerPreferences.defaults.focusReading}
229
199
  data-section-tracking={contentsNavigation ? 'enabled' : undefined}
200
+ data-navigation-continuity={projectConfig.site.basePath}
201
+ data-area-navigation={areaNavigation ? '' : undefined}
202
+ data-area-reading-frame={areaReadingFrame ? '' : undefined}
230
203
  data-pagefind-ignore={searchable ? undefined : 'all'}
231
204
  style={documentStyle}
232
205
  >
@@ -248,6 +221,8 @@ const readerPreferencesScript = readerPreferencesEnabled
248
221
  {indexable && socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
249
222
  <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
250
223
  {readerPreferencesScript && <script is:inline set:html={readerPreferencesScript} />}
224
+ <NavigationContinuity />
225
+ {areaNavigation && <style is:inline set:html={areaNavigationStyles} />}
251
226
  <style is:inline set:html={marginNoteLayoutCss} />
252
227
  {indexable && <link rel="canonical" href={canonicalUrl} />}
253
228
  <meta name="generator" content={Astro.generator} />
@@ -272,5 +247,6 @@ const readerPreferencesScript = readerPreferencesEnabled
272
247
  </p>
273
248
  </footer>
274
249
  )}
250
+ <span id="norna-navigation-ready" hidden></span>
275
251
  </body>
276
252
  </html>
@@ -0,0 +1,42 @@
1
+ import type { SiteNavigationNode } from './siteNavigation';
2
+ import type { SiteNode } from './sitePages';
3
+
4
+ const containsPage = (root: SiteNavigationNode, page: SiteNode) => (
5
+ root.node.pagePath === page.pagePath
6
+ || (root.node.pagePath !== '' && page.pagePath.startsWith(`${root.node.pagePath}/`))
7
+ );
8
+
9
+ // Beyond twelve choices, use a destination with a local tree instead of a panel.
10
+ export const hasLargeAreaMenu = (root: SiteNavigationNode) => (
11
+ root.children.length + (root.node.kind === 'page' ? 1 : 0) > 12
12
+ );
13
+
14
+ export const getAreaMenuGroups = (root: SiteNavigationNode) => {
15
+ const choices = root.node.kind === 'page' ? [root, ...root.children] : root.children;
16
+ return {
17
+ branches: choices.filter(({ children }) => children.length > 0),
18
+ pages: choices.filter(({ children }) => children.length === 0),
19
+ };
20
+ };
21
+
22
+ // Only the listed tree enters this resolver. Flat collections keep their pages
23
+ // together; collections with subgroups select child branches. Large collections
24
+ // and a parent's own overview retain the full tree. Scope never depends on the
25
+ // arrival route, stored state or the number of headings on a page.
26
+ export const resolveAreaNavigation = (roots: SiteNavigationNode[], page?: SiteNode) => {
27
+ const globalRoot = page ? roots.find((root) => containsPage(root, page)) : undefined;
28
+ if (globalRoot && globalRoot.children.length > 0 && (
29
+ globalRoot.children.every((child) => child.children.length === 0)
30
+ || hasLargeAreaMenu(globalRoot) || page?.pagePath === globalRoot.node.pagePath
31
+ )) return { globalRoot, localRoot: globalRoot };
32
+ const area = page && globalRoot?.children.find((child) => (
33
+ child.children.length > 0 && containsPage(child, page)
34
+ ));
35
+ return { globalRoot, localRoot: area };
36
+ };
37
+
38
+ export const withH2Outlines = (roots: SiteNavigationNode[]): SiteNavigationNode[] => roots.map((root) => ({
39
+ ...root,
40
+ headings: root.headings.filter(({ depth }) => depth === 2),
41
+ children: withH2Outlines(root.children),
42
+ }));
@@ -5,6 +5,9 @@ export type ManagedImage = {
5
5
  src?: string;
6
6
  alt?: string;
7
7
  caption?: string;
8
+ title?: string;
9
+ href?: string;
10
+ linkTitle?: string;
8
11
  };
9
12
 
10
13
  type GeneratedImageWithDimensions = NonNullable<ReturnType<typeof getGeneratedImage>> & {
@@ -0,0 +1,101 @@
1
+ import { parseFragment, serializeOuter, type DefaultTreeAdapterMap } from 'parse5';
2
+ import { getPageImageSourceKey } from '../../scripts/lib/site-conventions.mjs';
3
+ import { getGeneratedImage, getImageAttributes, getImageInspectionAttributes } from './generatedImages';
4
+ import { getImageClass, getImageLoadingProps, getManagedImageSizes } from './imagePresentation';
5
+ import projectConfig from '../../scripts/lib/project-config.mjs';
6
+
7
+ type Node = DefaultTreeAdapterMap['node'];
8
+ type Element = DefaultTreeAdapterMap['element'];
9
+ const isElement = (node: Node): node is Element => 'tagName' in node;
10
+ const attribute = (node: Element, name: string) => node.attrs.find((attr) => attr.name === name)?.value;
11
+ const meaningfulChildren = (node: Element) => node.childNodes.filter((child) => child.nodeName !== '#text' || ('value' in child && child.value.trim()));
12
+ const soleImage = (node: Element): { image: Element; href?: string; linkTitle?: string } | null => {
13
+ const children = meaningfulChildren(node);
14
+ if (children.length !== 1 || !isElement(children[0])) return null;
15
+ const child = children[0];
16
+ if (child.tagName === 'img' && attribute(child, 'data-norna-managed-image')) return { image: child };
17
+ if (child.tagName === 'a') {
18
+ const nested = soleImage(child);
19
+ if (nested) return { image: nested.image, href: attribute(child, 'href'), linkTitle: attribute(child, 'title') };
20
+ }
21
+ return null;
22
+ };
23
+
24
+ // Promote only top-level, single-image paragraphs to the shared Astro stack.
25
+ // Nested images stay in their original list, quote, table, details or text.
26
+ export const splitMarkdownImages = (html: string, pageDirectory: string) => {
27
+ if (!html.includes('data-norna-managed-image=')) return [{ type: 'html' as const, html }];
28
+ const fragment = parseFragment(html);
29
+ const result: Array<{ type: 'html'; html: string } | { type: 'image-stack'; images: Array<{ image: string; src: string; alt: string; title?: string; href?: string; linkTitle?: string }> }> = [];
30
+ let pending = '';
31
+ const flush = () => { if (pending.trim()) result.push({ type: 'html', html: pending }); pending = ''; };
32
+ const markSource = (node: Node) => {
33
+ if (!isElement(node)) return;
34
+ const marker = node.attrs.find((attr) => attr.name === 'data-norna-managed-image');
35
+ if (marker) marker.value = getPageImageSourceKey(pageDirectory, marker.value);
36
+ for (const child of node.childNodes) markSource(child);
37
+ };
38
+ for (const node of fragment.childNodes) {
39
+ const standalone = isElement(node) && node.tagName === 'p' ? soleImage(node) : null;
40
+ if (standalone) {
41
+ flush();
42
+ const name = attribute(standalone.image, 'data-norna-managed-image')!;
43
+ result.push({ type: 'image-stack', images: [{ image: name, src: getPageImageSourceKey(pageDirectory, name), alt: attribute(standalone.image, 'alt') ?? '', title: attribute(standalone.image, 'title'), href: standalone.href, linkTitle: standalone.linkTitle }] });
44
+ } else {
45
+ markSource(node);
46
+ pending += serializeOuter(node);
47
+ }
48
+ }
49
+ flush();
50
+ return result;
51
+ };
52
+
53
+ const escapeAttribute = (value: unknown) => String(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
54
+ const attributes = (values: Record<string, unknown>) => Object.entries(values).filter(([, value]) => value !== undefined && value !== null).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join('');
55
+
56
+ export const renderNestedMarkdownImages = (html: string, title: string, width: { desktop: number; mobile: number }) => {
57
+ if (!html.includes('data-norna-managed-image=')) return html;
58
+ const fragment = parseFragment(html, { sourceCodeLocationInfo: true });
59
+ const edits: Array<{ start: number; end: number; html: string }> = [];
60
+ const renderImage = (node: Element, standalone = false, href?: string, linkTitle?: string) => {
61
+ const src = attribute(node, 'data-norna-managed-image')!;
62
+ if (!getGeneratedImage(src)) throw new Error(`Managed Markdown image is missing from the generated manifest: ${src}. Run Norna's image preparation after correcting the source file.`);
63
+ const alt = attribute(node, 'alt') ?? '';
64
+ const imageClass = standalone ? ['managed-image', getImageClass({ image: src })].filter(Boolean).join(' ') : 'managed-markdown-inline';
65
+ const image = `<img${attributes({ ...getImageAttributes(src, getManagedImageSizes(width)), class: imageClass, alt, title: attribute(node, 'title'), ...getImageLoadingProps(false) })}>`;
66
+ if (!standalone) return image;
67
+ const inspection = getImageInspectionAttributes(src);
68
+ const link = attributes({
69
+ class: 'managed-image-inspection-link', href: href ?? inspection.href, title: linkTitle,
70
+ 'aria-label': href === undefined ? projectConfig.locale.labels.inspectImage.replace('{description}', alt || title) : undefined,
71
+ 'data-image-inspection-trigger': href === undefined ? '' : undefined,
72
+ 'data-image-intrinsic-width': inspection.width, 'data-image-intrinsic-height': inspection.height,
73
+ 'data-image-inspection-scalable': inspection.scalable ? 'true' : undefined,
74
+ style: inspection.width && inspection.height ? `--managed-image-intrinsic-width: ${inspection.width}px; --managed-image-aspect-ratio: ${inspection.width / inspection.height}` : undefined,
75
+ });
76
+ // The same Lucide Maximize2 affordance used by ImageStack.astro; this
77
+ // fragment stays inside the authored Markdown container.
78
+ const affordance = href === undefined ? '<span class="managed-image-inspection-affordance" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/></svg></span>' : '';
79
+ return `<div class="image-stack managed-markdown-standalone content-block-note-lane-boundary"><figure class="managed-image-item"><div class="managed-image-frame"><a${link}>${image}${affordance}</a></div></figure></div>`;
80
+ };
81
+ const visit = (node: Node) => {
82
+ if (!isElement(node)) return;
83
+ const location = node.sourceCodeLocation;
84
+ if (location && (node.tagName === 'p' || node.tagName === 'li')) {
85
+ const standalone = soleImage(node);
86
+ if (standalone) {
87
+ const start = node.tagName === 'li' ? location.startTag!.endOffset : location.startOffset;
88
+ const end = node.tagName === 'li' ? (location.endTag?.startOffset ?? location.endOffset) : location.endOffset;
89
+ edits.push({ start, end, html: renderImage(standalone.image, true, standalone.href, standalone.linkTitle) });
90
+ return;
91
+ }
92
+ }
93
+ if (location && node.tagName === 'img' && attribute(node, 'data-norna-managed-image')) {
94
+ edits.push({ start: location.startOffset, end: location.endOffset, html: renderImage(node) });
95
+ return;
96
+ }
97
+ for (const child of node.childNodes) visit(child);
98
+ };
99
+ for (const node of fragment.childNodes) visit(node);
100
+ return edits.sort((a, b) => b.start - a.start).reduce((result, edit) => result.slice(0, edit.start) + edit.html + result.slice(edit.end), html);
101
+ };