@janga/norna 0.7.22 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/astro.config.mjs +3 -0
  2. package/bin/norna-cli.mjs +2 -2
  3. package/package.json +17 -3
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +22 -24
  6. package/schemas/content-frontmatter.schema.json +25 -5
  7. package/schemas/page-theme.schema.json +53 -25
  8. package/schemas/sitewide-content.schema.json +19 -18
  9. package/schemas/theme.schema.json +383 -250
  10. package/scripts/check-config.mjs +24 -4
  11. package/scripts/deploy-site.mjs +0 -1
  12. package/scripts/dev-local.mjs +175 -112
  13. package/scripts/lib/content-sync-apply.mjs +34 -0
  14. package/scripts/lib/content-sync-plan.mjs +179 -0
  15. package/scripts/lib/editor-language-service.mjs +128 -0
  16. package/scripts/lib/heading-ids.mjs +1 -1
  17. package/scripts/lib/image-presentation.mjs +2 -0
  18. package/scripts/lib/markdown-links.mjs +182 -0
  19. package/scripts/lib/navigation-model.mjs +25 -14
  20. package/scripts/lib/norna-markdown-blocks.mjs +1 -0
  21. package/scripts/lib/page-aliases.mjs +164 -0
  22. package/scripts/lib/page-markdown.mjs +56 -6
  23. package/scripts/lib/presentation.mjs +19 -37
  24. package/scripts/lib/project-config.mjs +50 -29
  25. package/scripts/lib/schema-definitions.mjs +24 -13
  26. package/scripts/lib/schema-editor-metadata.mjs +43 -30
  27. package/scripts/lib/schema-value-definitions.mjs +6 -2
  28. package/scripts/lib/site-content.mjs +26 -4
  29. package/scripts/lib/site-link-graph.mjs +326 -0
  30. package/scripts/lib/site-page-urls.mjs +10 -0
  31. package/scripts/lib/sitemap.mjs +34 -0
  32. package/scripts/lib/theme-presets.mjs +51 -23
  33. package/scripts/lib/theme-profiles.mjs +7 -5
  34. package/scripts/lib/yaml-config.mjs +6 -2
  35. package/scripts/sync-content-sections.mjs +116 -213
  36. package/scripts/sync-site-public.mjs +23 -2
  37. package/src/components/CodeBlockCopyScript.astro +92 -0
  38. package/src/components/DisplaySettings.astro +4 -4
  39. package/src/components/ImageCarousel.astro +7 -3
  40. package/src/components/NavigationPageTree.astro +44 -60
  41. package/src/components/PageAliasRedirect.astro +49 -0
  42. package/src/components/PageContentsNavigation.astro +21 -0
  43. package/src/components/SectionNavigationScript.astro +62 -6
  44. package/src/components/SiteNavigation.astro +15 -3
  45. package/src/components/SitePage.astro +42 -2
  46. package/src/components/SiteSection.astro +5 -0
  47. package/src/components/SiteTreeNavigation.astro +0 -1
  48. package/src/content.config.ts +5 -2
  49. package/src/layouts/BaseLayout.astro +14 -8
  50. package/src/lib/readerPreferencesScript.mjs +2 -2
  51. package/src/lib/sitePages.ts +3 -2
  52. package/src/pages/[...slug].astro +35 -8
  53. package/src/styles/content.css +494 -0
  54. package/src/styles/foundations.css +284 -0
  55. package/src/styles/global.css +6 -2510
  56. package/src/styles/media.css +523 -0
  57. package/src/styles/navigation.css +495 -0
  58. package/src/styles/page-layout.css +437 -0
  59. package/src/styles/responsive.css +525 -0
  60. package/starters/basic/README.md +1 -1
@@ -0,0 +1,179 @@
1
+ import path from 'node:path';
2
+
3
+ import { toPosixPath } from './site-content.mjs';
4
+
5
+ export const getExpectedImagePath = (contentFile, imageName) =>
6
+ path.join(contentFile.imagesDir, imageName);
7
+
8
+ export const getExpectedImageLabel = (contentFile, imageName) =>
9
+ `${contentFile.imagesLabel}/${imageName}`;
10
+
11
+ export const getImageRootLabel = (contentFile) => contentFile.imagesLabel.replace(/\/images$/, '');
12
+
13
+ export const getImageCandidateLabel = ({ contentFile, imagePath }) =>
14
+ `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
15
+
16
+ const getReferenceLabel = (contentFile, section) =>
17
+ `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
18
+
19
+ const addMapEntry = (map, key, value) => {
20
+ if (!map.has(key)) map.set(key, []);
21
+ map.get(key).push(value);
22
+ };
23
+
24
+ const addReferenceIssue = (issues, entry, issue) => {
25
+ issues.push({ ...entry, issue, phase: 'reference' });
26
+ };
27
+
28
+ const addConflictIssue = (issues, move, issue) => {
29
+ issues.push({
30
+ contentFile: move.contentFile,
31
+ section: move.section,
32
+ reference: move.reference,
33
+ issue,
34
+ phase: 'conflict',
35
+ });
36
+ };
37
+
38
+ const addMoveConflicts = (moves, issues) => {
39
+ const movesBySource = new Map();
40
+ const movesByDestination = new Map();
41
+
42
+ for (const move of moves) {
43
+ addMapEntry(movesBySource, move.from, move);
44
+ addMapEntry(movesByDestination, move.to, move);
45
+ }
46
+
47
+ for (const sourceMoves of movesBySource.values()) {
48
+ const destinations = new Set(sourceMoves.map((move) => move.to));
49
+ if (destinations.size <= 1) continue;
50
+
51
+ const firstMove = sourceMoves[0];
52
+ addConflictIssue(issues, firstMove, {
53
+ severity: 'error',
54
+ message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${sourceMoves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
55
+ fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
56
+ });
57
+ }
58
+
59
+ for (const destinationMoves of movesByDestination.values()) {
60
+ const sources = new Set(destinationMoves.map((move) => move.from));
61
+ if (sources.size <= 1) continue;
62
+
63
+ const firstMove = destinationMoves[0];
64
+ addConflictIssue(issues, firstMove, {
65
+ severity: 'error',
66
+ message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${destinationMoves.map((move) => getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })).join(', ')}.`,
67
+ fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
68
+ });
69
+ }
70
+ };
71
+
72
+ export const createImageSyncPlan = ({
73
+ imageCandidates,
74
+ references,
75
+ reportMisplaced,
76
+ }) => {
77
+ const candidatesByName = new Map();
78
+ const expectedReferencesByPath = new Map();
79
+ const issues = [];
80
+ const moves = [];
81
+ const referencedImagePaths = new Set();
82
+ const resolvedPathByReference = new Map();
83
+
84
+ for (const candidate of imageCandidates) {
85
+ addMapEntry(candidatesByName, candidate.imageName, candidate);
86
+ }
87
+
88
+ for (const candidates of candidatesByName.values()) {
89
+ candidates.sort((left, right) => left.imagePath.localeCompare(right.imagePath, 'sv'));
90
+ }
91
+
92
+ for (const entry of references) {
93
+ const expectedPath = getExpectedImagePath(entry.contentFile, entry.reference.image);
94
+ addMapEntry(expectedReferencesByPath, expectedPath, entry);
95
+ }
96
+
97
+ for (const entry of references) {
98
+ const { contentFile, reference, section } = entry;
99
+ const imageName = reference.image;
100
+ const expectedPath = getExpectedImagePath(contentFile, imageName);
101
+ const expectedLabel = getExpectedImageLabel(contentFile, imageName);
102
+ const candidates = (candidatesByName.get(imageName) ?? [])
103
+ .filter(({ imagePath }) => imagePath !== expectedPath);
104
+ const expectedCandidate = (candidatesByName.get(imageName) ?? [])
105
+ .find(({ imagePath }) => imagePath === expectedPath);
106
+
107
+ if (expectedCandidate) {
108
+ referencedImagePaths.add(expectedPath);
109
+ resolvedPathByReference.set(reference, expectedPath);
110
+ continue;
111
+ }
112
+
113
+ if (candidates.length === 0) {
114
+ addReferenceIssue(issues, entry, {
115
+ severity: 'error',
116
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
117
+ fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
118
+ });
119
+ continue;
120
+ }
121
+
122
+ if (candidates.length > 1) {
123
+ addReferenceIssue(issues, entry, {
124
+ severity: 'error',
125
+ message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getImageCandidateLabel).join(', ')}.`,
126
+ fix: 'Move the intended file manually or rename files so the move is unambiguous.',
127
+ });
128
+ continue;
129
+ }
130
+
131
+ const sourceCandidate = candidates[0];
132
+ const sourcePath = sourceCandidate.imagePath;
133
+ const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
134
+ .filter((expectedReference) => expectedReference.contentFile !== contentFile);
135
+
136
+ if (referencesAtCurrentLocation.length > 0) {
137
+ addReferenceIssue(issues, entry, {
138
+ severity: 'error',
139
+ message: `Cannot relocate "${imageName}" from ${getImageCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
140
+ fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
141
+ });
142
+ continue;
143
+ }
144
+
145
+ if (!moves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
146
+ moves.push({
147
+ imageName,
148
+ from: sourcePath,
149
+ to: expectedPath,
150
+ contentFile,
151
+ sourceContentFile: sourceCandidate.contentFile,
152
+ section,
153
+ reference,
154
+ });
155
+ }
156
+
157
+ referencedImagePaths.add(sourcePath);
158
+ resolvedPathByReference.set(reference, sourcePath);
159
+
160
+ if (reportMisplaced) {
161
+ addReferenceIssue(issues, entry, {
162
+ severity: 'error',
163
+ message: `Image "${imageName}" is used here but is located in ${getImageCandidateLabel(sourceCandidate)}.`,
164
+ fix: sourceCandidate.contentFile === contentFile
165
+ ? 'Run norna content:sync to move it directly into the current page image root.'
166
+ : `Run norna content:sync to move it from ${getImageRootLabel(sourceCandidate.contentFile)} to ${getImageRootLabel(contentFile)}.`,
167
+ });
168
+ }
169
+ }
170
+
171
+ addMoveConflicts(moves, issues);
172
+
173
+ return {
174
+ issues,
175
+ moves,
176
+ referencedImagePaths,
177
+ resolvedPathByReference,
178
+ };
179
+ };
@@ -5,9 +5,11 @@ import {
5
5
  getOpenMarkdownFenceAtLine,
6
6
  nornaMarkdownBlockDefinitions,
7
7
  } from './norna-markdown-blocks.mjs';
8
+ import { resolveNavigationModel } from './navigation-model.mjs';
8
9
  import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
9
10
  import { parsePageMarkdownSource } from './page-markdown.mjs';
10
11
  import { siteSchema } from './schema-definitions.mjs';
12
+ import { homePageDirectory } from './site-conventions.mjs';
11
13
 
12
14
  const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png', '.svg']);
13
15
  const siteConfigNames = ['config.yaml'];
@@ -33,6 +35,132 @@ export const findNornaSiteRoot = async (documentPath) => {
33
35
  }
34
36
  };
35
37
 
38
+ const readEditorYaml = async (filePath) => {
39
+ try {
40
+ const source = await readFile(filePath, 'utf8');
41
+ const data = load(source) ?? {};
42
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ const readContentFrontmatterForEditor = (source) => {
49
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
50
+ if (!match) return {};
51
+ try {
52
+ const data = load(match[1]) ?? {};
53
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ };
58
+
59
+ const getNavigationNodesForEditor = async (siteRoot) => {
60
+ const nodes = [];
61
+
62
+ const visit = async (pagesDirectory, depth = 1) => {
63
+ const entries = (await readdir(pagesDirectory, { withFileTypes: true }).catch((error) => {
64
+ if (error?.code === 'ENOENT') return [];
65
+ throw error;
66
+ }))
67
+ .filter((entry) => entry.isDirectory())
68
+ .sort((left, right) => left.name.localeCompare(right.name, 'en'));
69
+
70
+ for (const entry of entries) {
71
+ const nodeDirectory = path.join(pagesDirectory, entry.name);
72
+ const contentPath = path.join(nodeDirectory, 'content.md');
73
+ const categoryPath = path.join(nodeDirectory, 'category.yaml');
74
+ const [hasContent, hasCategory] = await Promise.all([
75
+ fileExists(contentPath),
76
+ fileExists(categoryPath),
77
+ ]);
78
+
79
+ if (hasContent !== hasCategory) {
80
+ if (hasCategory) {
81
+ nodes.push({
82
+ depth,
83
+ headings: [],
84
+ isHome: false,
85
+ kind: 'category',
86
+ listed: true,
87
+ });
88
+ } else {
89
+ const source = await readFile(contentPath, 'utf8');
90
+ const data = readContentFrontmatterForEditor(source);
91
+ const document = await parsePageMarkdownSource(source, { label: contentPath });
92
+ nodes.push({
93
+ depth,
94
+ headings: document.navigationHeadings,
95
+ isHome: depth === 1 && entry.name === homePageDirectory,
96
+ kind: 'page',
97
+ listed: data.navigation?.listed !== false,
98
+ });
99
+ }
100
+ }
101
+
102
+ await visit(path.join(nodeDirectory, 'pages'), depth + 1);
103
+ }
104
+ };
105
+
106
+ await visit(path.join(siteRoot, 'pages'));
107
+ return nodes;
108
+ };
109
+
110
+ const getNavigationModeForEditor = async (siteRoot) => {
111
+ const config = await readEditorYaml(path.join(siteRoot, 'config.yaml'));
112
+ if (!config) return null;
113
+ const requestedMode = config.navigation?.mode ?? 'automatic';
114
+ if (requestedMode !== 'automatic') return requestedMode;
115
+
116
+ try {
117
+ return resolveNavigationModel({
118
+ mode: requestedMode,
119
+ nodes: await getNavigationNodesForEditor(siteRoot),
120
+ }).mode;
121
+ } catch {
122
+ return null;
123
+ }
124
+ };
125
+
126
+ const findNestedYamlPropertyLine = (source, parentKey, propertyKey) => {
127
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
128
+ let insideParent = false;
129
+ for (let index = 0; index < lines.length; index += 1) {
130
+ const line = lines[index];
131
+ if (new RegExp(`^${parentKey}:\\s*(?:#.*)?$`).test(line)) {
132
+ insideParent = true;
133
+ continue;
134
+ }
135
+ if (!insideParent) continue;
136
+ if (/^[A-Za-z][A-Za-z0-9-]*:/.test(line)) return 1;
137
+ if (new RegExp(`^ ${propertyKey}:`).test(line)) return index + 1;
138
+ }
139
+ return 1;
140
+ };
141
+
142
+ export const getThemeDiagnostics = async ({ documentPath, source }) => {
143
+ let theme;
144
+ try {
145
+ theme = load(source) ?? {};
146
+ } catch {
147
+ return [];
148
+ }
149
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) return [];
150
+
151
+ const backgroundPattern = theme.sections?.backgroundPattern;
152
+ if (backgroundPattern === undefined || backgroundPattern === 'uniform') return [];
153
+ const siteRoot = await findNornaSiteRoot(documentPath);
154
+ if (!siteRoot || await getNavigationModeForEditor(siteRoot) !== 'tree') return [];
155
+
156
+ return [{
157
+ code: 'tree-section-background-pattern',
158
+ line: findNestedYamlPropertyLine(source, 'sections', 'backgroundPattern'),
159
+ message: `sections.backgroundPattern "${backgroundPattern}" cannot be used because this site resolves to tree navigation. Tree navigation uses one uniform reading surface so the navigation rail and page content remain distinct. Remove sections.backgroundPattern or set it to uniform.`,
160
+ severity: 'error',
161
+ }];
162
+ };
163
+
36
164
  const readSitewideLogoForEditor = async (siteRoot) => {
37
165
  const filename = await findFile(siteRoot, ['sitewide-content.yaml']);
38
166
  if (!filename) return null;
@@ -79,7 +79,7 @@ export const getMarkdownHeadings = async (source) => {
79
79
  ),
80
80
  }));
81
81
 
82
- return { headings, source: normalizedSource };
82
+ return { headings, source: normalizedSource, tree };
83
83
  };
84
84
 
85
85
  export const getHeadingIdentifierIssues = (headings) => {
@@ -0,0 +1,2 @@
1
+ export const imagePresentationNames = Object.freeze(['prose-aligned', 'centered-fit']);
2
+ export const defaultImagePresentation = 'prose-aligned';
@@ -0,0 +1,182 @@
1
+ const getNodeRange = (node) => {
2
+ const start = node.position?.start.offset;
3
+ const end = node.position?.end.offset;
4
+ return Number.isInteger(start) && Number.isInteger(end)
5
+ ? { start, end }
6
+ : null;
7
+ };
8
+
9
+ const visitNodes = (node, visit) => {
10
+ if (!node || typeof node !== 'object') return;
11
+ visit(node);
12
+ if (!Array.isArray(node.children)) return;
13
+ for (const child of node.children) visitNodes(child, visit);
14
+ };
15
+
16
+ const scanDestination = (source, start, end) => {
17
+ let cursor = start;
18
+ while (cursor < end && /\s/.test(source[cursor] ?? '')) cursor += 1;
19
+
20
+ if (source[cursor] === '<') {
21
+ const targetStart = cursor + 1;
22
+ for (cursor = targetStart; cursor < end; cursor += 1) {
23
+ if (source[cursor] === '>' && source[cursor - 1] !== '\\') {
24
+ return { start: targetStart, end: cursor };
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const targetStart = cursor;
31
+ let parenthesisDepth = 0;
32
+ let escaped = false;
33
+ for (; cursor < end; cursor += 1) {
34
+ const character = source[cursor] ?? '';
35
+ if (escaped) {
36
+ escaped = false;
37
+ continue;
38
+ }
39
+ if (character === '\\') {
40
+ escaped = true;
41
+ continue;
42
+ }
43
+ if (character === '(') {
44
+ parenthesisDepth += 1;
45
+ continue;
46
+ }
47
+ if (character === ')') {
48
+ if (parenthesisDepth === 0) break;
49
+ parenthesisDepth -= 1;
50
+ continue;
51
+ }
52
+ if (/\s/.test(character) && parenthesisDepth === 0) break;
53
+ }
54
+
55
+ return { start: targetStart, end: cursor };
56
+ };
57
+
58
+ const getInlineLinkTargetRange = (source, node) => {
59
+ const nodeRange = getNodeRange(node);
60
+ if (!nodeRange) return null;
61
+ const childEnd = Array.isArray(node.children)
62
+ ? Math.max(nodeRange.start, ...node.children.map((child) => child.position?.end.offset ?? nodeRange.start))
63
+ : nodeRange.start;
64
+ const delimiter = source.indexOf('](', childEnd);
65
+ if (delimiter < 0 || delimiter >= nodeRange.end) return null;
66
+ return scanDestination(source, delimiter + 2, nodeRange.end);
67
+ };
68
+
69
+ const getDefinitionTargetRange = (source, node) => {
70
+ const nodeRange = getNodeRange(node);
71
+ if (!nodeRange) return null;
72
+ const delimiter = source.indexOf(']:', nodeRange.start);
73
+ if (delimiter < 0 || delimiter >= nodeRange.end) return null;
74
+ return scanDestination(source, delimiter + 2, nodeRange.end);
75
+ };
76
+
77
+ const getSourceTarget = (source, range, fallback) => range
78
+ ? source.slice(range.start, range.end)
79
+ : fallback;
80
+
81
+ export const extractMarkdownLinks = ({ source, tree, lineOffset = 0 }) => {
82
+ const definitions = new Map();
83
+ visitNodes(tree, (node) => {
84
+ if (node.type !== 'definition') return;
85
+ definitions.set(node.identifier, node);
86
+ });
87
+
88
+ const links = [];
89
+ visitNodes(tree, (node) => {
90
+ if (node.type === 'link') {
91
+ const range = getNodeRange(node);
92
+ const targetRange = getInlineLinkTargetRange(source, node);
93
+ links.push({
94
+ kind: 'markdown-link',
95
+ column: node.position?.start.column ?? 1,
96
+ line: lineOffset + (node.position?.start.line ?? 1),
97
+ range,
98
+ target: node.url ?? '',
99
+ targetRange,
100
+ targetSource: getSourceTarget(source, targetRange, node.url ?? ''),
101
+ });
102
+ return;
103
+ }
104
+
105
+ if (node.type !== 'linkReference') return;
106
+ const definition = definitions.get(node.identifier);
107
+ if (!definition) return;
108
+ const targetRange = getDefinitionTargetRange(source, definition);
109
+ links.push({
110
+ kind: 'markdown-reference-link',
111
+ column: node.position?.start.column ?? 1,
112
+ definitionLine: lineOffset + (definition.position?.start.line ?? 1),
113
+ line: lineOffset + (node.position?.start.line ?? 1),
114
+ range: getNodeRange(node),
115
+ target: definition.url ?? '',
116
+ targetRange,
117
+ targetSource: getSourceTarget(source, targetRange, definition.url ?? ''),
118
+ });
119
+ });
120
+
121
+ return links;
122
+ };
123
+
124
+ const getLineOffsets = (source) => {
125
+ const offsets = [0];
126
+ for (let index = 0; index < source.length; index += 1) {
127
+ if (source[index] === '\n') offsets.push(index + 1);
128
+ }
129
+ return offsets;
130
+ };
131
+
132
+ const getCardLinkTargetRange = (source, lineStart) => {
133
+ const lineEnd = source.indexOf('\n', lineStart);
134
+ const end = lineEnd < 0 ? source.length : lineEnd;
135
+ const line = source.slice(lineStart, end);
136
+ const match = line.match(/^\s*link:\s*(.*?)\s*$/);
137
+ if (!match) return null;
138
+
139
+ const value = match[1] ?? '';
140
+ let valueStart = lineStart + (match.index ?? 0) + match[0].indexOf(value);
141
+ let valueEnd = valueStart + value.length;
142
+ if (
143
+ value.length >= 2
144
+ && (value[0] === '"' || value[0] === "'")
145
+ && value.at(-1) === value[0]
146
+ ) {
147
+ valueStart += 1;
148
+ valueEnd -= 1;
149
+ }
150
+
151
+ return { start: valueStart, end: valueEnd };
152
+ };
153
+
154
+ export const extractNornaBlockLinks = ({ source, blocks, lineOffset = 0 }) => {
155
+ const lineOffsets = getLineOffsets(source);
156
+ const links = [];
157
+
158
+ for (const block of blocks) {
159
+ if (block.type !== 'card-list') continue;
160
+ for (const card of block.cards) {
161
+ if (!card.link || !card.linkLine) continue;
162
+ const localLine = card.linkLine - lineOffset;
163
+ const lineStart = lineOffsets[localLine - 1];
164
+ const targetRange = Number.isInteger(lineStart)
165
+ ? getCardLinkTargetRange(source, lineStart)
166
+ : null;
167
+ links.push({
168
+ kind: 'card-link',
169
+ column: targetRange && Number.isInteger(lineStart)
170
+ ? targetRange.start - lineStart + 1
171
+ : 3,
172
+ line: card.linkLine,
173
+ range: targetRange,
174
+ target: card.link,
175
+ targetRange,
176
+ targetSource: getSourceTarget(source, targetRange, card.link),
177
+ });
178
+ }
179
+ }
180
+
181
+ return links;
182
+ };
@@ -15,26 +15,35 @@ const assertNavigationMode = (mode) => {
15
15
 
16
16
  const getListedNodes = (nodes) => nodes.filter((node) => node.isHome || node.listed !== false);
17
17
 
18
- const getNodeNavigationDepth = (node) => {
19
- const nodeDepth = node.depth ?? 1;
20
- return (node.headings ?? []).reduce((maximum, heading) => (
21
- Math.max(maximum, nodeDepth + heading.depth - 1)
22
- ), nodeDepth);
18
+ const getNodeDepth = (node) => node.depth ?? 1;
19
+
20
+ const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
21
+
22
+ const branchNeedsPageRail = (nodes, pagePath) => {
23
+ const rootPath = getTopLevelPagePath(pagePath);
24
+ if (!rootPath) return false;
25
+
26
+ return nodes.some((node) => (
27
+ (node.pagePath === rootPath && node.kind === 'category')
28
+ || node.pagePath?.startsWith(`${rootPath}/`)
29
+ ));
23
30
  };
24
31
 
25
- export const getAutomaticNavigationMode = (nodes) => {
32
+ export const getAutomaticNavigationMode = (nodes, currentPage = null) => {
26
33
  const listedNodes = getListedNodes(nodes);
27
34
  if (listedNodes.length <= 1) return 'sections';
28
- if (listedNodes.some((node) => node.kind === 'category')) return 'tree';
29
35
 
30
- const maximumDepth = listedNodes.reduce((maximum, node) => (
31
- Math.max(maximum, getNodeNavigationDepth(node))
32
- ), 1);
36
+ if (currentPage) {
37
+ if (currentPage.isHome) return 'top';
38
+ return branchNeedsPageRail(listedNodes, currentPage.pagePath) ? 'tree' : 'top';
39
+ }
33
40
 
34
- return maximumDepth <= 2 ? 'top' : 'tree';
41
+ return listedNodes.some((node) => node.kind === 'category' || getNodeDepth(node) > 1)
42
+ ? 'tree'
43
+ : 'top';
35
44
  };
36
45
 
37
- export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
46
+ export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage = null }) => {
38
47
  const requestedMode = assertNavigationMode(mode);
39
48
  const listedNodes = getListedNodes(nodes);
40
49
  const hasCategories = listedNodes.some((node) => node.kind === 'category');
@@ -42,16 +51,18 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
42
51
  throw new Error(`Navigation categories require tree navigation. Remove navigation.mode: ${requestedMode}, or set navigation.mode: tree.`);
43
52
  }
44
53
  const maximumDepth = listedNodes.reduce((maximum, node) => (
45
- Math.max(maximum, getNodeNavigationDepth(node))
54
+ Math.max(maximum, getNodeDepth(node))
46
55
  ), 1);
56
+ const hasNestedPages = listedNodes.some((node) => getNodeDepth(node) > 1);
47
57
 
48
58
  return Object.freeze({
49
59
  mode: requestedMode === 'automatic'
50
- ? getAutomaticNavigationMode(listedNodes)
60
+ ? getAutomaticNavigationMode(listedNodes, currentPage)
51
61
  : requestedMode,
52
62
  requestedMode,
53
63
  listedNodeCount: listedNodes.length,
54
64
  hasCategories,
65
+ hasNestedPages,
55
66
  maximumDepth,
56
67
  });
57
68
  };
@@ -495,6 +495,7 @@ const parseCardListBlock = (source, options = {}) => {
495
495
  }
496
496
 
497
497
  current[entry.key] = entry.value;
498
+ if (entry.key === 'link') current.linkLine = lineNumber;
498
499
  }
499
500
 
500
501
  for (const card of cards) {