@janga/norna 0.7.27 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -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: 'Go to the homepage',
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: `/${entry}`,
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 !== 'linkReference') return;
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) => node.kind === 'category' || getNodeDepth(node) > 1)
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.filter(({ node }) => node.kind === 'page').length,
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 toGraphError = (diagnostic) => ({
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.kind === 'page'
89
- ? pagesByDirectory.get(siteNode.pageDirectory)
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: siteNode.kind === 'page' ? page.pathname : null,
103
- title: siteNode.kind === 'page' ? page.title : siteNode.label,
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.filter(({ node }) => node.kind === 'page')) {
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: 1,
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(toGraphError),
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), ${plural(review.site.categoryCount, 'category', 'categories')} (${review.site.listedCategoryCount} 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.categoryCount, 'category', 'categories')}, ${plural(branch.maximumLevels, 'visible level')}; navigation ${branch.navigationModes.join(', ') || 'unresolved'}`
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 root theme\'s maximum width for this card list.', {
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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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
- : 'Remove or change the alias. A public URL can identify only one site resource.',
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
  });