@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.
- package/README.md +41 -29
- package/astro.config.mjs +33 -3
- package/bin/norna-cli.mjs +5 -3
- package/package.json +18 -12
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +81 -66
- package/schemas/content-frontmatter.schema.json +23 -7
- package/schemas/manifest.json +49 -3
- package/schemas/page-theme.schema.json +1585 -41
- package/schemas/root-theme.schema.json +1767 -0
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +243 -243
- package/scripts/add-site-node.mjs +31 -250
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +51 -17
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +89 -70
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +70 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +20 -0
- package/scripts/lib/editor-language-service.mjs +74 -73
- package/scripts/lib/editor-page-addresses.mjs +57 -0
- package/scripts/lib/editor-page-placement.mjs +102 -0
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +181 -0
- package/scripts/lib/editor-site-files.mjs +154 -0
- package/scripts/lib/editor-site-links.mjs +81 -0
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +158 -0
- package/scripts/lib/editor-source-files.mjs +87 -0
- package/scripts/lib/editor-theme-help.mjs +43 -0
- package/scripts/lib/locale-registry.mjs +1 -1
- package/scripts/lib/locales/en.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -58
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +8 -1
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +19 -36
- package/scripts/lib/page-move-apply.mjs +119 -0
- package/scripts/lib/page-move-plan.mjs +64 -78
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +10 -8
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +15 -38
- package/scripts/lib/schema-editor-metadata.mjs +16 -60
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +8 -25
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +182 -0
- package/scripts/lib/site-paths.mjs +24 -12
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +86 -95
- package/scripts/lib/site-upgrade.mjs +32 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +34 -0
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/lib/theme-config.mjs +26 -68
- package/scripts/lib/theme-packages.mjs +47 -0
- package/scripts/lib/theme-presets.mjs +20 -53
- package/scripts/lib/typography.mjs +2 -2
- package/scripts/lib/yaml-config.mjs +1 -1
- package/scripts/lib/yaml-source-edit.mjs +94 -0
- package/scripts/move-site-page.mjs +46 -254
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +17 -17
- package/scripts/sync-site-public.mjs +44 -66
- package/scripts/upgrade-site.mjs +17 -0
- package/src/components/AreaNavigationMenu.astro +24 -0
- package/src/components/AreaNavigationPanel.astro +55 -0
- package/src/components/AreaNavigationSwitcher.astro +30 -0
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/ImageStackEnhancement.astro +0 -7
- package/src/components/NavigationContinuity.astro +26 -0
- package/src/components/NavigationPageTree.astro +13 -4
- package/src/components/NavigationState.astro +11 -0
- package/src/components/NavigationTreeControls.astro +3 -2
- package/src/components/SearchPage.astro +11 -2
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +38 -61
- package/src/components/SiteSection.astro +1 -0
- package/src/components/SiteTreeNavigation.astro +38 -18
- package/src/components/TopPageMenu.astro +4 -3
- package/src/components/TreeNavigationScript.astro +50 -57
- package/src/content.config.ts +11 -12
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -0
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/navigationContinuity.ts +212 -0
- package/src/lib/navigationFollowing.ts +28 -18
- package/src/lib/navigationState.mjs +88 -0
- package/src/lib/navigationStateScript.mjs +76 -0
- package/src/lib/navigationVisibility.ts +9 -0
- package/src/lib/pageThemes.ts +5 -42
- package/src/lib/sectionContent.ts +67 -21
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +13 -36
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +7 -16
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +408 -0
- package/src/styles/content.css +8 -29
- package/src/styles/media.css +24 -0
- package/src/styles/navigation-continuity.css +163 -0
- package/src/styles/page-layout.css +57 -68
- package/src/styles/responsive.css +0 -4
- package/starters/basic/README.md +9 -8
- package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
- package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
- package/schemas/category.schema.json +0 -20
- package/src/components/PageContentsNavigation.astro +0 -20
- /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
- /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getSourcePageLocation } from './source-files.mjs';
|
|
3
|
+
import { selectPageTheme } from './theme-packages.mjs';
|
|
4
|
+
|
|
5
|
+
const presetHelp = (siteRoot, selection) => {
|
|
6
|
+
const origin = selection.sourceFiles.findLast((file) => file.config.preset !== undefined);
|
|
7
|
+
return `Preset: ${selection.selected.config.preset}\nPreset source: ${path.relative(siteRoot, origin.path)}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Cache only within one tree read: dirty buffers and external changes must be
|
|
11
|
+
// reflected on the next refresh. The engine resolver owns all inheritance rules.
|
|
12
|
+
export const createEditorThemeHelp = ({ siteRoot, sources }) => {
|
|
13
|
+
const selections = new Map();
|
|
14
|
+
return async (directory, filename) => {
|
|
15
|
+
const includePageTheme = !filename || path.basename(filename) === 'page-theme.yaml';
|
|
16
|
+
const key = JSON.stringify([directory, includePageTheme]);
|
|
17
|
+
if (!selections.has(key)) selections.set(key, selectPageTheme({ siteRoot, sources,
|
|
18
|
+
pageDirectory: getSourcePageLocation(siteRoot, directory).pageDirectory, includePageTheme })
|
|
19
|
+
.catch(() => null));
|
|
20
|
+
const selection = await selections.get(key);
|
|
21
|
+
if (!selection) return 'Preset unavailable. Check the theme diagnostics.';
|
|
22
|
+
const file = filename && selection.sourceFiles.find((entry) => entry.path === filename);
|
|
23
|
+
const mode = !file ? '' : file.config.preset === undefined
|
|
24
|
+
? 'Modifies inherited settings. Add preset to replace the base.'
|
|
25
|
+
: getSourcePageLocation(siteRoot, directory).isHome && !includePageTheme
|
|
26
|
+
? 'Required base for the site. Its preset cannot be omitted.'
|
|
27
|
+
: 'Replaces inherited settings, even with the same preset name. Remove preset to resume inheritance.';
|
|
28
|
+
return [presetHelp(siteRoot, selection), mode].filter(Boolean).join('\n');
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getEditorThemeRemovalHelp = async ({ siteRoot, filename, sources }) => {
|
|
33
|
+
const afterRemoval = new Map(sources);
|
|
34
|
+
afterRemoval.set(filename, null);
|
|
35
|
+
try {
|
|
36
|
+
const selection = await selectPageTheme({ siteRoot, sources: afterRemoval,
|
|
37
|
+
pageDirectory: getSourcePageLocation(siteRoot, path.dirname(filename)).pageDirectory,
|
|
38
|
+
includePageTheme: false });
|
|
39
|
+
return `After removal, the inherited tree theme is ${path.relative(siteRoot, selection.treeTheme.path)}.\n${presetHelp(siteRoot, selection)}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return 'The inherited preset cannot be determined until the remaining theme errors are repaired.';
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -562,7 +562,7 @@ const normalizeLanguageTag = (value) => {
|
|
|
562
562
|
|
|
563
563
|
const formatSupportedLanguages = () => supportedLocaleTags.join(', ');
|
|
564
564
|
|
|
565
|
-
export const resolveLocale = (configuredLanguage = 'en', sourceLabel = 'site/config.yaml') => {
|
|
565
|
+
export const resolveLocale = (configuredLanguage = 'en', sourceLabel = 'site/site-config/settings.yaml') => {
|
|
566
566
|
let parsed;
|
|
567
567
|
try {
|
|
568
568
|
parsed = normalizeLanguageTag(configuredLanguage);
|
|
@@ -61,7 +61,7 @@ export default Object.freeze({
|
|
|
61
61
|
pageSections: 'Sections',
|
|
62
62
|
previousImage: 'Previous image',
|
|
63
63
|
previousPage: 'Previous page',
|
|
64
|
-
returnHome: '
|
|
64
|
+
returnHome: 'Home',
|
|
65
65
|
searchReturn: 'Back to {page}',
|
|
66
66
|
search: 'Search',
|
|
67
67
|
searchDescription: 'Search the published content on this site.',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publicFilePublication } from './publication-paths.mjs';
|
|
1
2
|
import { readdirSync } from 'node:fs';
|
|
2
3
|
import { sitePublicDir, sitePublicLabel } from './site-paths.mjs';
|
|
3
4
|
import {
|
|
@@ -23,7 +24,7 @@ export const getPublicAssetInspection = () => inspectPublicAssetFilenames(getPub
|
|
|
23
24
|
export const getLogoAssets = () => getPublicAssetInspection().logos
|
|
24
25
|
.map((entry) => ({
|
|
25
26
|
filename: entry,
|
|
26
|
-
href:
|
|
27
|
+
href: publicFilePublication(entry).pathname,
|
|
27
28
|
}));
|
|
28
29
|
|
|
29
30
|
export const getLogoAsset = () => {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getNodeRange, getInlineLinkTargetRange, getDefinitionTargetRange, visitNodes } from './markdown-links.mjs';
|
|
2
|
+
import { managedImageFilenamePattern } from './structured-blocks.mjs';
|
|
3
|
+
|
|
4
|
+
const filenamePattern = new RegExp(managedImageFilenamePattern);
|
|
5
|
+
|
|
6
|
+
// Public URLs and remote resources keep their existing rendering rules.
|
|
7
|
+
export const classifyMarkdownImage = (target) => {
|
|
8
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) return { kind: 'external' };
|
|
9
|
+
let decoded;
|
|
10
|
+
try { decoded = decodeURIComponent(target); } catch { return { kind: 'invalid' }; }
|
|
11
|
+
const image = decoded.replace(/^(?:\.\/)?images\//, '');
|
|
12
|
+
return filenamePattern.test(image) ? { kind: 'managed', image } : { kind: 'invalid' };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const getImageDefinitions = (tree) => {
|
|
16
|
+
const definitions = new Map();
|
|
17
|
+
visitNodes(tree, (node) => {
|
|
18
|
+
if (node.type === 'definition' && !definitions.has(node.identifier)) definitions.set(node.identifier, node);
|
|
19
|
+
});
|
|
20
|
+
return definitions;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const extractMarkdownImageReferences = ({ source, tree, lineOffset = 0 }) => {
|
|
24
|
+
const definitions = getImageDefinitions(tree);
|
|
25
|
+
const references = [];
|
|
26
|
+
visitNodes(tree, (node) => {
|
|
27
|
+
if (!['image', 'imageReference'].includes(node.type)) return;
|
|
28
|
+
const destination = node.type === 'image' ? node : definitions.get(node.identifier);
|
|
29
|
+
if (!destination) return;
|
|
30
|
+
const target = destination.url ?? '';
|
|
31
|
+
const classification = classifyMarkdownImage(target);
|
|
32
|
+
if (classification.kind === 'external') return;
|
|
33
|
+
const targetRange = node.type === 'image'
|
|
34
|
+
? getInlineLinkTargetRange(source, node)
|
|
35
|
+
: getDefinitionTargetRange(source, destination);
|
|
36
|
+
references.push({
|
|
37
|
+
...classification,
|
|
38
|
+
target,
|
|
39
|
+
alt: node.alt ?? '',
|
|
40
|
+
title: destination.title,
|
|
41
|
+
line: lineOffset + (node.position?.start.line ?? 1),
|
|
42
|
+
blockType: 'markdown-image',
|
|
43
|
+
blockDisplayType: 'Markdown image',
|
|
44
|
+
range: getNodeRange(node),
|
|
45
|
+
targetRange,
|
|
46
|
+
targetSource: targetRange ? source.slice(targetRange.start, targetRange.end) : target,
|
|
47
|
+
definitionSource: node.type === 'imageReference' ? source.slice(destination.position.start.offset, destination.position.end.offset) : undefined,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return references;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const markdownImageDiagnostic = (reference, label) => ({
|
|
54
|
+
code: 'invalid-markdown-image',
|
|
55
|
+
severity: 'error',
|
|
56
|
+
line: reference.line,
|
|
57
|
+
message: `${label} line ${reference.line}: Local Markdown image "${reference.target}" must identify a supported file in this page's images/ folder.`,
|
|
58
|
+
fix: 'Use images/name.jpg (or .jpeg, .png, .svg), with a filename containing letters, numbers, dots or hyphens. Norna also accepts ./images/name.jpg and the bare filename.',
|
|
59
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const getNodeRange = (node) => {
|
|
1
|
+
export const getNodeRange = (node) => {
|
|
2
2
|
const start = node.position?.start.offset;
|
|
3
3
|
const end = node.position?.end.offset;
|
|
4
4
|
return Number.isInteger(start) && Number.isInteger(end)
|
|
@@ -6,7 +6,7 @@ const getNodeRange = (node) => {
|
|
|
6
6
|
: null;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
const visitNodes = (node, visit) => {
|
|
9
|
+
export const visitNodes = (node, visit) => {
|
|
10
10
|
if (!node || typeof node !== 'object') return;
|
|
11
11
|
visit(node);
|
|
12
12
|
if (!Array.isArray(node.children)) return;
|
|
@@ -55,9 +55,28 @@ const scanDestination = (source, start, end) => {
|
|
|
55
55
|
return { start: targetStart, end: cursor };
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
const getInlineLinkTargetRange = (source, node) => {
|
|
58
|
+
export const getInlineLinkTargetRange = (source, node) => {
|
|
59
59
|
const nodeRange = getNodeRange(node);
|
|
60
60
|
if (!nodeRange) return null;
|
|
61
|
+
if (node.type === 'image') {
|
|
62
|
+
let depth = 1;
|
|
63
|
+
for (let cursor = nodeRange.start + 2; cursor < nodeRange.end; cursor += 1) {
|
|
64
|
+
if (source[cursor] === '\\') { cursor += 1; continue; }
|
|
65
|
+
if (source[cursor] === '`') {
|
|
66
|
+
const run = source.slice(cursor).match(/^`+/)[0];
|
|
67
|
+
const followingRuns = source.slice(cursor + run.length, nodeRange.end).matchAll(/`+/g);
|
|
68
|
+
const closing = [...followingRuns].find((match) => match[0].length === run.length);
|
|
69
|
+
if (closing) { cursor += run.length + closing.index + run.length - 1; continue; }
|
|
70
|
+
cursor += run.length - 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (source[cursor] === '[') depth += 1;
|
|
74
|
+
if (source[cursor] === ']' && --depth === 0) {
|
|
75
|
+
return source[cursor + 1] === '(' ? scanDestination(source, cursor + 2, nodeRange.end) : null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
61
80
|
const childEnd = Array.isArray(node.children)
|
|
62
81
|
? Math.max(nodeRange.start, ...node.children.map((child) => child.position?.end.offset ?? nodeRange.start))
|
|
63
82
|
: nodeRange.start;
|
|
@@ -66,7 +85,7 @@ const getInlineLinkTargetRange = (source, node) => {
|
|
|
66
85
|
return scanDestination(source, delimiter + 2, nodeRange.end);
|
|
67
86
|
};
|
|
68
87
|
|
|
69
|
-
const getDefinitionTargetRange = (source, node) => {
|
|
88
|
+
export const getDefinitionTargetRange = (source, node) => {
|
|
70
89
|
const nodeRange = getNodeRange(node);
|
|
71
90
|
if (!nodeRange) return null;
|
|
72
91
|
const delimiter = source.indexOf(']:', nodeRange.start);
|
|
@@ -82,16 +101,16 @@ export const extractMarkdownLinks = ({ source, tree, lineOffset = 0 }) => {
|
|
|
82
101
|
const definitions = new Map();
|
|
83
102
|
visitNodes(tree, (node) => {
|
|
84
103
|
if (node.type !== 'definition') return;
|
|
85
|
-
definitions.set(node.identifier, node);
|
|
104
|
+
if (!definitions.has(node.identifier)) definitions.set(node.identifier, node);
|
|
86
105
|
});
|
|
87
106
|
|
|
88
107
|
const links = [];
|
|
89
108
|
visitNodes(tree, (node) => {
|
|
90
|
-
if (node.type === 'link') {
|
|
109
|
+
if (node.type === 'link' || node.type === 'image' && /^(?:\/|https?:)/i.test(node.url ?? '')) {
|
|
91
110
|
const range = getNodeRange(node);
|
|
92
111
|
const targetRange = getInlineLinkTargetRange(source, node);
|
|
93
112
|
links.push({
|
|
94
|
-
kind: 'markdown-link',
|
|
113
|
+
kind: node.type === 'image' ? 'markdown-image-link' : 'markdown-link',
|
|
95
114
|
column: node.position?.start.column ?? 1,
|
|
96
115
|
line: lineOffset + (node.position?.start.line ?? 1),
|
|
97
116
|
range,
|
|
@@ -102,14 +121,15 @@ export const extractMarkdownLinks = ({ source, tree, lineOffset = 0 }) => {
|
|
|
102
121
|
return;
|
|
103
122
|
}
|
|
104
123
|
|
|
105
|
-
if (node.type
|
|
124
|
+
if (!['linkReference', 'imageReference'].includes(node.type)) return;
|
|
106
125
|
const definition = definitions.get(node.identifier);
|
|
107
|
-
if (!definition) return;
|
|
126
|
+
if (!definition || node.type === 'imageReference' && !/^(?:\/|https?:)/i.test(definition.url ?? '')) return;
|
|
108
127
|
const targetRange = getDefinitionTargetRange(source, definition);
|
|
109
128
|
links.push({
|
|
110
129
|
kind: 'markdown-reference-link',
|
|
111
130
|
column: node.position?.start.column ?? 1,
|
|
112
131
|
definitionLine: lineOffset + (definition.position?.start.line ?? 1),
|
|
132
|
+
definitionSource: source.slice(definition.position.start.offset, definition.position.end.offset),
|
|
113
133
|
line: lineOffset + (node.position?.start.line ?? 1),
|
|
114
134
|
range: getNodeRange(node),
|
|
115
135
|
target: definition.url ?? '',
|
|
@@ -17,24 +17,11 @@ const getListedNodes = (nodes) => nodes.filter((node) => node.isHome || node.lis
|
|
|
17
17
|
|
|
18
18
|
const getNodeDepth = (node) => node.depth ?? 1;
|
|
19
19
|
|
|
20
|
-
const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
|
|
21
|
-
|
|
22
|
-
const getActiveBranchNodes = (nodes, currentPage) => {
|
|
23
|
-
if (!currentPage) return [];
|
|
24
|
-
if (currentPage.isHome) return nodes.filter((node) => node.isHome);
|
|
25
|
-
|
|
26
|
-
const rootPath = getTopLevelPagePath(currentPage.pagePath);
|
|
27
|
-
return nodes.filter((node) => (
|
|
28
|
-
node.pagePath === rootPath
|
|
29
|
-
|| node.pagePath?.startsWith(`${rootPath}/`)
|
|
30
|
-
));
|
|
31
|
-
};
|
|
32
|
-
|
|
33
20
|
export const getAutomaticNavigationMode = (nodes) => {
|
|
34
21
|
const listedNodes = getListedNodes(nodes);
|
|
35
22
|
if (listedNodes.length <= 1) return 'sections';
|
|
36
23
|
|
|
37
|
-
return listedNodes.some((node) =>
|
|
24
|
+
return listedNodes.some((node) => getNodeDepth(node) > 1)
|
|
38
25
|
? 'tree'
|
|
39
26
|
: 'top';
|
|
40
27
|
};
|
|
@@ -42,10 +29,6 @@ export const getAutomaticNavigationMode = (nodes) => {
|
|
|
42
29
|
export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
|
|
43
30
|
const requestedMode = assertNavigationMode(mode);
|
|
44
31
|
const listedNodes = getListedNodes(nodes);
|
|
45
|
-
const hasCategories = listedNodes.some((node) => node.kind === 'category');
|
|
46
|
-
if (hasCategories && requestedMode !== 'automatic' && requestedMode !== 'tree') {
|
|
47
|
-
throw new Error(`Navigation categories require tree navigation. Remove navigation.mode: ${requestedMode}, or set navigation.mode: tree.`);
|
|
48
|
-
}
|
|
49
32
|
const maximumDepth = listedNodes.reduce((maximum, node) => (
|
|
50
33
|
Math.max(maximum, getNodeDepth(node))
|
|
51
34
|
), 1);
|
|
@@ -57,33 +40,8 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
|
|
|
57
40
|
: requestedMode,
|
|
58
41
|
requestedMode,
|
|
59
42
|
listedNodeCount: listedNodes.length,
|
|
60
|
-
hasCategories,
|
|
43
|
+
hasCategories: false,
|
|
61
44
|
hasNestedPages,
|
|
62
45
|
maximumDepth,
|
|
63
46
|
});
|
|
64
47
|
};
|
|
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
|
-
};
|
|
@@ -26,12 +26,11 @@ const getTreeMetrics = (root) => {
|
|
|
26
26
|
const nodes = flattenSiteNavigationTree([root]);
|
|
27
27
|
const rootDepth = root.node.depth;
|
|
28
28
|
return {
|
|
29
|
-
categoryCount: nodes.filter(({ node }) => node.kind === 'category').length,
|
|
30
29
|
maximumLevels: nodes.reduce((maximum, { node }) => (
|
|
31
30
|
Math.max(maximum, node.depth - rootDepth + 1)
|
|
32
31
|
), 1),
|
|
33
32
|
nodeCount: nodes.length,
|
|
34
|
-
pageCount: nodes.
|
|
33
|
+
pageCount: nodes.length,
|
|
35
34
|
};
|
|
36
35
|
};
|
|
37
36
|
|
|
@@ -58,7 +57,7 @@ const getSiblingGroups = (roots) => {
|
|
|
58
57
|
return groups;
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
const
|
|
60
|
+
const toGraphFinding = (diagnostic) => ({
|
|
62
61
|
code: diagnostic.code,
|
|
63
62
|
...(diagnostic.fix ? { fix: diagnostic.fix } : {}),
|
|
64
63
|
...(diagnostic.reference?.line ? { line: diagnostic.reference.line } : {}),
|
|
@@ -85,10 +84,8 @@ const getNavigationEntries = (siteStructure, linkGraph) => {
|
|
|
85
84
|
]));
|
|
86
85
|
|
|
87
86
|
return siteStructure.nodes.map((siteNode) => {
|
|
88
|
-
const page = siteNode.
|
|
89
|
-
|
|
90
|
-
: null;
|
|
91
|
-
if (siteNode.kind === 'page' && !page) {
|
|
87
|
+
const page = pagesByDirectory.get(siteNode.pageDirectory);
|
|
88
|
+
if (!page) {
|
|
92
89
|
throw new Error(`Navigation review could not find parsed content for ${siteNode.contentLabel}.`);
|
|
93
90
|
}
|
|
94
91
|
|
|
@@ -99,8 +96,8 @@ const getNavigationEntries = (siteStructure, linkGraph) => {
|
|
|
99
96
|
navigation: {
|
|
100
97
|
listed: siteNode.isHome || (page?.navigation.listed ?? true),
|
|
101
98
|
},
|
|
102
|
-
pathname:
|
|
103
|
-
title:
|
|
99
|
+
pathname: page.pathname,
|
|
100
|
+
title: page.title,
|
|
104
101
|
},
|
|
105
102
|
page,
|
|
106
103
|
sections: [],
|
|
@@ -121,7 +118,7 @@ const getNavigationModes = ({ entries, listedEntries, requestedNavigationMode })
|
|
|
121
118
|
const modes = new Map();
|
|
122
119
|
const seenErrors = new Set();
|
|
123
120
|
|
|
124
|
-
for (const entry of entries
|
|
121
|
+
for (const entry of entries) {
|
|
125
122
|
try {
|
|
126
123
|
const model = resolveNavigationModel({
|
|
127
124
|
currentPage: {
|
|
@@ -173,9 +170,7 @@ export const createNavigationReview = ({
|
|
|
173
170
|
requestedNavigationMode,
|
|
174
171
|
});
|
|
175
172
|
|
|
176
|
-
const pages = completeEntries
|
|
177
|
-
.filter(({ node }) => node.kind === 'page')
|
|
178
|
-
.map(({ headings, node, page }) => {
|
|
173
|
+
const pages = completeEntries.map(({ headings, node, page }) => {
|
|
179
174
|
const incomingPageLinkCount = resolvedPageReferences.filter(({ resolution }) => (
|
|
180
175
|
resolution.page.pathname === page.pathname
|
|
181
176
|
)).length;
|
|
@@ -200,21 +195,6 @@ export const createNavigationReview = ({
|
|
|
200
195
|
};
|
|
201
196
|
});
|
|
202
197
|
|
|
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
198
|
const branches = listedTree.map((root) => {
|
|
219
199
|
const metrics = getTreeMetrics(root);
|
|
220
200
|
const branchPaths = new Set(flattenSiteNavigationTree([root]).map(({ node }) => node.pagePath));
|
|
@@ -235,7 +215,8 @@ export const createNavigationReview = ({
|
|
|
235
215
|
const siblingGroups = getSiblingGroups(listedTree);
|
|
236
216
|
const widestSiblingCount = siblingGroups.reduce((maximum, group) => Math.max(maximum, group.count), 0);
|
|
237
217
|
const observations = [
|
|
238
|
-
...siteStructure.warnings.map(toStructureObservation),
|
|
218
|
+
...siteStructure.warnings.filter(({ code }) => code !== 'empty-category').map(toStructureObservation),
|
|
219
|
+
...linkGraph.diagnostics.filter(({ severity }) => severity === 'warning').map(toGraphFinding),
|
|
239
220
|
];
|
|
240
221
|
const unlistedPages = pages.filter(({ listed }) => !listed);
|
|
241
222
|
if (unlistedPages.length > 0) {
|
|
@@ -246,13 +227,6 @@ export const createNavigationReview = ({
|
|
|
246
227
|
}
|
|
247
228
|
|
|
248
229
|
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
230
|
for (const branch of branches.filter(({ maximumLevels }) => maximumLevels >= thresholds.deepBranchLevels)) {
|
|
257
231
|
recommendations.push({
|
|
258
232
|
code: 'deep-branch',
|
|
@@ -279,7 +253,7 @@ export const createNavigationReview = ({
|
|
|
279
253
|
|
|
280
254
|
return {
|
|
281
255
|
command: 'navigation:review',
|
|
282
|
-
schemaVersion:
|
|
256
|
+
schemaVersion: 2,
|
|
283
257
|
thresholds: {
|
|
284
258
|
deepBranchLevels: thresholds.deepBranchLevels,
|
|
285
259
|
sectionCount: thresholds.sectionCount,
|
|
@@ -287,10 +261,8 @@ export const createNavigationReview = ({
|
|
|
287
261
|
},
|
|
288
262
|
site: {
|
|
289
263
|
branchCount: branches.length,
|
|
290
|
-
categoryCount: categories.length,
|
|
291
264
|
effectiveNavigationModes,
|
|
292
265
|
internalReferenceCount: linkGraph.references.length,
|
|
293
|
-
listedCategoryCount: categories.filter(({ listed }) => listed).length,
|
|
294
266
|
listedPageCount: pages.filter(({ listed }) => listed).length,
|
|
295
267
|
maximumDepth: listedEntries.reduce((maximum, { node }) => Math.max(maximum, node.depth), 0),
|
|
296
268
|
pageCount: pages.length,
|
|
@@ -301,9 +273,8 @@ export const createNavigationReview = ({
|
|
|
301
273
|
branches,
|
|
302
274
|
siblingGroups,
|
|
303
275
|
pages,
|
|
304
|
-
categories,
|
|
305
276
|
errors: [
|
|
306
|
-
...linkGraph.diagnostics.map(
|
|
277
|
+
...linkGraph.diagnostics.filter(({ severity }) => severity === 'error').map(toGraphFinding),
|
|
307
278
|
...navigationErrors,
|
|
308
279
|
],
|
|
309
280
|
observations,
|
|
@@ -330,13 +301,13 @@ export const formatNavigationReviewText = (review) => {
|
|
|
330
301
|
'',
|
|
331
302
|
'Site',
|
|
332
303
|
`- Navigation: ${review.site.requestedNavigationMode} configured; ${review.site.effectiveNavigationModes.join(', ') || 'unresolved'} effective`,
|
|
333
|
-
`- Content: ${plural(review.site.pageCount, 'page')} (${review.site.listedPageCount} listed)
|
|
304
|
+
`- Content: ${plural(review.site.pageCount, 'page')} (${review.site.listedPageCount} listed)`,
|
|
334
305
|
`- 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
306
|
`- Links: ${plural(review.site.internalReferenceCount, 'internal reference')}, ${review.site.resolvedPageLinkCount} resolved to pages`,
|
|
336
307
|
'',
|
|
337
308
|
'Branches',
|
|
338
309
|
...review.branches.map((branch) => (
|
|
339
|
-
`- ${branch.title} (${branch.path}; ${branch.kind}): ${plural(branch.pageCount, 'page')}, ${plural(branch.
|
|
310
|
+
`- ${branch.title} (${branch.path}; ${branch.kind}): ${plural(branch.pageCount, 'page')}, ${plural(branch.maximumLevels, 'visible level')}; navigation ${branch.navigationModes.join(', ') || 'unresolved'}`
|
|
340
311
|
)),
|
|
341
312
|
'',
|
|
342
313
|
'Pages',
|
|
@@ -344,13 +315,6 @@ export const formatNavigationReviewText = (review) => {
|
|
|
344
315
|
`- ${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
316
|
)),
|
|
346
317
|
'',
|
|
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
318
|
...formatFindingSection('Errors', review.errors),
|
|
355
319
|
'',
|
|
356
320
|
...formatFindingSection('Observations', review.observations),
|
|
@@ -56,7 +56,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
|
56
56
|
xl: value('Extra large', 'Use the largest card and image size.'),
|
|
57
57
|
}),
|
|
58
58
|
}),
|
|
59
|
-
width: field('Override the
|
|
59
|
+
width: field('Override the shared theme\'s maximum width for this card list.', {
|
|
60
60
|
values: Object.freeze({
|
|
61
61
|
text: value('Text', 'Match the active body-text width, including a reader-selected reading width.'),
|
|
62
62
|
narrow: value('Narrow', 'Limit the complete card list to at most 48rem.'),
|
|
@@ -534,63 +534,6 @@ export const splitNornaRenderedBlocks = (html, blocks) => {
|
|
|
534
534
|
return result.filter((block) => block.type !== 'html' || block.html.trim());
|
|
535
535
|
};
|
|
536
536
|
|
|
537
|
-
const maskFencedCodeBlocks = (markdown) => {
|
|
538
|
-
const lines = normalizeLines(markdown);
|
|
539
|
-
const maskedLines = [];
|
|
540
|
-
let index = 0;
|
|
541
|
-
|
|
542
|
-
while (index < lines.length) {
|
|
543
|
-
const fence = getFenceInfo(lines[index]);
|
|
544
|
-
if (!fence || fence.length < 3) {
|
|
545
|
-
maskedLines.push(lines[index]);
|
|
546
|
-
index += 1;
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
let closingIndex = -1;
|
|
551
|
-
for (let candidateIndex = index + 1; candidateIndex < lines.length; candidateIndex += 1) {
|
|
552
|
-
if (getFenceCloseInfo(lines[candidateIndex], fence)) {
|
|
553
|
-
closingIndex = candidateIndex;
|
|
554
|
-
break;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (closingIndex === -1) {
|
|
559
|
-
maskedLines.push(lines[index]);
|
|
560
|
-
index += 1;
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
for (let maskedIndex = index; maskedIndex <= closingIndex; maskedIndex += 1) {
|
|
565
|
-
maskedLines.push(lines[maskedIndex].replace(/[^\r\n]/g, ' '));
|
|
566
|
-
}
|
|
567
|
-
index = closingIndex + 1;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
return maskedLines.join('\n');
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
const isExternalImageTarget = (target) => /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//');
|
|
574
|
-
const isRootRelativePublicTarget = (target) => target.startsWith('/');
|
|
575
|
-
|
|
576
|
-
export const extractMarkdownImageReferences = (markdown) => {
|
|
577
|
-
const masked = maskFencedCodeBlocks(markdown);
|
|
578
|
-
const references = [];
|
|
579
|
-
const imageRegex = /!\[[^\]\n]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
580
|
-
|
|
581
|
-
for (const match of masked.matchAll(imageRegex)) {
|
|
582
|
-
const target = match[1] ?? '';
|
|
583
|
-
if (!target || target.startsWith('#') || isExternalImageTarget(target) || isRootRelativePublicTarget(target)) continue;
|
|
584
|
-
|
|
585
|
-
references.push({
|
|
586
|
-
target,
|
|
587
|
-
line: getLineNumber(markdown, match.index ?? 0),
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return references;
|
|
592
|
-
};
|
|
593
|
-
|
|
594
537
|
export const getNornaBlockImageReferences = (blocks) =>
|
|
595
538
|
blocks.flatMap((block) => {
|
|
596
539
|
if (block.type === 'card-list') {
|
|
@@ -3,6 +3,26 @@ import { nornaBlockTypes } from './norna-markdown-blocks.mjs';
|
|
|
3
3
|
import projectConfig from './project-config.mjs';
|
|
4
4
|
import { getSemanticCalloutMarker } from './semantic-callouts.mjs';
|
|
5
5
|
import { applyTableRowHeaderDeclaration } from './table-row-headers.mjs';
|
|
6
|
+
import { classifyMarkdownImage, getImageDefinitions } from './markdown-images.mjs';
|
|
7
|
+
|
|
8
|
+
const escapeAttribute = (value) => String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
9
|
+
const markManagedImage = (node, context) => {
|
|
10
|
+
let root = node;
|
|
11
|
+
while (context.parent(root)) root = context.parent(root);
|
|
12
|
+
const destination = node.type === 'image' ? node : getImageDefinitions(root).get(node.identifier);
|
|
13
|
+
if (!destination) return;
|
|
14
|
+
const result = classifyMarkdownImage(destination.url ?? '');
|
|
15
|
+
if (result.kind === 'external') return;
|
|
16
|
+
if (result.kind === 'invalid') throw new Error(`Local Markdown image "${destination.url}" must reference a .jpg, .jpeg, .png or .svg file directly in this page's images/ folder.`);
|
|
17
|
+
const title = destination.title == null ? '' : ` title="${escapeAttribute(destination.title)}"`;
|
|
18
|
+
// Raw HTML keeps Astro's independent image importer from processing these
|
|
19
|
+
// sources. The page renderer resolves this marker through Norna's manifest.
|
|
20
|
+
context.replaceNode(node, { type: 'html', value: `<img data-norna-managed-image="${escapeAttribute(result.image)}" alt="${escapeAttribute(node.alt ?? '')}"${title}>` });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const nornaMarkdownImagePlugin = defineMdastPlugin({
|
|
24
|
+
name: 'norna-markdown-images', image: markManagedImage, imageReference: markManagedImage,
|
|
25
|
+
});
|
|
6
26
|
|
|
7
27
|
const stateKey = 'nornaMarkdownRender';
|
|
8
28
|
|
|
@@ -33,6 +53,8 @@ const hasBlockquoteAncestor = (node, context) => {
|
|
|
33
53
|
|
|
34
54
|
export const nornaMarkdownRenderPlugin = defineMdastPlugin({
|
|
35
55
|
name: 'norna-markdown-render',
|
|
56
|
+
image: markManagedImage,
|
|
57
|
+
imageReference: markManagedImage,
|
|
36
58
|
blockquote(node, context) {
|
|
37
59
|
const marker = getSemanticCalloutMarker(node);
|
|
38
60
|
if (
|
|
@@ -88,6 +110,13 @@ export const nornaMarkdownRenderPlugin = defineMdastPlugin({
|
|
|
88
110
|
});
|
|
89
111
|
},
|
|
90
112
|
heading(node, context) {
|
|
113
|
+
if (node.depth === 3) {
|
|
114
|
+
if (context.parent(node)?.type !== 'root') return;
|
|
115
|
+
context.setProperty(node, 'data', {
|
|
116
|
+
hProperties: { 'data-norna-page-heading': 'true' },
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
91
120
|
if (node.depth !== 1 && node.depth !== 2) return;
|
|
92
121
|
const state = getState(context);
|
|
93
122
|
const regionIndex = state.regionIndex;
|
|
@@ -2,6 +2,11 @@ import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
|
2
2
|
import { sitemapFilename } from './sitemap.mjs';
|
|
3
3
|
|
|
4
4
|
export const getGeneratedSiteRoutes = ({ searchEnabled = false } = {}) => Object.freeze([
|
|
5
|
+
Object.freeze({
|
|
6
|
+
kind: 'generated-route',
|
|
7
|
+
label: 'Norna generated 404 page',
|
|
8
|
+
pathname: '/404.html',
|
|
9
|
+
}),
|
|
5
10
|
Object.freeze({
|
|
6
11
|
kind: 'generated-route',
|
|
7
12
|
label: `Norna generated ${sitemapFilename}`,
|
|
@@ -154,7 +159,9 @@ export const createPageAliasModel = ({
|
|
|
154
159
|
contentFile,
|
|
155
160
|
fix: conflict.kind === 'page-alias'
|
|
156
161
|
? 'Keep the old URL on exactly one current page.'
|
|
157
|
-
:
|
|
162
|
+
: conflict.kind === 'page'
|
|
163
|
+
? `Rename the page at ${conflict.label}, or remove ${pathname} from the previous addresses in ${contentLabel}. Removing it may break old links.`
|
|
164
|
+
: 'Remove or change the alias. A public URL can identify only one site resource.',
|
|
158
165
|
message: `Page alias "${pathname}" in ${contentLabel} conflicts with ${describeIdentity(conflict)}.`,
|
|
159
166
|
severity: 'error',
|
|
160
167
|
});
|