@janga/norna 0.7.28 → 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.
- package/README.md +8 -5
- package/astro.config.mjs +14 -2
- package/package.json +10 -8
- package/schemas/config.schema.json +80 -65
- package/schemas/content-frontmatter.schema.json +15 -8
- package/schemas/manifest.json +1 -1
- package/schemas/page-theme.schema.json +237 -237
- package/schemas/root-theme.schema.json +237 -237
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -237
- package/scripts/add-site-node.mjs +29 -100
- package/scripts/dev-local.mjs +34 -7
- package/scripts/generate-images.mjs +83 -65
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-routes.json +63 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +2 -0
- package/scripts/lib/editor-language-service.mjs +21 -18
- package/scripts/lib/editor-page-addresses.mjs +5 -3
- package/scripts/lib/editor-page-placement.mjs +28 -98
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +44 -9
- package/scripts/lib/editor-site-files.mjs +3 -2
- package/scripts/lib/editor-site-links.mjs +6 -4
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +10 -3
- package/scripts/lib/editor-source-files.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/norna-markdown-blocks.mjs +0 -57
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +5 -0
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +8 -22
- package/scripts/lib/page-move-apply.mjs +6 -4
- package/scripts/lib/page-move-plan.mjs +37 -19
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/project-config.mjs +3 -0
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +4 -0
- package/scripts/lib/schema-editor-metadata.mjs +4 -1
- package/scripts/lib/site-content.mjs +1 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-node-create.mjs +47 -34
- package/scripts/lib/site-paths.mjs +2 -2
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +14 -13
- package/scripts/lib/site-upgrade.mjs +2 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +1 -1
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/move-site-page.mjs +46 -133
- package/scripts/sync-content-sections.mjs +8 -10
- package/scripts/sync-site-public.mjs +44 -66
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/SearchPage.astro +2 -1
- package/src/components/SitePage.astro +1 -0
- package/src/components/SiteSection.astro +1 -0
- package/src/content.config.ts +1 -4
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/sectionContent.ts +64 -19
- package/src/lib/sitePages.ts +4 -30
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/[...slug].astro +2 -1
- package/src/styles/area-navigation.css +14 -5
- package/src/styles/content.css +6 -0
- package/src/styles/media.css +24 -0
- package/starters/basic/site/site-config/settings.yaml +1 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { rename } from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { checkedPath, snapshot } from './editor-site-files.mjs';
|
|
@@ -6,16 +5,15 @@ import { readEditorLinkState } from './editor-site-links.mjs';
|
|
|
6
5
|
import { createPageMovePlan } from './page-move-plan.mjs';
|
|
7
6
|
import { applyPageMovePlan } from './page-move-apply.mjs';
|
|
8
7
|
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
9
|
-
import {
|
|
8
|
+
import { comparePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
|
|
9
|
+
import { pageOrderFileChanges } from './page-order-source.mjs';
|
|
10
10
|
|
|
11
|
-
export const sitePagePlacementApiVersion =
|
|
11
|
+
export const sitePagePlacementApiVersion = 2;
|
|
12
|
+
export const sitePagePlacementOptionsApiVersion = 1;
|
|
12
13
|
|
|
13
14
|
const urlFor = (node) => node.isHome ? '/' : `/${node.pagePath}/`;
|
|
14
15
|
const childOf = (node, parent) => node.parentPagePath === (parent.pagePath || null);
|
|
15
16
|
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
17
|
const explainDestination = (owner, address) => {
|
|
20
18
|
if (owner.kind === 'page') return `Cannot move here. ${address} already belongs to “${owner.source.title}”. Choose another location.`;
|
|
21
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.`;
|
|
@@ -24,45 +22,8 @@ const explainDestination = (owner, address) => {
|
|
|
24
22
|
return `Cannot move here. ${address} is already used by ${owner.label}. Choose another location.`;
|
|
25
23
|
};
|
|
26
24
|
|
|
27
|
-
const
|
|
28
|
-
|
|
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() }) => {
|
|
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.');
|
|
66
27
|
if (!['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first child or last child.');
|
|
67
28
|
siteRoot = path.resolve(siteRoot);
|
|
68
29
|
await checkedPath(siteRoot, sourcePath);
|
|
@@ -84,32 +45,18 @@ export const planEditorPagePlacement = async ({ siteRoot, sourcePath, targetPath
|
|
|
84
45
|
if (!parent || inBranch(parent, source)) throw new Error('A page cannot move inside its own branch. Choose another destination.');
|
|
85
46
|
const sameParent = source.parentPagePath === (parent.pagePath || null);
|
|
86
47
|
const existing = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && childOf(node, parent) && node.contentPath !== sourcePath)
|
|
87
|
-
.sort(
|
|
48
|
+
.sort(comparePageOrder);
|
|
88
49
|
const targetIndex = existing.findIndex((node) => node.contentPath === targetPath);
|
|
89
50
|
if ((placement === 'before' || placement === 'after') && targetIndex < 0) throw new Error('The destination page changed. Refresh Site Tree and try again.');
|
|
90
51
|
const index = placement === 'first' ? 0 : placement === 'last' ? existing.length
|
|
91
52
|
: targetIndex + (placement === 'after' ? 1 : 0);
|
|
92
53
|
const current = state.structure.nodes.filter((node) => node.kind === 'page' && !node.isHome && node.parentPagePath === source.parentPagePath)
|
|
93
|
-
.sort(
|
|
54
|
+
.sort(comparePageOrder);
|
|
94
55
|
if (sameParent && current[index]?.contentPath === sourcePath) throw new Error('This page is already in that position.');
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
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));
|
|
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;
|
|
113
60
|
const to = sameParent ? urlFor(source) : `${urlFor(parent)}${source.pageId}/`;
|
|
114
61
|
let movePreview = null;
|
|
115
62
|
if (!sameParent) {
|
|
@@ -117,17 +64,13 @@ export const planEditorPagePlacement = async ({ siteRoot, sourcePath, targetPath
|
|
|
117
64
|
if (owner && !(owner.kind === 'page-alias' && owner.source.page.contentFile.contentPath === sourcePath)) {
|
|
118
65
|
throw new Error(explainDestination(owner, to));
|
|
119
66
|
}
|
|
120
|
-
|
|
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,
|
|
67
|
+
movePreview = await createPageMovePlan({ from: urlFor(source), to, order: sourceOrder, siblingOrderChanges: insertion.orderChanges, preserveAliases,
|
|
125
68
|
graph: state.graph, publicFiles: state.publicFiles, siteStructure: state.structure,
|
|
126
69
|
sitePagesDir: getSiteSourcePaths(siteRoot).pages,
|
|
127
70
|
sitePagesLabel: `${siteRoot}/root/pages`, generatedRoutes: state.generatedRoutes });
|
|
128
71
|
}
|
|
129
72
|
const watched = new Set([source.nodeDir, path.join(siteRoot, 'site-config/settings.yaml'),
|
|
130
|
-
...orderChanges.map(({
|
|
73
|
+
...orderChanges.map(({ contentPath }) => contentPath),
|
|
131
74
|
...state.structure.nodes.map((node) => node.contentPath ?? node.categoryPath),
|
|
132
75
|
...state.publicFiles.map((file) => file.filePath)]);
|
|
133
76
|
const fingerprints = [];
|
|
@@ -135,38 +78,25 @@ export const planEditorPagePlacement = async ({ siteRoot, sourcePath, targetPath
|
|
|
135
78
|
return {
|
|
136
79
|
siteRoot, sourcePath, targetPath, placement, sourceDirectory: source.nodeDir,
|
|
137
80
|
sameParent, sourceOrder, sourceUrl: urlFor(source), destinationUrl: to,
|
|
138
|
-
sourceDestination, orderChanges, movePreview,
|
|
139
|
-
|
|
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]),
|
|
140
85
|
};
|
|
141
86
|
};
|
|
142
87
|
|
|
143
|
-
export const applyEditorPagePlacement = async (plan, { renameDirectory = rename } = {}) => {
|
|
144
|
-
const current = await planEditorPagePlacement(plan);
|
|
88
|
+
export const applyEditorPagePlacement = async (plan, { renameDirectory = rename, writeSource, allowRenumber = false, sources = new Map() } = {}) => {
|
|
89
|
+
const current = await planEditorPagePlacement({ ...plan, sources });
|
|
145
90
|
if (current.fingerprint !== plan.fingerprint || current.sourceOrder !== plan.sourceOrder
|
|
146
91
|
|| JSON.stringify(current.orderChanges) !== JSON.stringify(plan.orderChanges)) {
|
|
147
92
|
throw new Error('The site changed since this move was previewed. Review the placement and addresses again.');
|
|
148
93
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
}
|
|
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 };
|
|
172
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
|
+
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { comparePageOrder } from './page-order.mjs';
|
|
1
2
|
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
2
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { editorPageLocation, editorSourceDefinition } from './editor-source-files.mjs';
|
|
5
|
+
import { editorPageLocation, editorSourceDefinition, getEditorSourceFileChoices } from './editor-source-files.mjs';
|
|
5
6
|
import { editorFileRemovalPolicy } from './editor-file-policy.mjs';
|
|
6
7
|
import { parseYamlConfig } from './yaml-config.mjs';
|
|
7
8
|
import { parsePageMarkdownSource } from './page-markdown.mjs';
|
|
@@ -82,14 +83,16 @@ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snaps
|
|
|
82
83
|
} else {
|
|
83
84
|
container = { id: resourceId(directory), parentId, ownerId: owner.id, sourcePath: directory,
|
|
84
85
|
kind: 'directory', role, title: path.basename(directory), description: role === 'public'
|
|
85
|
-
? '
|
|
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' : '' };
|
|
86
87
|
items.push(container);
|
|
87
88
|
}
|
|
88
|
-
const order = location ? ['images', 'tree-theme.yaml', 'page-theme.yaml', 'pages']
|
|
89
|
+
const order = location ? ['images', 'downloads', 'tree-theme.yaml', 'page-theme.yaml', 'pages']
|
|
89
90
|
: role === 'site' ? ['site-config', 'public', 'root']
|
|
90
91
|
: role === 'configuration' ? ['settings.yaml', 'shared-content.yaml'] : [];
|
|
91
92
|
const rank = (entry) => order.includes(entry.name) ? order.indexOf(entry.name) : order.length;
|
|
92
|
-
entries.sort((a, b) =>
|
|
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 }));
|
|
93
96
|
for (const entry of entries) {
|
|
94
97
|
const filename = path.join(directory, entry.name);
|
|
95
98
|
if (location && entry.name === 'content.md' && entry.isFile() && owner.kind === 'page') continue;
|
|
@@ -97,12 +100,20 @@ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snaps
|
|
|
97
100
|
const childLocation = role === 'pages' || role === 'site' && entry.name === 'root' ? editorPageLocation(siteRoot, filename) : null;
|
|
98
101
|
const childRole = childLocation ? 'page' : location && entry.name === 'pages' ? 'pages'
|
|
99
102
|
: location && entry.name === 'images' && owner.kind === 'page' ? 'images'
|
|
103
|
+
: location && entry.name === 'downloads' && owner.kind === 'page' ? 'downloads'
|
|
100
104
|
: role === 'site' && entry.name === 'site-config' ? 'configuration'
|
|
101
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
|
+
}
|
|
102
113
|
await visit(filename, container.id, owner, childRole, childLocation);
|
|
103
114
|
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/
|
|
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');
|
|
106
117
|
}
|
|
107
118
|
continue;
|
|
108
119
|
}
|
|
@@ -113,10 +124,10 @@ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snaps
|
|
|
113
124
|
items.push(file);
|
|
114
125
|
if (['rootTheme', 'theme', 'pageTheme'].includes(definition?.schemaKind)) file.themeHelp = await themeHelp(directory, filename);
|
|
115
126
|
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');
|
|
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');
|
|
117
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');
|
|
118
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');
|
|
119
|
-
else if (
|
|
130
|
+
else if (!['public', 'downloads'].includes(role) && !(role === 'images' && /\.(jpe?g|png|svg)$/i.test(entry.name))) file.note = 'Not used by Norna';
|
|
120
131
|
}
|
|
121
132
|
};
|
|
122
133
|
const source = getSiteSourcePaths(siteRoot);
|
|
@@ -135,11 +146,35 @@ export const readSiteEditingTree = async ({ siteRoot, sources = new Map(), snaps
|
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
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
|
+
|
|
138
173
|
// Keep shared structural diagnostics, including duplicate sibling identifiers.
|
|
139
174
|
for (const problem of snapshot.problems) {
|
|
140
175
|
if (![owner, ...items].some((item) => item.issues?.some((entry) => entry.message === problem.message && entry.path === problem.path))) {
|
|
141
176
|
const target = items.find((item) => item.sourcePath === problem.path || item.directory === problem.path) ?? owner;
|
|
142
|
-
issue(target, problem.message, 'error', problem.path);
|
|
177
|
+
issue(target, problem.message, problem.severity ?? 'error', problem.path);
|
|
143
178
|
}
|
|
144
179
|
}
|
|
145
180
|
return { ...snapshot, items, problems: owner.issues };
|
|
@@ -82,6 +82,7 @@ export const getEditorImageUsage = async ({ siteRoot, sourcePath, imagePath, sou
|
|
|
82
82
|
const references = [];
|
|
83
83
|
const incomplete = structure.problems.map((problem) => problem.message);
|
|
84
84
|
for (const node of structure.nodes.filter((node) => node.contentPath)) {
|
|
85
|
+
if (path.dirname(node.contentPath) !== directory) continue;
|
|
85
86
|
try {
|
|
86
87
|
const source = sources.has(node.contentPath) ? sources.get(node.contentPath) : await readFile(node.contentPath, 'utf8');
|
|
87
88
|
const document = await parsePageMarkdownSource(source, { label: node.contentPath });
|
|
@@ -89,7 +90,7 @@ export const getEditorImageUsage = async ({ siteRoot, sourcePath, imagePath, sou
|
|
|
89
90
|
for (const reference of document.managedImages.filter((reference) => reference.image.toLowerCase() === path.basename(target).toLowerCase())) {
|
|
90
91
|
const localPath = path.join(path.dirname(node.contentPath), 'images', reference.image);
|
|
91
92
|
const local = await stat(localPath);
|
|
92
|
-
if (local
|
|
93
|
+
if (!local || local.dev !== targetStat.dev || local.ino !== targetStat.ino) continue;
|
|
93
94
|
references.push({ sourcePath: node.contentPath, line: reference.line, unresolved: !local });
|
|
94
95
|
}
|
|
95
96
|
} catch (error) { incomplete.push(`${node.contentPath}: ${error.message}`); }
|
|
@@ -106,7 +107,7 @@ export const planEditorRemoval = async ({ siteRoot, sourcePath, imagePath, fileP
|
|
|
106
107
|
const policy = filePath ? editorFileRemovalPolicy({ siteRoot: path.resolve(siteRoot), sourcePath: target.sourcePath, filePath: filename }) : null;
|
|
107
108
|
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
109
|
const usage = imagePath ? await getEditorImageUsage({ siteRoot, sourcePath, imagePath, sources })
|
|
109
|
-
: !filePath || policy.kind
|
|
110
|
+
: !filePath || ['public', 'attachment'].includes(policy.kind) ? await getEditorIncomingLinks({ siteRoot, sourcePath, sources,
|
|
110
111
|
descendants: !filePath, excludeBranch: !filePath, filePath }) : null;
|
|
111
112
|
const files = await snapshot(filename);
|
|
112
113
|
const effect = policy?.kind === 'theme'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getSiteAttachments } from './page-attachments.mjs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { getGeneratedSiteRoutes } from './page-aliases.mjs';
|
|
@@ -43,13 +44,13 @@ export const readEditorLinkState = async ({ siteRoot, sources = new Map() }) =>
|
|
|
43
44
|
return { contentFile, data, document };
|
|
44
45
|
}));
|
|
45
46
|
let publicFiles = [];
|
|
46
|
-
try { publicFiles = await getSitePublicFiles(path.join(siteRoot, 'public')); }
|
|
47
|
+
try { publicFiles = await getSitePublicFiles(path.join(siteRoot, 'public'), settings); }
|
|
47
48
|
catch (error) { incomplete.push(error.message); }
|
|
48
49
|
const generatedRoutes = getGeneratedSiteRoutes({ searchEnabled: settings?.search });
|
|
49
|
-
const graph = createSiteLinkGraph({ siteStructure: structure, pageDocuments, publicFiles, generatedRoutes });
|
|
50
|
+
const graph = createSiteLinkGraph({ siteUrl: settings?.url, siteStructure: structure, pageDocuments, publicFiles, generatedRoutes, attachments: await getSiteAttachments(structure) });
|
|
50
51
|
// Missing destinations do not make reference discovery incomplete. Ambiguous
|
|
51
52
|
// 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
|
+
incomplete.push(...graph.diagnostics.filter((entry) => (!entry.reference || entry.code === 'ambiguous-attachment') && entry.severity === 'error').map(({ message }) => message));
|
|
53
54
|
return { structure, graph, publicFiles, generatedRoutes, settings, incomplete: [...new Set(incomplete)].sort() };
|
|
54
55
|
};
|
|
55
56
|
|
|
@@ -72,7 +73,8 @@ export const getEditorIncomingLinks = async ({ siteRoot, sourcePath, sources, de
|
|
|
72
73
|
const references = state.graph.references.filter((reference) => {
|
|
73
74
|
if (excludeBranch && isInside(directory, reference.sourceContentFile.contentPath)) return false;
|
|
74
75
|
if (alias) return reference.target.pageLookupPathname === alias;
|
|
75
|
-
if (filePath) return reference.resolution?.kind
|
|
76
|
+
if (filePath) return ['public-file', 'attachment'].includes(reference.resolution?.kind) && reference.resolution.file.filePath === filePath;
|
|
77
|
+
if (reference.resolution?.kind === 'attachment' && targetPaths.has(reference.resolution.file.ownerPathname)) return true;
|
|
76
78
|
return targetPaths.has(reference.resolution?.pathname ?? reference.target.pageLookupPathname);
|
|
77
79
|
}).map(summarizeReference);
|
|
78
80
|
return { references, incomplete: state.incomplete };
|