@janga/norna 0.7.27 → 0.7.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +37 -28
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +9 -5
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +66 -66
  7. package/schemas/content-frontmatter.schema.json +16 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +13 -161
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +17 -10
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +7 -6
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/lib/category-destinations.mjs +13 -12
  21. package/scripts/lib/documentation-links.mjs +1 -1
  22. package/scripts/lib/documentation-routes.json +67 -60
  23. package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
  24. package/scripts/lib/editor-file-policy.mjs +18 -0
  25. package/scripts/lib/editor-language-service.mjs +53 -55
  26. package/scripts/lib/editor-page-addresses.mjs +55 -0
  27. package/scripts/lib/editor-page-placement.mjs +172 -0
  28. package/scripts/lib/editor-site-editing-tree.mjs +146 -0
  29. package/scripts/lib/editor-site-files.mjs +153 -0
  30. package/scripts/lib/editor-site-links.mjs +79 -0
  31. package/scripts/lib/editor-site-tree.mjs +151 -0
  32. package/scripts/lib/editor-source-files.mjs +87 -0
  33. package/scripts/lib/editor-theme-help.mjs +43 -0
  34. package/scripts/lib/locale-registry.mjs +1 -1
  35. package/scripts/lib/locales/en.mjs +1 -1
  36. package/scripts/lib/navigation-model.mjs +2 -44
  37. package/scripts/lib/navigation-review.mjs +14 -50
  38. package/scripts/lib/norna-markdown-blocks.mjs +1 -1
  39. package/scripts/lib/page-aliases.mjs +3 -1
  40. package/scripts/lib/page-model.mjs +17 -20
  41. package/scripts/lib/page-move-apply.mjs +117 -0
  42. package/scripts/lib/page-move-plan.mjs +27 -59
  43. package/scripts/lib/presentation.mjs +3 -3
  44. package/scripts/lib/project-config.mjs +7 -8
  45. package/scripts/lib/schema-definitions.mjs +11 -38
  46. package/scripts/lib/schema-editor-metadata.mjs +12 -59
  47. package/scripts/lib/schema-value-definitions.mjs +1 -1
  48. package/scripts/lib/site-content.mjs +7 -24
  49. package/scripts/lib/site-conventions.mjs +19 -1
  50. package/scripts/lib/site-navigation-tree.mjs +2 -7
  51. package/scripts/lib/site-node-create.mjs +169 -0
  52. package/scripts/lib/site-paths.mjs +22 -10
  53. package/scripts/lib/site-structure.mjs +72 -82
  54. package/scripts/lib/site-upgrade.mjs +30 -0
  55. package/scripts/lib/source-files.mjs +34 -0
  56. package/scripts/lib/theme-config.mjs +26 -68
  57. package/scripts/lib/theme-packages.mjs +47 -0
  58. package/scripts/lib/theme-presets.mjs +20 -53
  59. package/scripts/lib/typography.mjs +2 -2
  60. package/scripts/lib/yaml-config.mjs +1 -1
  61. package/scripts/lib/yaml-source-edit.mjs +94 -0
  62. package/scripts/move-site-page.mjs +3 -124
  63. package/scripts/show-typography.mjs +32 -17
  64. package/scripts/sync-content-sections.mjs +9 -7
  65. package/scripts/sync-site-public.mjs +1 -1
  66. package/scripts/upgrade-site.mjs +17 -0
  67. package/src/components/AreaNavigationMenu.astro +24 -0
  68. package/src/components/AreaNavigationPanel.astro +55 -0
  69. package/src/components/AreaNavigationSwitcher.astro +30 -0
  70. package/src/components/ImageStackEnhancement.astro +0 -7
  71. package/src/components/NavigationContinuity.astro +26 -0
  72. package/src/components/NavigationPageTree.astro +13 -4
  73. package/src/components/NavigationState.astro +11 -0
  74. package/src/components/NavigationTreeControls.astro +3 -2
  75. package/src/components/SearchPage.astro +9 -1
  76. package/src/components/SectionNavigationScript.astro +106 -26
  77. package/src/components/SiteNavigation.astro +45 -20
  78. package/src/components/SitePage.astro +37 -61
  79. package/src/components/SiteTreeNavigation.astro +38 -18
  80. package/src/components/TopPageMenu.astro +4 -3
  81. package/src/components/TreeNavigationScript.astro +50 -57
  82. package/src/content.config.ts +10 -8
  83. package/src/layouts/BaseLayout.astro +15 -39
  84. package/src/lib/areaNavigation.ts +42 -0
  85. package/src/lib/navigationContinuity.ts +212 -0
  86. package/src/lib/navigationFollowing.ts +28 -18
  87. package/src/lib/navigationState.mjs +88 -0
  88. package/src/lib/navigationStateScript.mjs +76 -0
  89. package/src/lib/navigationVisibility.ts +9 -0
  90. package/src/lib/pageThemes.ts +5 -42
  91. package/src/lib/sectionContent.ts +3 -2
  92. package/src/lib/siteNavigation.ts +6 -0
  93. package/src/lib/sitePages.ts +9 -6
  94. package/src/pages/404.astro +1 -1
  95. package/src/pages/[...slug].astro +5 -15
  96. package/src/pages/index.astro +1 -3
  97. package/src/styles/area-navigation.css +399 -0
  98. package/src/styles/content.css +2 -29
  99. package/src/styles/navigation-continuity.css +163 -0
  100. package/src/styles/page-layout.css +57 -68
  101. package/src/styles/responsive.css +0 -4
  102. package/starters/basic/README.md +9 -8
  103. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  104. package/schemas/category.schema.json +0 -20
  105. package/src/components/PageContentsNavigation.astro +0 -20
  106. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  107. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  108. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  109. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -0,0 +1,172 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { rename } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { checkedPath, snapshot } from './editor-site-files.mjs';
5
+ import { readEditorLinkState } from './editor-site-links.mjs';
6
+ import { createPageMovePlan } from './page-move-plan.mjs';
7
+ import { applyPageMovePlan } from './page-move-apply.mjs';
8
+ import { getSiteSourcePaths } from './site-conventions.mjs';
9
+ import { getSiteStructure } from './site-structure.mjs';
10
+
11
+ export const sitePagePlacementApiVersion = 1;
12
+
13
+ const urlFor = (node) => node.isHome ? '/' : `/${node.pagePath}/`;
14
+ const childOf = (node, parent) => node.parentPagePath === (parent.pagePath || null);
15
+ const inBranch = (node, ancestor) => node.pagePath === ancestor.pagePath || node.pagePath.startsWith(`${ancestor.pagePath}/`);
16
+ const pad = (order) => String(order).padStart(3, '0');
17
+ const renamedDirectory = (node, order) => path.join(path.dirname(node.nodeDir), `${pad(order)}-${node.pageId}`);
18
+
19
+ const explainDestination = (owner, address) => {
20
+ if (owner.kind === 'page') return `Cannot move here. ${address} already belongs to “${owner.source.title}”. Choose another location.`;
21
+ if (owner.kind === 'page-alias') return `Cannot move here. ${address} is a previous address for “${owner.source.page.title}” at ${owner.source.targetPathname}. Choose another location.`;
22
+ if (owner.kind === 'public-file') return `Cannot move here. ${address} is used by public file ${owner.label}. Choose another location.`;
23
+ if (owner.kind === 'generated-route') return `Cannot move here. ${address} is used by generated route ${owner.label}. Choose another location.`;
24
+ return `Cannot move here. ${address} is already used by ${owner.label}. Choose another location.`;
25
+ };
26
+
27
+ const orderForInsertion = (siblings, index) => {
28
+ const before = siblings[index - 1]?.pageOrder ?? 0;
29
+ const after = siblings[index]?.pageOrder ?? 1000;
30
+ return after - before > 1 ? Math.floor((before + after) / 2) : null;
31
+ };
32
+
33
+ const applyDirectoryRenames = async (changes, renameDirectory) => {
34
+ const staged = changes.map((change) => ({ ...change,
35
+ temporary: path.join(path.dirname(change.from), `.${path.basename(change.from)}.norna-order-${randomUUID()}.tmp`),
36
+ current: change.from }));
37
+ const restore = async () => {
38
+ const failures = [];
39
+ for (const change of staged.filter((entry) => entry.current === entry.to).reverse()) {
40
+ try { await renameDirectory(change.to, change.temporary); change.current = change.temporary; }
41
+ catch (error) { failures.push(`${change.to}: ${error.message}`); }
42
+ }
43
+ for (const change of staged.filter((entry) => entry.current === entry.temporary).reverse()) {
44
+ try { await renameDirectory(change.temporary, change.from); change.current = change.from; }
45
+ catch (error) { failures.push(`${change.from}: ${error.message}`); }
46
+ }
47
+ return failures;
48
+ };
49
+ try {
50
+ for (const change of staged) {
51
+ await renameDirectory(change.from, change.temporary);
52
+ change.current = change.temporary;
53
+ }
54
+ for (const change of staged) {
55
+ await renameDirectory(change.temporary, change.to);
56
+ change.current = change.to;
57
+ }
58
+ } catch (error) {
59
+ const failures = await restore();
60
+ throw new Error(`${error.message}${failures.length ? `\nRestoration was incomplete. Inspect:\n${failures.join('\n')}` : '\nThe original page order was restored.'}`);
61
+ }
62
+ return restore;
63
+ };
64
+
65
+ export const planEditorPagePlacement = async ({ siteRoot, sourcePath, targetPath, placement, sources = new Map() }) => {
66
+ if (!['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first child or last child.');
67
+ siteRoot = path.resolve(siteRoot);
68
+ await checkedPath(siteRoot, sourcePath);
69
+ await checkedPath(siteRoot, targetPath);
70
+ const state = await readEditorLinkState({ siteRoot, sources });
71
+ if (state.incomplete.length) throw new Error(`Repair the site before moving a page. ${state.incomplete.join('\n')}`);
72
+ const contentPaths = new Set(state.structure.nodes.map((node) => node.contentPath ?? node.categoryPath));
73
+ const dirtyPaths = (files) => `Save or undo these unsaved files before moving:\n${files.map((filename) => `- ${path.relative(siteRoot, filename)}`).join('\n')}`;
74
+ const dirtySiteFiles = [...sources.keys()].filter((filename) => contentPaths.has(filename)
75
+ || filename === path.join(siteRoot, 'site-config/settings.yaml')
76
+ || filename === sourcePath || filename.startsWith(path.dirname(sourcePath) + path.sep));
77
+ if (dirtySiteFiles.length) throw new Error(dirtyPaths(dirtySiteFiles));
78
+ const source = state.structure.nodes.find((node) => node.contentPath === sourcePath);
79
+ const target = state.structure.nodes.find((node) => node.contentPath === targetPath);
80
+ if (!source || source.isHome || !target) throw new Error('Select an existing page below the homepage and an existing destination page.');
81
+ const parent = placement === 'first' || placement === 'last' ? target
82
+ : state.structure.nodes.find((node) => node.pagePath === target.parentPagePath)
83
+ ?? state.structure.nodes.find((node) => node.isHome && !target.parentPagePath);
84
+ if (!parent || inBranch(parent, source)) throw new Error('A page cannot move inside its own branch. Choose another destination.');
85
+ const sameParent = source.parentPagePath === (parent.pagePath || null);
86
+ const existing = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && childOf(node, parent) && node.contentPath !== sourcePath)
87
+ .sort((left, right) => left.pageOrder - right.pageOrder);
88
+ const targetIndex = existing.findIndex((node) => node.contentPath === targetPath);
89
+ if ((placement === 'before' || placement === 'after') && targetIndex < 0) throw new Error('The destination page changed. Refresh Site Tree and try again.');
90
+ const index = placement === 'first' ? 0 : placement === 'last' ? existing.length
91
+ : targetIndex + (placement === 'after' ? 1 : 0);
92
+ const current = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && node.parentPagePath === source.parentPagePath)
93
+ .sort((left, right) => left.pageOrder - right.pageOrder);
94
+ if (sameParent && current[index]?.contentPath === sourcePath) throw new Error('This page is already in that position.');
95
+ if (existing.length >= 999) throw new Error('This parent already has the maximum of 999 pages. Choose another parent.');
96
+ const inserted = [...existing];
97
+ inserted.splice(index, 0, source);
98
+ let sourceOrder = orderForInsertion(existing, index);
99
+ const orderChanges = [];
100
+ if (sourceOrder === null) {
101
+ const step = Math.floor(999 / inserted.length);
102
+ for (let position = 0; position < inserted.length; position += 1) {
103
+ const node = inserted[position];
104
+ const order = (position + 1) * step;
105
+ if (node.contentPath === sourcePath) sourceOrder = order;
106
+ else if (node.pageOrder !== order) orderChanges.push({ from: node.nodeDir, to: renamedDirectory(node, order) });
107
+ }
108
+ }
109
+ const sourceDestination = sameParent ? renamedDirectory(source, sourceOrder) : null;
110
+ if (sameParent && sourceDestination !== source.nodeDir) orderChanges.push({ from: source.nodeDir, to: sourceDestination });
111
+ const dirtyRenamedFiles = [...sources.keys()].filter((filename) => orderChanges.some(({ from }) => filename.startsWith(from + path.sep)));
112
+ if (dirtyRenamedFiles.length) throw new Error(dirtyPaths(dirtyRenamedFiles));
113
+ const to = sameParent ? urlFor(source) : `${urlFor(parent)}${source.pageId}/`;
114
+ let movePreview = null;
115
+ if (!sameParent) {
116
+ const owner = state.graph.aliasModel.identitiesByPathname.get(to);
117
+ if (owner && !(owner.kind === 'page-alias' && owner.source.page.contentFile.contentPath === sourcePath)) {
118
+ throw new Error(explainDestination(owner, to));
119
+ }
120
+ const occupied = new Set(existing.map((node) => node.pageOrder));
121
+ const provisionalOrder = occupied.has(sourceOrder)
122
+ ? Array.from({ length: 999 }, (_, position) => position + 1).find((order) => !occupied.has(order))
123
+ : sourceOrder;
124
+ movePreview = await createPageMovePlan({ from: urlFor(source), to, order: provisionalOrder,
125
+ graph: state.graph, publicFiles: state.publicFiles, siteStructure: state.structure,
126
+ sitePagesDir: getSiteSourcePaths(siteRoot).pages,
127
+ sitePagesLabel: `${siteRoot}/root/pages`, generatedRoutes: state.generatedRoutes });
128
+ }
129
+ const watched = new Set([source.nodeDir, path.join(siteRoot, 'site-config/settings.yaml'),
130
+ ...orderChanges.map(({ from }) => from),
131
+ ...state.structure.nodes.map((node) => node.contentPath ?? node.categoryPath),
132
+ ...state.publicFiles.map((file) => file.filePath)]);
133
+ const fingerprints = [];
134
+ for (const filename of [...watched].sort()) fingerprints.push([filename, await snapshot(filename)]);
135
+ return {
136
+ siteRoot, sourcePath, targetPath, placement, sourceDirectory: source.nodeDir,
137
+ sameParent, sourceOrder, sourceUrl: urlFor(source), destinationUrl: to,
138
+ sourceDestination, orderChanges, movePreview,
139
+ fingerprint: JSON.stringify(fingerprints),
140
+ };
141
+ };
142
+
143
+ export const applyEditorPagePlacement = async (plan, { renameDirectory = rename } = {}) => {
144
+ const current = await planEditorPagePlacement(plan);
145
+ if (current.fingerprint !== plan.fingerprint || current.sourceOrder !== plan.sourceOrder
146
+ || JSON.stringify(current.orderChanges) !== JSON.stringify(plan.orderChanges)) {
147
+ throw new Error('The site changed since this move was previewed. Review the placement and addresses again.');
148
+ }
149
+ if (current.sameParent) {
150
+ await applyDirectoryRenames(current.orderChanges, renameDirectory);
151
+ try { await getSiteStructure({ siteRoot: plan.siteRoot }); }
152
+ catch (error) {
153
+ const reversed = current.orderChanges.map(({ from, to }) => ({ from: to, to: from }));
154
+ const restoreErrors = await applyDirectoryRenames(reversed, renameDirectory).then(() => [], (failure) => [failure.message]);
155
+ throw new Error(`${error.message}${restoreErrors.length ? `\nRestoration was incomplete: ${restoreErrors.join('\n')}` : '\nThe original page order was restored.'}`);
156
+ }
157
+ return { sourcePath: path.join(current.sourceDestination, 'content.md'), url: current.sourceUrl };
158
+ }
159
+ const restoreOrder = await applyDirectoryRenames(current.orderChanges, renameDirectory);
160
+ try {
161
+ const state = await readEditorLinkState({ siteRoot: plan.siteRoot });
162
+ const move = await createPageMovePlan({ from: current.sourceUrl, to: current.destinationUrl, order: current.sourceOrder,
163
+ graph: state.graph, publicFiles: state.publicFiles, siteStructure: state.structure,
164
+ sitePagesDir: getSiteSourcePaths(plan.siteRoot).pages,
165
+ sitePagesLabel: `${plan.siteRoot}/root/pages`, generatedRoutes: state.generatedRoutes });
166
+ await applyPageMovePlan(move, { siteRoot: plan.siteRoot, generatedRoutes: state.generatedRoutes, renameDirectory });
167
+ return { sourcePath: path.join(move.destinationDirectory, 'content.md'), url: current.destinationUrl };
168
+ } catch (error) {
169
+ const failures = await restoreOrder();
170
+ throw new Error(`${error.message}${failures.length ? `\nPage order restoration was incomplete. Inspect:\n${failures.join('\n')}` : ''}`);
171
+ }
172
+ };
@@ -0,0 +1,146 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { editorPageLocation, editorSourceDefinition } from './editor-source-files.mjs';
5
+ import { editorFileRemovalPolicy } from './editor-file-policy.mjs';
6
+ import { parseYamlConfig } from './yaml-config.mjs';
7
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
8
+ import { sourceFileDefinitions } from './source-files.mjs';
9
+ import { getThemeDiagnostics } from './editor-language-service.mjs';
10
+ import { createEditorThemeHelp } from './editor-theme-help.mjs';
11
+
12
+ const excluded = new Set(['.norna', '.git', '.DS_Store', 'node_modules', 'dist', '.vscode-test']);
13
+ const knownNames = new Set([...sourceFileDefinitions.map((entry) => entry.name), 'category.yaml', 'theme.yaml', 'site-theme.yaml']);
14
+ const resourceId = (filename) => `resource:${filename}`;
15
+
16
+ // The physical traversal retains damaged entries that the logical page model
17
+ // cannot route. It reuses source schemas and page-path rules, without changing
18
+ // build validation.
19
+ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snapshot, getInformation }) => {
20
+ siteRoot = path.resolve(siteRoot);
21
+ const items = [];
22
+ const themeHelp = createEditorThemeHelp({ siteRoot, sources });
23
+ const byDirectory = new Map(snapshot.nodes.map((node) => [path.dirname(node.sourcePath), node]));
24
+ const read = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
25
+ const entriesAt = async (directory) => (await readdir(directory, { withFileTypes: true }))
26
+ .filter((entry) => !excluded.has(entry.name) && !entry.name.startsWith('._') && (entry.isFile() || entry.isDirectory()));
27
+ const issue = (item, message, severity = 'error', filename = item.sourcePath, line = 1) => {
28
+ (item.issues ??= []).push({ message, severity, path: filename, line });
29
+ };
30
+ const validate = async (item) => {
31
+ const definition = editorSourceDefinition(siteRoot, item.sourcePath);
32
+ if (!definition) return;
33
+ try {
34
+ const source = await read(item.sourcePath);
35
+ if (definition.schema) {
36
+ parseYamlConfig(source, item.sourcePath, { schema: definition.schema });
37
+ if (['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) {
38
+ for (const diagnostic of await getThemeDiagnostics({ siteRoot, documentPath: item.sourcePath, source, sources })) issue(item, diagnostic.message, diagnostic.severity, item.sourcePath, diagnostic.line);
39
+ }
40
+ } else {
41
+ const document = await parsePageMarkdownSource(source, { label: item.sourcePath });
42
+ for (const diagnostic of document.diagnostics) issue(item, diagnostic.message, diagnostic.severity, item.sourcePath, diagnostic.line);
43
+ }
44
+ } catch (error) { issue(item, error.message); }
45
+ };
46
+ const visit = async (directory, parentId, owner, role, location = null) => {
47
+ let entries;
48
+ try { entries = await entriesAt(directory); }
49
+ catch (error) {
50
+ if (location?.isHome && error.code === 'ENOENT') entries = [];
51
+ else {
52
+ if (owner) issue(owner, `Cannot read ${directory}: ${error.message}`, 'error', directory);
53
+ return;
54
+ }
55
+ }
56
+ const names = new Map(entries.map((entry) => [entry.name, entry]));
57
+ let container;
58
+ if (location) {
59
+ const hasContent = names.get('content.md')?.isFile();
60
+ const hasCategory = names.get('category.yaml')?.isFile();
61
+ const kind = hasContent ? 'page' : 'incomplete';
62
+ const sourcePath = path.join(directory, 'content.md');
63
+ const logical = byDirectory.get(directory);
64
+ let information = {};
65
+ if (kind !== 'incomplete') {
66
+ try { information = logical?.kind === kind ? logical : await getInformation({ source: await read(sourcePath), sourcePath, kind, isHome: location.isHome, fallbackTitle: location.pageId }); }
67
+ catch (error) { information = { problem: `Cannot read ${sourcePath}: ${error.message}` }; }
68
+ }
69
+ container = { ...logical, ...location, ...information, kind, sourcePath, id: sourcePath, ownerId: sourcePath, parentId,
70
+ directory, title: information.title ?? (location.isHome ? path.basename(siteRoot) : location.pageId),
71
+ url: location.isHome ? '/' : `/${location.pagePath}/`, missingSource: kind === 'incomplete' };
72
+ items.push(container);
73
+ if (kind === 'page') container.themeHelp = await themeHelp(directory);
74
+ if (information.problem) issue(container, information.problem);
75
+ if (kind !== 'incomplete') await validate(container);
76
+ else issue(container, location.isHome ? 'Homepage content.md is missing. Use Add to create it.'
77
+ : 'Page content.md is missing. Use Add to create it.');
78
+ if (hasCategory) issue(container, 'category.yaml is no longer supported. Use content.md with page.listChildren: true for an overview.');
79
+ owner = container;
80
+ } else if (role === 'site') {
81
+ container = { id: null };
82
+ } else {
83
+ container = { id: resourceId(directory), parentId, ownerId: owner.id, sourcePath: directory,
84
+ kind: 'directory', role, title: path.basename(directory), description: role === 'public'
85
+ ? 'Files published unchanged with the site, such as robots.txt and icons.' : role === 'configuration' ? 'Settings and content shared by the complete site' : '' };
86
+ items.push(container);
87
+ }
88
+ const order = location ? ['images', 'tree-theme.yaml', 'page-theme.yaml', 'pages']
89
+ : role === 'site' ? ['site-config', 'public', 'root']
90
+ : role === 'configuration' ? ['settings.yaml', 'shared-content.yaml'] : [];
91
+ const rank = (entry) => order.includes(entry.name) ? order.indexOf(entry.name) : order.length;
92
+ entries.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, 'en', { numeric: true }));
93
+ for (const entry of entries) {
94
+ const filename = path.join(directory, entry.name);
95
+ if (location && entry.name === 'content.md' && entry.isFile() && owner.kind === 'page') continue;
96
+ if (entry.isDirectory()) {
97
+ const childLocation = role === 'pages' || role === 'site' && entry.name === 'root' ? editorPageLocation(siteRoot, filename) : null;
98
+ const childRole = childLocation ? 'page' : location && entry.name === 'pages' ? 'pages'
99
+ : location && entry.name === 'images' && owner.kind === 'page' ? 'images'
100
+ : role === 'site' && entry.name === 'site-config' ? 'configuration'
101
+ : role === 'public' || role === 'site' && entry.name === 'public' ? 'public' : 'extra';
102
+ await visit(filename, container.id, owner, childRole, childLocation);
103
+ const child = items.find((item) => item.sourcePath === filename);
104
+ if (child && childRole === 'extra' && (knownNames.has(entry.name) || ['images', 'pages', 'site-config', 'public'].includes(entry.name) || role === 'pages')) {
105
+ issue(child, `This directory is not at a permitted Norna location: ${filename}. Use pages/NNN-page-id for child entries, images/ beside page content, and site-config/ or public/ only at the site root.`, 'warning');
106
+ }
107
+ continue;
108
+ }
109
+ const definition = role !== 'public' ? editorSourceDefinition(siteRoot, filename) : null;
110
+ const file = { id: resourceId(filename), parentId: container.id, ownerId: owner.id, kind: 'file', sourcePath: filename,
111
+ title: entry.name, role: definition ? 'configuration' : 'asset', description: definition?.description ?? '',
112
+ removable: Boolean(editorFileRemovalPolicy({ siteRoot, sourcePath: owner.sourcePath, filePath: filename })) };
113
+ items.push(file);
114
+ if (['rootTheme', 'theme', 'pageTheme'].includes(definition?.schemaKind)) file.themeHelp = await themeHelp(directory, filename);
115
+ if (definition) await validate(file);
116
+ else if (role !== 'public' && ['site-config', 'public', 'pages', 'images'].includes(entry.name)) issue(file, `Expected a directory named ${entry.name}, but this is a file. Rename or move this file through Explorer before creating the directory.`, 'warning');
117
+ else if (role !== 'public' && entry.name === 'category.yaml') issue(file, 'category.yaml is no longer supported. Create content.md with page.listChildren: true, then remove this file.', 'warning');
118
+ else if (role !== 'public' && knownNames.has(entry.name)) issue(file, `This source file is in the wrong location. Put content.md, tree-theme.yaml and page-theme.yaml in a valid page directory; put settings.yaml and shared-content.yaml in the site's site-config/.`, 'warning');
119
+ else if (role !== 'public' && !(role === 'images' && /\.(jpe?g|png|svg)$/i.test(entry.name))) file.note = 'Not used by Norna';
120
+ }
121
+ };
122
+ const source = getSiteSourcePaths(siteRoot);
123
+ // Site resources have their own owner even when root/content.md is unreadable.
124
+ // The site context is not a visible row in the physical tree.
125
+ const owner = { id: siteRoot, sourcePath: siteRoot, issues: [] };
126
+ await visit(siteRoot, null, owner, 'site');
127
+ if (!items.some((item) => item.isHome)) await visit(source.root, null, owner, 'page', editorPageLocation(siteRoot, source.root));
128
+ const configuration = items.find((item) => item.role === 'configuration' && item.kind === 'directory');
129
+ const homepage = items.find((item) => item.isHome);
130
+ for (const [filename, target] of [[path.join(siteRoot, 'site-config/settings.yaml'), configuration], [source.theme, homepage]]) {
131
+ try { await read(filename); }
132
+ catch (error) {
133
+ issue(target ?? owner, error.code === 'ENOENT' || error.code === 'ENOTDIR'
134
+ ? `Required ${path.relative(siteRoot, filename)} is missing. Use Add to create it.` : error.message, 'error', filename);
135
+ }
136
+ }
137
+
138
+ // Keep shared structural diagnostics, including duplicate sibling identifiers.
139
+ for (const problem of snapshot.problems) {
140
+ if (![owner, ...items].some((item) => item.issues?.some((entry) => entry.message === problem.message && entry.path === problem.path))) {
141
+ const target = items.find((item) => item.sourcePath === problem.path || item.directory === problem.path) ?? owner;
142
+ issue(target, problem.message, 'error', problem.path);
143
+ }
144
+ }
145
+ return { ...snapshot, items, problems: owner.issues };
146
+ };
@@ -0,0 +1,153 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { markdownToMdast } from 'satteri';
4
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
5
+ import { getSiteStructure } from './site-structure.mjs';
6
+ import { getEditorIncomingLinks } from './editor-site-links.mjs';
7
+ import { editorFileRemovalPolicy } from './editor-file-policy.mjs';
8
+ import { getEditorThemeRemovalHelp } from './editor-theme-help.mjs';
9
+
10
+ export const siteFileOperationsApiVersion = 1;
11
+ export const siteRemovalApiVersion = 1;
12
+ const imageName = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
13
+ const stat = (filename) => lstat(filename).catch((error) => {
14
+ if (error.code === 'ENOENT') return null;
15
+ throw error;
16
+ });
17
+
18
+ export const checkedPath = async (siteRoot, filename) => {
19
+ const root = path.resolve(siteRoot);
20
+ const target = path.resolve(filename);
21
+ const relative = path.relative(root, target);
22
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
23
+ throw new Error('Choose a file in the selected site.');
24
+ }
25
+ let current = root;
26
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
27
+ current = path.join(current, segment);
28
+ if ((await stat(current))?.isSymbolicLink()) throw new Error('Symbolic links cannot be changed through Site Tree. Use the real source directory.');
29
+ }
30
+ return target;
31
+ };
32
+
33
+ const pageTarget = async ({ siteRoot, sourcePath }) => {
34
+ const filename = await checkedPath(siteRoot, sourcePath);
35
+ const structure = await getSiteStructure({ siteRoot, tolerant: true });
36
+ const page = structure.nodes.find((node) => (node.contentPath ?? node.categoryPath) === filename);
37
+ if (!page) throw new Error('The selected page is no longer in this site. Refresh Site Tree and select it again.');
38
+ return { page, structure, directory: path.dirname(filename), sourcePath: filename };
39
+ };
40
+
41
+ export const snapshot = async (filename) => {
42
+ const result = [];
43
+ const visit = async (current) => {
44
+ const information = await stat(current);
45
+ if (!information) throw new Error('The selected file no longer exists. Refresh Site Tree.');
46
+ if (information.isSymbolicLink()) throw new Error('This selection contains a symbolic link. Remove it through the ordinary Explorer instead.');
47
+ if (!information.isFile() && !information.isDirectory()) throw new Error('This selection contains an unsupported filesystem entry.');
48
+ result.push([path.relative(filename, current), information.isDirectory() ? 'directory' : 'file', information.size, information.mtimeMs, information.ctimeMs, information.ino]);
49
+ if (information.isDirectory()) for (const entry of (await readdir(current)).sort()) await visit(path.join(current, entry));
50
+ };
51
+ await visit(filename);
52
+ return result;
53
+ };
54
+
55
+ export const planEditorImageCopy = async ({ siteRoot, sourcePath, imagePath, filename, replace = false }) => {
56
+ const target = await pageTarget({ siteRoot, sourcePath });
57
+ if (target.page.kind !== 'page') throw new Error('Choose a content page for this image.');
58
+ if (!imageName.test(filename)) throw new Error('Use a filename starting with a letter or number, followed by letters, numbers, dots or hyphens, ending in .jpg, .jpeg, .png or .svg.');
59
+ const source = path.resolve(imagePath);
60
+ const information = await stat(source);
61
+ if (!information?.isFile() || information.isSymbolicLink()) throw new Error('Choose a regular image file to copy.');
62
+ if (!/\.(jpe?g|png|svg)$/i.test(source) || path.extname(source).toLowerCase() !== path.extname(filename).toLowerCase()) {
63
+ throw new Error('Keep the source image extension. This action does not convert image formats.');
64
+ }
65
+ const destination = await checkedPath(siteRoot, path.join(target.directory, 'images', filename));
66
+ if (source === destination) throw new Error('This image already belongs to the selected page. Use Insert Image instead.');
67
+ const existing = await stat(destination);
68
+ if (replace ? !existing?.isFile() : existing !== null) {
69
+ throw new Error(replace ? 'The image to replace no longer exists.' : 'An image already has this name on the page. Choose another filename or use Replace Image.');
70
+ }
71
+ return { source, destination, sourcePath: target.sourcePath, filename,
72
+ fingerprint: JSON.stringify([await snapshot(source), existing ? await snapshot(destination) : null]) };
73
+ };
74
+
75
+ export const getEditorImageUsage = async ({ siteRoot, sourcePath, imagePath, sources = new Map() }) => {
76
+ const { page, structure, directory } = await pageTarget({ siteRoot, sourcePath });
77
+ const target = await checkedPath(siteRoot, imagePath);
78
+ const targetStat = await stat(target);
79
+ if (page.kind !== 'page' || path.dirname(target) !== path.join(directory, 'images') || !imageName.test(path.basename(target)) || !targetStat?.isFile()) {
80
+ throw new Error('Choose an image directly inside this page’s images folder.');
81
+ }
82
+ const references = [];
83
+ const incomplete = structure.problems.map((problem) => problem.message);
84
+ for (const node of structure.nodes.filter((node) => node.contentPath)) {
85
+ try {
86
+ const source = sources.has(node.contentPath) ? sources.get(node.contentPath) : await readFile(node.contentPath, 'utf8');
87
+ const document = await parsePageMarkdownSource(source, { label: node.contentPath });
88
+ if (document.diagnostics.some(({ severity }) => severity === 'error')) incomplete.push(node.contentPath);
89
+ for (const reference of document.managedImages.filter((reference) => reference.image.toLowerCase() === path.basename(target).toLowerCase())) {
90
+ const localPath = path.join(path.dirname(node.contentPath), 'images', reference.image);
91
+ const local = await stat(localPath);
92
+ if (local ? local.dev !== targetStat.dev || local.ino !== targetStat.ino : reference.image !== path.basename(target)) continue;
93
+ references.push({ sourcePath: node.contentPath, line: reference.line, unresolved: !local });
94
+ }
95
+ } catch (error) { incomplete.push(`${node.contentPath}: ${error.message}`); }
96
+ }
97
+ return { references, incomplete };
98
+ };
99
+
100
+ export const planEditorRemoval = async ({ siteRoot, sourcePath, imagePath, filePath, sources }) => {
101
+ siteRoot = path.resolve(siteRoot);
102
+ const siteFile = filePath && !imagePath && path.resolve(sourcePath) === siteRoot;
103
+ const target = siteFile ? { sourcePath: await checkedPath(siteRoot, sourcePath) } : await pageTarget({ siteRoot, sourcePath });
104
+ if (!imagePath && !filePath && target.page.isHome) throw new Error('The homepage is required and cannot be removed.');
105
+ const filename = imagePath || filePath ? await checkedPath(siteRoot, imagePath ?? filePath) : target.directory;
106
+ const policy = filePath ? editorFileRemovalPolicy({ siteRoot: path.resolve(siteRoot), sourcePath: target.sourcePath, filePath: filename }) : null;
107
+ if (filePath && (!policy || !(await stat(filename))?.isFile())) throw new Error('This file cannot be removed separately. Required page files belong to their page; required site settings must remain.');
108
+ const usage = imagePath ? await getEditorImageUsage({ siteRoot, sourcePath, imagePath, sources })
109
+ : !filePath || policy.kind === 'public' ? await getEditorIncomingLinks({ siteRoot, sourcePath, sources,
110
+ descendants: !filePath, excludeBranch: !filePath, filePath }) : null;
111
+ const files = await snapshot(filename);
112
+ const effect = policy?.kind === 'theme'
113
+ ? `${policy.effect}\n\n${await getEditorThemeRemovalHelp({ siteRoot, filename, sources })}` : policy?.effect;
114
+ return { target: filename, recursive: !imagePath && !filePath, effect,
115
+ files: files.filter(([, kind]) => kind === 'file').map(([relative]) => relative || path.basename(filename)),
116
+ pages: imagePath || filePath ? 0 : target.structure.nodes.filter((node) => {
117
+ const relative = path.relative(filename, node.contentPath ?? node.categoryPath);
118
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
119
+ }).length,
120
+ usage, fingerprint: JSON.stringify([files, usage, effect]) };
121
+ };
122
+
123
+ export const createEditorImageAppend = async ({ source, filename, alt, caption = '' }) => {
124
+ if (typeof alt !== 'string' || !alt.trim()) throw new Error('Describe the image before inserting it.');
125
+ return createEditorImageBatchAppend({ source, items: [{ filename, alt, caption }] });
126
+ };
127
+
128
+ export const createEditorImageBatchAppend = async ({ source, items }) => {
129
+ if (!Array.isArray(items) || !items.length) throw new Error('Choose at least one image to insert.');
130
+ for (const item of items) {
131
+ if (!imageName.test(item.filename)) throw new Error('Choose a managed image filename.');
132
+ if (item.alt !== undefined && typeof item.alt !== 'string') throw new Error('Alternative text must be text.');
133
+ if (item.caption !== undefined && typeof item.caption !== 'string') throw new Error('Caption must be text.');
134
+ }
135
+ const document = await parsePageMarkdownSource(source, { label: 'content.md' });
136
+ if (document.diagnostics.some(({ severity }) => severity === 'error')) {
137
+ throw new Error('Repair this page’s content errors before appending an image block. The imported file remains available.');
138
+ }
139
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
140
+ const block = ['```image-stack', 'items:', ...items.flatMap(({ filename, alt, caption }) => [
141
+ ` - image: ${filename}`,
142
+ ...(alt !== undefined ? [` alt: ${JSON.stringify(alt)}`] : []),
143
+ ...(caption ? [` caption: ${JSON.stringify(caption)}`] : []),
144
+ ]), '```', ''].join(eol);
145
+ const text = `${source.endsWith(eol + eol) ? '' : source.endsWith(eol) ? eol : eol + eol}${block}`;
146
+ const result = await parsePageMarkdownSource(source + text, { label: 'content.md' });
147
+ const appended = markdownToMdast(source + text, { features: { gfm: true, frontmatter: true } }).children.at(-1);
148
+ if (appended?.type !== 'code' || appended.lang !== 'image-stack' || appended.position.start.offset < source.length
149
+ || result.managedImages.length !== document.managedImages.length + items.length || result.diagnostics.some(({ severity }) => severity === 'error')) {
150
+ throw new Error('Close the open Markdown block or comment before appending an image. The imported file remains available.');
151
+ }
152
+ return { start: source.length, end: source.length, text };
153
+ };
@@ -0,0 +1,79 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getGeneratedSiteRoutes } from './page-aliases.mjs';
4
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
5
+ import { configSchema } from './schema-definitions.mjs';
6
+ import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
7
+ import { createSiteLinkGraph, getSitePublicFiles } from './site-link-graph.mjs';
8
+ import { getSiteStructure } from './site-structure.mjs';
9
+ import { parseYamlConfig } from './yaml-config.mjs';
10
+
11
+ export const isInside = (root, filename) => {
12
+ const relative = path.relative(root, filename);
13
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
14
+ };
15
+
16
+ // The engine remains the only URL resolver. Editor reads overlay unsaved
17
+ // buffers and retain useful results when another page needs repair.
18
+ export const readEditorLinkState = async ({ siteRoot, sources = new Map() }) => {
19
+ const readSource = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
20
+ const structure = await getSiteStructure({ siteRoot, tolerant: true, readSource });
21
+ const incomplete = structure.problems.map(({ message }) => message);
22
+ let settings;
23
+ const settingsPath = path.join(siteRoot, 'site-config/settings.yaml');
24
+ try { settings = parseYamlConfig(await readSource(settingsPath), settingsPath, { schema: configSchema }); }
25
+ catch (error) { incomplete.push(error.message); }
26
+ const pageDocuments = await Promise.all(structure.contentFiles.map(async (contentFile) => {
27
+ let source = '';
28
+ let data = {};
29
+ try {
30
+ source = await readSource(contentFile.contentPath);
31
+ data = parseContentFrontmatter(splitSiteFile(source, contentFile.contentLabel).frontmatterBody, contentFile.contentLabel);
32
+ } catch (error) { incomplete.push(error.message); }
33
+ let document;
34
+ try {
35
+ document = await parsePageMarkdownSource(source, { label: contentFile.contentLabel });
36
+ for (const diagnostic of document.diagnostics.filter(({ severity }) => severity === 'error')) {
37
+ incomplete.push(`${contentFile.contentLabel}:${diagnostic.line}: ${diagnostic.message}`);
38
+ }
39
+ } catch (error) {
40
+ incomplete.push(error.message);
41
+ document = await parsePageMarkdownSource('# Unavailable page\n', { label: contentFile.contentLabel });
42
+ }
43
+ return { contentFile, data, document };
44
+ }));
45
+ let publicFiles = [];
46
+ try { publicFiles = await getSitePublicFiles(path.join(siteRoot, 'public')); }
47
+ catch (error) { incomplete.push(error.message); }
48
+ const generatedRoutes = getGeneratedSiteRoutes({ searchEnabled: settings?.search });
49
+ const graph = createSiteLinkGraph({ siteStructure: structure, pageDocuments, publicFiles, generatedRoutes });
50
+ // Missing destinations do not make reference discovery incomplete. Ambiguous
51
+ // ownership of an address does, and must never be presented as a clean check.
52
+ incomplete.push(...graph.diagnostics.filter((entry) => !entry.reference && entry.severity === 'error').map(({ message }) => message));
53
+ return { structure, graph, publicFiles, generatedRoutes, settings, incomplete: [...new Set(incomplete)].sort() };
54
+ };
55
+
56
+ export const summarizeReference = (reference) => ({
57
+ sourcePath: reference.sourceContentFile.contentPath,
58
+ title: reference.sourcePage.title,
59
+ line: reference.line,
60
+ column: reference.column ?? 1,
61
+ target: reference.target.kind === 'internal' ? reference.resolution?.pathname ?? reference.target.pathname : '',
62
+ text: reference.sourcePage.document.fullSource.split(/\r?\n/)[reference.line - 1]?.trim() ?? '',
63
+ });
64
+
65
+ export const getEditorIncomingLinks = async ({ siteRoot, sourcePath, sources, descendants = false, excludeBranch = false, filePath, alias }) => {
66
+ const state = await readEditorLinkState({ siteRoot, sources });
67
+ const node = state.structure.nodes.find((entry) => (entry.contentPath ?? entry.categoryPath) === sourcePath);
68
+ if (!node && !(filePath && path.resolve(sourcePath) === path.resolve(siteRoot))) throw new Error('This page is no longer in the selected site. Refresh Site Tree.');
69
+ const directory = node ? path.dirname(sourcePath) : siteRoot;
70
+ const targetPaths = new Set(state.structure.nodes.filter((entry) => entry === node || (descendants && isInside(directory, entry.nodeDir)))
71
+ .map((entry) => entry.isHome ? '/' : `/${entry.pagePath}/`));
72
+ const references = state.graph.references.filter((reference) => {
73
+ if (excludeBranch && isInside(directory, reference.sourceContentFile.contentPath)) return false;
74
+ if (alias) return reference.target.pageLookupPathname === alias;
75
+ if (filePath) return reference.resolution?.kind === 'public-file' && reference.resolution.file.filePath === filePath;
76
+ return targetPaths.has(reference.resolution?.pathname ?? reference.target.pageLookupPathname);
77
+ }).map(summarizeReference);
78
+ return { references, incomplete: state.incomplete };
79
+ };