@janga/norna 0.7.26 → 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 (110) hide show
  1. package/README.md +51 -35
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +6 -4
  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 +17 -8
  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 +33 -2
  22. package/scripts/lib/documentation-routes.json +229 -0
  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 +39 -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 +12 -39
  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/PageAliasRedirect.astro +44 -3
  76. package/src/components/SearchPage.astro +9 -1
  77. package/src/components/SectionNavigationScript.astro +106 -26
  78. package/src/components/SiteNavigation.astro +45 -20
  79. package/src/components/SitePage.astro +37 -61
  80. package/src/components/SiteTreeNavigation.astro +38 -18
  81. package/src/components/TopPageMenu.astro +4 -3
  82. package/src/components/TreeNavigationScript.astro +50 -57
  83. package/src/content.config.ts +15 -16
  84. package/src/layouts/BaseLayout.astro +15 -39
  85. package/src/lib/areaNavigation.ts +42 -0
  86. package/src/lib/navigationContinuity.ts +212 -0
  87. package/src/lib/navigationFollowing.ts +28 -18
  88. package/src/lib/navigationState.mjs +88 -0
  89. package/src/lib/navigationStateScript.mjs +76 -0
  90. package/src/lib/navigationVisibility.ts +9 -0
  91. package/src/lib/pageThemes.ts +5 -42
  92. package/src/lib/sectionContent.ts +3 -2
  93. package/src/lib/siteNavigation.ts +6 -0
  94. package/src/lib/sitePages.ts +12 -23
  95. package/src/pages/404.astro +1 -1
  96. package/src/pages/[...slug].astro +5 -15
  97. package/src/pages/index.astro +1 -3
  98. package/src/styles/area-navigation.css +399 -0
  99. package/src/styles/content.css +5 -31
  100. package/src/styles/navigation-continuity.css +163 -0
  101. package/src/styles/page-layout.css +57 -68
  102. package/src/styles/responsive.css +0 -4
  103. package/starters/basic/README.md +10 -9
  104. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  105. package/schemas/category.schema.json +0 -20
  106. package/src/components/PageContentsNavigation.astro +0 -20
  107. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  108. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  109. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  110. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -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}".`);
@@ -94,6 +91,28 @@ export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path'
94
91
  return pageDirectories.join('/pages/');
95
92
  };
96
93
 
94
+ export const getSiteEntryPrefix = (siteDirLabel) => siteDirLabel
95
+ .replace(/^[./\\]+/, '')
96
+ .replace(/[^a-zA-Z0-9-]+/g, '-')
97
+ .replace(/^-+|-+$/g, '') || 'site';
98
+
99
+ const getPageEntryPrefix = (siteDirLabel) => `${getSiteEntryPrefix(siteDirLabel)}-page-`;
100
+
101
+ export const encodePageEntryId = (siteDirLabel, pageDirectoryPath) => (
102
+ `${getPageEntryPrefix(siteDirLabel)}${encodePageDirectoryPath(pageDirectoryPath)}`
103
+ );
104
+
105
+ export const decodePageEntryId = (siteDirLabel, entryId) => {
106
+ const prefix = getPageEntryPrefix(siteDirLabel);
107
+ if (!entryId.startsWith(prefix)) return null;
108
+
109
+ try {
110
+ return parsePageDirectoryPath(decodePageDirectoryPath(entryId.slice(prefix.length))).pageDirectory;
111
+ } catch {
112
+ return null;
113
+ }
114
+ };
115
+
97
116
  export const getPageDirectoryAncestors = (pageDirectoryPath) => {
98
117
  const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
99
118
  return pageDirectories.map((_, index) => (
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getSiteLinkGraph } from './site-link-graph.mjs';
5
+ import { getSiteStructure } from './site-structure.mjs';
6
+
7
+ const writeFileAtomically = async (filePath, source, mode) => {
8
+ const temporaryPath = path.join(
9
+ path.dirname(filePath),
10
+ `.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
11
+ );
12
+ try {
13
+ await writeFile(temporaryPath, source, { mode: mode & 0o777 });
14
+ await rename(temporaryPath, filePath);
15
+ } finally {
16
+ await rm(temporaryPath, { force: true });
17
+ }
18
+ };
19
+
20
+ const assertSourcesUnchanged = async (plan) => {
21
+ for (const file of plan.sourceFiles) {
22
+ const currentSource = await readFile(file.contentPath, 'utf8');
23
+ if (currentSource !== file.source) {
24
+ throw new Error([
25
+ `Page move stopped because ${file.contentLabel} changed after the plan was created.`,
26
+ 'Run the command again to build a fresh plan. No file was changed.',
27
+ ].join('\n'));
28
+ }
29
+ }
30
+ };
31
+
32
+ const assertFinalSite = async (siteRoot, generatedRoutes) => {
33
+ const structure = await getSiteStructure({ siteRoot });
34
+ const graph = await getSiteLinkGraph({ siteStructure: structure, publicDir: path.join(siteRoot, 'public'), generatedRoutes });
35
+ const errors = graph.diagnostics.filter(({ severity }) => severity === 'error');
36
+ if (errors.length === 0) return;
37
+ throw new Error([
38
+ 'Post-move link validation failed.',
39
+ ...errors.flatMap((diagnostic) => [
40
+ `- ${diagnostic.message}`,
41
+ ...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
42
+ ]),
43
+ ].join('\n'));
44
+ };
45
+
46
+ export const applyPageMovePlan = async (plan, { siteRoot, generatedRoutes, renameDirectory = rename }) => {
47
+ await assertSourcesUnchanged(plan);
48
+ let collectionCreated = false;
49
+ let directoryMoved = false;
50
+ let fileWritten = false;
51
+
52
+ try {
53
+ if (plan.mode === 'move') {
54
+ if (!plan.destinationCollectionExists) {
55
+ await mkdir(plan.destinationCollectionDirectory);
56
+ collectionCreated = true;
57
+ }
58
+ try {
59
+ await renameDirectory(plan.sourceDirectory, plan.destinationDirectory);
60
+ directoryMoved = true;
61
+ } catch (error) {
62
+ if (error?.code === 'EXDEV') {
63
+ throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ for (const change of plan.fileChanges) {
70
+ await writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
71
+ fileWritten = true;
72
+ }
73
+ await assertFinalSite(siteRoot, generatedRoutes);
74
+ } catch (error) {
75
+ const changesStarted = directoryMoved || fileWritten;
76
+ const rollbackErrors = [];
77
+ if (directoryMoved) {
78
+ try {
79
+ await renameDirectory(plan.destinationDirectory, plan.sourceDirectory);
80
+ directoryMoved = false;
81
+ } catch (rollbackError) {
82
+ rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
83
+ }
84
+ }
85
+
86
+ for (const change of changesStarted ? plan.fileChanges : []) {
87
+ try {
88
+ const restorePath = directoryMoved && change.finalPath !== change.originalPath
89
+ ? change.finalPath
90
+ : change.originalPath;
91
+ await writeFileAtomically(restorePath, change.originalSource, change.mode);
92
+ } catch (rollbackError) {
93
+ rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
94
+ }
95
+ }
96
+
97
+ if (collectionCreated && !directoryMoved) {
98
+ try {
99
+ await rmdir(plan.destinationCollectionDirectory);
100
+ } catch (rollbackError) {
101
+ if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
102
+ rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ throw new Error([
108
+ error instanceof Error ? error.message : String(error),
109
+ rollbackErrors.length === 0
110
+ ? (changesStarted
111
+ ? 'The original page directory and content files were restored.'
112
+ : 'No source file was changed.')
113
+ : 'Rollback was incomplete. Inspect these paths before running page:move again:',
114
+ ...rollbackErrors.map((message) => `- ${message}`),
115
+ ].join('\n'));
116
+ }
117
+ };
@@ -11,6 +11,7 @@ import {
11
11
  toPosixPath,
12
12
  } from './site-content.mjs';
13
13
  import { getSiteNodePathname } from './site-page-urls.mjs';
14
+ import { changeYamlField } from './yaml-source-edit.mjs';
14
15
 
15
16
  const pagePathPattern = /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/;
16
17
 
@@ -61,12 +62,6 @@ const applyReplacements = (source, replacements) => {
61
62
  return updated;
62
63
  };
63
64
 
64
- const insertBeforeTrailingBlankLines = (lines, additions) => {
65
- let index = lines.length;
66
- while (index > 0 && !lines[index - 1].trim()) index -= 1;
67
- lines.splice(index, 0, ...additions);
68
- };
69
-
70
65
  const addPageAliasToSource = (source, alias, label) => {
71
66
  const eol = source.includes('\r\n') ? '\r\n' : '\n';
72
67
  const { body, frontmatter, frontmatterBody } = splitSiteFile(source, label);
@@ -88,64 +83,31 @@ const addPageAliasToSource = (source, alias, label) => {
88
83
  };
89
84
  }
90
85
 
91
- const lines = frontmatterBody.split(/\r?\n/);
92
- const pageIndex = lines.findIndex((line) => /^page:\s*(?:#.*)?$/.test(line));
93
- if (data.page && pageIndex < 0) {
94
- throw new Error([
95
- `Cannot add ${alias} to ${label} without rewriting its flow-style page metadata.`,
96
- 'Use block YAML beginning with "page:" on its own line, or rerun with --no-aliases.',
97
- ].join('\n'));
98
- }
99
-
100
- if (pageIndex < 0) {
101
- insertBeforeTrailingBlankLines(lines, [
102
- 'page:',
103
- ' aliases:',
104
- ` - ${alias}`,
105
- ]);
106
- } else {
107
- let pageEnd = lines.length;
108
- for (let index = pageIndex + 1; index < lines.length; index += 1) {
109
- const line = lines[index];
110
- if (!line.trim() || line.trimStart().startsWith('#')) continue;
111
- if (!line.startsWith(' ')) {
112
- pageEnd = index;
113
- break;
114
- }
115
- }
116
-
117
- const aliasesIndex = lines.findIndex((line, index) => (
118
- index > pageIndex
119
- && index < pageEnd
120
- && /^ {2}aliases:\s*(?:#.*)?$/.test(line)
121
- ));
122
- if (data.page?.aliases && aliasesIndex < 0) {
123
- throw new Error([
124
- `Cannot add ${alias} to ${label} without rewriting its flow-style aliases list.`,
125
- 'Use a block list below " aliases:", or rerun with --no-aliases.',
126
- ].join('\n'));
127
- }
128
-
129
- if (aliasesIndex < 0) {
130
- lines.splice(pageIndex + 1, 0, ' aliases:', ` - ${alias}`);
131
- } else {
132
- let lastAliasIndex = -1;
133
- for (let index = aliasesIndex + 1; index < pageEnd; index += 1) {
134
- if (/^ {4}-\s+/.test(lines[index])) lastAliasIndex = index;
135
- }
136
- if (lastAliasIndex < 0) {
137
- throw new Error(`Cannot find the block-list entries below "aliases:" in ${label}.`);
138
- }
139
- lines.splice(lastAliasIndex + 1, 0, ` - ${alias}`);
140
- }
86
+ let yaml;
87
+ try {
88
+ yaml = changeYamlField(frontmatterBody, ['page', 'aliases'], [...(data.page?.aliases ?? []), alias], eol);
89
+ } catch (error) {
90
+ throw Object.assign(new Error(`Cannot preserve ${alias} in ${label}. ${error.message}`), { sourcePath: label });
141
91
  }
142
92
 
143
93
  return {
144
94
  changed: true,
145
- source: `---${eol}${lines.join(eol)}${eol}---${eol}${body}`,
95
+ source: `---${eol}${yaml.replace(/\r?\n$/, '')}${eol}---${eol}${body}`,
146
96
  };
147
97
  };
148
98
 
99
+ const removePageAliasFromSource = (source, alias, label) => {
100
+ const { body, frontmatter, frontmatterBody } = splitSiteFile(source, label);
101
+ if (!frontmatter) return source;
102
+ const data = parseContentFrontmatter(frontmatterBody, label);
103
+ if (!data.page?.aliases?.includes(alias)) return source;
104
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
105
+ const remaining = data.page.aliases.filter((entry) => entry !== alias);
106
+ const yaml = changeYamlField(frontmatterBody, ['page', 'aliases'], remaining.length ? remaining : undefined, eol);
107
+ if (!yaml.trim()) return body.replace(/^\r?\n/, '');
108
+ return `---${eol}${yaml.replace(/\r?\n$/, '')}${eol}---${eol}${body}`;
109
+ };
110
+
149
111
  const normalizeMovePathname = (value, optionName) => {
150
112
  if (value === '/') return { pagePath: '', pathname: '/' };
151
113
  if (typeof value !== 'string' || !pagePathPattern.test(value)) {
@@ -396,6 +358,7 @@ const formatGraphDiagnostics = (diagnostics) => diagnostics.flatMap((diagnostic)
396
358
  export const createPageMovePlan = async ({
397
359
  from,
398
360
  graph,
361
+ generatedRoutes,
399
362
  order = null,
400
363
  preserveAliases = true,
401
364
  publicFiles,
@@ -559,6 +522,9 @@ export const createPageMovePlan = async ({
559
522
  })));
560
523
 
561
524
  const mapping = mappingByContentPath.get(contentPath);
525
+ if (mapping && page.aliases.includes(mapping.newPathname)) {
526
+ updatedSource = removePageAliasFromSource(updatedSource, mapping.newPathname, page.contentFile.contentLabel);
527
+ }
562
528
  if (mapping && preserveAliases) {
563
529
  const aliasResult = addPageAliasToSource(updatedSource, mapping.oldPathname, page.contentFile.contentLabel);
564
530
  updatedSource = aliasResult.source;
@@ -614,11 +580,13 @@ export const createPageMovePlan = async ({
614
580
  pageDocuments: virtualPageDocuments,
615
581
  publicFiles,
616
582
  siteStructure: virtualStructure,
583
+ generatedRoutes,
617
584
  });
618
- if (virtualGraph.diagnostics.length > 0) {
585
+ const errors = virtualGraph.diagnostics.filter(({ severity }) => severity === 'error');
586
+ if (errors.length > 0) {
619
587
  throw new Error([
620
588
  'Page move preflight failed. No file was changed.',
621
- ...formatGraphDiagnostics(virtualGraph.diagnostics),
589
+ ...formatGraphDiagnostics(errors),
622
590
  ].join('\n'));
623
591
  }
624
592
 
@@ -453,7 +453,7 @@ export const getPresentationCssVariables = (presentation) => {
453
453
  return variables;
454
454
  };
455
455
 
456
- const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'theme.yaml') => {
456
+ const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'root/tree-theme.yaml') => {
457
457
  const surfaces = sectionBackgroundPatterns[pattern];
458
458
  if (!surfaces) {
459
459
  throw new Error(`sections.backgroundPattern must be one of ${Object.keys(sectionBackgroundPatterns).join(', ')} in ${sourceLabel}.`);
@@ -462,7 +462,7 @@ const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'theme.yaml') =>
462
462
  return surfaces;
463
463
  };
464
464
 
465
- export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
465
+ export const resolveThemePresentation = (theme, sourceLabel = 'root/tree-theme.yaml') => {
466
466
  const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
467
467
  const paletteName = normalizedTheme.palette ?? 'near-monochrome';
468
468
  const palette = getPresentationPalette(paletteName);
@@ -509,7 +509,7 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
509
509
 
510
510
  export const assertSectionBackgroundPatternCompatibility = (
511
511
  theme,
512
- sourceLabel = 'theme.yaml',
512
+ sourceLabel = 'root/tree-theme.yaml',
513
513
  navigationMode = 'sections',
514
514
  ) => {
515
515
  const requestedPattern = theme?.sections?.backgroundPattern;
@@ -1,4 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
  import {
3
5
  validateConfigYamlStructure,
4
6
  } from './site-content.mjs';
@@ -11,6 +13,7 @@ import { navigationModeNames } from './navigation-model.mjs';
11
13
  import {
12
14
  siteConfigLabel,
13
15
  siteConfigPath,
16
+ siteDir,
14
17
  siteThemeLabel,
15
18
  } from './site-paths.mjs';
16
19
  import { localEditorNames, normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
@@ -20,6 +23,9 @@ import { resolveThemeConfig } from './theme-presets.mjs';
20
23
  import { parseYamlConfig } from './yaml-config.mjs';
21
24
 
22
25
  const readSiteConfig = async () => {
26
+ if (existsSync(path.join(siteDir, 'site-config/site-theme.yaml')) || existsSync(path.join(siteDir, 'root/theme.yaml')) || existsSync(path.join(siteDir, 'config.yaml')) || existsSync(path.join(siteDir, 'page-theme.yaml')) || existsSync(path.join(siteDir, 'sitewide-content.yaml'))) {
27
+ throw new Error('This site has configuration files in the former locations. Stop its development server and follow the source-layout reference to place technical settings in site-config/ and page files in root/. Move the shared theme to root/tree-theme.yaml with an explicit preset and homepage-only overrides to root/page-theme.yaml. Automatic historical conversion is not provided.');
28
+ }
23
29
  const source = await readFile(siteConfigPath, 'utf8').catch((error) => {
24
30
  if (error?.code === 'ENOENT') {
25
31
  throw new Error(`${siteConfigLabel} is required. Create it before running Norna.`);
@@ -302,13 +308,6 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
302
308
  defaultImagePresentation,
303
309
  sourceLabel,
304
310
  );
305
- if (imagePresentation === 'prose-aligned' && rawImagesConfig.maxAvailableHeightPercent !== undefined) {
306
- throw new Error([
307
- `images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}.`,
308
- 'Prose-aligned images follow available horizontal space instead of a viewport-height limit.',
309
- 'Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".',
310
- ].join('\n'));
311
- }
312
311
 
313
312
  return Object.freeze({
314
313
  layout: Object.freeze({
@@ -371,7 +370,7 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
371
370
  if (Object.hasOwn(rawNavigation, 'sectionTracking')) {
372
371
  throw new Error([
373
372
  `navigation.sectionTracking is no longer supported in ${sourceLabel}.`,
374
- 'Section tracking is automatic on pages with a contents rail.',
373
+ 'Section tracking is automatic on tree-navigation pages with H2 headings.',
375
374
  'Remove navigation.sectionTracking.',
376
375
  ].join('\n'));
377
376
  }
@@ -167,13 +167,6 @@ const themeLayout = z.object({
167
167
  gutter: responsiveCssLength.optional().describe('Horizontal page gutter as one value or separate desktop and mobile values.'),
168
168
  spacing: themeLayoutSpacing.optional().describe('Fine-grained spacing overrides.'),
169
169
  }).strict().describe('Optional layout overrides applied after the preset.');
170
- const pageThemeLayout = z.object({
171
- contentSpacing: contentSpacing.optional().describe('Page content spacing. Descendant pages inherit this value.'),
172
- textWidth: textWidth.optional().describe('Body-text line length. Descendant pages inherit this value.'),
173
- }).strict().refine(
174
- (value) => value.contentSpacing !== undefined || value.textWidth !== undefined,
175
- 'Specify contentSpacing, textWidth, or both.',
176
- ).describe('Page-local layout settings inherited by descendant pages.');
177
170
  const themeImages = z.object({
178
171
  presentation: imagePresentation.optional().describe('Managed-image presentation method. Omit this to keep the selected preset or inherited page setting.'),
179
172
  width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
@@ -182,25 +175,19 @@ const themeImages = z.object({
182
175
  }).strict().describe('Optional defaults for placing managed images inside the page content area. Persistent navigation remains outside this area.');
183
176
  const themeCardList = z.object({
184
177
  width: cardListWidth,
185
- }).strict().describe('Site-wide card-list defaults.');
178
+ }).strict().describe('Card-list defaults.');
186
179
  const themeBlocks = z.object({
187
180
  cardList: themeCardList.optional(),
188
181
  }).strict().refine(
189
182
  (value) => value.cardList !== undefined,
190
183
  'Specify cardList.',
191
- ).describe('Optional site-wide defaults for structured Norna content blocks.');
184
+ ).describe('Optional defaults for structured Norna content blocks.');
192
185
  const configNavigation = z.object({
193
186
  mode: navigationMode.optional().describe('How Norna presents the discovered page and heading hierarchy. Omit this to select automatic.'),
194
187
  }).strict().describe('Site-wide navigation behavior.');
195
188
  const themeSections = z.object({
196
189
  backgroundPattern: backgroundPattern.optional().describe('Section background pattern. Alternating and accented create full-width bands with sections or top navigation; tree navigation requires uniform.'),
197
190
  }).strict().describe('Defaults for page section presentation.');
198
- const pageThemeSections = z.object({
199
- backgroundPattern: backgroundPattern.optional().describe('Section background sequence inherited by descendant pages. Non-uniform patterns are invalid with tree navigation.'),
200
- }).strict().refine(
201
- (value) => value.backgroundPattern !== undefined,
202
- 'Specify backgroundPattern.',
203
- ).describe('Page-local section presentation inherited by descendant pages.');
204
191
  const pageNavigation = z.object({
205
192
  listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
206
193
  }).strict();
@@ -209,7 +196,8 @@ const pageAlias = z.string().regex(
209
196
  'Use an old site-relative Norna page URL such as "/guides/install/". Start and end with / and use lowercase letters, numbers, and single hyphens.',
210
197
  ).describe('Previous site-relative URL that permanently identifies this page.');
211
198
  const pageMetadata = z.object({
212
- description: z.string().min(1).optional().describe('Optional page-specific meta description.'),
199
+ description: z.string().min(1).optional().describe('Page description used in metadata, social previews and generated child-page lists.'),
200
+ listChildren: z.boolean().optional().describe('Append links to every listed direct child page after this page\'s authored content.'),
213
201
  aliases: z.array(pageAlias)
214
202
  .min(1)
215
203
  .refine((values) => new Set(values).size === values.length, 'Page aliases must be unique within the page.')
@@ -251,9 +239,9 @@ const siteShape = {
251
239
  };
252
240
 
253
241
  const themeVisualShape = {
254
- preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
242
+ preset: themePreset.optional().describe('Select a new visual base, discarding all inherited theme values. Required in root/tree-theme.yaml; omit elsewhere to modify inherited values. Even the same preset starts a new base.'),
255
243
  appearance: themeAppearance.optional(),
256
- corners: cornerTreatment.optional().describe('Site-wide corner treatment. Omit this to use the selected preset.'),
244
+ corners: cornerTreatment.optional().describe('Corner treatment. Omit this to use the selected preset.'),
257
245
  layout: themeLayout.optional(),
258
246
  images: themeImages.optional(),
259
247
  blocks: themeBlocks.optional(),
@@ -262,44 +250,29 @@ const themeVisualShape = {
262
250
  sections: themeSections.optional(),
263
251
  };
264
252
 
265
- const pageThemeShape = {
266
- layout: pageThemeLayout.optional(),
267
- images: themeImages.optional(),
268
- sections: pageThemeSections.optional(),
269
- };
270
-
271
253
  const sitewideShape = {
272
254
  logo: sitewideLogo.optional(),
273
255
  banners: banners.describe('Site-wide dismissible notices.'),
274
256
  footer: sitewideFooter.optional().describe('Site-wide footer content.'),
275
257
  };
276
258
 
277
- const categoryShape = {
278
- label: z.string().trim().min(1).describe('Navigation label for this non-routable page category.'),
279
- };
280
-
281
259
  export const schemaTopLevelKeys = Object.freeze({
282
260
  config: Object.freeze(Object.keys(configShape)),
283
- category: Object.freeze(Object.keys(categoryShape)),
284
261
  content: Object.freeze(Object.keys(siteShape)),
285
262
  sitewide: Object.freeze(Object.keys(sitewideShape)),
286
263
  theme: Object.freeze(Object.keys(themeVisualShape)),
287
- pageTheme: Object.freeze(Object.keys(pageThemeShape)),
264
+ pageTheme: Object.freeze(Object.keys(themeVisualShape)),
288
265
  });
289
266
 
290
267
  export const configSchema = z.object(configShape).strict()
291
268
  .describe('Technical settings for one Norna site. Additional pages cannot provide technical configuration.');
292
- export const categorySchema = z.object(categoryShape).strict()
293
- .describe('A non-routable navigation category containing child pages.');
294
269
  export const siteSchema = z.object(siteShape).strict()
295
270
  .describe('Frontmatter for a homepage or additional page content.md file.');
296
271
  export const themeVisualSchema = z.object(themeVisualShape).strict()
297
- .describe('Site-wide visual identity and default page presentation. Presets may be selected only in the root theme.');
298
- export const pageThemeSchema = z.object(pageThemeShape).strict()
299
- .refine(
300
- (value) => value.layout !== undefined || value.images !== undefined || value.sections !== undefined,
301
- 'Specify layout, images, sections, or a combination of them.',
302
- )
303
- .describe('Limited page presentation overrides inherited by descendant pages. Site colors, corners, typography, content-block defaults and navigation remain global.');
272
+ .describe('Visual settings for this page and its descendants. Omit preset to modify inherited values; an explicit preset replaces the complete inherited base, even when the preset name is unchanged.');
273
+ export const rootThemeSchema = z.object({ ...themeVisualShape, preset: themePreset }).strict()
274
+ .describe('Required root/tree-theme.yaml. Select a preset for the site, then add the visual settings you want to override.');
275
+ export const pageThemeSchema = z.object(themeVisualShape).strict()
276
+ .describe('Visual settings for this page only. Omit preset to modify the active tree theme; an explicit preset replaces that base. Descendants keep their tree theme.');
304
277
  export const sitewideSchema = z.object(sitewideShape).strict()
305
278
  .describe('Editorial content and optional navigation logo display settings shared by every page.');