@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
@@ -1 +1,19 @@
1
- export const homePageDirectory = '000-home';
1
+ import path from 'node:path';
2
+
3
+ // Logical identities stay independent of the physical root/ directory.
4
+ export const homePageDirectory = '.';
5
+ export const legacyHomePageDirectory = '000-home';
6
+
7
+ export const getSiteSourcePaths = (siteRoot) => {
8
+ const root = path.join(siteRoot, 'root');
9
+ return { root, pages: path.join(root, 'pages'), content: path.join(root, 'content.md'),
10
+ images: path.join(root, 'images'), theme: path.join(root, 'tree-theme.yaml') };
11
+ };
12
+
13
+ export const getPageSourceDirectory = (pageDirectory) => (
14
+ pageDirectory === homePageDirectory ? 'root' : `root/pages/${pageDirectory}`
15
+ );
16
+
17
+ export const getPageImageSourceKey = (pageDirectory, image) => (
18
+ [getPageSourceDirectory(pageDirectory), 'images', image].filter(Boolean).join('/')
19
+ );
@@ -30,7 +30,6 @@ export const getListedSiteNavigationTree = (entries) => {
30
30
  const filterListed = (nodes) => nodes.flatMap((node) => {
31
31
  if (!node.node.isHome && !node.node.navigation.listed) return [];
32
32
  const children = filterListed(node.children);
33
- if (node.node.kind === 'category' && children.length === 0) return [];
34
33
  return [{ ...node, children }];
35
34
  });
36
35
 
@@ -48,9 +47,7 @@ export const getSequentialPageNavigation = (nodes, currentPagePath) => {
48
47
  ));
49
48
  if (!activeRoot) return { previous: null, next: null };
50
49
 
51
- const pages = flattenSiteNavigationTree([activeRoot])
52
- .filter(({ node }) => node.kind === 'page')
53
- .map(({ node }) => node);
50
+ const pages = flattenSiteNavigationTree([activeRoot]).map(({ node }) => node);
54
51
  const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
55
52
  if (currentIndex < 0) return { previous: null, next: null };
56
53
 
@@ -68,7 +65,5 @@ export const getDirectChildPages = (nodes, currentPagePath) => {
68
65
  ? nodes.filter(({ node }) => !node.isHome)
69
66
  : current.children;
70
67
 
71
- return children
72
- .filter(({ node }) => node.kind === 'page')
73
- .map(({ node }) => node);
68
+ return children.map(({ node }) => node);
74
69
  };
@@ -0,0 +1,169 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
2
+ import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { dump as dumpYaml } from 'js-yaml';
5
+ import { slugifyAsciiIdentifier } from './heading-ids.mjs';
6
+ import { pageDirectoryPattern } from './page-model.mjs';
7
+ import { getSiteStructure } from './site-structure.mjs';
8
+ import { siteSchema } from './schema-definitions.mjs';
9
+ import { readEditorLinkState } from './editor-site-links.mjs';
10
+ import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
11
+ import { parseYamlConfig } from './yaml-config.mjs';
12
+
13
+ const parseOrder = (value) => {
14
+ if (!/^\d{1,3}$/.test(value)) {
15
+ throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
16
+ }
17
+ const order = Number.parseInt(value, 10);
18
+ if (order < 1 || order > 999) {
19
+ throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
20
+ }
21
+ return order;
22
+ };
23
+
24
+ const normalizeParentPath = (value) => {
25
+ if (value === '/') return '';
26
+ if (value.includes('\\') || value.includes('?') || value.includes('#')) {
27
+ throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
28
+ }
29
+ const normalized = value.replace(/^\/+|\/+$/g, '');
30
+ if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
31
+ throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
32
+ }
33
+ return normalized;
34
+ };
35
+
36
+ const getNextOrder = (siblings) => {
37
+ const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
38
+ const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
39
+ if (nextOrder > 999) {
40
+ throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
41
+ }
42
+ return nextOrder;
43
+ };
44
+
45
+ export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
46
+
47
+ const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
48
+ if (error?.code === 'ENOENT') return path.resolve(directory);
49
+ throw error;
50
+ });
51
+
52
+ const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) => {
53
+ const sitePagesDir = getSiteSourcePaths(siteRoot).pages;
54
+ if (parent !== null) {
55
+ const parentPath = normalizeParentPath(parent);
56
+ if (!parentPath) {
57
+ return { collectionDir: sitePagesDir, node: null, pagePath: '' };
58
+ }
59
+ const node = nodes.find((candidate) => candidate.pagePath === parentPath);
60
+ if (!node) {
61
+ throw new Error(`Cannot find parent "${parent}". Use an existing page path, or / for the top level.`);
62
+ }
63
+ return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
64
+ }
65
+
66
+ const currentDirectory = await canonicalDirectory(invocationDirectory);
67
+ if (currentDirectory === await canonicalDirectory(sitePagesDir) || currentDirectory === await canonicalDirectory(siteRoot)) {
68
+ return { collectionDir: sitePagesDir, node: null, pagePath: '' };
69
+ }
70
+ let node = null;
71
+ for (const candidate of nodes) {
72
+ if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
73
+ node = candidate;
74
+ break;
75
+ }
76
+ }
77
+ if (!node) {
78
+ throw new Error([
79
+ 'Cannot infer where to add the node from the current directory.',
80
+ `Run the command from ${sitePagesDir} for a top-level node, from an existing page directory for a child, or pass --parent.`,
81
+ ].join('\n'));
82
+ }
83
+ return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
84
+ };
85
+
86
+ // Planning never writes. Callers must present this same destination before applying.
87
+ export const planSiteNodeCreation = async ({
88
+ siteRoot, kind, title, slug = null, order = null, parentPath = '/',
89
+ invocationDirectory = siteRoot, metadata, sources = new Map(),
90
+ }) => {
91
+ if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
92
+ siteRoot = path.resolve(siteRoot);
93
+ title = String(title ?? '').trim();
94
+ if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
95
+ const generatedSlug = slug === null;
96
+ slug ??= slugifyAsciiIdentifier(title);
97
+ if (!slug || !pageDirectoryPattern.test(`010-${slug}`)) {
98
+ throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
99
+ }
100
+
101
+ const structure = await getSiteStructure({ siteRoot });
102
+ const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
103
+ const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null));
104
+ if (siblings.some((node) => node.pageId === slug)) {
105
+ throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
106
+ }
107
+ order = order === null ? getNextOrder(siblings) : parseOrder(String(order));
108
+ if (siblings.some((node) => node.pageOrder === order)) {
109
+ throw new Error(`Cannot use order ${String(order).padStart(3, '0')} below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling already uses it.`);
110
+ }
111
+
112
+ const directoryName = `${String(order).padStart(3, '0')}-${slug}`;
113
+ const destination = path.join(parent.collectionDir, directoryName);
114
+ const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
115
+ const state = await readEditorLinkState({ siteRoot, sources });
116
+ if (state.incomplete.length) throw new Error(`Page addresses could not be checked. ${state.incomplete.join('\n')}`);
117
+ const address = `/${pagePath}/`;
118
+ const owner = state.graph.aliasModel.identitiesByPathname.get(address);
119
+ if (owner?.kind === 'page-alias') {
120
+ const page = owner.source.page;
121
+ throw new Error(`${address} is a previous address for “${page.title}”, now at ${owner.source.targetPathname}. Choose another URL segment, or remove this previous address from “${page.title}” through Addresses and links… → Additional addresses….`);
122
+ }
123
+ if (owner) throw new Error(`Address ${address} is already in use by ${owner.label}. Choose another URL segment.`);
124
+ if (metadata !== undefined) {
125
+ metadata = parseYamlConfig(dumpYaml(metadata), 'Page information', { schema: siteSchema });
126
+ assertPageAliasModel(createPageAliasModel({ pages: [...state.graph.pages,
127
+ { pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
128
+ categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
129
+ }
130
+ return { siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
131
+ collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
132
+ };
133
+
134
+ export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
135
+ // Revalidate after the author has inspected the preview. Never change its placement.
136
+ const current = await planSiteNodeCreation({ ...plan, sources });
137
+ if (current.destination !== plan.destination) throw new Error('The site changed. Preview the new location before creating the node.');
138
+ let createdCollection = false;
139
+ try {
140
+ const info = await stat(current.collectionDir);
141
+ if (!info.isDirectory()) throw new Error(`${current.collectionDir} exists but is not a directory.`);
142
+ } catch (error) {
143
+ if (error.code !== 'ENOENT') throw error;
144
+ await mkdir(current.collectionDir);
145
+ createdCollection = true;
146
+ }
147
+ const created = [];
148
+ try {
149
+ // Exclusive reservation also protects an empty destination created after planning.
150
+ await mkdir(current.destination);
151
+ created.push({ file: current.destination, directory: true });
152
+ const filename = path.join(current.destination, 'content.md');
153
+ const source = `${current.metadata && Object.keys(current.metadata).length ? `---\n${dumpYaml(current.metadata, { lineWidth: -1, noRefs: true })}---\n\n` : ''}# ${escapeMarkdownHeading(current.title)}\n\n## Introduction\n\nStart writing here.\n`;
154
+ await writeFile(filename, source, { flag: 'wx' });
155
+ created.push({ file: filename });
156
+ const childDirectory = path.join(current.destination, 'images');
157
+ await mkdir(childDirectory);
158
+ created.push({ file: childDirectory, directory: true });
159
+ return { ...current, sourcePath: filename };
160
+ } catch (error) {
161
+ // Remove only entries this operation created; never recursively remove an
162
+ // unexpected file written concurrently by another process.
163
+ for (const entry of created.reverse()) {
164
+ await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
165
+ }
166
+ if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
167
+ throw error;
168
+ }
169
+ };
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { homePageDirectory } from './site-conventions.mjs';
4
+ import { getSiteSourcePaths, homePageDirectory, legacyHomePageDirectory } from './site-conventions.mjs';
5
5
 
6
6
  const siteDirectoryEnvName = 'NORNA_SITE_DIR';
7
7
  const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
@@ -32,9 +32,10 @@ if (hasStateDirectoryEnv && !configuredStateDirectory) {
32
32
 
33
33
  const hasSiteFilesInDirectory = (siteDir) => {
34
34
  return (
35
- existsSync(path.join(siteDir, 'config.yaml'))
35
+ (existsSync(path.join(siteDir, 'site-config', 'settings.yaml')) || existsSync(path.join(siteDir, 'config.yaml')))
36
36
  && (
37
- existsSync(path.join(siteDir, 'pages', homePageDirectory, 'content.md'))
37
+ existsSync(path.join(siteDir, 'pages', legacyHomePageDirectory, 'content.md'))
38
+ || existsSync(getSiteSourcePaths(siteDir).content)
38
39
  || existsSync(path.join(siteDir, 'content.md'))
39
40
  )
40
41
  );
@@ -63,6 +64,16 @@ const findSiteProjectRoot = (startDirectory, siteDirectory) => {
63
64
  }
64
65
  };
65
66
 
67
+ const findContainingSite = (startDirectory) => {
68
+ let current = path.resolve(startDirectory);
69
+ while (true) {
70
+ if (hasSiteFilesInDirectory(current)) return current;
71
+ const parent = path.dirname(current);
72
+ if (parent === current) return null;
73
+ current = parent;
74
+ }
75
+ };
76
+
66
77
  const hasSiteDirectoryEnv = Object.hasOwn(process.env, siteDirectoryEnvName);
67
78
  const configuredSiteDirectory = normalizeSiteDirectory(process.env[siteDirectoryEnvName]);
68
79
  const hasConfiguredSiteDirectory = hasSiteDirectoryEnv && configuredSiteDirectory !== '';
@@ -84,8 +95,9 @@ const resolveSitePaths = () => {
84
95
  };
85
96
  }
86
97
 
87
- if (!hasConfiguredSiteDirectory && hasSiteFilesInDirectory(invocationRoot)) {
88
- const siteDir = invocationRoot;
98
+ const containingSite = !hasConfiguredSiteDirectory && findContainingSite(invocationRoot);
99
+ if (containingSite) {
100
+ const siteDir = containingSite;
89
101
  const siteProjectRoot = path.dirname(siteDir);
90
102
 
91
103
  return {
@@ -126,11 +138,11 @@ const getPathLabel = (filePath) => {
126
138
  };
127
139
 
128
140
  export const siteDir = resolvedSitePaths.siteDir;
129
- export const siteConfigPath = path.join(siteDir, 'config.yaml');
130
- export const siteThemePath = path.join(siteDir, 'theme.yaml');
131
- export const sitewideContentPath = path.join(siteDir, 'sitewide-content.yaml');
132
- export const sitePagesDir = path.join(siteDir, 'pages');
133
- export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
141
+ export const siteConfigPath = path.join(siteDir, 'site-config', 'settings.yaml');
142
+ export const siteThemePath = getSiteSourcePaths(siteDir).theme;
143
+ export const sitewideContentPath = path.join(siteDir, 'site-config', 'shared-content.yaml');
144
+ export const sitePagesDir = getSiteSourcePaths(siteDir).pages;
145
+ export const siteHomePageDir = getSiteSourcePaths(siteDir).root;
134
146
  export const siteContentPath = path.join(siteHomePageDir, 'content.md');
135
147
  export const siteImagesDir = path.join(siteHomePageDir, 'images');
136
148
  export const sitePublicDir = path.join(siteDir, 'public');
@@ -1,16 +1,12 @@
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
5
  import {
6
6
  homePageDirectory,
7
- siteContentLabel,
8
- siteDir,
9
- siteDirLabel,
10
- sitePagesDir,
11
- sitePagesLabel,
7
+ siteDir as defaultSiteDir,
8
+ siteDirLabel as defaultSiteDirLabel,
12
9
  } from './site-paths.mjs';
13
- import { parseYamlConfig } from './yaml-config.mjs';
14
10
 
15
11
  const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
16
12
 
@@ -24,21 +20,11 @@ const compareNodeMetadata = (left, right) => (
24
20
  || left.pageId.localeCompare(right.pageId, 'en')
25
21
  );
26
22
 
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.`);
23
+ const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
24
+ for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes']) {
25
+ if (await fileExists(path.join(siteDir, name))) {
26
+ 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.`);
27
+ }
42
28
  }
43
29
  };
44
30
 
@@ -69,13 +55,29 @@ const assertUniqueSiblings = (nodes, pagesLabel) => {
69
55
  }
70
56
  };
71
57
 
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();
58
+ // Editors can inspect several sites in one process and retain malformed nodes
59
+ // for repair. Builds and mutation plans keep the strict default contract.
60
+ export const getSiteStructure = async ({
61
+ siteRoot = defaultSiteDir,
62
+ tolerant = false,
63
+ readSource = (filePath) => readFile(filePath, 'utf8'),
64
+ } = {}) => {
65
+ const siteDir = path.resolve(siteRoot);
66
+ const siteDirLabel = siteDir === defaultSiteDir ? defaultSiteDirLabel : siteDir;
67
+ const sitePagesDir = getSiteSourcePaths(siteDir).pages;
68
+ const sitePagesLabel = `${siteDirLabel}/root/pages`;
69
+ const siteContentLabel = `${siteDirLabel}/root/content.md`;
70
+ const problems = [];
71
+ const report = (error, node = null) => {
72
+ if (!tolerant) throw error;
73
+ problems.push({ message: error.message, path: node?.contentPath ?? node?.categoryPath ?? siteDir });
74
+ if (node) (node.problems ??= []).push(error.message);
75
+ };
76
+ try {
77
+ await assertLegacyStructureIsAbsent({ siteDir, siteDirLabel, sitePagesLabel });
78
+ } catch (error) {
79
+ report(error);
80
+ }
79
81
 
80
82
  const nodes = [];
81
83
  const warnings = [];
@@ -98,90 +100,78 @@ export const getSiteStructure = async () => {
98
100
  fileExists(contentPath),
99
101
  fileExists(categoryPath),
100
102
  ]);
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.`);
103
+ let metadata;
104
+ try {
105
+ metadata = parsePageDirectoryPath(pageDirectory, nodeLabel);
106
+ } catch (error) {
107
+ report(error);
108
+ continue;
108
109
  }
109
110
 
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.`);
111
+ if (hasCategory) report(new Error(`${nodeLabel}/category.yaml is no longer supported. Create content.md with an H1 and page.listChildren: true instead.`));
112
+ if (!hasContent) {
113
+ report(new Error(`${nodeLabel} has no content.md. Every page directory needs a content.md file.`));
114
+ continue;
113
115
  }
114
116
 
115
117
  const node = {
116
118
  ...metadata,
117
- isHome,
118
- kind: hasCategory ? 'category' : 'page',
119
+ isHome: false,
120
+ kind: 'page',
119
121
  nodeDir,
120
122
  nodeLabel,
121
- pagePath: isHome ? '' : metadata.pagePath,
122
- parentPagePath: isHome ? null : metadata.parentPagePath,
123
+ pagePath: metadata.pagePath,
124
+ parentPagePath: metadata.parentPagePath,
123
125
  };
124
126
 
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
- }
127
+ Object.assign(node, {
128
+ contentLabel: `${nodeLabel}/content.md`,
129
+ contentPath,
130
+ imagesDir: path.join(nodeDir, 'images'),
131
+ imagesLabel: `${nodeLabel}/images`,
132
+ });
143
133
 
144
134
  siblingNodes.push(node);
145
135
  }
146
136
 
147
- assertUniqueSiblings(siblingNodes, pagesLabel);
137
+ try {
138
+ assertUniqueSiblings(siblingNodes, pagesLabel);
139
+ } catch (error) {
140
+ report(error);
141
+ }
148
142
 
149
143
  for (const node of siblingNodes.sort(compareNodeMetadata)) {
150
144
  nodes.push(node);
151
145
  const childPagesDir = path.join(node.nodeDir, 'pages');
152
146
  const childDirectories = (await readDirectory(childPagesDir)).filter((entry) => entry.isDirectory());
153
147
 
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) {
148
+ if (childDirectories.length > 0) {
171
149
  await collectNodes(childPagesDir, `${node.nodeLabel}/pages`, node.pageDirectory);
172
150
  }
173
151
  }
174
152
  };
175
153
 
176
- await collectNodes(sitePagesDir, sitePagesLabel);
177
- if (!nodes.some(({ isHome }) => isHome)) {
178
- throw new Error(`Homepage content is missing. Create ${siteContentLabel}.`);
154
+ const rootContentPath = getSiteSourcePaths(siteDir).content;
155
+ if (await fileExists(path.join(getSiteSourcePaths(siteDir).root, 'category.yaml'))) {
156
+ report(new Error(`${siteDirLabel}/root/category.yaml is invalid. The site root must be a page with content.md.`));
179
157
  }
158
+ if (await fileExists(rootContentPath)) {
159
+ nodes.push({
160
+ ...parsePageDirectoryPath(homePageDirectory),
161
+ kind: 'page', isHome: true, nodeDir: getSiteSourcePaths(siteDir).root, nodeLabel: `${siteDirLabel}/root`,
162
+ contentPath: rootContentPath, contentLabel: siteContentLabel,
163
+ imagesDir: getSiteSourcePaths(siteDir).images, imagesLabel: `${siteDirLabel}/root/images`,
164
+ });
165
+ } else {
166
+ report(new Error(`Homepage content is missing. Create ${siteContentLabel}.`));
167
+ }
168
+ await collectNodes(sitePagesDir, sitePagesLabel);
180
169
 
181
170
  return {
182
171
  categories: nodes.filter(({ kind }) => kind === 'category'),
183
172
  contentFiles: nodes.filter(({ kind }) => kind === 'page'),
184
173
  nodes,
174
+ ...(tolerant ? { problems } : {}),
185
175
  warnings,
186
176
  };
187
177
  };
@@ -0,0 +1,30 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { readThemePackage } from './theme-packages.mjs';
4
+ import { getSiteSourcePaths } from './site-conventions.mjs';
5
+
6
+ const inspect = (filename) => lstat(filename).catch((error) => {
7
+ if (error.code === 'ENOENT') return null;
8
+ throw error;
9
+ });
10
+
11
+ // Keep the existing command without claiming the former layout is current.
12
+ // Our maintained sites are converted directly; no general converter is needed.
13
+ export const planSiteUpgrade = async (siteRoot) => {
14
+ siteRoot = path.resolve(siteRoot);
15
+ const source = getSiteSourcePaths(siteRoot);
16
+ if (!(await inspect(siteRoot))?.isDirectory()) throw new Error(`${siteRoot} must be a regular directory, not a symbolic link.`);
17
+ 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']) {
18
+ if (await inspect(path.join(siteRoot, name))) {
19
+ 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.`);
20
+ }
21
+ }
22
+ for (const directory of [source.root, path.join(siteRoot, 'site-config')]) {
23
+ if (!(await inspect(directory))?.isDirectory()) throw new Error(`${directory} must be a regular directory, not a symbolic link.`);
24
+ }
25
+ for (const filename of [source.content, path.join(siteRoot, 'site-config', 'settings.yaml'), source.theme]) {
26
+ if (!(await inspect(filename))?.isFile()) throw new Error(`${filename} is required and must be a regular file.`);
27
+ }
28
+ await readThemePackage({ siteRoot, filename: source.theme });
29
+ return { siteRoot, moves: [] };
30
+ };
@@ -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.replace('^', '^(?!000-)');
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
+ };