@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.
- package/README.md +41 -29
- package/astro.config.mjs +33 -3
- package/bin/norna-cli.mjs +5 -3
- package/package.json +18 -12
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +81 -66
- package/schemas/content-frontmatter.schema.json +23 -7
- package/schemas/manifest.json +49 -3
- package/schemas/page-theme.schema.json +1585 -41
- package/schemas/root-theme.schema.json +1767 -0
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +243 -243
- package/scripts/add-site-node.mjs +31 -250
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +51 -17
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +89 -70
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +70 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +20 -0
- package/scripts/lib/editor-language-service.mjs +74 -73
- package/scripts/lib/editor-page-addresses.mjs +57 -0
- package/scripts/lib/editor-page-placement.mjs +102 -0
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +181 -0
- package/scripts/lib/editor-site-files.mjs +154 -0
- package/scripts/lib/editor-site-links.mjs +81 -0
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +158 -0
- package/scripts/lib/editor-source-files.mjs +87 -0
- package/scripts/lib/editor-theme-help.mjs +43 -0
- package/scripts/lib/locale-registry.mjs +1 -1
- package/scripts/lib/locales/en.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/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -58
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +8 -1
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +19 -36
- package/scripts/lib/page-move-apply.mjs +119 -0
- package/scripts/lib/page-move-plan.mjs +64 -78
- 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/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +10 -8
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +15 -38
- package/scripts/lib/schema-editor-metadata.mjs +16 -60
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +8 -25
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +182 -0
- package/scripts/lib/site-paths.mjs +24 -12
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +86 -95
- package/scripts/lib/site-upgrade.mjs +32 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +34 -0
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/lib/theme-config.mjs +26 -68
- package/scripts/lib/theme-packages.mjs +47 -0
- package/scripts/lib/theme-presets.mjs +20 -53
- package/scripts/lib/typography.mjs +2 -2
- package/scripts/lib/yaml-config.mjs +1 -1
- package/scripts/lib/yaml-source-edit.mjs +94 -0
- package/scripts/move-site-page.mjs +46 -254
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +17 -17
- package/scripts/sync-site-public.mjs +44 -66
- package/scripts/upgrade-site.mjs +17 -0
- package/src/components/AreaNavigationMenu.astro +24 -0
- package/src/components/AreaNavigationPanel.astro +55 -0
- package/src/components/AreaNavigationSwitcher.astro +30 -0
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/ImageStackEnhancement.astro +0 -7
- package/src/components/NavigationContinuity.astro +26 -0
- package/src/components/NavigationPageTree.astro +13 -4
- package/src/components/NavigationState.astro +11 -0
- package/src/components/NavigationTreeControls.astro +3 -2
- package/src/components/SearchPage.astro +11 -2
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +38 -61
- package/src/components/SiteSection.astro +1 -0
- package/src/components/SiteTreeNavigation.astro +38 -18
- package/src/components/TopPageMenu.astro +4 -3
- package/src/components/TreeNavigationScript.astro +50 -57
- package/src/content.config.ts +11 -12
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -0
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/navigationContinuity.ts +212 -0
- package/src/lib/navigationFollowing.ts +28 -18
- package/src/lib/navigationState.mjs +88 -0
- package/src/lib/navigationStateScript.mjs +76 -0
- package/src/lib/navigationVisibility.ts +9 -0
- package/src/lib/pageThemes.ts +5 -42
- package/src/lib/sectionContent.ts +67 -21
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +13 -36
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +7 -16
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +408 -0
- package/src/styles/content.css +8 -29
- package/src/styles/media.css +24 -0
- package/src/styles/navigation-continuity.css +163 -0
- package/src/styles/page-layout.css +57 -68
- package/src/styles/responsive.css +0 -4
- package/starters/basic/README.md +9 -8
- package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
- package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
- package/schemas/category.schema.json +0 -20
- package/src/components/PageContentsNavigation.astro +0 -20
- /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
- /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
4
|
+
import { attachmentPublicationPath, publicationIssues } from './publication-paths.mjs';
|
|
5
|
+
|
|
6
|
+
export const attachmentPathname = attachmentPublicationPath;
|
|
7
|
+
export const encodeAttachmentPath = value => value.split('/').map(segment => encodeURIComponent(segment).replace(/[()']/g, char => '%' + char.charCodeAt(0).toString(16))).join('/');
|
|
8
|
+
export const validateAttachmentName = name => {
|
|
9
|
+
if (typeof name !== 'string' || !name || name !== name.trim() || /[\\/\x00-\x1f\x7f<>:"|?*#%]/.test(name) || /^\.{1,2}$/.test(name) || /[. ]$/.test(name)) throw new Error('Use one filename without slashes, URL punctuation or trailing dots/spaces.');
|
|
10
|
+
return name;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const readPageAttachments = async contentFile => {
|
|
14
|
+
const directory = path.join(path.dirname(contentFile.contentPath), 'downloads');
|
|
15
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
16
|
+
if (!info) return { files: [], diagnostics: [] };
|
|
17
|
+
const diagnostics = [], files = [];
|
|
18
|
+
const problem = message => diagnostics.push({ severity: 'error', code: 'invalid-attachment', contentFile, message, fix: 'Keep regular files directly in the page’s downloads/ folder; remove unsupported entries.' });
|
|
19
|
+
if (!info.isDirectory() || info.isSymbolicLink()) { problem(`${directory} must be a real directory.`); return { files, diagnostics }; }
|
|
20
|
+
const names = new Set();
|
|
21
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
22
|
+
if (entry.name === '.DS_Store' || entry.name.startsWith('._')) continue;
|
|
23
|
+
const filePath = path.join(directory, entry.name);
|
|
24
|
+
try { validateAttachmentName(entry.name); } catch (error) { problem(`${filePath}: ${error.message}`); continue; }
|
|
25
|
+
if (!entry.isFile()) { problem(`${filePath}: attachment subfolders and symbolic links are not supported.`); continue; }
|
|
26
|
+
const key = entry.name.normalize('NFC').toLowerCase();
|
|
27
|
+
if (names.has(key)) problem(`${directory}: attachment filenames must be unique regardless of case or Unicode normalization.`);
|
|
28
|
+
names.add(key);
|
|
29
|
+
files.push({ filePath, filename: entry.name, contentPath: contentFile.contentPath, ownerPathname: getSiteNodePathname(contentFile), pathname: attachmentPathname(getSiteNodePathname(contentFile), entry.name) });
|
|
30
|
+
}
|
|
31
|
+
return { files, diagnostics };
|
|
32
|
+
};
|
|
33
|
+
export const getSiteAttachments = async siteStructure => {
|
|
34
|
+
const results = await Promise.all(siteStructure.contentFiles.map(readPageAttachments));
|
|
35
|
+
return { files: results.flatMap(result => result.files), diagnostics: results.flatMap(result => result.diagnostics) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Explicit downloads paths identify page-owned files. Bare names retain the
|
|
39
|
+
// existing shorthand rules, including ambiguity with page/public links.
|
|
40
|
+
export const parseLocalAttachmentTarget = target => {
|
|
41
|
+
const raw = target.split(/[?#]/, 1)[0];
|
|
42
|
+
if (!raw || /^[a-z][a-z\d+.-]*:/i.test(raw)) return null;
|
|
43
|
+
const prefix = raw.match(/^(?:\.\/)?downloads\//)?.[0] ?? '';
|
|
44
|
+
try {
|
|
45
|
+
const filename = decodeURIComponent(raw.slice(prefix.length));
|
|
46
|
+
validateAttachmentName(filename);
|
|
47
|
+
return { filename, prefix };
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
};
|
|
50
|
+
export const localAttachmentName = target => parseLocalAttachmentTarget(target)?.filename ?? null;
|
|
51
|
+
export const resolveAttachment = (target, sourcePathname, files, resolvedPathname) => {
|
|
52
|
+
const name = localAttachmentName(target);
|
|
53
|
+
return name ? files.find(file => file.ownerPathname === sourcePathname && file.filename === name)
|
|
54
|
+
: files.find(file => file.pathname === resolvedPathname);
|
|
55
|
+
};
|
|
56
|
+
export const attachmentHref = (file, target) => encodeAttachmentPath(file.pathname) + (target.match(/[?#].*$/)?.[0] ?? '');
|
|
57
|
+
|
|
58
|
+
export const attachmentOutputIssues = ({ files, pages, publicFiles, generatedRoutes }) =>
|
|
59
|
+
publicationIssues({ attachments: files, pages, publicFiles, generatedRoutes });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractMarkdownImageReferences, markdownImageDiagnostic } from './markdown-images.mjs';
|
|
1
2
|
import { getCodeFenceMetadataDiagnostics } from './code-fence-metadata.mjs';
|
|
2
3
|
import { parseContentTabs } from './content-tabs.mjs';
|
|
3
4
|
import { getDetailsHeadingDiagnostics } from './details-headings.mjs';
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
} from './markdown-links.mjs';
|
|
12
13
|
import {
|
|
13
14
|
extractInlineNoteDiagnostics,
|
|
14
|
-
extractMarkdownImageReferences,
|
|
15
15
|
extractNornaMarkdownBlockDiagnostics,
|
|
16
16
|
getNornaBlockImageReferences,
|
|
17
17
|
nornaBlockTypes,
|
|
@@ -191,6 +191,7 @@ const createRegion = ({
|
|
|
191
191
|
tableErrors,
|
|
192
192
|
tabResult,
|
|
193
193
|
pageNotes,
|
|
194
|
+
imageReferences,
|
|
194
195
|
}) => {
|
|
195
196
|
const endOffset = nextHeading?.index ?? source.length;
|
|
196
197
|
const markdown = source.slice(heading.index, endOffset).trimEnd();
|
|
@@ -201,10 +202,7 @@ const createRegion = ({
|
|
|
201
202
|
});
|
|
202
203
|
const inRegion = (offset) => offset >= heading.index && offset < endOffset;
|
|
203
204
|
const fieldNoteErrors = getStructuredSidenoteDiagnostics(blockResult.blocks, { label, regionOffset: heading.index });
|
|
204
|
-
const markdownImages =
|
|
205
|
-
...reference,
|
|
206
|
-
line: regionLineOffset + reference.line,
|
|
207
|
-
}));
|
|
205
|
+
const markdownImages = imageReferences.filter((reference) => inRegion(reference.range.start));
|
|
208
206
|
const regionHeadings = headings.filter((candidate) => (
|
|
209
207
|
candidate.index >= heading.index
|
|
210
208
|
&& candidate.index < endOffset
|
|
@@ -226,7 +224,7 @@ const createRegion = ({
|
|
|
226
224
|
kind: heading.depth === 1 ? 'page-intro' : 'section',
|
|
227
225
|
bodyLine: heading.line,
|
|
228
226
|
line: lineOffset + heading.line,
|
|
229
|
-
managedImages: getNornaBlockImageReferences(blockResult.blocks),
|
|
227
|
+
managedImages: [...getNornaBlockImageReferences(blockResult.blocks), ...markdownImages.filter((reference) => reference.kind === 'managed')],
|
|
230
228
|
markdown,
|
|
231
229
|
markdownImages,
|
|
232
230
|
notes: pageNotes.notes.filter((note) => inRegion(note.referenceOffset)),
|
|
@@ -246,6 +244,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
|
|
|
246
244
|
const tabResult = parseContentTabs(source, { label, lineOffset });
|
|
247
245
|
const pageNotes = extractInlineNoteDiagnostics(source, { label, lineOffset, tabResult });
|
|
248
246
|
const { headings, tree } = await getMarkdownHeadings(tabResult.maskedSource);
|
|
247
|
+
const imageReferences = extractMarkdownImageReferences({ source, tree, lineOffset });
|
|
249
248
|
const detailsDiagnostics = getDetailsHeadingDiagnostics(tree, { source: tabResult.maskedSource, label, lineOffset });
|
|
250
249
|
const calloutErrors = getSemanticCalloutDiagnostics(tree, { label, lineOffset });
|
|
251
250
|
const codeFenceErrors = getCodeFenceMetadataDiagnostics(tree, {
|
|
@@ -271,6 +270,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
|
|
|
271
270
|
pageNotes,
|
|
272
271
|
tableErrors,
|
|
273
272
|
tabResult,
|
|
273
|
+
imageReferences,
|
|
274
274
|
}));
|
|
275
275
|
const headingIssues = getHeadingIdentifierIssues(headings);
|
|
276
276
|
const headingDiagnostics = getHeadingDiagnostics(headings, lineOffset);
|
|
@@ -346,6 +346,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
|
|
|
346
346
|
return {
|
|
347
347
|
blocks,
|
|
348
348
|
diagnostics: [
|
|
349
|
+
...imageReferences.filter((reference) => reference.kind === 'invalid').map((reference) => markdownImageDiagnostic(reference, label)),
|
|
349
350
|
...detailsDiagnostics,
|
|
350
351
|
...structureDiagnostics,
|
|
351
352
|
...headingDiagnostics,
|
|
@@ -363,7 +364,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
|
|
|
363
364
|
lineOffset,
|
|
364
365
|
links,
|
|
365
366
|
managedImages: regions.flatMap((region) => region.managedImages),
|
|
366
|
-
markdownImages:
|
|
367
|
+
markdownImages: imageReferences,
|
|
367
368
|
navigationHeadings,
|
|
368
369
|
notes: regions.flatMap((region) => region.notes),
|
|
369
370
|
noteDiagnostics,
|
|
@@ -395,8 +396,11 @@ export const parsePageMarkdownSource = async (source, options = {}) => {
|
|
|
395
396
|
};
|
|
396
397
|
};
|
|
397
398
|
|
|
399
|
+
const mapImage = (reference) => reference.blockType === 'markdown-image' ? { ...reference, range: toOriginalRange(reference.range), targetRange: toOriginalRange(reference.targetRange) } : reference;
|
|
398
400
|
return {
|
|
399
401
|
...model,
|
|
402
|
+
markdownImages: model.markdownImages.map(mapImage),
|
|
403
|
+
managedImages: model.managedImages.map(mapImage),
|
|
400
404
|
bodyOffset: normalization.originalOffsets[split.bodyOffset] ?? split.bodyOffset,
|
|
401
405
|
frontmatter: split.frontmatter,
|
|
402
406
|
frontmatterUnclosed: split.frontmatterUnclosed,
|
|
@@ -1,37 +1,32 @@
|
|
|
1
1
|
import { homePageDirectory } from './site-conventions.mjs';
|
|
2
2
|
|
|
3
|
-
export const pageDirectoryPattern = /^
|
|
3
|
+
export const pageDirectoryPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4
4
|
|
|
5
5
|
const pageEntryPathSeparator = '--';
|
|
6
6
|
|
|
7
7
|
export const parsePageDirectory = (pageDirectory, label = 'page directory') => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!match) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
`Invalid ${label} "${pageDirectory}". Page directories must use the form NNN-page-id, for example 010-getting-started. Page ids may contain only lowercase letters, numbers, and single hyphens.`,
|
|
13
|
-
);
|
|
8
|
+
if (!pageDirectoryPattern.test(pageDirectory)) {
|
|
9
|
+
throw new Error(`Invalid ${label} "${pageDirectory}". Page directories must use a slug with lowercase letters, numbers, and single hyphens, for example getting-started.`);
|
|
14
10
|
}
|
|
15
|
-
|
|
16
|
-
throw new Error(`Invalid ${label} "${pageDirectory}". The 000 prefix is reserved for ${homePageDirectory}.`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
pageDirectory,
|
|
21
|
-
pageOrder: Number.parseInt(match[1], 10),
|
|
22
|
-
pageId: match[2],
|
|
23
|
-
};
|
|
11
|
+
return { pageDirectory, pageOrder: null, pageId: pageDirectory };
|
|
24
12
|
};
|
|
25
13
|
|
|
26
14
|
const normalizePageDirectoryPath = (pageDirectoryPath) => pageDirectoryPath.replaceAll('\\', '/');
|
|
27
15
|
|
|
28
16
|
export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page directory path') => {
|
|
29
17
|
const normalizedPath = normalizePageDirectoryPath(pageDirectoryPath);
|
|
18
|
+
if (normalizedPath === homePageDirectory) {
|
|
19
|
+
return {
|
|
20
|
+
pageDirectory: homePageDirectory, pageDirectories: [], pageId: 'home',
|
|
21
|
+
pageIds: [], pageOrder: 0, pagePath: '',
|
|
22
|
+
parentPagePath: null, depth: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
30
25
|
const segments = normalizedPath.split('/').filter(Boolean);
|
|
31
26
|
|
|
32
27
|
if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
|
|
33
28
|
throw new Error(
|
|
34
|
-
`Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form
|
|
29
|
+
`Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form page-id/pages/child-id.`,
|
|
35
30
|
);
|
|
36
31
|
}
|
|
37
32
|
|
|
@@ -50,31 +45,18 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
50
45
|
}
|
|
51
46
|
|
|
52
47
|
const pageIds = pageDirectories.map(({ pageId }) => pageId);
|
|
53
|
-
const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
|
|
54
48
|
const currentPage = pageDirectories.at(-1);
|
|
55
|
-
const homeIndex = pageDirectories.findIndex(({ pageDirectory }) => pageDirectory === homePageDirectory);
|
|
56
|
-
if (homeIndex > 0) {
|
|
57
|
-
throw new Error(`Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is allowed only as a top-level page.`);
|
|
58
|
-
}
|
|
59
|
-
if (homeIndex === 0 && pageDirectories.length > 1) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
`Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is the homepage and cannot contain child pages. Place the page beside it under site/pages/, or below another non-home page.`,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const isHome = homeIndex === 0;
|
|
65
|
-
const logicalPageIds = isHome ? [] : pageIds;
|
|
66
|
-
const logicalPageOrders = isHome ? [] : pageOrders;
|
|
67
49
|
|
|
68
50
|
return {
|
|
69
51
|
pageDirectory: normalizedPath,
|
|
70
52
|
pageDirectories: pageDirectories.map(({ pageDirectory }) => pageDirectory),
|
|
71
53
|
pageId: currentPage.pageId,
|
|
72
|
-
pageIds
|
|
54
|
+
pageIds,
|
|
73
55
|
pageOrder: currentPage.pageOrder,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
parentPagePath:
|
|
77
|
-
?
|
|
56
|
+
pagePath: pageIds.join('/'),
|
|
57
|
+
// Navigation projects the root's children as top-level entries.
|
|
58
|
+
parentPagePath: pageIds.length > 1
|
|
59
|
+
? pageIds.slice(0, -1).join('/')
|
|
78
60
|
: null,
|
|
79
61
|
depth: pageDirectories.length,
|
|
80
62
|
};
|
|
@@ -82,10 +64,11 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
82
64
|
|
|
83
65
|
export const encodePageDirectoryPath = (pageDirectoryPath) => {
|
|
84
66
|
const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
|
|
85
|
-
return pageDirectories.join(pageEntryPathSeparator);
|
|
67
|
+
return pageDirectories.length ? pageDirectories.join(pageEntryPathSeparator) : '~home';
|
|
86
68
|
};
|
|
87
69
|
|
|
88
70
|
export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
|
|
71
|
+
if (pageEntryPath === '~home') return homePageDirectory;
|
|
89
72
|
const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
|
|
90
73
|
if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
|
|
91
74
|
throw new Error(`Invalid ${label} "${pageEntryPath}".`);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { assertRenumberingAllowed } from './page-order.mjs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { getSiteLinkGraph } from './site-link-graph.mjs';
|
|
6
|
+
import { getSiteStructure } from './site-structure.mjs';
|
|
7
|
+
|
|
8
|
+
export const writeFileAtomically = async (filePath, source, mode) => {
|
|
9
|
+
const temporaryPath = path.join(
|
|
10
|
+
path.dirname(filePath),
|
|
11
|
+
`.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
|
|
12
|
+
);
|
|
13
|
+
try {
|
|
14
|
+
await writeFile(temporaryPath, source, { mode: mode & 0o777 });
|
|
15
|
+
await rename(temporaryPath, filePath);
|
|
16
|
+
} finally {
|
|
17
|
+
await rm(temporaryPath, { force: true });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const assertSourcesUnchanged = async (plan) => {
|
|
22
|
+
for (const file of plan.sourceFiles) {
|
|
23
|
+
const currentSource = await readFile(file.contentPath, 'utf8');
|
|
24
|
+
if (currentSource !== file.source) {
|
|
25
|
+
throw new Error([
|
|
26
|
+
`Page move stopped because ${file.contentLabel} changed after the plan was created.`,
|
|
27
|
+
'Run the command again to build a fresh plan. No file was changed.',
|
|
28
|
+
].join('\n'));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const assertFinalSite = async (siteRoot, generatedRoutes) => {
|
|
34
|
+
const structure = await getSiteStructure({ siteRoot });
|
|
35
|
+
const graph = await getSiteLinkGraph({ siteRoot, siteStructure: structure, publicDir: path.join(siteRoot, 'public'), generatedRoutes });
|
|
36
|
+
const errors = graph.diagnostics.filter(({ severity }) => severity === 'error');
|
|
37
|
+
if (errors.length === 0) return;
|
|
38
|
+
throw new Error([
|
|
39
|
+
'Post-move link validation failed.',
|
|
40
|
+
...errors.flatMap((diagnostic) => [
|
|
41
|
+
`- ${diagnostic.message}`,
|
|
42
|
+
...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
|
|
43
|
+
]),
|
|
44
|
+
].join('\n'));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const applyPageMovePlan = async (plan, { siteRoot, generatedRoutes, renameDirectory = rename, writeSource = writeFileAtomically, allowRenumber = false }) => {
|
|
48
|
+
assertRenumberingAllowed(plan.orderChanges ?? [], allowRenumber);
|
|
49
|
+
await assertSourcesUnchanged(plan);
|
|
50
|
+
let collectionCreated = false;
|
|
51
|
+
let directoryMoved = false;
|
|
52
|
+
let fileWritten = false;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
if (plan.mode === 'move') {
|
|
56
|
+
if (!plan.destinationCollectionExists) {
|
|
57
|
+
await mkdir(plan.destinationCollectionDirectory);
|
|
58
|
+
collectionCreated = true;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await renameDirectory(plan.sourceDirectory, plan.destinationDirectory);
|
|
62
|
+
directoryMoved = true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code === 'EXDEV') {
|
|
65
|
+
throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const change of plan.fileChanges) {
|
|
72
|
+
await writeSource(change.finalPath, change.updatedSource, change.mode);
|
|
73
|
+
fileWritten = true;
|
|
74
|
+
}
|
|
75
|
+
await assertFinalSite(siteRoot, generatedRoutes);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const changesStarted = directoryMoved || fileWritten;
|
|
78
|
+
const rollbackErrors = [];
|
|
79
|
+
if (directoryMoved) {
|
|
80
|
+
try {
|
|
81
|
+
await renameDirectory(plan.destinationDirectory, plan.sourceDirectory);
|
|
82
|
+
directoryMoved = false;
|
|
83
|
+
} catch (rollbackError) {
|
|
84
|
+
rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const change of changesStarted ? plan.fileChanges : []) {
|
|
89
|
+
try {
|
|
90
|
+
const restorePath = directoryMoved && change.finalPath !== change.originalPath
|
|
91
|
+
? change.finalPath
|
|
92
|
+
: change.originalPath;
|
|
93
|
+
await writeFileAtomically(restorePath, change.originalSource, change.mode);
|
|
94
|
+
} catch (rollbackError) {
|
|
95
|
+
rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (collectionCreated && !directoryMoved) {
|
|
100
|
+
try {
|
|
101
|
+
await rmdir(plan.destinationCollectionDirectory);
|
|
102
|
+
} catch (rollbackError) {
|
|
103
|
+
if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
|
|
104
|
+
rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error([
|
|
110
|
+
error instanceof Error ? error.message : String(error),
|
|
111
|
+
rollbackErrors.length === 0
|
|
112
|
+
? (changesStarted
|
|
113
|
+
? 'The original page directory and content files were restored.'
|
|
114
|
+
: 'No source file was changed.')
|
|
115
|
+
: 'Rollback was incomplete. Inspect these paths before running page:move again:',
|
|
116
|
+
...rollbackErrors.map((message) => `- ${message}`),
|
|
117
|
+
].join('\n'));
|
|
118
|
+
}
|
|
119
|
+
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { comparePageOrder, parsePageOrder, planPageOrder, describeRenumbering } from './page-order.mjs';
|
|
2
|
+
import { setPageOrder } from './page-order-source.mjs';
|
|
3
|
+
import { attachmentPathname, encodeAttachmentPath, localAttachmentName } from './page-attachments.mjs';
|
|
1
4
|
import { access, constants, readFile, stat } from 'node:fs/promises';
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import {
|
|
@@ -11,6 +14,7 @@ import {
|
|
|
11
14
|
toPosixPath,
|
|
12
15
|
} from './site-content.mjs';
|
|
13
16
|
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
17
|
+
import { changeYamlField } from './yaml-source-edit.mjs';
|
|
14
18
|
|
|
15
19
|
const pagePathPattern = /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/;
|
|
16
20
|
|
|
@@ -37,15 +41,6 @@ const replaceFilePathPrefix = (filePath, fromDirectory, toDirectory) => (
|
|
|
37
41
|
path.join(toDirectory, path.relative(fromDirectory, filePath))
|
|
38
42
|
);
|
|
39
43
|
|
|
40
|
-
const getNextOrder = (siblings) => {
|
|
41
|
-
const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
|
|
42
|
-
const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
|
|
43
|
-
if (nextOrder > 999) {
|
|
44
|
-
throw new Error('No automatic page order remains below 1000. Reorder the destination siblings or pass --order.');
|
|
45
|
-
}
|
|
46
|
-
return nextOrder;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
44
|
const getTargetSuffix = (target) => {
|
|
50
45
|
const queryIndex = target.indexOf('?');
|
|
51
46
|
const fragmentIndex = target.indexOf('#');
|
|
@@ -61,12 +56,6 @@ const applyReplacements = (source, replacements) => {
|
|
|
61
56
|
return updated;
|
|
62
57
|
};
|
|
63
58
|
|
|
64
|
-
const insertBeforeTrailingBlankLines = (lines, additions) => {
|
|
65
|
-
let index = lines.length;
|
|
66
|
-
while (index > 0 && !lines[index - 1].trim()) index -= 1;
|
|
67
|
-
lines.splice(index, 0, ...additions);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
59
|
const addPageAliasToSource = (source, alias, label) => {
|
|
71
60
|
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
72
61
|
const { body, frontmatter, frontmatterBody } = splitSiteFile(source, label);
|
|
@@ -88,64 +77,31 @@ const addPageAliasToSource = (source, alias, label) => {
|
|
|
88
77
|
};
|
|
89
78
|
}
|
|
90
79
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'Use block YAML beginning with "page:" on its own line, or rerun with --no-aliases.',
|
|
97
|
-
].join('\n'));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (pageIndex < 0) {
|
|
101
|
-
insertBeforeTrailingBlankLines(lines, [
|
|
102
|
-
'page:',
|
|
103
|
-
' aliases:',
|
|
104
|
-
` - ${alias}`,
|
|
105
|
-
]);
|
|
106
|
-
} else {
|
|
107
|
-
let pageEnd = lines.length;
|
|
108
|
-
for (let index = pageIndex + 1; index < lines.length; index += 1) {
|
|
109
|
-
const line = lines[index];
|
|
110
|
-
if (!line.trim() || line.trimStart().startsWith('#')) continue;
|
|
111
|
-
if (!line.startsWith(' ')) {
|
|
112
|
-
pageEnd = index;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const aliasesIndex = lines.findIndex((line, index) => (
|
|
118
|
-
index > pageIndex
|
|
119
|
-
&& index < pageEnd
|
|
120
|
-
&& /^ {2}aliases:\s*(?:#.*)?$/.test(line)
|
|
121
|
-
));
|
|
122
|
-
if (data.page?.aliases && aliasesIndex < 0) {
|
|
123
|
-
throw new Error([
|
|
124
|
-
`Cannot add ${alias} to ${label} without rewriting its flow-style aliases list.`,
|
|
125
|
-
'Use a block list below " aliases:", or rerun with --no-aliases.',
|
|
126
|
-
].join('\n'));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (aliasesIndex < 0) {
|
|
130
|
-
lines.splice(pageIndex + 1, 0, ' aliases:', ` - ${alias}`);
|
|
131
|
-
} else {
|
|
132
|
-
let lastAliasIndex = -1;
|
|
133
|
-
for (let index = aliasesIndex + 1; index < pageEnd; index += 1) {
|
|
134
|
-
if (/^ {4}-\s+/.test(lines[index])) lastAliasIndex = index;
|
|
135
|
-
}
|
|
136
|
-
if (lastAliasIndex < 0) {
|
|
137
|
-
throw new Error(`Cannot find the block-list entries below "aliases:" in ${label}.`);
|
|
138
|
-
}
|
|
139
|
-
lines.splice(lastAliasIndex + 1, 0, ` - ${alias}`);
|
|
140
|
-
}
|
|
80
|
+
let yaml;
|
|
81
|
+
try {
|
|
82
|
+
yaml = changeYamlField(frontmatterBody, ['page', 'aliases'], [...(data.page?.aliases ?? []), alias], eol);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw Object.assign(new Error(`Cannot preserve ${alias} in ${label}. ${error.message}`), { sourcePath: label });
|
|
141
85
|
}
|
|
142
86
|
|
|
143
87
|
return {
|
|
144
88
|
changed: true,
|
|
145
|
-
source: `---${eol}${
|
|
89
|
+
source: `---${eol}${yaml.replace(/\r?\n$/, '')}${eol}---${eol}${body}`,
|
|
146
90
|
};
|
|
147
91
|
};
|
|
148
92
|
|
|
93
|
+
const removePageAliasFromSource = (source, alias, label) => {
|
|
94
|
+
const { body, frontmatter, frontmatterBody } = splitSiteFile(source, label);
|
|
95
|
+
if (!frontmatter) return source;
|
|
96
|
+
const data = parseContentFrontmatter(frontmatterBody, label);
|
|
97
|
+
if (!data.page?.aliases?.includes(alias)) return source;
|
|
98
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
99
|
+
const remaining = data.page.aliases.filter((entry) => entry !== alias);
|
|
100
|
+
const yaml = changeYamlField(frontmatterBody, ['page', 'aliases'], remaining.length ? remaining : undefined, eol);
|
|
101
|
+
if (!yaml.trim()) return body.replace(/^\r?\n/, '');
|
|
102
|
+
return `---${eol}${yaml.replace(/\r?\n$/, '')}${eol}---${eol}${body}`;
|
|
103
|
+
};
|
|
104
|
+
|
|
149
105
|
const normalizeMovePathname = (value, optionName) => {
|
|
150
106
|
if (value === '/') return { pagePath: '', pathname: '/' };
|
|
151
107
|
if (typeof value !== 'string' || !pagePathPattern.test(value)) {
|
|
@@ -239,6 +195,11 @@ const getResolvedReferenceIntent = ({
|
|
|
239
195
|
};
|
|
240
196
|
}
|
|
241
197
|
|
|
198
|
+
if (resolution?.kind === 'attachment') {
|
|
199
|
+
const owner = movedCurrentToNew.get(resolution.file.ownerPathname) ?? resolution.file.ownerPathname;
|
|
200
|
+
return { kind: 'attachment', lookupPathname: attachmentPathname(owner, resolution.file.filename), owner, filename: resolution.file.filename };
|
|
201
|
+
}
|
|
202
|
+
|
|
242
203
|
if (resolution?.kind === 'public-file') {
|
|
243
204
|
return { kind: 'public-file', lookupPathname: target.pathname };
|
|
244
205
|
}
|
|
@@ -297,7 +258,7 @@ const getReferenceIntent = ({
|
|
|
297
258
|
const sourceMapping = mappingByCurrentPathname.get(reference.sourcePage.pathname);
|
|
298
259
|
if (!sourceMapping || sourceMapping.oldPathname === sourceMapping.currentPathname) return currentIntent;
|
|
299
260
|
|
|
300
|
-
const oldTarget = resolveInternalTarget(reference.targetSource, sourceMapping.oldPathname);
|
|
261
|
+
const oldTarget = resolveInternalTarget(reference.targetSource, sourceMapping.oldPathname, graph.siteUrl);
|
|
301
262
|
const oldIntent = getTargetIntent({
|
|
302
263
|
graph,
|
|
303
264
|
movedCurrentToNew,
|
|
@@ -349,8 +310,9 @@ const createReferenceChanges = ({ graph, mappings }) => {
|
|
|
349
310
|
|
|
350
311
|
const sourcePathname = movedCurrentToNew.get(reference.sourcePage.pathname)
|
|
351
312
|
?? reference.sourcePage.pathname;
|
|
352
|
-
const
|
|
353
|
-
const
|
|
313
|
+
const originalTarget = intent.kind === 'attachment' ? reference.sourceTarget ?? reference.targetSource : reference.targetSource;
|
|
314
|
+
const resolvedAfterMove = resolveInternalTarget(originalTarget, sourcePathname, graph.siteUrl);
|
|
315
|
+
const stillResolves = intent.kind === 'attachment' ? (localAttachmentName(originalTarget) === intent.filename && sourcePathname === intent.owner || resolvedAfterMove.pathname === intent.lookupPathname) : resolvedAfterMove.kind === 'internal'
|
|
354
316
|
&& (intent.kind === 'public-file'
|
|
355
317
|
? resolvedAfterMove.pathname === intent.lookupPathname
|
|
356
318
|
: resolvedAfterMove.pageLookupPathname === intent.lookupPathname);
|
|
@@ -362,7 +324,7 @@ const createReferenceChanges = ({ graph, mappings }) => {
|
|
|
362
324
|
);
|
|
363
325
|
}
|
|
364
326
|
|
|
365
|
-
const value = `${intent.lookupPathname}${getTargetSuffix(reference.targetSource)}`;
|
|
327
|
+
const value = `${reference.target.absolutePrefix ?? ''}${intent.kind === 'attachment' ? encodeAttachmentPath(intent.lookupPathname) : intent.lookupPathname}${getTargetSuffix(reference.targetSource)}`;
|
|
366
328
|
const key = `${reference.sourceContentFile.contentPath}:${reference.targetRange.start}:${reference.targetRange.end}`;
|
|
367
329
|
const existing = changeKeys.get(key);
|
|
368
330
|
if (existing && existing.to !== value) {
|
|
@@ -396,7 +358,9 @@ const formatGraphDiagnostics = (diagnostics) => diagnostics.flatMap((diagnostic)
|
|
|
396
358
|
export const createPageMovePlan = async ({
|
|
397
359
|
from,
|
|
398
360
|
graph,
|
|
361
|
+
generatedRoutes,
|
|
399
362
|
order = null,
|
|
363
|
+
siblingOrderChanges = [],
|
|
400
364
|
preserveAliases = true,
|
|
401
365
|
publicFiles,
|
|
402
366
|
sitePagesDir,
|
|
@@ -442,6 +406,7 @@ export const createPageMovePlan = async ({
|
|
|
442
406
|
let destinationCollectionDirectory = null;
|
|
443
407
|
let destinationCollectionExists = true;
|
|
444
408
|
let virtualNodes = siteStructure.nodes;
|
|
409
|
+
let orderChanges = siblingOrderChanges;
|
|
445
410
|
|
|
446
411
|
if (mode === 'move') {
|
|
447
412
|
const destinationParentPath = getParentPagePath(destination.pagePath);
|
|
@@ -456,22 +421,23 @@ export const createPageMovePlan = async ({
|
|
|
456
421
|
}
|
|
457
422
|
|
|
458
423
|
const destinationSiblings = siteStructure.nodes.filter((node) => (
|
|
459
|
-
node.parentPagePath === destinationParentPath
|
|
424
|
+
!node.isHome && node.parentPagePath === destinationParentPath
|
|
460
425
|
&& !isPathInSubtree(node.pagePath, source.pagePath)
|
|
461
426
|
));
|
|
462
427
|
const sameParent = sourceNode.parentPagePath === destinationParentPath;
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
428
|
+
if (order !== null) destinationOrder = parsePageOrder(order, '--order');
|
|
429
|
+
else if (!sameParent) {
|
|
430
|
+
const insertion = planPageOrder(destinationSiblings.sort(comparePageOrder), destinationSiblings.length);
|
|
431
|
+
destinationOrder = insertion.order;
|
|
432
|
+
orderChanges = insertion.orderChanges;
|
|
466
433
|
}
|
|
467
|
-
|
|
468
434
|
const destinationSlug = destination.pagePath.split('/').at(-1);
|
|
469
435
|
destinationCollectionDirectory = destinationParent
|
|
470
436
|
? path.join(destinationParent.nodeDir, 'pages')
|
|
471
437
|
: sitePagesDir;
|
|
472
438
|
destinationDirectory = path.join(
|
|
473
439
|
destinationCollectionDirectory,
|
|
474
|
-
|
|
440
|
+
destinationSlug,
|
|
475
441
|
);
|
|
476
442
|
if (await exists(destinationDirectory)) {
|
|
477
443
|
throw new Error(`Destination directory ${destinationDirectory} already exists.`);
|
|
@@ -559,6 +525,9 @@ export const createPageMovePlan = async ({
|
|
|
559
525
|
})));
|
|
560
526
|
|
|
561
527
|
const mapping = mappingByContentPath.get(contentPath);
|
|
528
|
+
if (mapping && page.aliases.includes(mapping.newPathname)) {
|
|
529
|
+
updatedSource = removePageAliasFromSource(updatedSource, mapping.newPathname, page.contentFile.contentLabel);
|
|
530
|
+
}
|
|
562
531
|
if (mapping && preserveAliases) {
|
|
563
532
|
const aliasResult = addPageAliasToSource(updatedSource, mapping.oldPathname, page.contentFile.contentLabel);
|
|
564
533
|
updatedSource = aliasResult.source;
|
|
@@ -570,6 +539,11 @@ export const createPageMovePlan = async ({
|
|
|
570
539
|
}
|
|
571
540
|
}
|
|
572
541
|
|
|
542
|
+
const siblingChange = orderChanges.find(change => change.node.contentPath === contentPath);
|
|
543
|
+
if (siblingChange) updatedSource = setPageOrder(updatedSource, siblingChange.newOrder, contentPath);
|
|
544
|
+
if (mode === 'move' && contentPath === activeRoot.contentPath && destinationOrder !== activeRoot.pageOrder) {
|
|
545
|
+
updatedSource = setPageOrder(updatedSource, destinationOrder, contentPath);
|
|
546
|
+
}
|
|
573
547
|
updatedSources.set(contentPath, updatedSource);
|
|
574
548
|
if (updatedSource !== page.document.fullSource) {
|
|
575
549
|
const fileStat = await stat(contentPath);
|
|
@@ -610,15 +584,24 @@ export const createPageMovePlan = async ({
|
|
|
610
584
|
nodes: virtualNodes,
|
|
611
585
|
warnings: siteStructure.warnings,
|
|
612
586
|
};
|
|
587
|
+
const virtualAttachments = { diagnostics: graph.attachments?.diagnostics ?? [], files: (graph.attachments?.files ?? []).map(file => {
|
|
588
|
+
const mapping = mappings.find(entry => entry.currentPathname === file.ownerPathname);
|
|
589
|
+
if (!mapping) return file;
|
|
590
|
+
return { ...file, ownerPathname: mapping.newPathname, pathname: attachmentPathname(mapping.newPathname, file.filename) };
|
|
591
|
+
}) };
|
|
613
592
|
const virtualGraph = createSiteLinkGraph({
|
|
593
|
+
siteUrl: graph.siteUrl,
|
|
594
|
+
attachments: virtualAttachments,
|
|
614
595
|
pageDocuments: virtualPageDocuments,
|
|
615
596
|
publicFiles,
|
|
616
597
|
siteStructure: virtualStructure,
|
|
598
|
+
generatedRoutes,
|
|
617
599
|
});
|
|
618
|
-
|
|
600
|
+
const errors = virtualGraph.diagnostics.filter(({ severity }) => severity === 'error');
|
|
601
|
+
if (errors.length > 0) {
|
|
619
602
|
throw new Error([
|
|
620
603
|
'Page move preflight failed. No file was changed.',
|
|
621
|
-
...formatGraphDiagnostics(
|
|
604
|
+
...formatGraphDiagnostics(errors),
|
|
622
605
|
].join('\n'));
|
|
623
606
|
}
|
|
624
607
|
|
|
@@ -634,6 +617,8 @@ export const createPageMovePlan = async ({
|
|
|
634
617
|
|
|
635
618
|
return {
|
|
636
619
|
aliasChanges,
|
|
620
|
+
generatedRoutes,
|
|
621
|
+
orderChanges,
|
|
637
622
|
destinationCollectionDirectory,
|
|
638
623
|
destinationCollectionExists,
|
|
639
624
|
destinationDirectory,
|
|
@@ -668,6 +653,7 @@ export const formatPageMovePlan = (plan, displayPath) => {
|
|
|
668
653
|
for (const change of plan.linkChanges) {
|
|
669
654
|
lines.push(`- ${change.contentLabel} line ${change.line}: ${change.from} -> ${change.to}`);
|
|
670
655
|
}
|
|
656
|
+
if (plan.orderChanges?.length) lines.push('', describeRenumbering(plan.orderChanges, displayPath));
|
|
671
657
|
lines.push('', 'Aliases:');
|
|
672
658
|
if (!plan.preserveAliases) lines.push('- skipped by --no-aliases');
|
|
673
659
|
else if (plan.aliasChanges.length === 0) lines.push('- none needed');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { splitSiteFile } from './site-content.mjs';
|
|
3
|
+
import { changeYamlField } from './yaml-source-edit.mjs';
|
|
4
|
+
import { parsePageOrder } from './page-order.mjs';
|
|
5
|
+
|
|
6
|
+
export const setPageOrder = (source, order, label) => {
|
|
7
|
+
parsePageOrder(order);
|
|
8
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
9
|
+
const { frontmatter, frontmatterBody, body } = splitSiteFile(source, label);
|
|
10
|
+
try {
|
|
11
|
+
const yaml = changeYamlField(frontmatterBody, ['navigation', 'order'], order ?? undefined, eol);
|
|
12
|
+
return frontmatter ? `---${eol}${yaml.replace(/\r?\n$/, '')}${eol}---${eol}${body}`
|
|
13
|
+
: `---${eol}${yaml}---${eol}${eol}${source}`;
|
|
14
|
+
} catch (error) { throw Object.assign(new Error(`Cannot set page order in ${label}. ${error.message}`), { sourcePath: label }); }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const pageOrderFileChanges = async changes => Promise.all(changes.map(async ({ node, oldOrder, newOrder }) => {
|
|
18
|
+
const originalSource = await readFile(node.contentPath, 'utf8');
|
|
19
|
+
return { contentPath: node.contentPath, contentLabel: node.contentLabel, oldOrder, newOrder,
|
|
20
|
+
originalPath: node.contentPath, finalPath: node.contentPath, originalSource,
|
|
21
|
+
updatedSource: setPageOrder(originalSource, newOrder, node.contentPath), mode: (await stat(node.contentPath)).mode };
|
|
22
|
+
}));
|