@janga/norna 0.7.23 → 0.7.25
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.
- package/README.md +14 -18
- package/astro.config.mjs +14 -0
- package/bin/norna-cli.mjs +6 -0
- package/package.json +25 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +80 -15
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +246 -246
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +16 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +3 -0
- package/scripts/lib/code-fence-metadata.mjs +228 -0
- package/scripts/lib/edit-source-link.mjs +102 -0
- package/scripts/lib/editor-language-service.mjs +32 -12
- package/scripts/lib/image-presentation.mjs +4 -0
- package/scripts/lib/navigation-model.mjs +34 -13
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +164 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +21 -1
- package/scripts/lib/page-markdown.mjs +32 -1
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +1 -10
- package/scripts/lib/project-config.mjs +141 -5
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +30 -5
- package/scripts/lib/schema-editor-metadata.mjs +36 -4
- package/scripts/lib/schema-value-definitions.mjs +4 -1
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +2 -1
- package/scripts/lib/site-link-graph.mjs +33 -2
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/scripts/lib/theme-presets.mjs +2 -2
- package/scripts/lib/theme-profiles.mjs +0 -4
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +67 -4
- package/scripts/sync-site-public.mjs +24 -5
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +11 -5
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +9 -7
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/NavigationPageTree.astro +66 -55
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +1 -1
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +102 -51
- package/src/components/SitePage.astro +107 -39
- package/src/components/SiteSection.astro +11 -3
- package/src/components/SiteTreeNavigation.astro +11 -1
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +271 -34
- package/src/layouts/BaseLayout.astro +83 -2
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +36 -30
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +6 -3
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +15 -5
- package/src/styles/content.css +538 -39
- package/src/styles/media.css +187 -8
- package/src/styles/navigation.css +199 -3
- package/src/styles/page-layout.css +481 -100
- package/src/styles/responsive.css +144 -28
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
nornaMarkdownBlockDefinitions,
|
|
7
7
|
} from './norna-markdown-blocks.mjs';
|
|
8
8
|
import { resolveNavigationModel } from './navigation-model.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
inspectPublicAssetFilenames,
|
|
11
|
+
logoAssetFilenames,
|
|
12
|
+
socialImageAssetFilenames,
|
|
13
|
+
} from './public-asset-conventions.mjs';
|
|
10
14
|
import { parsePageMarkdownSource } from './page-markdown.mjs';
|
|
11
15
|
import { siteSchema } from './schema-definitions.mjs';
|
|
12
16
|
import { homePageDirectory } from './site-conventions.mjs';
|
|
@@ -217,6 +221,18 @@ export const getSitePublicAssetStatus = async (documentPath) => {
|
|
|
217
221
|
});
|
|
218
222
|
}
|
|
219
223
|
}
|
|
224
|
+
if (inspection.socialImages.length > 1) {
|
|
225
|
+
const message = `Multiple social sharing images were found: ${inspection.socialImages.join(', ')}. Keep exactly one of ${socialImageAssetFilenames.join(', ')} in site/public.`;
|
|
226
|
+
for (const filename of inspection.socialImages) {
|
|
227
|
+
issues.push({
|
|
228
|
+
absolutePath: path.join(publicDirectory, filename),
|
|
229
|
+
code: 'multiple-social-image-files',
|
|
230
|
+
filename,
|
|
231
|
+
message,
|
|
232
|
+
severity: 'error',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
220
236
|
if (inspection.logos.length === 0 && sitewideLogo?.logoConfigured) {
|
|
221
237
|
const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
|
|
222
238
|
issues.push({
|
|
@@ -464,7 +480,7 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
|
|
|
464
480
|
const page = getPageContext(siteRoot, documentPath);
|
|
465
481
|
if (!page) return null;
|
|
466
482
|
const fence = getOpenMarkdownFenceAtLine(source, line);
|
|
467
|
-
if (!fence || !['
|
|
483
|
+
if (!fence || !['image-stack', 'image-carousel', 'card-list'].includes(fence.type)) return null;
|
|
468
484
|
|
|
469
485
|
const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
|
|
470
486
|
if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
|
|
@@ -563,7 +579,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
|
563
579
|
code: 'local-markdown-image',
|
|
564
580
|
severity: 'warning',
|
|
565
581
|
line: reference.line,
|
|
566
|
-
message: `Local Markdown image "${reference.target}" is not managed by Norna. Use
|
|
582
|
+
message: `Local Markdown image "${reference.target}" is not managed by Norna. Use image-stack, image-carousel, or card-list for validated and synchronized site images.`,
|
|
567
583
|
});
|
|
568
584
|
}
|
|
569
585
|
|
|
@@ -619,16 +635,20 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
|
619
635
|
};
|
|
620
636
|
|
|
621
637
|
export const nornaBlockDefinitions = Object.freeze({
|
|
622
|
-
'
|
|
623
|
-
...nornaMarkdownBlockDefinitions['
|
|
624
|
-
snippet: '```
|
|
638
|
+
'image-stack': Object.freeze({
|
|
639
|
+
...nornaMarkdownBlockDefinitions['image-stack'],
|
|
640
|
+
snippet: '```image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
|
|
641
|
+
}),
|
|
642
|
+
'image-carousel': Object.freeze({
|
|
643
|
+
...nornaMarkdownBlockDefinitions['image-carousel'],
|
|
644
|
+
snippet: '```image-carousel\n- image: ${1:first.jpg}\n alt: ${2:Alternative text}\n- image: ${3:second.jpg}\n alt: ${4:Alternative text}\n```',
|
|
625
645
|
}),
|
|
626
|
-
'
|
|
627
|
-
...nornaMarkdownBlockDefinitions['
|
|
628
|
-
snippet: '```
|
|
646
|
+
'card-list': Object.freeze({
|
|
647
|
+
...nornaMarkdownBlockDefinitions['card-list'],
|
|
648
|
+
snippet: '```card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\n\n- title: ${4:Card title}\n text: ${5:Card text}\n image: ${6:filename.jpg}\n```',
|
|
629
649
|
}),
|
|
630
|
-
'
|
|
631
|
-
...nornaMarkdownBlockDefinitions['
|
|
632
|
-
snippet: '```
|
|
650
|
+
'page-list': Object.freeze({
|
|
651
|
+
...nornaMarkdownBlockDefinitions['page-list'],
|
|
652
|
+
snippet: '```page-list\n```',
|
|
633
653
|
}),
|
|
634
654
|
});
|
|
@@ -19,31 +19,27 @@ const getNodeDepth = (node) => node.depth ?? 1;
|
|
|
19
19
|
|
|
20
20
|
const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
if (
|
|
22
|
+
const getActiveBranchNodes = (nodes, currentPage) => {
|
|
23
|
+
if (!currentPage) return [];
|
|
24
|
+
if (currentPage.isHome) return nodes.filter((node) => node.isHome);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const rootPath = getTopLevelPagePath(currentPage.pagePath);
|
|
27
|
+
return nodes.filter((node) => (
|
|
28
|
+
node.pagePath === rootPath
|
|
28
29
|
|| node.pagePath?.startsWith(`${rootPath}/`)
|
|
29
30
|
));
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
export const getAutomaticNavigationMode = (nodes
|
|
33
|
+
export const getAutomaticNavigationMode = (nodes) => {
|
|
33
34
|
const listedNodes = getListedNodes(nodes);
|
|
34
35
|
if (listedNodes.length <= 1) return 'sections';
|
|
35
36
|
|
|
36
|
-
if (currentPage) {
|
|
37
|
-
if (currentPage.isHome) return 'top';
|
|
38
|
-
return branchNeedsPageRail(listedNodes, currentPage.pagePath) ? 'tree' : 'top';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
return listedNodes.some((node) => node.kind === 'category' || getNodeDepth(node) > 1)
|
|
42
38
|
? 'tree'
|
|
43
39
|
: 'top';
|
|
44
40
|
};
|
|
45
41
|
|
|
46
|
-
export const resolveNavigationModel = ({ mode = 'automatic', nodes
|
|
42
|
+
export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
|
|
47
43
|
const requestedMode = assertNavigationMode(mode);
|
|
48
44
|
const listedNodes = getListedNodes(nodes);
|
|
49
45
|
const hasCategories = listedNodes.some((node) => node.kind === 'category');
|
|
@@ -57,7 +53,7 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage
|
|
|
57
53
|
|
|
58
54
|
return Object.freeze({
|
|
59
55
|
mode: requestedMode === 'automatic'
|
|
60
|
-
? getAutomaticNavigationMode(listedNodes
|
|
56
|
+
? getAutomaticNavigationMode(listedNodes)
|
|
61
57
|
: requestedMode,
|
|
62
58
|
requestedMode,
|
|
63
59
|
listedNodeCount: listedNodes.length,
|
|
@@ -66,3 +62,28 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes, currentPage
|
|
|
66
62
|
maximumDepth,
|
|
67
63
|
});
|
|
68
64
|
};
|
|
65
|
+
|
|
66
|
+
export const resolvePageContentsPlacement = ({
|
|
67
|
+
navigationMode,
|
|
68
|
+
nodes,
|
|
69
|
+
currentPage,
|
|
70
|
+
headingCount,
|
|
71
|
+
}) => {
|
|
72
|
+
const listedNodes = getListedNodes(nodes);
|
|
73
|
+
const activeBranchNodes = getActiveBranchNodes(listedNodes, currentPage);
|
|
74
|
+
const activeBranchDepth = activeBranchNodes.reduce((maximum, node) => (
|
|
75
|
+
Math.max(maximum, getNodeDepth(node))
|
|
76
|
+
), 0);
|
|
77
|
+
const hasPageContents = headingCount >= 2;
|
|
78
|
+
const placement = currentPage?.isHome || navigationMode !== 'tree' || !hasPageContents
|
|
79
|
+
? 'none'
|
|
80
|
+
: activeBranchDepth <= 2
|
|
81
|
+
? 'page-tree'
|
|
82
|
+
: 'contents-rail';
|
|
83
|
+
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
activeBranchDepth,
|
|
86
|
+
hasPageContents,
|
|
87
|
+
placement,
|
|
88
|
+
});
|
|
89
|
+
};
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { resolveNavigationModel } from './navigation-model.mjs';
|
|
2
|
+
import { getSiteLinkGraph } from './site-link-graph.mjs';
|
|
3
|
+
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
4
|
+
import {
|
|
5
|
+
flattenSiteNavigationTree,
|
|
6
|
+
getListedSiteNavigationTree,
|
|
7
|
+
getSiteNavigationTree,
|
|
8
|
+
} from './site-navigation-tree.mjs';
|
|
9
|
+
import { getSiteStructure } from './site-structure.mjs';
|
|
10
|
+
|
|
11
|
+
export const navigationReviewFormatNames = Object.freeze(['text', 'json']);
|
|
12
|
+
|
|
13
|
+
export const navigationReviewThresholds = Object.freeze({
|
|
14
|
+
deepBranchLevels: 4,
|
|
15
|
+
sectionCount: 8,
|
|
16
|
+
wideSiblingCount: 10,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const plural = (count, singular, pluralForm = `${singular}s`) => (
|
|
20
|
+
`${count} ${count === 1 ? singular : pluralForm}`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const logicalPathname = (node) => getSiteNodePathname(node);
|
|
24
|
+
|
|
25
|
+
const getTreeMetrics = (root) => {
|
|
26
|
+
const nodes = flattenSiteNavigationTree([root]);
|
|
27
|
+
const rootDepth = root.node.depth;
|
|
28
|
+
return {
|
|
29
|
+
categoryCount: nodes.filter(({ node }) => node.kind === 'category').length,
|
|
30
|
+
maximumLevels: nodes.reduce((maximum, { node }) => (
|
|
31
|
+
Math.max(maximum, node.depth - rootDepth + 1)
|
|
32
|
+
), 1),
|
|
33
|
+
nodeCount: nodes.length,
|
|
34
|
+
pageCount: nodes.filter(({ node }) => node.kind === 'page').length,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getSiblingGroups = (roots) => {
|
|
39
|
+
const groups = [];
|
|
40
|
+
const collect = (nodes, parent = null) => {
|
|
41
|
+
if (nodes.length > 0) {
|
|
42
|
+
groups.push({
|
|
43
|
+
count: nodes.length,
|
|
44
|
+
entries: nodes.map(({ node }) => ({
|
|
45
|
+
kind: node.kind,
|
|
46
|
+
path: logicalPathname(node),
|
|
47
|
+
title: node.title,
|
|
48
|
+
})),
|
|
49
|
+
parentPath: parent ? logicalPathname(parent.node) : null,
|
|
50
|
+
parentTitle: parent?.node.title ?? 'Site root',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const node of nodes) collect(node.children, node);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
collect(roots);
|
|
58
|
+
return groups;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const toGraphError = (diagnostic) => ({
|
|
62
|
+
code: diagnostic.code,
|
|
63
|
+
...(diagnostic.fix ? { fix: diagnostic.fix } : {}),
|
|
64
|
+
...(diagnostic.reference?.line ? { line: diagnostic.reference.line } : {}),
|
|
65
|
+
message: diagnostic.message,
|
|
66
|
+
...(diagnostic.reference?.sourceContentFile?.contentLabel
|
|
67
|
+
? { source: diagnostic.reference.sourceContentFile.contentLabel }
|
|
68
|
+
: {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const toStructureObservation = (warning) => ({
|
|
72
|
+
code: warning.code,
|
|
73
|
+
message: warning.message,
|
|
74
|
+
...(warning.label ? { source: warning.label } : {}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const getResolvedPageReferences = (linkGraph) => linkGraph.references.filter(({ resolution }) => (
|
|
78
|
+
resolution?.kind === 'page' || resolution?.kind === 'page-alias'
|
|
79
|
+
));
|
|
80
|
+
|
|
81
|
+
const getNavigationEntries = (siteStructure, linkGraph) => {
|
|
82
|
+
const pagesByDirectory = new Map(linkGraph.pages.map((page) => [
|
|
83
|
+
page.contentFile.pageDirectory,
|
|
84
|
+
page,
|
|
85
|
+
]));
|
|
86
|
+
|
|
87
|
+
return siteStructure.nodes.map((siteNode) => {
|
|
88
|
+
const page = siteNode.kind === 'page'
|
|
89
|
+
? pagesByDirectory.get(siteNode.pageDirectory)
|
|
90
|
+
: null;
|
|
91
|
+
if (siteNode.kind === 'page' && !page) {
|
|
92
|
+
throw new Error(`Navigation review could not find parsed content for ${siteNode.contentLabel}.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
headings: page?.document.headings.filter(({ depth }) => depth === 2 || depth === 3) ?? [],
|
|
97
|
+
node: {
|
|
98
|
+
...siteNode,
|
|
99
|
+
navigation: {
|
|
100
|
+
listed: siteNode.isHome || (page?.navigation.listed ?? true),
|
|
101
|
+
},
|
|
102
|
+
pathname: siteNode.kind === 'page' ? page.pathname : null,
|
|
103
|
+
title: siteNode.kind === 'page' ? page.title : siteNode.label,
|
|
104
|
+
},
|
|
105
|
+
page,
|
|
106
|
+
sections: [],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const getNavigationModes = ({ entries, listedEntries, requestedNavigationMode }) => {
|
|
112
|
+
const modelNodes = listedEntries.map(({ headings, node }) => ({
|
|
113
|
+
depth: node.depth,
|
|
114
|
+
headings,
|
|
115
|
+
isHome: node.isHome,
|
|
116
|
+
kind: node.kind,
|
|
117
|
+
listed: true,
|
|
118
|
+
pagePath: node.pagePath,
|
|
119
|
+
}));
|
|
120
|
+
const errors = [];
|
|
121
|
+
const modes = new Map();
|
|
122
|
+
const seenErrors = new Set();
|
|
123
|
+
|
|
124
|
+
for (const entry of entries.filter(({ node }) => node.kind === 'page')) {
|
|
125
|
+
try {
|
|
126
|
+
const model = resolveNavigationModel({
|
|
127
|
+
currentPage: {
|
|
128
|
+
depth: entry.node.depth,
|
|
129
|
+
headings: entry.headings,
|
|
130
|
+
isHome: entry.node.isHome,
|
|
131
|
+
kind: 'page',
|
|
132
|
+
listed: entry.node.navigation.listed,
|
|
133
|
+
pagePath: entry.node.pagePath,
|
|
134
|
+
},
|
|
135
|
+
mode: requestedNavigationMode,
|
|
136
|
+
nodes: modelNodes,
|
|
137
|
+
});
|
|
138
|
+
modes.set(entry.node.pagePath, model.mode);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
if (!seenErrors.has(message)) {
|
|
142
|
+
seenErrors.add(message);
|
|
143
|
+
errors.push({
|
|
144
|
+
code: 'invalid-navigation-model',
|
|
145
|
+
message,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
modes.set(entry.node.pagePath, null);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { errors, modes };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const sortUnique = (values) => [...new Set(values)].sort((left, right) => left.localeCompare(right, 'en'));
|
|
156
|
+
|
|
157
|
+
export const createNavigationReview = ({
|
|
158
|
+
linkGraph,
|
|
159
|
+
requestedNavigationMode = 'automatic',
|
|
160
|
+
siteStructure,
|
|
161
|
+
thresholds = navigationReviewThresholds,
|
|
162
|
+
}) => {
|
|
163
|
+
const entries = getNavigationEntries(siteStructure, linkGraph);
|
|
164
|
+
const completeTree = getSiteNavigationTree(entries);
|
|
165
|
+
const listedTree = getListedSiteNavigationTree(entries);
|
|
166
|
+
const completeEntries = flattenSiteNavigationTree(completeTree);
|
|
167
|
+
const listedEntries = flattenSiteNavigationTree(listedTree);
|
|
168
|
+
const listedPaths = new Set(listedEntries.map(({ node }) => node.pagePath));
|
|
169
|
+
const resolvedPageReferences = getResolvedPageReferences(linkGraph);
|
|
170
|
+
const { errors: navigationErrors, modes } = getNavigationModes({
|
|
171
|
+
entries: completeEntries,
|
|
172
|
+
listedEntries,
|
|
173
|
+
requestedNavigationMode,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const pages = completeEntries
|
|
177
|
+
.filter(({ node }) => node.kind === 'page')
|
|
178
|
+
.map(({ headings, node, page }) => {
|
|
179
|
+
const incomingPageLinkCount = resolvedPageReferences.filter(({ resolution }) => (
|
|
180
|
+
resolution.page.pathname === page.pathname
|
|
181
|
+
)).length;
|
|
182
|
+
const outgoingReferences = linkGraph.references.filter(({ sourcePage }) => sourcePage.pathname === page.pathname);
|
|
183
|
+
const outgoingPageLinkCount = outgoingReferences.filter(({ resolution }) => (
|
|
184
|
+
resolution?.kind === 'page' || resolution?.kind === 'page-alias'
|
|
185
|
+
)).length;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
contentFile: node.contentLabel,
|
|
189
|
+
depth: node.depth,
|
|
190
|
+
h2Count: headings.filter(({ depth }) => depth === 2).length,
|
|
191
|
+
h3Count: headings.filter(({ depth }) => depth === 3).length,
|
|
192
|
+
incomingPageLinkCount,
|
|
193
|
+
listed: listedPaths.has(node.pagePath),
|
|
194
|
+
navigationMode: modes.get(node.pagePath) ?? null,
|
|
195
|
+
outgoingInternalReferenceCount: outgoingReferences.length,
|
|
196
|
+
outgoingPageLinkCount,
|
|
197
|
+
parentPath: node.parentPagePath === null ? null : `/${node.parentPagePath}/`,
|
|
198
|
+
pathname: page.pathname,
|
|
199
|
+
title: node.title,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const categories = completeEntries
|
|
204
|
+
.filter(({ node }) => node.kind === 'category')
|
|
205
|
+
.map(({ children, node }) => ({
|
|
206
|
+
childCount: children.length,
|
|
207
|
+
depth: node.depth,
|
|
208
|
+
listed: listedPaths.has(node.pagePath),
|
|
209
|
+
listedChildCount: listedPaths.has(node.pagePath)
|
|
210
|
+
? (listedEntries.find(({ node: listedNode }) => listedNode.pagePath === node.pagePath)?.children.length ?? 0)
|
|
211
|
+
: 0,
|
|
212
|
+
parentPath: node.parentPagePath === null ? null : `/${node.parentPagePath}/`,
|
|
213
|
+
path: logicalPathname(node),
|
|
214
|
+
source: node.categorySourceLabel,
|
|
215
|
+
title: node.title,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
const branches = listedTree.map((root) => {
|
|
219
|
+
const metrics = getTreeMetrics(root);
|
|
220
|
+
const branchPaths = new Set(flattenSiteNavigationTree([root]).map(({ node }) => node.pagePath));
|
|
221
|
+
const navigationModes = sortUnique(pages
|
|
222
|
+
.filter((page) => branchPaths.has(page.pathname === '/' ? '' : page.pathname.slice(1, -1)))
|
|
223
|
+
.map(({ navigationMode }) => navigationMode)
|
|
224
|
+
.filter(Boolean));
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
...metrics,
|
|
228
|
+
kind: root.node.kind,
|
|
229
|
+
navigationModes,
|
|
230
|
+
path: logicalPathname(root.node),
|
|
231
|
+
title: root.node.title,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const siblingGroups = getSiblingGroups(listedTree);
|
|
236
|
+
const widestSiblingCount = siblingGroups.reduce((maximum, group) => Math.max(maximum, group.count), 0);
|
|
237
|
+
const observations = [
|
|
238
|
+
...siteStructure.warnings.map(toStructureObservation),
|
|
239
|
+
];
|
|
240
|
+
const unlistedPages = pages.filter(({ listed }) => !listed);
|
|
241
|
+
if (unlistedPages.length > 0) {
|
|
242
|
+
observations.push({
|
|
243
|
+
code: 'unlisted-pages',
|
|
244
|
+
message: `${plural(unlistedPages.length, 'page')} and any descendants are outside generated navigation: ${unlistedPages.map(({ pathname }) => pathname).join(', ')}.`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const recommendations = [];
|
|
249
|
+
for (const category of categories.filter(({ listed, listedChildCount }) => listed && listedChildCount === 1)) {
|
|
250
|
+
recommendations.push({
|
|
251
|
+
code: 'single-child-category',
|
|
252
|
+
message: `${category.title} (${category.path}) has one listed child. Keep the category when its label adds useful orientation; otherwise consider moving the child to the category's parent.`,
|
|
253
|
+
path: category.path,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
for (const branch of branches.filter(({ maximumLevels }) => maximumLevels >= thresholds.deepBranchLevels)) {
|
|
257
|
+
recommendations.push({
|
|
258
|
+
code: 'deep-branch',
|
|
259
|
+
message: `${branch.title} (${branch.path}) has ${branch.maximumLevels} visible levels. Review representative navigation tasks before adding another level.`,
|
|
260
|
+
path: branch.path,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
for (const group of siblingGroups.filter(({ count }) => count >= thresholds.wideSiblingCount)) {
|
|
264
|
+
recommendations.push({
|
|
265
|
+
code: 'wide-sibling-group',
|
|
266
|
+
message: `${group.parentTitle} has ${group.count} listed child entries. Review whether stable, meaningful groups would make scanning easier.`,
|
|
267
|
+
...(group.parentPath ? { path: group.parentPath } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
for (const page of pages.filter(({ h2Count }) => h2Count >= thresholds.sectionCount)) {
|
|
271
|
+
recommendations.push({
|
|
272
|
+
code: 'section-heavy-page',
|
|
273
|
+
message: `${page.title} (${page.pathname}) has ${page.h2Count} H2 sections. Confirm that they still support one coherent reading task; otherwise consider child pages.`,
|
|
274
|
+
path: page.pathname,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const effectiveNavigationModes = sortUnique(pages.map(({ navigationMode }) => navigationMode).filter(Boolean));
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
command: 'navigation:review',
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
thresholds: {
|
|
284
|
+
deepBranchLevels: thresholds.deepBranchLevels,
|
|
285
|
+
sectionCount: thresholds.sectionCount,
|
|
286
|
+
wideSiblingCount: thresholds.wideSiblingCount,
|
|
287
|
+
},
|
|
288
|
+
site: {
|
|
289
|
+
branchCount: branches.length,
|
|
290
|
+
categoryCount: categories.length,
|
|
291
|
+
effectiveNavigationModes,
|
|
292
|
+
internalReferenceCount: linkGraph.references.length,
|
|
293
|
+
listedCategoryCount: categories.filter(({ listed }) => listed).length,
|
|
294
|
+
listedPageCount: pages.filter(({ listed }) => listed).length,
|
|
295
|
+
maximumDepth: listedEntries.reduce((maximum, { node }) => Math.max(maximum, node.depth), 0),
|
|
296
|
+
pageCount: pages.length,
|
|
297
|
+
requestedNavigationMode,
|
|
298
|
+
resolvedPageLinkCount: resolvedPageReferences.length,
|
|
299
|
+
widestSiblingCount,
|
|
300
|
+
},
|
|
301
|
+
branches,
|
|
302
|
+
siblingGroups,
|
|
303
|
+
pages,
|
|
304
|
+
categories,
|
|
305
|
+
errors: [
|
|
306
|
+
...linkGraph.diagnostics.map(toGraphError),
|
|
307
|
+
...navigationErrors,
|
|
308
|
+
],
|
|
309
|
+
observations,
|
|
310
|
+
recommendations,
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export const getNavigationReview = async ({ requestedNavigationMode = 'automatic' } = {}) => {
|
|
315
|
+
const siteStructure = await getSiteStructure();
|
|
316
|
+
const linkGraph = await getSiteLinkGraph({ siteStructure });
|
|
317
|
+
return createNavigationReview({ linkGraph, requestedNavigationMode, siteStructure });
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const formatFindingSection = (title, findings) => [
|
|
321
|
+
title,
|
|
322
|
+
...(findings.length === 0
|
|
323
|
+
? ['- None.']
|
|
324
|
+
: findings.map((finding) => `- [${finding.code}] ${finding.message}${finding.source ? ` (${finding.source}${finding.line ? `:${finding.line}` : ''})` : ''}${finding.fix ? ` Fix: ${finding.fix}` : ''}`)),
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
export const formatNavigationReviewText = (review) => {
|
|
328
|
+
const lines = [
|
|
329
|
+
'Navigation Review',
|
|
330
|
+
'',
|
|
331
|
+
'Site',
|
|
332
|
+
`- Navigation: ${review.site.requestedNavigationMode} configured; ${review.site.effectiveNavigationModes.join(', ') || 'unresolved'} effective`,
|
|
333
|
+
`- Content: ${plural(review.site.pageCount, 'page')} (${review.site.listedPageCount} listed), ${plural(review.site.categoryCount, 'category', 'categories')} (${review.site.listedCategoryCount} listed)`,
|
|
334
|
+
`- Structure: ${plural(review.site.branchCount, 'top-level branch', 'top-level branches')}, ${review.site.maximumDepth} listed ${review.site.maximumDepth === 1 ? 'level' : 'levels'}, widest sibling group ${review.site.widestSiblingCount}`,
|
|
335
|
+
`- Links: ${plural(review.site.internalReferenceCount, 'internal reference')}, ${review.site.resolvedPageLinkCount} resolved to pages`,
|
|
336
|
+
'',
|
|
337
|
+
'Branches',
|
|
338
|
+
...review.branches.map((branch) => (
|
|
339
|
+
`- ${branch.title} (${branch.path}; ${branch.kind}): ${plural(branch.pageCount, 'page')}, ${plural(branch.categoryCount, 'category', 'categories')}, ${plural(branch.maximumLevels, 'visible level')}; navigation ${branch.navigationModes.join(', ') || 'unresolved'}`
|
|
340
|
+
)),
|
|
341
|
+
'',
|
|
342
|
+
'Pages',
|
|
343
|
+
...review.pages.map((page) => (
|
|
344
|
+
`- ${page.title} (${page.pathname}; ${page.listed ? 'listed' : 'not listed'}): H2 ${page.h2Count}, H3 ${page.h3Count}; page links ${page.outgoingPageLinkCount} out / ${page.incomingPageLinkCount} in; navigation ${page.navigationMode ?? 'unresolved'}`
|
|
345
|
+
)),
|
|
346
|
+
'',
|
|
347
|
+
'Categories',
|
|
348
|
+
...(review.categories.length === 0
|
|
349
|
+
? ['- None.']
|
|
350
|
+
: review.categories.map((category) => (
|
|
351
|
+
`- ${category.title} (${category.path}; ${category.listed ? 'listed' : 'not listed'}): ${category.listedChildCount} listed of ${plural(category.childCount, 'direct child', 'direct children')}`
|
|
352
|
+
))),
|
|
353
|
+
'',
|
|
354
|
+
...formatFindingSection('Errors', review.errors),
|
|
355
|
+
'',
|
|
356
|
+
...formatFindingSection('Observations', review.observations),
|
|
357
|
+
'',
|
|
358
|
+
...formatFindingSection('Recommendations', review.recommendations),
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
return `${lines.join('\n')}\n`;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export const parseNavigationReviewArgs = (args) => {
|
|
365
|
+
let format = 'text';
|
|
366
|
+
let formatSeen = false;
|
|
367
|
+
let help = false;
|
|
368
|
+
|
|
369
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
370
|
+
const arg = args[index];
|
|
371
|
+
if (arg === '-h' || arg === '--help') {
|
|
372
|
+
help = true;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let value = null;
|
|
377
|
+
if (arg === '--format') {
|
|
378
|
+
value = args[index + 1];
|
|
379
|
+
if (!value || value.startsWith('-')) throw new Error('--format requires text or json.');
|
|
380
|
+
index += 1;
|
|
381
|
+
} else if (arg.startsWith('--format=')) {
|
|
382
|
+
value = arg.slice('--format='.length);
|
|
383
|
+
} else {
|
|
384
|
+
throw new Error(`Unknown navigation:review option "${arg}". Use --format text or --format json.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (formatSeen) throw new Error('Specify --format only once.');
|
|
388
|
+
if (!navigationReviewFormatNames.includes(value)) {
|
|
389
|
+
throw new Error(`Unknown navigation:review format "${value}". Use one of: ${navigationReviewFormatNames.join(', ')}.`);
|
|
390
|
+
}
|
|
391
|
+
format = value;
|
|
392
|
+
formatSeen = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { format, help };
|
|
396
|
+
};
|