@janga/norna 0.7.27 → 0.7.28

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 (109) hide show
  1. package/README.md +37 -28
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +9 -5
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +66 -66
  7. package/schemas/content-frontmatter.schema.json +16 -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 +13 -161
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +17 -10
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +7 -6
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/lib/category-destinations.mjs +13 -12
  21. package/scripts/lib/documentation-links.mjs +1 -1
  22. package/scripts/lib/documentation-routes.json +67 -60
  23. package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
  24. package/scripts/lib/editor-file-policy.mjs +18 -0
  25. package/scripts/lib/editor-language-service.mjs +53 -55
  26. package/scripts/lib/editor-page-addresses.mjs +55 -0
  27. package/scripts/lib/editor-page-placement.mjs +172 -0
  28. package/scripts/lib/editor-site-editing-tree.mjs +146 -0
  29. package/scripts/lib/editor-site-files.mjs +153 -0
  30. package/scripts/lib/editor-site-links.mjs +79 -0
  31. package/scripts/lib/editor-site-tree.mjs +151 -0
  32. package/scripts/lib/editor-source-files.mjs +87 -0
  33. package/scripts/lib/editor-theme-help.mjs +43 -0
  34. package/scripts/lib/locale-registry.mjs +1 -1
  35. package/scripts/lib/locales/en.mjs +1 -1
  36. package/scripts/lib/navigation-model.mjs +2 -44
  37. package/scripts/lib/navigation-review.mjs +14 -50
  38. package/scripts/lib/norna-markdown-blocks.mjs +1 -1
  39. package/scripts/lib/page-aliases.mjs +3 -1
  40. package/scripts/lib/page-model.mjs +17 -20
  41. package/scripts/lib/page-move-apply.mjs +117 -0
  42. package/scripts/lib/page-move-plan.mjs +27 -59
  43. package/scripts/lib/presentation.mjs +3 -3
  44. package/scripts/lib/project-config.mjs +7 -8
  45. package/scripts/lib/schema-definitions.mjs +11 -38
  46. package/scripts/lib/schema-editor-metadata.mjs +12 -59
  47. package/scripts/lib/schema-value-definitions.mjs +1 -1
  48. package/scripts/lib/site-content.mjs +7 -24
  49. package/scripts/lib/site-conventions.mjs +19 -1
  50. package/scripts/lib/site-navigation-tree.mjs +2 -7
  51. package/scripts/lib/site-node-create.mjs +169 -0
  52. package/scripts/lib/site-paths.mjs +22 -10
  53. package/scripts/lib/site-structure.mjs +72 -82
  54. package/scripts/lib/site-upgrade.mjs +30 -0
  55. package/scripts/lib/source-files.mjs +34 -0
  56. package/scripts/lib/theme-config.mjs +26 -68
  57. package/scripts/lib/theme-packages.mjs +47 -0
  58. package/scripts/lib/theme-presets.mjs +20 -53
  59. package/scripts/lib/typography.mjs +2 -2
  60. package/scripts/lib/yaml-config.mjs +1 -1
  61. package/scripts/lib/yaml-source-edit.mjs +94 -0
  62. package/scripts/move-site-page.mjs +3 -124
  63. package/scripts/show-typography.mjs +32 -17
  64. package/scripts/sync-content-sections.mjs +9 -7
  65. package/scripts/sync-site-public.mjs +1 -1
  66. package/scripts/upgrade-site.mjs +17 -0
  67. package/src/components/AreaNavigationMenu.astro +24 -0
  68. package/src/components/AreaNavigationPanel.astro +55 -0
  69. package/src/components/AreaNavigationSwitcher.astro +30 -0
  70. package/src/components/ImageStackEnhancement.astro +0 -7
  71. package/src/components/NavigationContinuity.astro +26 -0
  72. package/src/components/NavigationPageTree.astro +13 -4
  73. package/src/components/NavigationState.astro +11 -0
  74. package/src/components/NavigationTreeControls.astro +3 -2
  75. package/src/components/SearchPage.astro +9 -1
  76. package/src/components/SectionNavigationScript.astro +106 -26
  77. package/src/components/SiteNavigation.astro +45 -20
  78. package/src/components/SitePage.astro +37 -61
  79. package/src/components/SiteTreeNavigation.astro +38 -18
  80. package/src/components/TopPageMenu.astro +4 -3
  81. package/src/components/TreeNavigationScript.astro +50 -57
  82. package/src/content.config.ts +10 -8
  83. package/src/layouts/BaseLayout.astro +15 -39
  84. package/src/lib/areaNavigation.ts +42 -0
  85. package/src/lib/navigationContinuity.ts +212 -0
  86. package/src/lib/navigationFollowing.ts +28 -18
  87. package/src/lib/navigationState.mjs +88 -0
  88. package/src/lib/navigationStateScript.mjs +76 -0
  89. package/src/lib/navigationVisibility.ts +9 -0
  90. package/src/lib/pageThemes.ts +5 -42
  91. package/src/lib/sectionContent.ts +3 -2
  92. package/src/lib/siteNavigation.ts +6 -0
  93. package/src/lib/sitePages.ts +9 -6
  94. package/src/pages/404.astro +1 -1
  95. package/src/pages/[...slug].astro +5 -15
  96. package/src/pages/index.astro +1 -3
  97. package/src/styles/area-navigation.css +399 -0
  98. package/src/styles/content.css +2 -29
  99. package/src/styles/navigation-continuity.css +163 -0
  100. package/src/styles/page-layout.css +57 -68
  101. package/src/styles/responsive.css +0 -4
  102. package/starters/basic/README.md +9 -8
  103. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  104. package/schemas/category.schema.json +0 -20
  105. package/src/components/PageContentsNavigation.astro +0 -20
  106. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  107. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  108. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  109. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -0,0 +1,151 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getMarkdownHeadings, slugifyAsciiIdentifier } from './heading-ids.mjs';
4
+ import { parsePageDirectoryPath } from './page-model.mjs';
5
+ import { splitPageMarkdownSource } from './page-markdown.mjs';
6
+ import { siteSchema } from './schema-definitions.mjs';
7
+ import { getSiteSourcePaths, homePageDirectory } from './site-conventions.mjs';
8
+ import { createSiteNode, escapeMarkdownHeading, planSiteNodeCreation } from './site-node-create.mjs';
9
+ import { getSiteStructure } from './site-structure.mjs';
10
+ import { parseYamlConfig } from './yaml-config.mjs';
11
+ import { changeYamlField } from './yaml-source-edit.mjs';
12
+ import { readEditorLinkState } from './editor-site-links.mjs';
13
+ import { readSiteEditingTree } from './editor-site-editing-tree.mjs';
14
+
15
+ // Optional capability: older engines keep IntelliSense without exposing writes
16
+ // through a site-tree API whose contract they do not implement.
17
+ export const siteTreeApiVersion = 1;
18
+ export const siteFileTreeApiVersion = 1;
19
+ export const sitePageFormApiVersion = 1;
20
+ export { siteTreeEditingApiVersion, getEditorSourceFileChoices, planEditorSourceFileCreation, createEditorSourceFile } from './editor-source-files.mjs';
21
+ export { createSiteNode, planSiteNodeCreation, slugifyAsciiIdentifier };
22
+ export { siteFileOperationsApiVersion, siteRemovalApiVersion, planEditorImageCopy, planEditorRemoval, getEditorImageUsage, createEditorImageAppend, createEditorImageBatchAppend } from './editor-site-files.mjs';
23
+ export { getEditorIncomingLinks } from './editor-site-links.mjs';
24
+ export { siteAddressApiVersion, getEditorPageAddresses, planEditorPageAddress, applyEditorPageAddress } from './editor-page-addresses.mjs';
25
+ export { sitePagePlacementApiVersion, planEditorPagePlacement, applyEditorPagePlacement } from './editor-page-placement.mjs';
26
+
27
+ const sourceParts = (source, kind) => {
28
+ const split = splitPageMarkdownSource(source);
29
+ if (split.frontmatterUnclosed) throw new Error('Close the YAML frontmatter with --- before editing page information.');
30
+ const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
31
+ const bodyLine = split.lineOffset;
32
+ return {
33
+ yaml: bodyLine ? lines.slice(1, bodyLine - 1).join('') : '',
34
+ offset: bodyLine ? lines[0].length : 0,
35
+ body: split.body,
36
+ bodyLine,
37
+ };
38
+ };
39
+
40
+ const parseInformation = async ({ source, kind, isHome, sourcePath }) => {
41
+ const parts = sourceParts(source, kind);
42
+ const data = parseYamlConfig(parts.yaml, sourcePath, { schema: siteSchema });
43
+ const { headings } = await getMarkdownHeadings(parts.body);
44
+ const titles = headings.filter(({ depth }) => depth === 1);
45
+ if (titles.length !== 1) throw new Error('Keep exactly one Markdown H1 title in this page before editing its information.');
46
+ if (isHome && data.navigation?.listed === false) throw new Error('Home must remain listed. Remove navigation.listed: false.');
47
+ return {
48
+ title: titles[0].title,
49
+ description: data.page?.description ?? '',
50
+ listed: data.navigation?.listed !== false,
51
+ aliases: data.page?.aliases ?? [],
52
+ listChildren: data.page?.listChildren === true,
53
+ titleLine: parts.bodyLine + titles[0].line - 1,
54
+ titleLineCount: titles[0].source.split('\n').length,
55
+ };
56
+ };
57
+
58
+ export const getSiteNodeInformation = async (input) => {
59
+ try {
60
+ return { ...await parseInformation(input), problem: null };
61
+ } catch (error) {
62
+ return { title: input.fallbackTitle ?? path.basename(path.dirname(input.sourcePath)),
63
+ description: '', listed: true, aliases: [], problem: error.message };
64
+ }
65
+ };
66
+
67
+ export const readSiteTree = async ({ siteRoot, sources = new Map(), cache = new Map() }) => {
68
+ const readSource = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
69
+ const structure = await getSiteStructure({ siteRoot, tolerant: true, readSource });
70
+ const nodes = [];
71
+ const used = new Set();
72
+ for (const node of structure.nodes) {
73
+ const sourcePath = node.contentPath ?? node.categoryPath;
74
+ used.add(sourcePath);
75
+ let information;
76
+ try {
77
+ const source = await readSource(sourcePath);
78
+ const previous = cache.get(sourcePath);
79
+ information = previous?.source === source ? previous.information : await getSiteNodeInformation({
80
+ kind: node.kind, isHome: node.isHome, source, sourcePath, fallbackTitle: node.label ?? node.pageId,
81
+ });
82
+ cache.set(sourcePath, { source, information });
83
+ } catch (error) {
84
+ information = { title: node.label ?? node.pageId, listed: true, aliases: [], problem: error.message };
85
+ }
86
+ nodes.push({ ...node, ...information, sourcePath, url: node.isHome ? '/' : `/${node.pagePath}/`,
87
+ problem: information.problem ?? node.problems?.[0] ?? null });
88
+ }
89
+ for (const filename of cache.keys()) if (!used.has(filename)) cache.delete(filename);
90
+ return { nodes, problems: structure.problems };
91
+ };
92
+
93
+ // One physical projection preserves resources and repairable entries even when
94
+ // the logical structure cannot be read. The logical page API remains separate.
95
+ export const readSiteFileTree = async (options) => {
96
+ const siteRoot = path.resolve(options.siteRoot);
97
+ const snapshot = await readSiteTree({ ...options, siteRoot }).catch((error) => ({
98
+ nodes: [], problems: [{ path: siteRoot, message: error.message }],
99
+ }));
100
+ return readSiteEditingTree({ ...options, siteRoot, snapshot, getInformation: getSiteNodeInformation });
101
+ };
102
+
103
+ export const editSiteNodeInformation = async ({ siteRoot, sourcePath, source, field, value, sources = new Map() }) => {
104
+ const relative = path.relative(getSiteSourcePaths(siteRoot).pages, sourcePath).split(path.sep).join('/');
105
+ const filename = path.posix.basename(relative);
106
+ if (filename !== 'content.md') throw new Error('Choose a page content.md source file.');
107
+ const pageDirectory = path.resolve(sourcePath) === getSiteSourcePaths(path.resolve(siteRoot)).content
108
+ ? homePageDirectory : path.posix.dirname(relative);
109
+ parsePageDirectoryPath(pageDirectory);
110
+ const kind = 'page';
111
+ const isHome = pageDirectory === homePageDirectory;
112
+ const info = await parseInformation({ source, kind, isHome, sourcePath });
113
+ const allowed = ['title', 'description', 'listed', 'aliases', 'listChildren'];
114
+ if (!allowed.includes(field)) throw new Error('This information field is read-only.');
115
+ if (field === 'aliases') {
116
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new Error('Enter additional addresses as site-relative paths, such as /old-guide/.');
117
+ } else if (field === 'listed' || field === 'listChildren') {
118
+ if (typeof value !== 'boolean') throw new Error(field === 'listed' ? 'Choose whether this page is listed in navigation.' : 'Choose whether to append the list of child pages.');
119
+ if (field === 'listed' && isHome && !value) throw new Error('Home must remain listed.');
120
+ } else {
121
+ if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new Error('Use a single line of text.');
122
+ value = value.trim();
123
+ if (field === 'title' && !value) throw new Error('Enter a title.');
124
+ }
125
+ if (info[field] === value) return [];
126
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
127
+ if (field === 'title') {
128
+ const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
129
+ const start = lines.slice(0, info.titleLine).join('').length;
130
+ const original = lines.slice(info.titleLine, info.titleLine + info.titleLineCount).join('');
131
+ return [{ start, end: start + original.replace(/\r?\n$/, '').length, text: `# ${escapeMarkdownHeading(value)}` }];
132
+ }
133
+ const parts = sourceParts(source, kind);
134
+ const keys = field === 'listed' ? ['navigation', 'listed'] : ['page', field === 'aliases' ? 'aliases' : field === 'listChildren' ? 'listChildren' : 'description'];
135
+ const yaml = changeYamlField(parts.yaml, keys, (field === 'description' && !value) || (field === 'aliases' && !value.length) || (field === 'listChildren' && !value) ? undefined : value, eol);
136
+ const next = parts.bodyLine ? !yaml.trim() ? source.slice(parts.offset + parts.yaml.length).replace(/^---[ \t]*(?:\r?\n|$)/, '')
137
+ : source.slice(0, parts.offset) + yaml + source.slice(parts.offset + parts.yaml.length)
138
+ : `---${eol}${yaml}---${eol}${eol}${source}`;
139
+ // Validate the complete result before offering it to the editor.
140
+ await parseInformation({ source: next, kind, isHome, sourcePath });
141
+ if (field === 'aliases') {
142
+ const state = await readEditorLinkState({ siteRoot, sources: new Map([...sources, [sourcePath, next]]) });
143
+ if (state.incomplete.length) throw new Error(`Additional addresses could not be checked. ${state.incomplete.join('\n')}`);
144
+ }
145
+ let start = 0;
146
+ while (start < source.length && start < next.length && source[start] === next[start]) start++;
147
+ let end = source.length;
148
+ let nextEnd = next.length;
149
+ while (end > start && nextEnd > start && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
150
+ return [{ start, end, text: next.slice(start, nextEnd) }];
151
+ };
@@ -0,0 +1,87 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
2
+ import { lstat, mkdir, open, rmdir, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getSourcePageLocation, getSourceFileDefinition, sourceFileDefinitions } from './source-files.mjs';
5
+ import { parseYamlConfig } from './yaml-config.mjs';
6
+ import { checkedPath } from './editor-site-files.mjs';
7
+ import { escapeMarkdownHeading } from './site-node-create.mjs';
8
+
9
+ export const siteTreeEditingApiVersion = 1;
10
+ const stat = (filename) => lstat(filename).catch((error) => {
11
+ if (error.code === 'ENOENT') return null;
12
+ throw error;
13
+ });
14
+
15
+ export const editorPageLocation = getSourcePageLocation;
16
+ export const editorSourceDefinition = getSourceFileDefinition;
17
+
18
+ export const getEditorSourceFileChoices = async ({ siteRoot, directory }) => {
19
+ siteRoot = path.resolve(siteRoot);
20
+ directory = await checkedPath(siteRoot, directory);
21
+ const location = editorPageLocation(siteRoot, directory);
22
+ const directoryInfo = await stat(directory);
23
+ if ((!location && directory !== siteRoot) || (!directoryInfo?.isDirectory() && !(location?.isHome && !directoryInfo))) {
24
+ throw new Error('Select an existing page directory at a permitted location in this site.');
25
+ }
26
+ const content = await stat(path.join(directory, 'content.md'));
27
+ const names = location ? sourceFileDefinitions.filter((entry) => entry.location === 'page' && (entry.input === 'title' ? !content : content?.isFile() || location.isHome)).map((entry) => entry.name) : [];
28
+ if (directory === siteRoot || location?.isHome) {
29
+ names.push(...sourceFileDefinitions.filter((entry) => entry.location === 'site-config').map((entry) => `site-config/${entry.name}`));
30
+ }
31
+ if (directory === siteRoot) names.push('root/content.md', 'root/tree-theme.yaml');
32
+ const choices = [];
33
+ for (const name of names) {
34
+ const filename = path.join(name.startsWith('site-config/') || name.startsWith('root/') ? siteRoot : directory, name);
35
+ if (await stat(filename)) continue;
36
+ // A file or symlink occupying site-config must be repaired, not replaced.
37
+ const parent = await stat(path.dirname(filename));
38
+ if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) continue;
39
+ const definition = editorSourceDefinition(siteRoot, filename);
40
+ choices.push({ filename, name, description: definition.description, input: definition.input,
41
+ required: Boolean(definition.required), effect: definition.description });
42
+ }
43
+ return choices;
44
+ };
45
+
46
+ export const planEditorSourceFileCreation = async ({ siteRoot, directory, filename, value }) => {
47
+ siteRoot = path.resolve(siteRoot);
48
+ const choices = await getEditorSourceFileChoices({ siteRoot, directory });
49
+ const choice = choices.find((entry) => entry.filename === filename);
50
+ if (!choice) throw new Error('This file already exists or is not allowed here. Refresh Site Tree and review Add again.');
51
+ await checkedPath(siteRoot, filename);
52
+ const definition = editorSourceDefinition(siteRoot, filename);
53
+ let text = definition.text;
54
+ if (definition.input) {
55
+ if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) throw new Error('Enter a non-empty, single line of text.');
56
+ text = definition.input === 'title' ? `# ${escapeMarkdownHeading(value.trim())}\n`
57
+ : `${definition.input}: ${JSON.stringify(value.trim())}\n`;
58
+ }
59
+ if (definition.schema) parseYamlConfig(text, filename, { schema: definition.schema });
60
+ const identity = async (location) => { const entry = await stat(location); return entry ? [entry.dev, entry.ino] : null; };
61
+ return { siteRoot, directory: path.resolve(directory), filename, value, text,
62
+ fingerprint: JSON.stringify([await identity(siteRoot), await identity(directory), filename, text]) };
63
+ };
64
+
65
+ export const createEditorSourceFile = async (plan) => {
66
+ const current = await planEditorSourceFileCreation(plan);
67
+ if (plan.fingerprint !== current.fingerprint) throw new Error('The selected directory changed. Review Add again.');
68
+ const parent = path.dirname(current.filename);
69
+ let createdDirectory = false;
70
+ let handle;
71
+ try {
72
+ if (!await stat(parent)) { await mkdir(parent); createdDirectory = true; }
73
+ await checkedPath(current.siteRoot, current.filename);
74
+ handle = await open(current.filename, 'wx');
75
+ await handle.writeFile(current.text, 'utf8');
76
+ } catch (error) {
77
+ if (handle) {
78
+ const owned = await handle.stat();
79
+ await handle.close(); handle = null;
80
+ const existing = await stat(current.filename);
81
+ if (existing?.ino === owned.ino && existing?.dev === owned.dev) await unlink(current.filename);
82
+ }
83
+ if (createdDirectory) await rmdir(parent).catch(() => {});
84
+ throw error;
85
+ } finally { await handle?.close(); }
86
+ return { sourcePath: current.filename };
87
+ };
@@ -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.',
@@ -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.'),
@@ -154,7 +154,9 @@ export const createPageAliasModel = ({
154
154
  contentFile,
155
155
  fix: conflict.kind === 'page-alias'
156
156
  ? 'Keep the old URL on exactly one current page.'
157
- : 'Remove or change the alias. A public URL can identify only one site resource.',
157
+ : conflict.kind === 'page'
158
+ ? `Rename the page at ${conflict.label}, or remove ${pathname} from the previous addresses in ${contentLabel}. Removing it may break old links.`
159
+ : 'Remove or change the alias. A public URL can identify only one site resource.',
158
160
  message: `Page alias "${pathname}" in ${contentLabel} conflicts with ${describeIdentity(conflict)}.`,
159
161
  severity: 'error',
160
162
  });
@@ -12,8 +12,8 @@ export const parsePageDirectory = (pageDirectory, label = 'page directory') => {
12
12
  `Invalid ${label} "${pageDirectory}". Page directories must use the form NNN-page-id, for example 010-getting-started. Page ids may contain only lowercase letters, numbers, and single hyphens.`,
13
13
  );
14
14
  }
15
- if (match[1] === '000' && pageDirectory !== homePageDirectory) {
16
- throw new Error(`Invalid ${label} "${pageDirectory}". The 000 prefix is reserved for ${homePageDirectory}.`);
15
+ if (match[1] === '000') {
16
+ throw new Error(`Invalid ${label} "${pageDirectory}". Child page orders must be 001–999. The homepage is root/content.md in the site container.`);
17
17
  }
18
18
 
19
19
  return {
@@ -27,6 +27,13 @@ const normalizePageDirectoryPath = (pageDirectoryPath) => pageDirectoryPath.repl
27
27
 
28
28
  export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page directory path') => {
29
29
  const normalizedPath = normalizePageDirectoryPath(pageDirectoryPath);
30
+ if (normalizedPath === homePageDirectory) {
31
+ return {
32
+ pageDirectory: homePageDirectory, pageDirectories: [], pageId: 'home',
33
+ pageIds: [], pageOrder: 0, pageOrders: [], pagePath: '',
34
+ parentPagePath: null, depth: 0,
35
+ };
36
+ }
30
37
  const segments = normalizedPath.split('/').filter(Boolean);
31
38
 
32
39
  if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
@@ -52,29 +59,18 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
52
59
  const pageIds = pageDirectories.map(({ pageId }) => pageId);
53
60
  const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
54
61
  const currentPage = pageDirectories.at(-1);
55
- const homeIndex = pageDirectories.findIndex(({ pageDirectory }) => pageDirectory === homePageDirectory);
56
- if (homeIndex > 0) {
57
- throw new Error(`Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is allowed only as a top-level page.`);
58
- }
59
- if (homeIndex === 0 && pageDirectories.length > 1) {
60
- throw new Error(
61
- `Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is the homepage and cannot contain child pages. Place the page beside it under site/pages/, or below another non-home page.`,
62
- );
63
- }
64
- const isHome = homeIndex === 0;
65
- const logicalPageIds = isHome ? [] : pageIds;
66
- const logicalPageOrders = isHome ? [] : pageOrders;
67
62
 
68
63
  return {
69
64
  pageDirectory: normalizedPath,
70
65
  pageDirectories: pageDirectories.map(({ pageDirectory }) => pageDirectory),
71
66
  pageId: currentPage.pageId,
72
- pageIds: logicalPageIds,
67
+ pageIds,
73
68
  pageOrder: currentPage.pageOrder,
74
- pageOrders: logicalPageOrders,
75
- pagePath: logicalPageIds.join('/'),
76
- parentPagePath: logicalPageIds.length > 1
77
- ? logicalPageIds.slice(0, -1).join('/')
69
+ pageOrders,
70
+ pagePath: pageIds.join('/'),
71
+ // Navigation projects the root's children as top-level entries.
72
+ parentPagePath: pageIds.length > 1
73
+ ? pageIds.slice(0, -1).join('/')
78
74
  : null,
79
75
  depth: pageDirectories.length,
80
76
  };
@@ -82,10 +78,11 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
82
78
 
83
79
  export const encodePageDirectoryPath = (pageDirectoryPath) => {
84
80
  const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
85
- return pageDirectories.join(pageEntryPathSeparator);
81
+ return pageDirectories.length ? pageDirectories.join(pageEntryPathSeparator) : 'root';
86
82
  };
87
83
 
88
84
  export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
85
+ if (pageEntryPath === 'root') return homePageDirectory;
89
86
  const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
90
87
  if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
91
88
  throw new Error(`Invalid ${label} "${pageEntryPath}".`);