@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
@@ -0,0 +1,57 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
2
+ import path from 'node:path';
3
+ import { readEditorLinkState, isInside } from './editor-site-links.mjs';
4
+ import { checkedPath, snapshot } from './editor-site-files.mjs';
5
+ import { createPageMovePlan } from './page-move-plan.mjs';
6
+ import { applyPageMovePlan } from './page-move-apply.mjs';
7
+
8
+ export const siteAddressApiVersion = 1;
9
+ export const sitePageAddressOptionsApiVersion = 1;
10
+
11
+ export const getEditorPageAddresses = async (options) => {
12
+ const { structure, graph, settings, incomplete } = await readEditorLinkState(options);
13
+ const node = structure.nodes.find((entry) => (entry.contentPath ?? entry.categoryPath) === options.sourcePath);
14
+ if (!node) throw new Error('This page is no longer in the selected site. Refresh Site Tree.');
15
+ const internalLink = node.isHome ? '/' : `/${node.pagePath}/`;
16
+ const base = settings?.url?.replace(/\/$/, '') + '/';
17
+ return {
18
+ internalLink, webAddress: settings ? new URL(internalLink.slice(1), base).href : null,
19
+ segment: node.isHome ? '' : node.pageId,
20
+ aliases: graph.pagesByPathname.get(internalLink)?.aliases ?? [],
21
+ incomplete,
22
+ };
23
+ };
24
+
25
+ export const planEditorPageAddress = async ({ siteRoot, sourcePath, segment, preserveAliases = true, sources = new Map() }) => {
26
+ if (typeof preserveAliases !== 'boolean') throw new Error('Choose whether to preserve old addresses as aliases.');
27
+ await checkedPath(siteRoot, sourcePath);
28
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment)) throw new Error('Use lowercase letters, numbers and single hyphens, for example install-norna. Enter one URL segment, without slashes.');
29
+ const state = await readEditorLinkState({ siteRoot, sources });
30
+ if (state.incomplete.length) throw new Error(`Repair the site before changing an address. ${state.incomplete.join('\n')}`);
31
+ const node = state.structure.nodes.find((entry) => entry.contentPath === sourcePath);
32
+ if (!node || node.isHome) throw new Error('Choose a content page other than the homepage. Category addresses follow their folders and cannot be changed through this action.');
33
+ const sourcePaths = [...state.structure.nodes.map((entry) => entry.contentPath ?? entry.categoryPath), path.join(siteRoot, 'site-config/settings.yaml')];
34
+ if (sourcePaths.some((filename) => sources.has(filename))) throw new Error('Save or undo unsaved page, category and site-settings edits before changing an address.');
35
+ for (const filename of sourcePaths) await checkedPath(siteRoot, filename);
36
+ const to = `/${node.parentPagePath ? node.parentPagePath + '/' : ''}${segment}/`;
37
+ const plan = await createPageMovePlan({ from: `/${node.pagePath}/`, to, preserveAliases,
38
+ graph: state.graph, publicFiles: state.publicFiles, siteStructure: state.structure,
39
+ sitePagesDir: getSiteSourcePaths(siteRoot).pages, sitePagesLabel: `${siteRoot}/root/pages`, generatedRoutes: state.generatedRoutes });
40
+ await checkedPath(siteRoot, plan.destinationDirectory);
41
+ const watched = [...new Set([...sourcePaths, ...state.publicFiles.map(({ filePath }) => filePath)])].sort();
42
+ const snapshots = [];
43
+ for (const filename of watched) snapshots.push([filename, await snapshot(filename)]);
44
+ const addresses = await getEditorPageAddresses({ siteRoot, sourcePath });
45
+ return { ...plan, siteRoot, sourcePath, segment, generatedRoutes: state.generatedRoutes,
46
+ webFrom: addresses.webAddress,
47
+ webTo: new URL(to.slice(1), state.settings.url.replace(/\/$/, '') + '/').href,
48
+ fingerprint: JSON.stringify([await snapshot(plan.sourceDirectory), snapshots, plan.to, preserveAliases, plan.fileChanges.map(({ updatedSource }) => updatedSource)]) };
49
+ };
50
+
51
+ export const applyEditorPageAddress = async (plan, { renameDirectory } = {}) => {
52
+ const current = await planEditorPageAddress(plan);
53
+ if (current.fingerprint !== plan.fingerprint) throw new Error('The site changed after the address preview. Start again to review the new result.');
54
+ if (!isInside(plan.siteRoot, current.sourceDirectory) || !isInside(plan.siteRoot, current.destinationDirectory)) throw new Error('The page must remain inside its site.');
55
+ await applyPageMovePlan(current, { siteRoot: plan.siteRoot, generatedRoutes: current.generatedRoutes, renameDirectory });
56
+ return { sourcePath: path.join(current.destinationDirectory, 'content.md'), url: current.to };
57
+ };
@@ -0,0 +1,102 @@
1
+ import { rename } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { checkedPath, snapshot } from './editor-site-files.mjs';
4
+ import { readEditorLinkState } from './editor-site-links.mjs';
5
+ import { createPageMovePlan } from './page-move-plan.mjs';
6
+ import { applyPageMovePlan } from './page-move-apply.mjs';
7
+ import { getSiteSourcePaths } from './site-conventions.mjs';
8
+ import { comparePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
9
+ import { pageOrderFileChanges } from './page-order-source.mjs';
10
+
11
+ export const sitePagePlacementApiVersion = 2;
12
+ export const sitePagePlacementOptionsApiVersion = 1;
13
+
14
+ const urlFor = (node) => node.isHome ? '/' : `/${node.pagePath}/`;
15
+ const childOf = (node, parent) => node.parentPagePath === (parent.pagePath || null);
16
+ const inBranch = (node, ancestor) => node.pagePath === ancestor.pagePath || node.pagePath.startsWith(`${ancestor.pagePath}/`);
17
+ const explainDestination = (owner, address) => {
18
+ if (owner.kind === 'page') return `Cannot move here. ${address} already belongs to “${owner.source.title}”. Choose another location.`;
19
+ 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.`;
20
+ if (owner.kind === 'public-file') return `Cannot move here. ${address} is used by public file ${owner.label}. Choose another location.`;
21
+ if (owner.kind === 'generated-route') return `Cannot move here. ${address} is used by generated route ${owner.label}. Choose another location.`;
22
+ return `Cannot move here. ${address} is already used by ${owner.label}. Choose another location.`;
23
+ };
24
+
25
+ export const planEditorPagePlacement = async ({ siteRoot, sourcePath, targetPath, placement, preserveAliases = true, sources = new Map() }) => {
26
+ if (typeof preserveAliases !== 'boolean') throw new Error('Choose whether to preserve old addresses as aliases.');
27
+ if (!['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first child or last child.');
28
+ siteRoot = path.resolve(siteRoot);
29
+ await checkedPath(siteRoot, sourcePath);
30
+ await checkedPath(siteRoot, targetPath);
31
+ const state = await readEditorLinkState({ siteRoot, sources });
32
+ if (state.incomplete.length) throw new Error(`Repair the site before moving a page. ${state.incomplete.join('\n')}`);
33
+ const contentPaths = new Set(state.structure.nodes.map((node) => node.contentPath ?? node.categoryPath));
34
+ const dirtyPaths = (files) => `Save or undo these unsaved files before moving:\n${files.map((filename) => `- ${path.relative(siteRoot, filename)}`).join('\n')}`;
35
+ const dirtySiteFiles = [...sources.keys()].filter((filename) => contentPaths.has(filename)
36
+ || filename === path.join(siteRoot, 'site-config/settings.yaml')
37
+ || filename === sourcePath || filename.startsWith(path.dirname(sourcePath) + path.sep));
38
+ if (dirtySiteFiles.length) throw new Error(dirtyPaths(dirtySiteFiles));
39
+ const source = state.structure.nodes.find((node) => node.contentPath === sourcePath);
40
+ const target = state.structure.nodes.find((node) => node.contentPath === targetPath);
41
+ if (!source || source.isHome || !target) throw new Error('Select an existing page below the homepage and an existing destination page.');
42
+ const parent = placement === 'first' || placement === 'last' ? target
43
+ : state.structure.nodes.find((node) => node.pagePath === target.parentPagePath)
44
+ ?? state.structure.nodes.find((node) => node.isHome && !target.parentPagePath);
45
+ if (!parent || inBranch(parent, source)) throw new Error('A page cannot move inside its own branch. Choose another destination.');
46
+ const sameParent = source.parentPagePath === (parent.pagePath || null);
47
+ const existing = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && childOf(node, parent) && node.contentPath !== sourcePath)
48
+ .sort(comparePageOrder);
49
+ const targetIndex = existing.findIndex((node) => node.contentPath === targetPath);
50
+ if ((placement === 'before' || placement === 'after') && targetIndex < 0) throw new Error('The destination page changed. Refresh Site Tree and try again.');
51
+ const index = placement === 'first' ? 0 : placement === 'last' ? existing.length
52
+ : targetIndex + (placement === 'after' ? 1 : 0);
53
+ const current = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && node.parentPagePath === source.parentPagePath)
54
+ .sort(comparePageOrder);
55
+ if (sameParent && current[index]?.contentPath === sourcePath) throw new Error('This page is already in that position.');
56
+ const insertion = planPageOrder(existing, index);
57
+ const sourceOrder = insertion.order;
58
+ const orderChanges = await pageOrderFileChanges(insertion.orderChanges);
59
+ const sourceDestination = sameParent ? source.nodeDir : null;
60
+ const to = sameParent ? urlFor(source) : `${urlFor(parent)}${source.pageId}/`;
61
+ let movePreview = null;
62
+ if (!sameParent) {
63
+ const owner = state.graph.aliasModel.identitiesByPathname.get(to);
64
+ if (owner && !(owner.kind === 'page-alias' && owner.source.page.contentFile.contentPath === sourcePath)) {
65
+ throw new Error(explainDestination(owner, to));
66
+ }
67
+ movePreview = await createPageMovePlan({ from: urlFor(source), to, order: sourceOrder, siblingOrderChanges: insertion.orderChanges, preserveAliases,
68
+ graph: state.graph, publicFiles: state.publicFiles, siteStructure: state.structure,
69
+ sitePagesDir: getSiteSourcePaths(siteRoot).pages,
70
+ sitePagesLabel: `${siteRoot}/root/pages`, generatedRoutes: state.generatedRoutes });
71
+ }
72
+ const watched = new Set([source.nodeDir, path.join(siteRoot, 'site-config/settings.yaml'),
73
+ ...orderChanges.map(({ contentPath }) => contentPath),
74
+ ...state.structure.nodes.map((node) => node.contentPath ?? node.categoryPath),
75
+ ...state.publicFiles.map((file) => file.filePath)]);
76
+ const fingerprints = [];
77
+ for (const filename of [...watched].sort()) fingerprints.push([filename, await snapshot(filename)]);
78
+ return {
79
+ siteRoot, sourcePath, targetPath, placement, sourceDirectory: source.nodeDir,
80
+ sameParent, sourceOrder, sourceUrl: urlFor(source), destinationUrl: to,
81
+ sourceDestination, orderChanges, movePreview, preserveAliases,
82
+ fileChanges: sameParent ? [...orderChanges, ...await pageOrderFileChanges([{ node: source, oldOrder: source.pageOrder, newOrder: sourceOrder }])] : movePreview.fileChanges,
83
+ stateFingerprint: JSON.stringify(fingerprints),
84
+ fingerprint: JSON.stringify([fingerprints, preserveAliases]),
85
+ };
86
+ };
87
+
88
+ export const applyEditorPagePlacement = async (plan, { renameDirectory = rename, writeSource, allowRenumber = false, sources = new Map() } = {}) => {
89
+ const current = await planEditorPagePlacement({ ...plan, sources });
90
+ if (current.fingerprint !== plan.fingerprint || current.sourceOrder !== plan.sourceOrder
91
+ || JSON.stringify(current.orderChanges) !== JSON.stringify(plan.orderChanges)) {
92
+ throw new Error('The site changed since this move was previewed. Review the placement and addresses again.');
93
+ }
94
+ assertRenumberingAllowed(current.orderChanges, allowRenumber);
95
+ const mutation = current.sameParent ? {
96
+ mode: 'reorder', orderChanges: current.orderChanges, fileChanges: current.fileChanges,
97
+ sourceFiles: current.fileChanges.map(change => ({ contentPath: change.originalPath, contentLabel: change.contentLabel, source: change.originalSource })),
98
+ } : current.movePreview;
99
+ await applyPageMovePlan(mutation, { siteRoot: plan.siteRoot, generatedRoutes: current.movePreview?.generatedRoutes, renameDirectory, writeSource, allowRenumber });
100
+ return { sourcePath: path.join(current.sameParent ? current.sourceDirectory : mutation.destinationDirectory, 'content.md'),
101
+ url: current.destinationUrl, renumberedPages: current.orderChanges.length };
102
+ };
@@ -0,0 +1,216 @@
1
+ import { publicFilePublication, publicationIssues } from './publication-paths.mjs';
2
+ import { attachmentHref, attachmentPathname, attachmentOutputIssues, parseLocalAttachmentTarget, encodeAttachmentPath } from './page-attachments.mjs';
3
+ import { lstat, readdir, readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { parseDocument, isScalar, isSeq } from 'yaml';
6
+ import { checkedPath, snapshot, getEditorImageUsage, planEditorRemoval } from './editor-site-files.mjs';
7
+ import { isInside, readEditorLinkState, summarizeReference } from './editor-site-links.mjs';
8
+ import { getSourcePageLocation } from './source-files.mjs';
9
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
10
+ import { inspectPublicAssetFilenames } from './public-asset-conventions.mjs';
11
+
12
+ export const siteResourceActionsApiVersion = 1;
13
+ const stat = (filename) => lstat(filename).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
14
+ const imageName = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
15
+ const publicPath = (siteRoot, filename, settings, directory = false) => publicFilePublication(path.relative(path.join(siteRoot, 'public'), filename).split(path.sep).join('/'), settings, { directory }).pathname;
16
+ const encodePath = encodeAttachmentPath;
17
+ const checkedName = (name) => {
18
+ if (typeof name !== 'string' || !name || name !== name.trim() || /[\\/\x00-\x1f\x7f<>:"|?*#%]/.test(name) || /^\.{1,2}$/.test(name) || /[. ]$/.test(name)) {
19
+ throw new Error('Enter one filename or folder name, without slashes, URL punctuation or trailing dots/spaces.');
20
+ }
21
+ return name;
22
+ };
23
+ const available = async (filename) => {
24
+ const entries = await readdir(path.dirname(filename)).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
25
+ if (entries.some(entry => entry.normalize('NFC').toLowerCase() === path.basename(filename).normalize('NFC').toLowerCase())) {
26
+ throw new Error('That name is already in use. Choose another name; existing files will not be overwritten.');
27
+ }
28
+ };
29
+ const resource = async ({ siteRoot, filePath }) => {
30
+ const filename = await checkedPath(siteRoot, filePath);
31
+ const info = await stat(filename);
32
+ if (!info || !(info.isFile() || info.isDirectory())) throw new Error('This file or folder no longer exists. Refresh Site Tree.');
33
+ const publicRoot = path.join(path.resolve(siteRoot), 'public');
34
+ if (isInside(publicRoot, filename)) return { filename, kind: 'public', directory: info.isDirectory(), fixed: filename === publicRoot };
35
+ const owner = path.dirname(path.dirname(filename));
36
+ if (info.isFile() && path.basename(path.dirname(filename)) === 'downloads' && getSourcePageLocation(siteRoot, owner)) return { filename, kind: 'attachment', directory: false, sourcePath: path.join(owner, 'content.md') };
37
+ if (info.isFile() && path.basename(path.dirname(filename)) === 'images' && getSourcePageLocation(siteRoot, owner) && imageName.test(path.basename(filename))) {
38
+ return { filename, kind: 'image', directory: false, sourcePath: path.join(owner, 'content.md') };
39
+ }
40
+ throw new Error('Choose a managed page image or a resource inside public/.');
41
+ };
42
+
43
+ const validatePublic = (siteRoot, state, filenames, directories = []) => {
44
+ for (const directory of directories) publicPath(siteRoot, directory, state.settings, true);
45
+ const issues = publicationIssues({ pages: state.graph.pages, attachments: state.graph.attachments.files, generatedRoutes: state.generatedRoutes,
46
+ publicFiles: filenames.map(filePath => ({ filePath, pathname: publicPath(siteRoot, filePath, state.settings) })) });
47
+ if (issues.length) throw new Error(issues.map(issue => issue.message).join('\n'));
48
+ const inspection = inspectPublicAssetFilenames(filenames.filter(filename => path.dirname(filename) === path.join(siteRoot, 'public')).map(filename => path.basename(filename)));
49
+ if (inspection.logos.length > 1) throw new Error(`Keep one navigation logo in public/: ${inspection.logos.join(', ')}. Use Replace on the existing logo.`);
50
+ if (inspection.socialImages.length > 1) throw new Error(`Keep one social image in public/: ${inspection.socialImages.join(', ')}.`);
51
+ return inspection.suspicious.map(entry => entry.message);
52
+ };
53
+
54
+ export const getEditorResourceReferences = async (options) => {
55
+ const selected = await resource(options);
56
+ if (selected.kind === 'image') return { ...await getEditorImageUsage({ ...options, sourcePath: selected.sourcePath, imagePath: selected.filename }), scope: 'Managed Markdown images and Norna image blocks on the owning page. HTML and external references are not checked.' };
57
+ const state = await readEditorLinkState(options);
58
+ return { references: state.graph.references.filter(ref => ['public-file', 'attachment'].includes(ref.resolution?.kind) && (selected.directory ? isInside(selected.filename, ref.resolution.file.filePath) : selected.filename === ref.resolution.file.filePath)).map(summarizeReference),
59
+ incomplete: state.incomplete, scope: 'Page-content links. HTML, configuration and external references are not checked.' };
60
+ };
61
+
62
+ // Change parsed image destinations and YAML scalars, preserving examples. Map the
63
+ // scanner's normalized offsets back to the original CRLF/LF source.
64
+ const renameImageReferences = async (source, oldName, newName) => {
65
+ const normalized = source.replace(/\r\n?/g, '\n');
66
+ const offsets = []; for (let i = 0; i < source.length; i++) { offsets.push(i); if (source[i] === '\r' && source[i + 1] === '\n') i++; } offsets.push(source.length);
67
+ const document = await parsePageMarkdownSource(normalized);
68
+ if (document.diagnostics.some(issue => issue.severity === 'error')) throw new Error('Repair the page content before renaming this image.');
69
+ const blocks = document.regions.flatMap(region => region.blocks.map(block => ({ ...block, sourceStartOffset: document.bodyOffset + region.startOffset + block.sourceStartOffset })));
70
+ const edits = [];
71
+ const destinations = new Set();
72
+ for (const reference of document.markdownImages.filter(reference => reference.image === oldName)) {
73
+ if (!reference.targetRange) throw new Error('This Markdown image reference has no editable source range.');
74
+ const { start, end } = reference.targetRange;
75
+ if (destinations.has(start)) continue;
76
+ destinations.add(start);
77
+ const prefix = reference.targetSource.slice(0, reference.targetSource.lastIndexOf('/') + 1);
78
+ edits.push({ start: offsets[start], end: offsets[end], text: prefix + newName });
79
+ }
80
+ for (const block of blocks) {
81
+ if (!['image-stack', 'image-carousel', 'card-list'].includes(block.blockType)) continue;
82
+ const doc = parseDocument(block.source);
83
+ const items = doc.get('items', true);
84
+ if (!isSeq(items)) continue;
85
+ for (const item of items.items) {
86
+ const image = item?.get?.('image', true);
87
+ if (!isScalar(image) || image.value !== oldName) continue;
88
+ if (!image.range) throw new Error('This image reference has no editable source range.');
89
+ edits.push({ start: offsets[block.sourceStartOffset + image.range[0]], end: offsets[block.sourceStartOffset + image.range[1]], text: JSON.stringify(newName) });
90
+ }
91
+ }
92
+ return edits;
93
+ };
94
+ const applyText = (source, edits) => [...edits].sort((a,b) => b.start-a.start).reduce((text,edit) => text.slice(0,edit.start)+edit.text+text.slice(edit.end),source);
95
+
96
+ export const planEditorResourceRename = async ({ siteRoot, filePath, name, destinationDirectory, sources = new Map() }) => {
97
+ siteRoot = path.resolve(siteRoot);
98
+ const selected = await resource({ siteRoot, filePath });
99
+ if (selected.fixed) throw new Error('The public folder has a fixed name and location.');
100
+ const directory = destinationDirectory ?? path.dirname(selected.filename);
101
+ if (['image', 'attachment'].includes(selected.kind) && directory !== path.dirname(selected.filename)) throw new Error('Images stay in their owning page’s images folder.');
102
+ const destination = await checkedPath(siteRoot, path.join(directory, checkedName(name)));
103
+ if (destination === selected.filename) throw new Error('Enter a different name or location, or cancel.');
104
+ if (!(await stat(directory))?.isDirectory()) throw new Error('Choose an existing destination folder.');
105
+ if (selected.kind === 'public' && !isInside(path.join(siteRoot, 'public'), destination)) throw new Error('Public resources must stay inside public/.');
106
+ if (selected.directory && isInside(selected.filename, destination)) throw new Error('A folder cannot be moved inside itself.');
107
+ if (selected.kind === 'image' && (!imageName.test(name) || path.extname(name) !== path.extname(selected.filename))) throw new Error('Use an image filename with letters, numbers, dots or hyphens. Keep the existing extension; rename does not convert images.');
108
+ await available(destination);
109
+ const usage = await getEditorResourceReferences({ siteRoot, filePath, sources });
110
+ if (usage.incomplete.length || usage.references.some(ref => ref.unresolved)) throw new Error(`References could not be resolved safely. Repair them before renaming or moving.\n${usage.incomplete.join('\n')}`);
111
+ const files = await snapshot(selected.filename);
112
+ const changes = [];
113
+ let warnings = [];
114
+ if (selected.kind === 'image') {
115
+ for (const sourcePath of new Set(usage.references.map(ref => ref.sourcePath))) {
116
+ await checkedPath(siteRoot, sourcePath);
117
+ const original = sources.get(sourcePath) ?? await readFile(sourcePath, 'utf8');
118
+ const edits = await renameImageReferences(original, path.basename(selected.filename), name);
119
+ if (!edits.length) throw new Error(`Cannot locate the image reference in ${sourcePath}. No files changed.`);
120
+ changes.push({ sourcePath, original, updated: applyText(original, edits) });
121
+ }
122
+ } else if (selected.kind === 'attachment') {
123
+ const state = await readEditorLinkState({ siteRoot, sources });
124
+ const file = state.graph.attachments.files.find(file => file.filePath === selected.filename);
125
+ if (!file) throw new Error('The attachment is no longer available.');
126
+ const updated = { ...file, filePath: destination, filename: name, pathname: attachmentPathname(file.ownerPathname, name) };
127
+ const problems = attachmentOutputIssues({ files: state.graph.attachments.files.map(entry => entry === file ? updated : entry), pages: state.graph.pages, publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes });
128
+ if (problems.length) throw new Error(problems.map(issue => issue.message).join('\n'));
129
+ const bySource = new Map();
130
+ for (const ref of state.graph.references.filter(ref => ref.resolution?.kind === 'attachment' && ref.resolution.file.filePath === file.filePath)) {
131
+ if (!ref.targetRange) throw new Error('Cannot safely locate this attachment reference. No files changed.');
132
+ const filename = ref.sourceContentFile.contentPath;
133
+ if (!bySource.has(filename)) bySource.set(filename, { original: ref.sourcePage.document.fullSource, edits: new Map() });
134
+ const local = parseLocalAttachmentTarget(ref.sourceTarget);
135
+ const href = local ? local.prefix + encodeAttachmentPath(name) + (ref.targetSource.match(/[?#].*$/)?.[0] ?? '') : (ref.target.absolutePrefix ?? '') + attachmentHref(updated, ref.targetSource);
136
+ bySource.get(filename).edits.set(ref.targetRange.start, { ...ref.targetRange, text: ref.yamlScalar ? JSON.stringify(href) : href });
137
+ }
138
+ for (const [sourcePath, { original, edits }] of bySource) { await checkedPath(siteRoot, sourcePath); changes.push({ sourcePath, original, updated: applyText(original, [...edits.values()]) }); }
139
+ } else {
140
+ const state = await readEditorLinkState({ siteRoot, sources });
141
+ const mapped = state.publicFiles.map(file => isInside(selected.filename, file.filePath) ? path.join(destination, path.relative(selected.filename, file.filePath)) : file.filePath);
142
+ warnings = validatePublic(siteRoot, state, mapped, selected.directory ? [destination] : []);
143
+ const before = inspectPublicAssetFilenames(state.publicFiles.filter(file => path.dirname(file.filePath) === path.join(siteRoot, 'public')).map(file => path.basename(file.filePath)));
144
+ const after = inspectPublicAssetFilenames(mapped.filter(filename => path.dirname(filename) === path.join(siteRoot, 'public')).map(filename => path.basename(filename)));
145
+ for (const kind of ['logos', 'browserIcons', 'socialImages']) for (const filename of before[kind]) if (!after[kind].includes(filename)) warnings.push(`${filename} will no longer be selected automatically as a site logo, icon or social image.`);
146
+ const bySource = new Map();
147
+ for (const ref of state.graph.references) {
148
+ if (ref.resolution?.kind !== 'public-file' || !isInside(selected.filename, ref.resolution.file.filePath)) continue;
149
+ if (!ref.targetRange) throw new Error(`Cannot safely update the reference on line ${ref.line} in ${ref.sourceContentFile.contentPath}.`);
150
+ const target = publicPath(siteRoot, path.join(destination, path.relative(selected.filename, ref.resolution.file.filePath)), state.settings);
151
+ const suffix = ref.targetSource.match(/[?#].*$/)?.[0] ?? '';
152
+ const filename = ref.sourceContentFile.contentPath;
153
+ if (!bySource.has(filename)) bySource.set(filename, { original: ref.sourcePage.document.fullSource, edits: new Map() });
154
+ bySource.get(filename).edits.set(ref.targetRange.start, { ...ref.targetRange, text: (ref.target.absolutePrefix ?? '') + encodePath(target) + suffix });
155
+ }
156
+ for (const [sourcePath, { original, edits }] of bySource) { await checkedPath(siteRoot, sourcePath); changes.push({ sourcePath, original, updated: applyText(original, [...edits.values()]) }); }
157
+ }
158
+ return { siteRoot, filePath: selected.filename, name, destinationDirectory: directory, destination, changes, usage, warnings,
159
+ fingerprint: JSON.stringify([files, destination, changes, usage, warnings]) };
160
+ };
161
+
162
+ export const planEditorPublicCreation = async ({ siteRoot, directory, name, source, folder = false, replace = false, pending = [], sources = new Map() }) => {
163
+ siteRoot = path.resolve(siteRoot);
164
+ directory = await checkedPath(siteRoot, directory ?? path.join(siteRoot, 'public'));
165
+ const publicRoot = path.join(siteRoot, 'public');
166
+ if (!isInside(publicRoot, directory)) throw new Error('Choose a folder inside public/.');
167
+ if (directory !== publicRoot && !(await stat(directory))?.isDirectory()) throw new Error('The destination folder no longer exists.');
168
+ const destination = await checkedPath(siteRoot, path.join(directory, checkedName(name)));
169
+ if (replace) { if (!(await stat(destination))?.isFile() || folder || !source) throw new Error('Select an existing public file and a replacement file.'); }
170
+ else await available(destination);
171
+ if (source && (!(await stat(source))?.isFile() || (await stat(source)).isSymbolicLink())) throw new Error('Choose a regular file on this computer.');
172
+ if (source && path.resolve(source) === destination) throw new Error('This file already belongs to this folder.');
173
+ const state = await readEditorLinkState({ siteRoot, sources });
174
+ for (const filename of pending) { await checkedPath(siteRoot, filename); if (!isInside(publicRoot, filename)) throw new Error('Pending public files must stay inside public/.'); }
175
+ const filenames = [...new Set([...state.publicFiles.map(file => file.filePath), ...pending])].filter(filename => filename !== destination);
176
+ const warnings = validatePublic(siteRoot, state, [...filenames, ...(!folder ? [destination] : [])], folder ? [destination] : []);
177
+ return { siteRoot, directory, name, source, folder, replace, destination, warnings,
178
+ fingerprint: JSON.stringify([source ? await snapshot(source) : null, replace ? await snapshot(destination) : null, destination, warnings]) };
179
+ };
180
+
181
+ export const planEditorFolderRemoval = async ({ siteRoot, directory, sources = new Map() }) => {
182
+ siteRoot = path.resolve(siteRoot);
183
+ directory = await checkedPath(siteRoot, directory);
184
+ const publicRoot = path.join(siteRoot, 'public');
185
+ const publicFolder = isInside(publicRoot, directory);
186
+ const kind = path.basename(directory);
187
+ const ownerDirectory = path.dirname(directory);
188
+ if (!publicFolder && (!['images', 'downloads', 'pages'].includes(kind) || !getSourcePageLocation(siteRoot, ownerDirectory))) throw new Error('Only optional pages, images and public folders can be deleted.');
189
+ if (!(await stat(directory))?.isDirectory()) throw new Error('The selected folder no longer exists.');
190
+ const files = await snapshot(directory);
191
+ const plans = [];
192
+ if (publicFolder) {
193
+ for (const [relative, type] of files) if (type === 'file') plans.push(await planEditorRemoval({ siteRoot, sourcePath: siteRoot, filePath: path.join(directory, relative), sources }));
194
+ } else {
195
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
196
+ const filename = path.join(directory, entry.name);
197
+ if (kind === 'downloads' && entry.isFile()) plans.push(await planEditorRemoval({ siteRoot, sourcePath: path.join(ownerDirectory, 'content.md'), filePath: filename, sources }));
198
+ else if (kind === 'images' && entry.isFile()) plans.push(await planEditorRemoval({ siteRoot, sourcePath: path.join(ownerDirectory, 'content.md'), imagePath: filename, sources }));
199
+ else if (kind === 'pages' && entry.isDirectory()) plans.push(await planEditorRemoval({ siteRoot, sourcePath: path.join(filename, 'content.md'), sources }));
200
+ else throw new Error(`Cannot delete this folder safely: ${entry.name} is not a supported ${kind === 'pages' ? 'page' : 'image'}. Review it in Explorer first.`);
201
+ }
202
+ }
203
+ const usage = { references: plans.flatMap(plan => plan.usage?.references ?? []).filter(ref => !isInside(directory, ref.sourcePath)), incomplete: [...new Set(plans.flatMap(plan => plan.usage?.incomplete ?? []))] };
204
+ return { target: directory, pages: plans.reduce((total,plan) => total + plan.pages, 0), files: files.filter(([,type]) => type === 'file').length, usage,
205
+ fingerprint: JSON.stringify([files, plans.map(plan => plan.fingerprint), usage]) };
206
+ };
207
+
208
+ export const getEditorResourceAddress = async (options) => {
209
+ const selected = await resource(options);
210
+ if (!['public', 'attachment'].includes(selected.kind) || selected.directory) throw new Error('Select a public file.');
211
+ const { settings, graph } = await readEditorLinkState(options);
212
+ if (!settings) throw new Error('Repair site-config/settings.yaml to copy the published address.');
213
+ const pathname = selected.kind === 'attachment' ? graph.attachments.files.find(file => file.filePath === selected.filename)?.pathname : publicPath(options.siteRoot, selected.filename, settings);
214
+ if (!pathname) throw new Error('The file no longer belongs to this site.');
215
+ return new URL(encodeAttachmentPath(pathname).slice(1), settings.url.replace(/\/$/, '') + '/').href;
216
+ };
@@ -0,0 +1,181 @@
1
+ import { comparePageOrder } from './page-order.mjs';
2
+ import { getSiteSourcePaths } from './site-conventions.mjs';
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { editorPageLocation, editorSourceDefinition, getEditorSourceFileChoices } from './editor-source-files.mjs';
6
+ import { editorFileRemovalPolicy } from './editor-file-policy.mjs';
7
+ import { parseYamlConfig } from './yaml-config.mjs';
8
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
9
+ import { sourceFileDefinitions } from './source-files.mjs';
10
+ import { getThemeDiagnostics } from './editor-language-service.mjs';
11
+ import { createEditorThemeHelp } from './editor-theme-help.mjs';
12
+
13
+ const excluded = new Set(['.norna', '.git', '.DS_Store', 'node_modules', 'dist', '.vscode-test']);
14
+ const knownNames = new Set([...sourceFileDefinitions.map((entry) => entry.name), 'category.yaml', 'theme.yaml', 'site-theme.yaml']);
15
+ const resourceId = (filename) => `resource:${filename}`;
16
+
17
+ // The physical traversal retains damaged entries that the logical page model
18
+ // cannot route. It reuses source schemas and page-path rules, without changing
19
+ // build validation.
20
+ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snapshot, getInformation }) => {
21
+ siteRoot = path.resolve(siteRoot);
22
+ const items = [];
23
+ const themeHelp = createEditorThemeHelp({ siteRoot, sources });
24
+ const byDirectory = new Map(snapshot.nodes.map((node) => [path.dirname(node.sourcePath), node]));
25
+ const read = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
26
+ const entriesAt = async (directory) => (await readdir(directory, { withFileTypes: true }))
27
+ .filter((entry) => !excluded.has(entry.name) && !entry.name.startsWith('._') && (entry.isFile() || entry.isDirectory()));
28
+ const issue = (item, message, severity = 'error', filename = item.sourcePath, line = 1) => {
29
+ (item.issues ??= []).push({ message, severity, path: filename, line });
30
+ };
31
+ const validate = async (item) => {
32
+ const definition = editorSourceDefinition(siteRoot, item.sourcePath);
33
+ if (!definition) return;
34
+ try {
35
+ const source = await read(item.sourcePath);
36
+ if (definition.schema) {
37
+ parseYamlConfig(source, item.sourcePath, { schema: definition.schema });
38
+ if (['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) {
39
+ for (const diagnostic of await getThemeDiagnostics({ siteRoot, documentPath: item.sourcePath, source, sources })) issue(item, diagnostic.message, diagnostic.severity, item.sourcePath, diagnostic.line);
40
+ }
41
+ } else {
42
+ const document = await parsePageMarkdownSource(source, { label: item.sourcePath });
43
+ for (const diagnostic of document.diagnostics) issue(item, diagnostic.message, diagnostic.severity, item.sourcePath, diagnostic.line);
44
+ }
45
+ } catch (error) { issue(item, error.message); }
46
+ };
47
+ const visit = async (directory, parentId, owner, role, location = null) => {
48
+ let entries;
49
+ try { entries = await entriesAt(directory); }
50
+ catch (error) {
51
+ if (location?.isHome && error.code === 'ENOENT') entries = [];
52
+ else {
53
+ if (owner) issue(owner, `Cannot read ${directory}: ${error.message}`, 'error', directory);
54
+ return;
55
+ }
56
+ }
57
+ const names = new Map(entries.map((entry) => [entry.name, entry]));
58
+ let container;
59
+ if (location) {
60
+ const hasContent = names.get('content.md')?.isFile();
61
+ const hasCategory = names.get('category.yaml')?.isFile();
62
+ const kind = hasContent ? 'page' : 'incomplete';
63
+ const sourcePath = path.join(directory, 'content.md');
64
+ const logical = byDirectory.get(directory);
65
+ let information = {};
66
+ if (kind !== 'incomplete') {
67
+ try { information = logical?.kind === kind ? logical : await getInformation({ source: await read(sourcePath), sourcePath, kind, isHome: location.isHome, fallbackTitle: location.pageId }); }
68
+ catch (error) { information = { problem: `Cannot read ${sourcePath}: ${error.message}` }; }
69
+ }
70
+ container = { ...logical, ...location, ...information, kind, sourcePath, id: sourcePath, ownerId: sourcePath, parentId,
71
+ directory, title: information.title ?? (location.isHome ? path.basename(siteRoot) : location.pageId),
72
+ url: location.isHome ? '/' : `/${location.pagePath}/`, missingSource: kind === 'incomplete' };
73
+ items.push(container);
74
+ if (kind === 'page') container.themeHelp = await themeHelp(directory);
75
+ if (information.problem) issue(container, information.problem);
76
+ if (kind !== 'incomplete') await validate(container);
77
+ else issue(container, location.isHome ? 'Homepage content.md is missing. Use Add to create it.'
78
+ : 'Page content.md is missing. Use Add to create it.');
79
+ if (hasCategory) issue(container, 'category.yaml is no longer supported. Use content.md with page.listChildren: true for an overview.');
80
+ owner = container;
81
+ } else if (role === 'site') {
82
+ container = { id: null };
83
+ } else {
84
+ container = { id: resourceId(directory), parentId, ownerId: owner.id, sourcePath: directory,
85
+ kind: 'directory', role, title: path.basename(directory), description: role === 'public'
86
+ ? 'Technical site files, logo, browser icons and social image. Put content images in a page’s images/ and documents in its downloads/.' : role === 'configuration' ? 'Settings and content shared by the complete site' : '' };
87
+ items.push(container);
88
+ }
89
+ const order = location ? ['images', 'downloads', 'tree-theme.yaml', 'page-theme.yaml', 'pages']
90
+ : role === 'site' ? ['site-config', 'public', 'root']
91
+ : role === 'configuration' ? ['settings.yaml', 'shared-content.yaml'] : [];
92
+ const rank = (entry) => order.includes(entry.name) ? order.indexOf(entry.name) : order.length;
93
+ entries.sort((a, b) => role === 'pages'
94
+ ? comparePageOrder(byDirectory.get(path.join(directory, a.name)) ?? { pageId: a.name }, byDirectory.get(path.join(directory, b.name)) ?? { pageId: b.name })
95
+ : rank(a) - rank(b) || a.name.localeCompare(b.name, 'en', { numeric: true }));
96
+ for (const entry of entries) {
97
+ const filename = path.join(directory, entry.name);
98
+ if (location && entry.name === 'content.md' && entry.isFile() && owner.kind === 'page') continue;
99
+ if (entry.isDirectory()) {
100
+ const childLocation = role === 'pages' || role === 'site' && entry.name === 'root' ? editorPageLocation(siteRoot, filename) : null;
101
+ const childRole = childLocation ? 'page' : location && entry.name === 'pages' ? 'pages'
102
+ : location && entry.name === 'images' && owner.kind === 'page' ? 'images'
103
+ : location && entry.name === 'downloads' && owner.kind === 'page' ? 'downloads'
104
+ : role === 'site' && entry.name === 'site-config' ? 'configuration'
105
+ : role === 'public' || role === 'site' && entry.name === 'public' ? 'public' : 'extra';
106
+ if (['images', 'downloads'].includes(childRole)) {
107
+ const resources = await entriesAt(filename);
108
+ if (!resources.some(resource => resource.isFile() && (childRole === 'downloads' || /\.(jpe?g|png|svg)$/i.test(resource.name)))) {
109
+ if (resources.length) issue(owner, `${filename} has no supported files; inspect its unsupported entries in Explorer.`, 'warning', filename);
110
+ continue;
111
+ }
112
+ }
113
+ await visit(filename, container.id, owner, childRole, childLocation);
114
+ const child = items.find((item) => item.sourcePath === filename);
115
+ if (child && childRole === 'extra' && (knownNames.has(entry.name) || ['images', 'downloads', 'pages', 'site-config', 'public'].includes(entry.name) || role === 'pages')) {
116
+ issue(child, `This directory is not at a permitted Norna location: ${filename}. Use pages/page-id for child entries, images/ beside page content, and site-config/ or public/ only at the site root.`, 'warning');
117
+ }
118
+ continue;
119
+ }
120
+ const definition = role !== 'public' ? editorSourceDefinition(siteRoot, filename) : null;
121
+ const file = { id: resourceId(filename), parentId: container.id, ownerId: owner.id, kind: 'file', sourcePath: filename,
122
+ title: entry.name, role: definition ? 'configuration' : 'asset', description: definition?.description ?? '',
123
+ removable: Boolean(editorFileRemovalPolicy({ siteRoot, sourcePath: owner.sourcePath, filePath: filename })) };
124
+ items.push(file);
125
+ if (['rootTheme', 'theme', 'pageTheme'].includes(definition?.schemaKind)) file.themeHelp = await themeHelp(directory, filename);
126
+ if (definition) await validate(file);
127
+ else if (role !== 'public' && ['site-config', 'public', 'pages', 'images', 'downloads'].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');
128
+ 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');
129
+ 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');
130
+ else if (!['public', 'downloads'].includes(role) && !(role === 'images' && /\.(jpe?g|png|svg)$/i.test(entry.name))) file.note = 'Not used by Norna';
131
+ }
132
+ };
133
+ const source = getSiteSourcePaths(siteRoot);
134
+ // Site resources have their own owner even when root/content.md is unreadable.
135
+ // The site context is not a visible row in the physical tree.
136
+ const owner = { id: siteRoot, sourcePath: siteRoot, issues: [] };
137
+ await visit(siteRoot, null, owner, 'site');
138
+ if (!items.some((item) => item.isHome)) await visit(source.root, null, owner, 'page', editorPageLocation(siteRoot, source.root));
139
+ const configuration = items.find((item) => item.role === 'configuration' && item.kind === 'directory');
140
+ const homepage = items.find((item) => item.isHome);
141
+ for (const [filename, target] of [[path.join(siteRoot, 'site-config/settings.yaml'), configuration], [source.theme, homepage]]) {
142
+ try { await read(filename); }
143
+ catch (error) {
144
+ issue(target ?? owner, error.code === 'ENOENT' || error.code === 'ENOTDIR'
145
+ ? `Required ${path.relative(siteRoot, filename)} is missing. Use Add to create it.` : error.message, 'error', filename);
146
+ }
147
+ }
148
+
149
+ // Roles own actions; the adapter only maps these names to native commands.
150
+ // Future page-owned resources can extend this without image/file guessing.
151
+ const byId = new Map(items.map(item => [item.id, item]));
152
+ for (const item of items) {
153
+ const parent = byId.get(item.parentId);
154
+ item.actions = [];
155
+ if (item.kind === 'page') item.actions.push('addPage', 'addImages', 'addAttachments', 'renamePage', 'properties', 'copyPageLink', 'pageReferences', ...(!item.isHome ? ['movePage', 'deletePage'] : []));
156
+ if (['page', 'incomplete'].includes(item.kind) || item.role === 'configuration' && item.kind === 'directory') {
157
+ const directory = item.kind === 'directory' ? siteRoot : item.directory;
158
+ const choices = await getEditorSourceFileChoices({ siteRoot, directory }).catch(() => []);
159
+ item.missingFiles = choices.filter(choice => path.dirname(choice.filename) === (item.kind === 'directory' ? item.sourcePath : item.directory));
160
+ for (const choice of item.missingFiles) item.actions.push('add_' + path.basename(choice.filename).replaceAll('.', '_').replaceAll('-', '_'));
161
+ }
162
+ if (item.kind === 'directory' && ['pages', 'images'].includes(item.role)) item.actions.push(item.role === 'pages' ? 'addPage' : 'addImages', 'deleteFolder');
163
+ if (item.kind === 'directory' && item.role === 'downloads') item.actions.push('addAttachments', 'deleteFolder');
164
+ if (item.kind === 'directory' && item.role === 'public') item.actions.push('newPublicFile', 'addPublicFiles', 'newPublicFolder', 'deleteFolder', ...(item.sourcePath !== path.join(siteRoot, 'public') ? ['renameResource', 'moveResource'] : []));
165
+ if (item.kind === 'file') {
166
+ if (parent?.role === 'public') item.actions.push('renameResource', 'replacePublicFile', 'moveResource', 'copyResourceLink', 'resourceReferences', 'deleteFile');
167
+ else if (parent?.role === 'downloads') item.actions.push('insertAttachment', 'renameResource', 'replaceAttachment', 'resourceReferences', 'copyResourceLink', 'deleteFile');
168
+ else if (parent?.role === 'images' && /\.(jpe?g|png|svg)$/i.test(item.title)) item.actions.push('insertImage', 'renameResource', 'replaceImage', 'resourceReferences', 'deleteImage');
169
+ else if (item.removable) item.actions.push('deleteFile');
170
+ }
171
+ }
172
+
173
+ // Keep shared structural diagnostics, including duplicate sibling identifiers.
174
+ for (const problem of snapshot.problems) {
175
+ if (![owner, ...items].some((item) => item.issues?.some((entry) => entry.message === problem.message && entry.path === problem.path))) {
176
+ const target = items.find((item) => item.sourcePath === problem.path || item.directory === problem.path) ?? owner;
177
+ issue(target, problem.message, problem.severity ?? 'error', problem.path);
178
+ }
179
+ }
180
+ return { ...snapshot, items, problems: owner.issues };
181
+ };