@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
@@ -1,16 +1,15 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
1
2
  import { access, readdir, readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { categorySchema } from './schema-definitions.mjs';
4
4
  import { parsePageDirectoryPath } from './page-model.mjs';
5
+ import { comparePageOrder, parsePageOrder } from './page-order.mjs';
6
+ import { assertSiteSourceFormat } from './site-source-format.mjs';
7
+ import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
5
8
  import {
6
9
  homePageDirectory,
7
- siteContentLabel,
8
- siteDir,
9
- siteDirLabel,
10
- sitePagesDir,
11
- sitePagesLabel,
10
+ siteDir as defaultSiteDir,
11
+ siteDirLabel as defaultSiteDirLabel,
12
12
  } from './site-paths.mjs';
13
- import { parseYamlConfig } from './yaml-config.mjs';
14
13
 
15
14
  const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
16
15
 
@@ -19,30 +18,15 @@ const readDirectory = async (directory) => readdir(directory, { withFileTypes: t
19
18
  throw error;
20
19
  });
21
20
 
22
- const compareNodeMetadata = (left, right) => (
23
- left.pageOrder - right.pageOrder
24
- || left.pageId.localeCompare(right.pageId, 'en')
25
- );
26
-
27
- const assertLegacyStructureIsAbsent = async () => {
28
- const legacyContentPath = path.join(siteDir, 'content.md');
29
- const legacyImagesPath = path.join(siteDir, 'images');
30
- const legacyPaths = [];
31
- if (await fileExists(legacyContentPath)) legacyPaths.push(`${siteDirLabel}/content.md`);
32
- if (await fileExists(legacyImagesPath)) legacyPaths.push(`${siteDirLabel}/images`);
33
- if (legacyPaths.length > 0) {
34
- throw new Error([
35
- `The old root-page structure is no longer supported: ${legacyPaths.join(', ')}.`,
36
- `Move the homepage content to ${sitePagesLabel}/${homePageDirectory}/content.md and its images to ${sitePagesLabel}/${homePageDirectory}/images/.`,
37
- ].join('\n'));
38
- }
39
-
40
- if (await fileExists(path.join(siteDir, 'routes'))) {
41
- throw new Error(`${siteDirLabel}/routes is no longer supported. Rename it to ${sitePagesLabel} and use NNN-page-id directory names.`);
21
+ const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
22
+ for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes']) {
23
+ if (await fileExists(path.join(siteDir, name))) {
24
+ throw new Error(`${siteDirLabel}/${name} uses the former page layout. Put the homepage content.md, tree-theme.yaml, optional page-theme.yaml, images/ and pages/ inside ${siteDirLabel}/root/. Keep site-config/ and public/ at the site level. Stop the development server before moving files.`);
25
+ }
42
26
  }
43
27
  };
44
28
 
45
- const assertUniqueSiblings = (nodes, pagesLabel) => {
29
+ const assertUniqueSiblings = (nodes, pagesLabel, warnings) => {
46
30
  const byId = new Map();
47
31
  const byOrder = new Map();
48
32
 
@@ -57,25 +41,40 @@ const assertUniqueSiblings = (nodes, pagesLabel) => {
57
41
  }
58
42
  byId.set(node.pageId, node);
59
43
 
44
+ if (node.pageOrder === null) continue;
60
45
  const existingOrder = byOrder.get(node.pageOrder);
61
46
  if (existingOrder) {
62
- throw new Error([
63
- `${pagesLabel} contains duplicate sibling order "${String(node.pageOrder).padStart(3, '0')}".`,
64
- `- ${existingOrder.nodeLabel}`,
65
- `- ${node.nodeLabel}`,
66
- ].join('\n'));
47
+ const message = `${pagesLabel} contains duplicate sibling navigation.order ${node.pageOrder}: ${existingOrder.pageId} and ${node.pageId}. Slug determines their order. Choose different values to set an explicit order.`;
48
+ for (const sibling of [existingOrder, node]) warnings.push({ code: 'duplicate-page-order', message, label: sibling.contentLabel, path: sibling.contentPath });
67
49
  }
68
50
  byOrder.set(node.pageOrder, node);
69
51
  }
70
52
  };
71
53
 
72
- const readCategory = async (categoryPath, categorySourceLabel) => {
73
- const source = await readFile(categoryPath, 'utf8');
74
- return parseYamlConfig(source, categorySourceLabel, { schema: categorySchema });
75
- };
76
-
77
- export const getSiteStructure = async () => {
78
- await assertLegacyStructureIsAbsent();
54
+ // Editors can inspect several sites in one process and retain malformed nodes
55
+ // for repair. Builds and mutation plans keep the strict default contract.
56
+ export const getSiteStructure = async ({
57
+ siteRoot = defaultSiteDir,
58
+ tolerant = false,
59
+ readSource = (filePath) => readFile(filePath, 'utf8'),
60
+ } = {}) => {
61
+ const siteDir = path.resolve(siteRoot);
62
+ const siteDirLabel = siteDir === defaultSiteDir ? defaultSiteDirLabel : siteDir;
63
+ const sitePagesDir = getSiteSourcePaths(siteDir).pages;
64
+ const sitePagesLabel = `${siteDirLabel}/root/pages`;
65
+ const siteContentLabel = `${siteDirLabel}/root/content.md`;
66
+ const problems = [];
67
+ const report = (error, node = null) => {
68
+ if (!tolerant) throw error;
69
+ problems.push({ message: error.message, path: node?.contentPath ?? node?.categoryPath ?? siteDir });
70
+ if (node) (node.problems ??= []).push(error.message);
71
+ };
72
+ try {
73
+ await assertLegacyStructureIsAbsent({ siteDir, siteDirLabel, sitePagesLabel });
74
+ await assertSiteSourceFormat(siteDir, readSource);
75
+ } catch (error) {
76
+ report(error);
77
+ }
79
78
 
80
79
  const nodes = [];
81
80
  const warnings = [];
@@ -98,90 +97,82 @@ export const getSiteStructure = async () => {
98
97
  fileExists(contentPath),
99
98
  fileExists(categoryPath),
100
99
  ]);
101
- const metadata = parsePageDirectoryPath(pageDirectory, nodeLabel);
102
-
103
- if (hasContent === hasCategory) {
104
- const problem = hasContent
105
- ? 'contains both content.md and category.yaml'
106
- : 'contains neither content.md nor category.yaml';
107
- throw new Error(`${nodeLabel} ${problem}. Keep exactly one: content.md for a page, or category.yaml for a navigation category.`);
100
+ let metadata;
101
+ try {
102
+ metadata = parsePageDirectoryPath(pageDirectory, nodeLabel);
103
+ } catch (error) {
104
+ report(error);
105
+ continue;
108
106
  }
109
107
 
110
- const isHome = pageDirectory === homePageDirectory;
111
- if (isHome && hasCategory) {
112
- throw new Error(`${nodeLabel}/category.yaml is invalid. The homepage must be a page with content.md.`);
108
+ if (hasCategory) report(new Error(`${nodeLabel}/category.yaml is no longer supported. Create content.md with an H1 and page.listChildren: true instead.`));
109
+ if (!hasContent) {
110
+ report(new Error(`${nodeLabel} has no content.md. Every page directory needs a content.md file.`));
111
+ continue;
113
112
  }
114
113
 
115
114
  const node = {
116
115
  ...metadata,
117
- isHome,
118
- kind: hasCategory ? 'category' : 'page',
116
+ isHome: false,
117
+ kind: 'page',
119
118
  nodeDir,
120
119
  nodeLabel,
121
- pagePath: isHome ? '' : metadata.pagePath,
122
- parentPagePath: isHome ? null : metadata.parentPagePath,
120
+ pagePath: metadata.pagePath,
121
+ parentPagePath: metadata.parentPagePath,
123
122
  };
124
123
 
125
- if (hasCategory) {
126
- if (await fileExists(path.join(nodeDir, 'images'))) {
127
- throw new Error(`${nodeLabel} is a navigation category and cannot contain images/. Use content.md when the collection needs editorial content or images.`);
128
- }
129
- const category = await readCategory(categoryPath, `${nodeLabel}/category.yaml`);
130
- Object.assign(node, {
131
- categorySourceLabel: `${nodeLabel}/category.yaml`,
132
- categoryPath,
133
- label: category.label,
134
- });
135
- } else {
136
- Object.assign(node, {
137
- contentLabel: `${nodeLabel}/content.md`,
138
- contentPath,
139
- imagesDir: path.join(nodeDir, 'images'),
140
- imagesLabel: `${nodeLabel}/images`,
141
- });
142
- }
143
-
124
+ Object.assign(node, {
125
+ contentLabel: `${nodeLabel}/content.md`,
126
+ contentPath,
127
+ imagesDir: path.join(nodeDir, 'images'),
128
+ imagesLabel: `${nodeLabel}/images`,
129
+ });
130
+
131
+ try {
132
+ const { frontmatterBody } = splitSiteFile(await readSource(contentPath), node.contentLabel);
133
+ node.pageOrder = parsePageOrder(parseContentFrontmatter(frontmatterBody, node.contentLabel).navigation?.order, `${node.contentLabel}: navigation.order`);
134
+ } catch (error) { report(error, node); }
144
135
  siblingNodes.push(node);
145
136
  }
146
137
 
147
- assertUniqueSiblings(siblingNodes, pagesLabel);
138
+ try {
139
+ assertUniqueSiblings(siblingNodes, pagesLabel, warnings);
140
+ } catch (error) {
141
+ report(error);
142
+ }
148
143
 
149
- for (const node of siblingNodes.sort(compareNodeMetadata)) {
144
+ for (const node of siblingNodes.sort(comparePageOrder)) {
150
145
  nodes.push(node);
151
146
  const childPagesDir = path.join(node.nodeDir, 'pages');
152
147
  const childDirectories = (await readDirectory(childPagesDir)).filter((entry) => entry.isDirectory());
153
148
 
154
- if (node.isHome && childDirectories.length > 0) {
155
- throw new Error([
156
- `${node.nodeLabel} is the homepage and cannot contain child pages.`,
157
- `Move these page directories beside ${homePageDirectory} under ${sitePagesLabel}/, or below another non-home page:`,
158
- ...childDirectories.map(({ name }) => `- ${node.nodeLabel}/pages/${name}`),
159
- ].join('\n'));
160
- }
161
-
162
- if (node.kind === 'category' && childDirectories.length === 0) {
163
- warnings.push({
164
- code: 'empty-category',
165
- label: node.categorySourceLabel,
166
- message: `${node.categorySourceLabel} defines an empty navigation category. Add at least one child page under ${node.nodeLabel}/pages/.`,
167
- });
168
- }
169
-
170
- if (!node.isHome && childDirectories.length > 0) {
149
+ if (childDirectories.length > 0) {
171
150
  await collectNodes(childPagesDir, `${node.nodeLabel}/pages`, node.pageDirectory);
172
151
  }
173
152
  }
174
153
  };
175
154
 
176
- await collectNodes(sitePagesDir, sitePagesLabel);
177
- if (!nodes.some(({ isHome }) => isHome)) {
178
- throw new Error(`Homepage content is missing. Create ${siteContentLabel}.`);
155
+ const rootContentPath = getSiteSourcePaths(siteDir).content;
156
+ if (await fileExists(path.join(getSiteSourcePaths(siteDir).root, 'category.yaml'))) {
157
+ report(new Error(`${siteDirLabel}/root/category.yaml is invalid. The site root must be a page with content.md.`));
179
158
  }
159
+ if (await fileExists(rootContentPath)) {
160
+ nodes.push({
161
+ ...parsePageDirectoryPath(homePageDirectory),
162
+ kind: 'page', isHome: true, nodeDir: getSiteSourcePaths(siteDir).root, nodeLabel: `${siteDirLabel}/root`,
163
+ contentPath: rootContentPath, contentLabel: siteContentLabel,
164
+ imagesDir: getSiteSourcePaths(siteDir).images, imagesLabel: `${siteDirLabel}/root/images`,
165
+ });
166
+ } else {
167
+ report(new Error(`Homepage content is missing. Create ${siteContentLabel}.`));
168
+ }
169
+ await collectNodes(sitePagesDir, sitePagesLabel);
180
170
 
181
171
  return {
182
172
  categories: nodes.filter(({ kind }) => kind === 'category'),
183
173
  contentFiles: nodes.filter(({ kind }) => kind === 'page'),
184
174
  nodes,
175
+ ...(tolerant ? { problems } : {}),
185
176
  warnings,
186
177
  };
187
178
  };
@@ -0,0 +1,32 @@
1
+ import { assertSiteSourceFormat } from './site-source-format.mjs';
2
+ import { lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { readThemePackage } from './theme-packages.mjs';
5
+ import { getSiteSourcePaths } from './site-conventions.mjs';
6
+
7
+ const inspect = (filename) => lstat(filename).catch((error) => {
8
+ if (error.code === 'ENOENT') return null;
9
+ throw error;
10
+ });
11
+
12
+ // Keep the existing command without claiming the former layout is current.
13
+ // Our maintained sites are converted directly; no general converter is needed.
14
+ export const planSiteUpgrade = async (siteRoot) => {
15
+ siteRoot = path.resolve(siteRoot);
16
+ const source = getSiteSourcePaths(siteRoot);
17
+ if (!(await inspect(siteRoot))?.isDirectory()) throw new Error(`${siteRoot} must be a regular directory, not a symbolic link.`);
18
+ for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes', 'config.yaml', 'page-theme.yaml', 'sitewide-content.yaml', 'site-config/site-theme.yaml', 'root/theme.yaml']) {
19
+ if (await inspect(path.join(siteRoot, name))) {
20
+ throw new Error(`This site uses former source locations (${name}). Automatic conversion to root/ is not provided. Stop the development server and back up the site. Keep site-config/ and public/ at the site level; put the homepage content.md, images/ and pages/ in root/. Put the required tree-theme.yaml with an explicit preset in root/; optional page-theme.yaml affects one page only. Rename descendant theme.yaml to tree-theme.yaml to preserve inherited overrides. For older formats, follow the source-layout reference before moving files. Update relative source/schema links, then run norna check and norna build. No files changed.`);
21
+ }
22
+ }
23
+ for (const directory of [source.root, path.join(siteRoot, 'site-config')]) {
24
+ if (!(await inspect(directory))?.isDirectory()) throw new Error(`${directory} must be a regular directory, not a symbolic link.`);
25
+ }
26
+ for (const filename of [source.content, path.join(siteRoot, 'site-config', 'settings.yaml'), source.theme]) {
27
+ if (!(await inspect(filename))?.isFile()) throw new Error(`${filename} is required and must be a regular file.`);
28
+ }
29
+ await assertSiteSourceFormat(siteRoot);
30
+ await readThemePackage({ siteRoot, filename: source.theme });
31
+ return { siteRoot, moves: [] };
32
+ };
@@ -1,3 +1,4 @@
1
+ import { publicFilePublication } from './publication-paths.mjs';
1
2
  import { getPublicAssetInspection } from './logo-assets.mjs';
2
3
  import {
3
4
  socialImageAssetDefinitions,
@@ -13,7 +14,7 @@ export const getSocialImageAssets = () => getPublicAssetInspection().socialImage
13
14
  .map((filename) => ({
14
15
  ...definitionsByFilename.get(filename),
15
16
  filename,
16
- href: `/${filename}`,
17
+ href: publicFilePublication(filename).pathname,
17
18
  }));
18
19
 
19
20
  export const getSocialImageAsset = () => {
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ import { pageDirectoryPattern, parsePageDirectoryPath } from './page-model.mjs';
3
+ import { getSiteSourcePaths } from './site-conventions.mjs';
4
+ import { configSchema, rootThemeSchema, pageThemeSchema, siteSchema, sitewideSchema, themeVisualSchema } from './schema-definitions.mjs';
5
+
6
+ // Also published in schemas/manifest.json for synchronous editor discovery.
7
+ export const sourceFileDefinitions = [
8
+ { name: 'settings.yaml', location: 'site-config', schemaKind: 'config', required: true, input: 'url', description: 'Required public URL and technical site settings.' },
9
+ { name: 'shared-content.yaml', location: 'site-config', schemaKind: 'sitewideContent', text: 'banners: []\n', description: 'Shared notices and footer content for the complete site.', removalEffect: 'Shared notices, authored footer content and logo display settings in this file will be removed from the whole site.' },
10
+ { name: 'content.md', location: 'page', schemaKind: 'contentFrontmatter', required: true, input: 'title', description: 'Required page content.' },
11
+ { name: 'tree-theme.yaml', location: 'page', schemaKind: 'theme', rootSchemaKind: 'rootTheme', required: 'root', rootText: 'preset: documentation\n', text: '# Modify inherited settings. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page and its descendants. Without preset, modifies inherited values; preset selects a new base. Required on the root page.', removalEffect: 'This branch resumes the ancestor tree theme. Overrides on descendants remain; a descendant preset starts an independent base.' },
12
+ { name: 'page-theme.yaml', location: 'page', schemaKind: 'pageTheme', text: '# Modify this page only. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page only. Without preset, modifies its tree theme; preset selects a new base. Descendants keep their tree theme.', removalEffect: 'This page will use its selected tree theme again. Other pages are unchanged.' },
13
+ ];
14
+ export const sourcePageDirectoryPattern = pageDirectoryPattern.source;
15
+ const schemas = { config: configSchema, contentFrontmatter: siteSchema, sitewideContent: sitewideSchema, theme: themeVisualSchema, rootTheme: rootThemeSchema, pageTheme: pageThemeSchema };
16
+
17
+ export const getSourcePageLocation = (siteRoot, directory) => {
18
+ const relative = path.relative(getSiteSourcePaths(siteRoot).root, directory);
19
+ if (!relative) return { isHome: true, ...parsePageDirectoryPath('.') };
20
+ if (!relative.startsWith(`pages${path.sep}`)) return null;
21
+ try { return { ...parsePageDirectoryPath(relative.slice(6).split(path.sep).join('/')), isHome: false }; }
22
+ catch { return null; }
23
+ };
24
+
25
+ export const getSourceFileDefinition = (siteRoot, filename) => {
26
+ const relative = path.relative(siteRoot, filename).split(path.sep).join('/');
27
+ const location = getSourcePageLocation(siteRoot, path.dirname(filename));
28
+ const definition = sourceFileDefinitions.find((entry) => entry.location === 'page'
29
+ ? location && path.basename(filename) === entry.name : relative === `${entry.location}/${entry.name}`);
30
+ if (!definition) return null;
31
+ const schemaKind = location?.isHome && definition.rootSchemaKind ? definition.rootSchemaKind : definition.schemaKind;
32
+ return { ...definition, schemaKind, text: location?.isHome && definition.rootText ? definition.rootText : definition.text, schema: schemaKind === 'contentFrontmatter' ? undefined : schemas[schemaKind],
33
+ required: definition.required === true || definition.required === 'root' && location?.isHome === true };
34
+ };
@@ -1,5 +1,7 @@
1
1
  import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
2
2
 
3
+ export const managedImageFilenamePattern = '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])';
4
+
3
5
  const stringSchema = (name, definition) => ({
4
6
  type: 'string',
5
7
  description: definition.description,
@@ -7,7 +9,7 @@ const stringSchema = (name, definition) => ({
7
9
  ...(definition.values ? { enum: Object.keys(definition.values) } : {}),
8
10
  ...(name === 'alt' ? {} : { minLength: 1 }),
9
11
  pattern: name === 'image'
10
- ? '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])'
12
+ ? managedImageFilenamePattern
11
13
  : name === 'caption' || name === 'text'
12
14
  ? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
13
15
  : name === 'alt'
@@ -1,77 +1,35 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import {
4
- validatePageThemeYamlStructure,
5
- validateThemeYamlStructure,
6
- } from './site-content.mjs';
7
- import {
8
- sitePagesDir,
9
- sitePagesLabel,
10
- siteThemeLabel,
11
- siteThemePath,
12
- } from './site-paths.mjs';
3
+ import { getSiteSourcePaths } from './site-conventions.mjs';
4
+ import { siteDir, siteThemePath } from './site-paths.mjs';
5
+ import { readThemePackage } from './theme-packages.mjs';
6
+ import { getSourcePageLocation } from './source-files.mjs';
13
7
  import { resolveThemePresentation } from './presentation.mjs';
14
- import { resolveThemeConfig } from './theme-presets.mjs';
15
- import { pageThemeSchema, themeVisualSchema } from './schema-definitions.mjs';
16
- import { parseYamlConfig } from './yaml-config.mjs';
17
8
 
18
9
  export const readThemeConfig = async () => {
19
- const themeFile = await readFile(siteThemePath, 'utf8').catch((error) => {
20
- if (error?.code === 'ENOENT') {
21
- throw new Error(`${siteThemeLabel} is required. Create it before running Norna.`);
22
- }
23
-
24
- throw error;
25
- });
26
- const config = parseYamlConfig(themeFile, siteThemeLabel, {
27
- schema: themeVisualSchema,
28
- validateStructure: validateThemeYamlStructure,
29
- });
30
- resolveThemeConfig(config, siteThemeLabel);
31
- resolveThemePresentation(config, siteThemeLabel);
32
-
33
- return config;
34
- };
35
-
36
- const getPageThemeFiles = async (directory, relativeDirectory = '') => {
37
- const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
38
- if (error?.code === 'ENOENT') return [];
39
- throw error;
40
- });
41
- const files = [];
42
-
43
- for (const entry of entries) {
44
- const relativePath = path.join(relativeDirectory, entry.name);
45
- const absolutePath = path.join(directory, entry.name);
46
- if (entry.isDirectory()) {
47
- files.push(...await getPageThemeFiles(absolutePath, relativePath));
48
- continue;
49
- }
50
-
51
- if (entry.name === 'theme.yaml') {
52
- files.push({
53
- path: absolutePath,
54
- label: `${sitePagesLabel}/${relativePath.split(path.sep).join('/')}`,
55
- });
56
- }
57
- }
58
-
59
- return files;
10
+ const theme = await readThemePackage({ siteRoot: siteDir, filename: siteThemePath });
11
+ resolveThemePresentation(theme.config, theme.label);
12
+ return theme.config;
60
13
  };
61
14
 
62
15
  export const validatePageThemeFiles = async () => {
63
- const files = await getPageThemeFiles(sitePagesDir);
64
- const configs = [];
65
-
66
- for (const file of files) {
67
- const source = await readFile(file.path, 'utf8');
68
- const config = parseYamlConfig(source, file.label, {
69
- schema: pageThemeSchema,
70
- validateStructure: validatePageThemeYamlStructure,
16
+ const themes = [];
17
+ const visit = async (directory) => {
18
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
19
+ if (error.code === 'ENOENT') return [];
20
+ throw error;
71
21
  });
72
-
73
- configs.push({ ...file, config });
74
- }
75
-
76
- return configs;
22
+ for (const entry of entries) {
23
+ const filename = path.join(directory, entry.name);
24
+ if (entry.isDirectory()) { await visit(filename); continue; }
25
+ if (!['tree-theme.yaml', 'page-theme.yaml', 'theme.yaml'].includes(entry.name)) continue;
26
+ const location = getSourcePageLocation(siteDir, directory);
27
+ if (!location) continue;
28
+ if (entry.name === 'theme.yaml') throw new Error(`${filename} uses the former theme filename. Use page-theme.yaml for one page only or tree-theme.yaml for inherited branch settings.`);
29
+ if (filename === siteThemePath) continue;
30
+ themes.push({ ...await readThemePackage({ siteRoot: siteDir, filename }), pageDirectory: location.pageDirectory });
31
+ }
32
+ };
33
+ await visit(getSiteSourcePaths(siteDir).root);
34
+ return themes;
77
35
  };
@@ -0,0 +1,47 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getPageDirectoryAncestors, parsePageDirectoryPath } from './page-model.mjs';
4
+ import { getPageSourceDirectory } from './site-conventions.mjs';
5
+ import { getSourceFileDefinition } from './source-files.mjs';
6
+ import { mergeThemeOverrides } from './theme-presets.mjs';
7
+ import { resolveThemePresentation } from './presentation.mjs';
8
+ import { validateThemeYamlStructure } from './site-content.mjs';
9
+ import { parseYamlConfig } from './yaml-config.mjs';
10
+
11
+ export const readThemePackage = async ({ siteRoot, filename, sources = new Map() }) => {
12
+ const definition = getSourceFileDefinition(siteRoot, filename);
13
+ if (!definition || !['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) throw new Error(`Not a permitted theme location: ${filename}.`);
14
+ const source = sources.has(filename) ? sources.get(filename) : await readFile(filename, 'utf8').catch((error) => {
15
+ if (error.code !== 'ENOENT') throw error;
16
+ if (definition.required) throw new Error(`${filename} is required. Create the root tree theme before running Norna.`);
17
+ return null;
18
+ });
19
+ if (source === null) return null;
20
+ return { path: filename, label: filename, config: parseYamlConfig(source, filename, { schema: definition.schema, validateStructure: validateThemeYamlStructure }) };
21
+ };
22
+
23
+ // Resolve authored overrides only; preset expansion stays in the shared visual
24
+ // resolvers. A page-only file is never considered while walking ancestors.
25
+ export const selectPageTheme = async ({ siteRoot, pageDirectory = '.', sources, includePageTheme = true }) => {
26
+ siteRoot = path.resolve(siteRoot);
27
+ parsePageDirectoryPath(pageDirectory);
28
+ const ancestors = pageDirectory === '.' ? ['.'] : ['.', ...getPageDirectoryAncestors(pageDirectory)];
29
+ let treeTheme;
30
+ const sourceFiles = [];
31
+ for (const ancestor of ancestors) {
32
+ const filename = path.join(siteRoot, getPageSourceDirectory(ancestor), 'tree-theme.yaml');
33
+ const candidate = await readThemePackage({ siteRoot, filename, sources });
34
+ if (candidate) {
35
+ treeTheme = { ...candidate, config: mergeThemeOverrides(treeTheme?.config, candidate.config) };
36
+ sourceFiles.push(candidate);
37
+ }
38
+ }
39
+ const pageTheme = includePageTheme
40
+ ? await readThemePackage({ siteRoot, filename: path.join(siteRoot, getPageSourceDirectory(pageDirectory), 'page-theme.yaml'), sources })
41
+ : null;
42
+ const selected = pageTheme ? { ...pageTheme, config: mergeThemeOverrides(treeTheme.config, pageTheme.config) } : treeTheme;
43
+ if (pageTheme) sourceFiles.push(pageTheme);
44
+ resolveThemePresentation(treeTheme.config, treeTheme.label);
45
+ resolveThemePresentation(selected.config, selected.label);
46
+ return { treeTheme, pageTheme, selected, sourceFiles };
47
+ };
@@ -93,7 +93,7 @@ export const getThemePresetMetadata = (presetName) => {
93
93
  };
94
94
  };
95
95
 
96
- export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
96
+ export const getThemePreset = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
97
97
  const preset = themePresets[presetName];
98
98
 
99
99
  if (!preset) {
@@ -103,62 +103,29 @@ export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
103
103
  return structuredClone(preset);
104
104
  };
105
105
 
106
- export const resolveThemeConfig = (theme = {}, sourceLabel = 'theme.yaml') => {
107
- const presetName = theme?.preset;
108
- if (presetName === undefined) return structuredClone(theme ?? {});
106
+ // Keep authored fields separate from preset defaults so navigation can distinguish
107
+ // an explicit surface request from an adaptable preset default.
108
+ export const mergeThemeOverrides = (base = {}, override = {}) => {
109
+ const merged = override.preset !== undefined ? structuredClone(override) : mergeDeep(base, override);
110
+ if (override.images?.presentation === 'prose-aligned' && override.images.maxAvailableHeightPercent === undefined) {
111
+ delete merged.images.maxAvailableHeightPercent;
112
+ }
113
+ return merged;
114
+ };
109
115
 
116
+ export const resolveThemeConfig = (theme = {}, sourceLabel = 'root/tree-theme.yaml') => {
117
+ const presetName = theme?.preset;
110
118
  const overrides = structuredClone(theme ?? {});
111
- delete overrides.preset;
112
-
113
- const resolved = {
114
- preset: presetName,
119
+ const resolved = presetName === undefined ? overrides : {
115
120
  ...mergeDeep(getThemePreset(presetName, sourceLabel), overrides),
116
121
  };
117
-
118
- if (
119
- overrides.images?.presentation === 'prose-aligned'
120
- && overrides.images.maxAvailableHeightPercent === undefined
121
- ) {
122
+ if (overrides.images?.presentation === 'prose-aligned' && overrides.images.maxAvailableHeightPercent === undefined) {
122
123
  delete resolved.images.maxAvailableHeightPercent;
123
124
  }
124
-
125
- return resolved;
126
- };
127
-
128
- const mergePageThemePart = (base, override, keys) => Object.fromEntries(keys
129
- .filter((key) => override?.[key] !== undefined || base?.[key] !== undefined)
130
- .map((key) => [key, override?.[key] ?? base?.[key]]));
131
-
132
- export const mergePageThemeConfig = (base = {}, override = {}) => {
133
- const images = {
134
- ...(base.images ?? {}),
135
- ...mergePageThemePart(base.images, override.images, [
136
- 'presentation',
137
- 'width',
138
- 'maxAvailableWidthPercent',
139
- 'maxAvailableHeightPercent',
140
- ]),
141
- };
142
-
143
- if (
144
- override.images?.presentation === 'prose-aligned'
145
- && override.images.maxAvailableHeightPercent === undefined
146
- ) {
147
- delete images.maxAvailableHeightPercent;
125
+ if (resolved.images?.presentation === 'prose-aligned' && resolved.images.maxAvailableHeightPercent !== undefined) {
126
+ throw new Error(`images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}. Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".`);
148
127
  }
149
-
150
- return {
151
- ...base,
152
- layout: {
153
- ...(base.layout ?? {}),
154
- ...mergePageThemePart(base.layout, override.layout, ['contentSpacing', 'textWidth']),
155
- },
156
- images,
157
- sections: {
158
- ...(base.sections ?? {}),
159
- ...mergePageThemePart(base.sections, override.sections, ['backgroundPattern']),
160
- },
161
- };
128
+ return resolved;
162
129
  };
163
130
 
164
131
  const quote = (value) => JSON.stringify(value);
@@ -171,7 +138,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
171
138
  ];
172
139
  };
173
140
 
174
- export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
141
+ export const renderThemePresetReference = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
175
142
  const preset = getThemePreset(presetName, sourceLabel);
176
143
  const metadata = getThemePresetMetadata(presetName);
177
144
  const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
@@ -179,8 +146,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
179
146
  return [
180
147
  `# Original values for Norna's "${presetName}" theme preset.`,
181
148
  `# ${metadata.description}`,
182
- '# This is a reference file. Norna only loads theme.yaml.',
183
- '# Keep the preset in theme.yaml and copy only the values you want to override.',
149
+ '# This is a reference file. Norna loads root/tree-theme.yaml for the shared theme.',
150
+ '# Keep the preset in root/tree-theme.yaml and copy only the values you want to override.',
184
151
  `# Available theme presets: ${themePresetNames.join(', ')}.`,
185
152
  `preset: ${presetName}`,
186
153
  '',
@@ -152,7 +152,7 @@ const evaluateResponsiveSize = (value, viewportWidth) => {
152
152
  return Math.min(parsed.maximumRem * 16, Math.max(parsed.minimumRem * 16, preferredPixels));
153
153
  };
154
154
 
155
- export const assertTypographyContract = (typography, sourceLabel = 'theme.yaml') => {
155
+ export const assertTypographyContract = (typography, sourceLabel = 'root/tree-theme.yaml') => {
156
156
  const values = typography?.values ?? typography;
157
157
  if (!values?.headings || !values?.body || !values?.caption) {
158
158
  throw new Error(`Incomplete typography configuration in ${sourceLabel}.`);
@@ -427,7 +427,7 @@ export const defaultTypography = {
427
427
  rhythm: 'normal',
428
428
  };
429
429
 
430
- export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'theme.yaml') => {
430
+ export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'root/tree-theme.yaml') => {
431
431
  const profileName = typography?.profile ?? defaultTypography.profile;
432
432
  const rhythmName = typography?.rhythm ?? defaultTypography.rhythm;
433
433
  const profile = typographyProfiles[profileName];
@@ -60,7 +60,7 @@ export const parseYamlConfig = (source, label, {
60
60
  if (issues.length > 0) {
61
61
  throw new Error([
62
62
  `${label} has invalid YAML structure.`,
63
- ...issues.map((issue) => `- ${issue.message}`),
63
+ ...issues.map((issue) => `- ${issue.message}${issue.fix ? ` ${issue.fix}` : ''}`),
64
64
  ].join('\n'));
65
65
  }
66
66