@janga/norna 0.7.23 → 0.7.25
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 +14 -18
- package/astro.config.mjs +14 -0
- package/bin/norna-cli.mjs +6 -0
- package/package.json +25 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +80 -15
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +246 -246
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +16 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +3 -0
- package/scripts/lib/code-fence-metadata.mjs +228 -0
- package/scripts/lib/edit-source-link.mjs +102 -0
- package/scripts/lib/editor-language-service.mjs +32 -12
- package/scripts/lib/image-presentation.mjs +4 -0
- package/scripts/lib/navigation-model.mjs +34 -13
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +164 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +21 -1
- package/scripts/lib/page-markdown.mjs +32 -1
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +1 -10
- package/scripts/lib/project-config.mjs +141 -5
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +30 -5
- package/scripts/lib/schema-editor-metadata.mjs +36 -4
- package/scripts/lib/schema-value-definitions.mjs +4 -1
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +2 -1
- package/scripts/lib/site-link-graph.mjs +33 -2
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/scripts/lib/theme-presets.mjs +2 -2
- package/scripts/lib/theme-profiles.mjs +0 -4
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +67 -4
- package/scripts/sync-site-public.mjs +24 -5
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +11 -5
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +9 -7
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/NavigationPageTree.astro +66 -55
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +1 -1
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +102 -51
- package/src/components/SitePage.astro +107 -39
- package/src/components/SiteSection.astro +11 -3
- package/src/components/SiteTreeNavigation.astro +11 -1
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +271 -34
- package/src/layouts/BaseLayout.astro +83 -2
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +36 -30
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +6 -3
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +15 -5
- package/src/styles/content.css +538 -39
- package/src/styles/media.css +187 -8
- package/src/styles/navigation.css +199 -3
- package/src/styles/page-layout.css +481 -100
- package/src/styles/responsive.css +144 -28
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { access, constants, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
createSiteLinkGraph,
|
|
5
|
+
resolveInternalTarget,
|
|
6
|
+
} from './site-link-graph.mjs';
|
|
7
|
+
import { parsePageMarkdownSource } from './page-markdown.mjs';
|
|
8
|
+
import {
|
|
9
|
+
parseContentFrontmatter,
|
|
10
|
+
splitSiteFile,
|
|
11
|
+
toPosixPath,
|
|
12
|
+
} from './site-content.mjs';
|
|
13
|
+
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
14
|
+
|
|
15
|
+
const pagePathPattern = /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/;
|
|
16
|
+
|
|
17
|
+
const exists = async (filePath) => access(filePath).then(() => true, (error) => {
|
|
18
|
+
if (error?.code === 'ENOENT') return false;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const getParentPagePath = (pagePath) => {
|
|
23
|
+
const segments = pagePath.split('/');
|
|
24
|
+
return segments.length > 1 ? segments.slice(0, -1).join('/') : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isPathInSubtree = (pagePath, rootPagePath) => (
|
|
28
|
+
pagePath === rootPagePath || pagePath.startsWith(`${rootPagePath}/`)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const replacePagePathPrefix = (pagePath, fromPagePath, toPagePath) => {
|
|
32
|
+
if (pagePath === fromPagePath) return toPagePath;
|
|
33
|
+
return `${toPagePath}${pagePath.slice(fromPagePath.length)}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const replaceFilePathPrefix = (filePath, fromDirectory, toDirectory) => (
|
|
37
|
+
path.join(toDirectory, path.relative(fromDirectory, filePath))
|
|
38
|
+
);
|
|
39
|
+
|
|
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
|
+
const getTargetSuffix = (target) => {
|
|
50
|
+
const queryIndex = target.indexOf('?');
|
|
51
|
+
const fragmentIndex = target.indexOf('#');
|
|
52
|
+
const indexes = [queryIndex, fragmentIndex].filter((index) => index >= 0);
|
|
53
|
+
return indexes.length > 0 ? target.slice(Math.min(...indexes)) : '';
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const applyReplacements = (source, replacements) => {
|
|
57
|
+
let updated = source;
|
|
58
|
+
for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) {
|
|
59
|
+
updated = `${updated.slice(0, replacement.start)}${replacement.value}${updated.slice(replacement.end)}`;
|
|
60
|
+
}
|
|
61
|
+
return updated;
|
|
62
|
+
};
|
|
63
|
+
|
|
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
|
+
const addPageAliasToSource = (source, alias, label) => {
|
|
71
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
72
|
+
const { body, frontmatter, frontmatterBody } = splitSiteFile(source, label);
|
|
73
|
+
const data = parseContentFrontmatter(frontmatterBody, label);
|
|
74
|
+
if (data.page?.aliases?.includes(alias)) return { changed: false, source };
|
|
75
|
+
|
|
76
|
+
if (!frontmatter) {
|
|
77
|
+
return {
|
|
78
|
+
changed: true,
|
|
79
|
+
source: [
|
|
80
|
+
'---',
|
|
81
|
+
'page:',
|
|
82
|
+
' aliases:',
|
|
83
|
+
` - ${alias}`,
|
|
84
|
+
'---',
|
|
85
|
+
'',
|
|
86
|
+
source,
|
|
87
|
+
].join(eol),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = frontmatterBody.split(/\r?\n/);
|
|
92
|
+
const pageIndex = lines.findIndex((line) => /^page:\s*(?:#.*)?$/.test(line));
|
|
93
|
+
if (data.page && pageIndex < 0) {
|
|
94
|
+
throw new Error([
|
|
95
|
+
`Cannot add ${alias} to ${label} without rewriting its flow-style page metadata.`,
|
|
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
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
changed: true,
|
|
145
|
+
source: `---${eol}${lines.join(eol)}${eol}---${eol}${body}`,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const normalizeMovePathname = (value, optionName) => {
|
|
150
|
+
if (value === '/') return { pagePath: '', pathname: '/' };
|
|
151
|
+
if (typeof value !== 'string' || !pagePathPattern.test(value)) {
|
|
152
|
+
throw new Error(`Invalid ${optionName} "${value ?? ''}". Use a site-relative page URL such as /guides/install/.`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
pagePath: value.slice(1, -1),
|
|
156
|
+
pathname: value,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const getVirtualNode = ({
|
|
161
|
+
destinationDirectory,
|
|
162
|
+
destinationOrder,
|
|
163
|
+
destinationPagePath,
|
|
164
|
+
node,
|
|
165
|
+
sitePagesDir,
|
|
166
|
+
sitePagesLabel,
|
|
167
|
+
sourceDirectory,
|
|
168
|
+
sourcePagePath,
|
|
169
|
+
}) => {
|
|
170
|
+
if (!isPathInSubtree(node.pagePath, sourcePagePath)) return node;
|
|
171
|
+
|
|
172
|
+
const pagePath = replacePagePathPrefix(node.pagePath, sourcePagePath, destinationPagePath);
|
|
173
|
+
const nodeDir = replaceFilePathPrefix(node.nodeDir, sourceDirectory, destinationDirectory);
|
|
174
|
+
const pageDirectory = toPosixPath(path.relative(sitePagesDir, nodeDir));
|
|
175
|
+
const nodeLabel = `${sitePagesLabel}/${pageDirectory}`;
|
|
176
|
+
const isRoot = node.pagePath === sourcePagePath;
|
|
177
|
+
const pageId = pagePath.split('/').at(-1);
|
|
178
|
+
const common = {
|
|
179
|
+
...node,
|
|
180
|
+
nodeDir,
|
|
181
|
+
nodeLabel,
|
|
182
|
+
pageDirectory,
|
|
183
|
+
pageId,
|
|
184
|
+
pageOrder: isRoot ? destinationOrder : node.pageOrder,
|
|
185
|
+
pagePath,
|
|
186
|
+
parentPagePath: getParentPagePath(pagePath),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (node.kind === 'category') {
|
|
190
|
+
return {
|
|
191
|
+
...common,
|
|
192
|
+
categoryPath: path.join(nodeDir, 'category.yaml'),
|
|
193
|
+
categorySourceLabel: `${nodeLabel}/category.yaml`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...common,
|
|
199
|
+
contentLabel: `${nodeLabel}/content.md`,
|
|
200
|
+
contentPath: path.join(nodeDir, 'content.md'),
|
|
201
|
+
imagesDir: path.join(nodeDir, 'images'),
|
|
202
|
+
imagesLabel: `${nodeLabel}/images`,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const getResolvedReferenceIntent = ({
|
|
207
|
+
movedCurrentToNew,
|
|
208
|
+
movedOldToNew,
|
|
209
|
+
pagesByNewPathname,
|
|
210
|
+
reference,
|
|
211
|
+
}) => {
|
|
212
|
+
const { resolution, target } = reference;
|
|
213
|
+
if (resolution?.kind === 'page') {
|
|
214
|
+
const currentPathname = resolution.page.pathname;
|
|
215
|
+
const pathname = movedCurrentToNew.get(currentPathname) ?? currentPathname;
|
|
216
|
+
return {
|
|
217
|
+
kind: 'page',
|
|
218
|
+
lookupPathname: pathname,
|
|
219
|
+
page: pagesByNewPathname.get(pathname) ?? resolution.page,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (resolution?.kind === 'page-alias') {
|
|
224
|
+
const movedAliasTarget = movedOldToNew.get(target.pageLookupPathname);
|
|
225
|
+
if (movedAliasTarget) {
|
|
226
|
+
return {
|
|
227
|
+
kind: 'page',
|
|
228
|
+
lookupPathname: movedAliasTarget,
|
|
229
|
+
page: pagesByNewPathname.get(movedAliasTarget) ?? resolution.page,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
kind: 'page',
|
|
234
|
+
lookupPathname: target.pageLookupPathname,
|
|
235
|
+
page: resolution.page,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (resolution?.kind === 'public-file') {
|
|
240
|
+
return { kind: 'public-file', lookupPathname: target.pathname };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (resolution?.kind === 'missing-page') {
|
|
244
|
+
const movedPathname = movedOldToNew.get(target.pageLookupPathname);
|
|
245
|
+
if (movedPathname) {
|
|
246
|
+
return {
|
|
247
|
+
kind: 'page',
|
|
248
|
+
lookupPathname: movedPathname,
|
|
249
|
+
page: pagesByNewPathname.get(movedPathname),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return null;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const getTargetIntent = ({ graph, movedCurrentToNew, movedOldToNew, pagesByNewPathname, target }) => {
|
|
258
|
+
if (target.kind !== 'internal') return null;
|
|
259
|
+
if (target.pageLookupPathname) {
|
|
260
|
+
const pathname = movedOldToNew.get(target.pageLookupPathname) ?? target.pageLookupPathname;
|
|
261
|
+
const page = pagesByNewPathname.get(pathname) ?? graph.pagesByPathname.get(pathname);
|
|
262
|
+
if (page) return { kind: 'page', lookupPathname: pathname, page };
|
|
263
|
+
const alias = graph.aliasesByPathname.get(target.pageLookupPathname);
|
|
264
|
+
if (alias) {
|
|
265
|
+
const aliasTarget = movedCurrentToNew.get(alias.targetPathname) ?? alias.targetPathname;
|
|
266
|
+
return {
|
|
267
|
+
kind: 'page',
|
|
268
|
+
lookupPathname: target.pageLookupPathname,
|
|
269
|
+
page: pagesByNewPathname.get(aliasTarget) ?? graph.pagesByPathname.get(alias.targetPathname),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (graph.publicFilesByPathname.has(target.pathname)) {
|
|
274
|
+
return { kind: 'public-file', lookupPathname: target.pathname };
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const getReferenceIntent = ({
|
|
280
|
+
graph,
|
|
281
|
+
mappingByCurrentPathname,
|
|
282
|
+
movedCurrentToNew,
|
|
283
|
+
movedOldToNew,
|
|
284
|
+
pagesByNewPathname,
|
|
285
|
+
reference,
|
|
286
|
+
}) => {
|
|
287
|
+
const currentIntent = getResolvedReferenceIntent({
|
|
288
|
+
movedCurrentToNew,
|
|
289
|
+
movedOldToNew,
|
|
290
|
+
pagesByNewPathname,
|
|
291
|
+
reference,
|
|
292
|
+
});
|
|
293
|
+
const sourceMapping = mappingByCurrentPathname.get(reference.sourcePage.pathname);
|
|
294
|
+
if (!sourceMapping || sourceMapping.oldPathname === sourceMapping.currentPathname) return currentIntent;
|
|
295
|
+
|
|
296
|
+
const oldTarget = resolveInternalTarget(reference.targetSource, sourceMapping.oldPathname);
|
|
297
|
+
const oldIntent = getTargetIntent({
|
|
298
|
+
graph,
|
|
299
|
+
movedCurrentToNew,
|
|
300
|
+
movedOldToNew,
|
|
301
|
+
pagesByNewPathname,
|
|
302
|
+
target: oldTarget,
|
|
303
|
+
});
|
|
304
|
+
if (currentIntent && oldIntent && (
|
|
305
|
+
currentIntent.kind !== oldIntent.kind
|
|
306
|
+
|| currentIntent.lookupPathname !== oldIntent.lookupPathname
|
|
307
|
+
)) {
|
|
308
|
+
throw new Error([
|
|
309
|
+
`Cannot safely reconcile "${reference.targetSource}" on line ${reference.line} in ${reference.sourceContentFile.contentLabel}.`,
|
|
310
|
+
`From the old page location it identifies ${oldIntent.lookupPathname}, but from the new location it identifies ${currentIntent.lookupPathname}.`,
|
|
311
|
+
'Make the intended link site-relative, then run page:move again.',
|
|
312
|
+
].join('\n'));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return currentIntent ?? oldIntent;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const createReferenceChanges = ({ graph, mappings }) => {
|
|
319
|
+
const movedCurrentToNew = new Map(mappings.map((mapping) => [mapping.currentPathname, mapping.newPathname]));
|
|
320
|
+
const movedOldToNew = new Map(mappings.map((mapping) => [mapping.oldPathname, mapping.newPathname]));
|
|
321
|
+
const pagesByNewPathname = new Map(mappings.map((mapping) => [mapping.newPathname, mapping.page]));
|
|
322
|
+
const mappingByCurrentPathname = new Map(mappings.map((mapping) => [mapping.currentPathname, mapping]));
|
|
323
|
+
const changesByPath = new Map();
|
|
324
|
+
const changeKeys = new Map();
|
|
325
|
+
|
|
326
|
+
for (const reference of graph.references) {
|
|
327
|
+
const intent = getReferenceIntent({
|
|
328
|
+
graph,
|
|
329
|
+
mappingByCurrentPathname,
|
|
330
|
+
movedCurrentToNew,
|
|
331
|
+
movedOldToNew,
|
|
332
|
+
pagesByNewPathname,
|
|
333
|
+
reference,
|
|
334
|
+
});
|
|
335
|
+
if (!intent) continue;
|
|
336
|
+
|
|
337
|
+
if (reference.target.fragment && intent.kind === 'page') {
|
|
338
|
+
if (!intent.page?.anchors.has(reference.target.fragment)) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Cannot update "${reference.targetSource}" on line ${reference.line} in ${reference.sourceContentFile.contentLabel}. `
|
|
341
|
+
+ `The destination ${intent.lookupPathname} has no #${reference.target.fragment} heading.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const sourcePathname = movedCurrentToNew.get(reference.sourcePage.pathname)
|
|
347
|
+
?? reference.sourcePage.pathname;
|
|
348
|
+
const resolvedAfterMove = resolveInternalTarget(reference.targetSource, sourcePathname);
|
|
349
|
+
const stillResolves = resolvedAfterMove.kind === 'internal'
|
|
350
|
+
&& (intent.kind === 'public-file'
|
|
351
|
+
? resolvedAfterMove.pathname === intent.lookupPathname
|
|
352
|
+
: resolvedAfterMove.pageLookupPathname === intent.lookupPathname);
|
|
353
|
+
if (stillResolves) continue;
|
|
354
|
+
|
|
355
|
+
if (!reference.targetRange) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Cannot safely edit "${reference.targetSource}" on line ${reference.line} in ${reference.sourceContentFile.contentLabel} because its source range is unavailable.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const value = `${intent.lookupPathname}${getTargetSuffix(reference.targetSource)}`;
|
|
362
|
+
const key = `${reference.sourceContentFile.contentPath}:${reference.targetRange.start}:${reference.targetRange.end}`;
|
|
363
|
+
const existing = changeKeys.get(key);
|
|
364
|
+
if (existing && existing.value !== value) {
|
|
365
|
+
throw new Error(`One link target in ${reference.sourceContentFile.contentLabel} produced conflicting page-move edits.`);
|
|
366
|
+
}
|
|
367
|
+
if (existing) continue;
|
|
368
|
+
|
|
369
|
+
const change = {
|
|
370
|
+
end: reference.targetRange.end,
|
|
371
|
+
from: reference.targetSource,
|
|
372
|
+
line: reference.definitionLine ?? reference.line,
|
|
373
|
+
start: reference.targetRange.start,
|
|
374
|
+
to: value,
|
|
375
|
+
value,
|
|
376
|
+
};
|
|
377
|
+
changeKeys.set(key, change);
|
|
378
|
+
if (!changesByPath.has(reference.sourceContentFile.contentPath)) {
|
|
379
|
+
changesByPath.set(reference.sourceContentFile.contentPath, []);
|
|
380
|
+
}
|
|
381
|
+
changesByPath.get(reference.sourceContentFile.contentPath).push(change);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return changesByPath;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const formatGraphDiagnostics = (diagnostics) => diagnostics.flatMap((diagnostic) => [
|
|
388
|
+
`- ${diagnostic.message}`,
|
|
389
|
+
...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
export const createPageMovePlan = async ({
|
|
393
|
+
from,
|
|
394
|
+
graph,
|
|
395
|
+
order = null,
|
|
396
|
+
preserveAliases = true,
|
|
397
|
+
publicFiles,
|
|
398
|
+
sitePagesDir,
|
|
399
|
+
sitePagesLabel,
|
|
400
|
+
siteStructure,
|
|
401
|
+
to,
|
|
402
|
+
}) => {
|
|
403
|
+
const source = normalizeMovePathname(from, 'source URL');
|
|
404
|
+
const destination = normalizeMovePathname(to, 'destination URL');
|
|
405
|
+
if (source.pathname === '/') throw new Error('The homepage cannot be moved.');
|
|
406
|
+
if (destination.pathname === '/') throw new Error('A page cannot replace the homepage URL /.');
|
|
407
|
+
if (source.pathname === destination.pathname) {
|
|
408
|
+
throw new Error('Source and destination URLs must be different.');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sourceNode = siteStructure.nodes.find((node) => node.pagePath === source.pagePath);
|
|
412
|
+
const destinationNode = siteStructure.nodes.find((node) => node.pagePath === destination.pagePath);
|
|
413
|
+
if (sourceNode?.kind === 'category') {
|
|
414
|
+
throw new Error(`Cannot move ${source.pathname} because it is a navigation category, not a page.`);
|
|
415
|
+
}
|
|
416
|
+
if (sourceNode?.isHome) throw new Error('The homepage cannot be moved.');
|
|
417
|
+
if (destinationNode?.kind === 'category') {
|
|
418
|
+
throw new Error(`Cannot use ${destination.pathname} because a navigation category already owns that path.`);
|
|
419
|
+
}
|
|
420
|
+
if (sourceNode && destinationNode) {
|
|
421
|
+
throw new Error(`Cannot move ${source.pathname} to ${destination.pathname} because both pages exist.`);
|
|
422
|
+
}
|
|
423
|
+
if (!sourceNode && !destinationNode) {
|
|
424
|
+
throw new Error(`Cannot move or reconcile the page because neither ${source.pathname} nor ${destination.pathname} exists.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const mode = sourceNode ? 'move' : 'reconcile';
|
|
428
|
+
if (mode === 'reconcile' && order !== null) {
|
|
429
|
+
throw new Error('--order cannot be used when reconciling a page that is already at its destination.');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const activeRoot = sourceNode ?? destinationNode;
|
|
433
|
+
const activeRootPath = activeRoot.pagePath;
|
|
434
|
+
const subtreeNodes = siteStructure.nodes.filter((node) => isPathInSubtree(node.pagePath, activeRootPath));
|
|
435
|
+
const subtreePages = subtreeNodes.filter((node) => node.kind === 'page');
|
|
436
|
+
let destinationDirectory = null;
|
|
437
|
+
let destinationOrder = activeRoot.pageOrder;
|
|
438
|
+
let destinationCollectionDirectory = null;
|
|
439
|
+
let destinationCollectionExists = true;
|
|
440
|
+
let virtualNodes = siteStructure.nodes;
|
|
441
|
+
|
|
442
|
+
if (mode === 'move') {
|
|
443
|
+
const destinationParentPath = getParentPagePath(destination.pagePath);
|
|
444
|
+
const destinationParent = destinationParentPath
|
|
445
|
+
? siteStructure.nodes.find((node) => node.pagePath === destinationParentPath)
|
|
446
|
+
: null;
|
|
447
|
+
if (destinationParentPath && !destinationParent) {
|
|
448
|
+
throw new Error(`Cannot find destination parent /${destinationParentPath}/.`);
|
|
449
|
+
}
|
|
450
|
+
if (destinationParent && isPathInSubtree(destinationParent.pagePath, source.pagePath)) {
|
|
451
|
+
throw new Error('A page cannot be moved below itself or one of its descendants.');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const destinationSiblings = siteStructure.nodes.filter((node) => (
|
|
455
|
+
node.parentPagePath === destinationParentPath
|
|
456
|
+
&& !isPathInSubtree(node.pagePath, source.pagePath)
|
|
457
|
+
));
|
|
458
|
+
const sameParent = sourceNode.parentPagePath === destinationParentPath;
|
|
459
|
+
destinationOrder = order ?? (sameParent ? sourceNode.pageOrder : getNextOrder(destinationSiblings));
|
|
460
|
+
if (destinationSiblings.some((node) => node.pageOrder === destinationOrder)) {
|
|
461
|
+
throw new Error(`Destination order ${String(destinationOrder).padStart(3, '0')} is already used below ${destinationParentPath ? `/${destinationParentPath}/` : '/'}.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const destinationSlug = destination.pagePath.split('/').at(-1);
|
|
465
|
+
destinationCollectionDirectory = destinationParent
|
|
466
|
+
? path.join(destinationParent.nodeDir, 'pages')
|
|
467
|
+
: sitePagesDir;
|
|
468
|
+
destinationDirectory = path.join(
|
|
469
|
+
destinationCollectionDirectory,
|
|
470
|
+
`${String(destinationOrder).padStart(3, '0')}-${destinationSlug}`,
|
|
471
|
+
);
|
|
472
|
+
if (await exists(destinationDirectory)) {
|
|
473
|
+
throw new Error(`Destination directory ${destinationDirectory} already exists.`);
|
|
474
|
+
}
|
|
475
|
+
destinationCollectionExists = await exists(destinationCollectionDirectory);
|
|
476
|
+
|
|
477
|
+
virtualNodes = siteStructure.nodes.map((node) => getVirtualNode({
|
|
478
|
+
destinationDirectory,
|
|
479
|
+
destinationOrder,
|
|
480
|
+
destinationPagePath: destination.pagePath,
|
|
481
|
+
node,
|
|
482
|
+
sitePagesDir,
|
|
483
|
+
sitePagesLabel,
|
|
484
|
+
sourceDirectory: sourceNode.nodeDir,
|
|
485
|
+
sourcePagePath: source.pagePath,
|
|
486
|
+
}));
|
|
487
|
+
const virtualPaths = new Set();
|
|
488
|
+
for (const node of virtualNodes) {
|
|
489
|
+
if (virtualPaths.has(node.pagePath)) {
|
|
490
|
+
throw new Error(`The move would give more than one page or category the path /${node.pagePath}/.`);
|
|
491
|
+
}
|
|
492
|
+
virtualPaths.add(node.pagePath);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const nodeMappings = siteStructure.nodes.map((node, index) => ({
|
|
497
|
+
originalNode: node,
|
|
498
|
+
virtualNode: virtualNodes[index],
|
|
499
|
+
}));
|
|
500
|
+
const virtualNodeByOriginalDirectory = new Map(nodeMappings.map(({ originalNode, virtualNode }) => [
|
|
501
|
+
originalNode.pageDirectory,
|
|
502
|
+
virtualNode,
|
|
503
|
+
]));
|
|
504
|
+
const originalNodeByVirtualDirectory = new Map(nodeMappings.map(({ originalNode, virtualNode }) => [
|
|
505
|
+
virtualNode.pageDirectory,
|
|
506
|
+
originalNode,
|
|
507
|
+
]));
|
|
508
|
+
const mappings = subtreePages.map((node) => {
|
|
509
|
+
const page = graph.pagesByPathname.get(getSiteNodePathname(node));
|
|
510
|
+
if (!page) throw new Error(`Cannot find parsed page data for ${node.contentLabel}.`);
|
|
511
|
+
const currentPathname = page.pathname;
|
|
512
|
+
const oldPathname = mode === 'move'
|
|
513
|
+
? currentPathname
|
|
514
|
+
: `/${replacePagePathPrefix(node.pagePath, destination.pagePath, source.pagePath)}/`;
|
|
515
|
+
const newPathname = mode === 'move'
|
|
516
|
+
? `/${replacePagePathPrefix(node.pagePath, source.pagePath, destination.pagePath)}/`
|
|
517
|
+
: currentPathname;
|
|
518
|
+
return {
|
|
519
|
+
currentPathname,
|
|
520
|
+
newPathname,
|
|
521
|
+
node,
|
|
522
|
+
oldPathname,
|
|
523
|
+
page,
|
|
524
|
+
virtualNode: virtualNodeByOriginalDirectory.get(node.pageDirectory),
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const referenceChangesByPath = createReferenceChanges({ graph, mappings });
|
|
529
|
+
const mappingByContentPath = new Map(mappings.map((mapping) => [mapping.node.contentPath, mapping]));
|
|
530
|
+
const fileChanges = [];
|
|
531
|
+
const updatedSources = new Map();
|
|
532
|
+
const aliasChanges = [];
|
|
533
|
+
const linkChanges = [];
|
|
534
|
+
|
|
535
|
+
for (const page of graph.pages) {
|
|
536
|
+
const contentPath = page.contentFile.contentPath;
|
|
537
|
+
const replacements = referenceChangesByPath.get(contentPath) ?? [];
|
|
538
|
+
let updatedSource = applyReplacements(page.document.fullSource, replacements);
|
|
539
|
+
linkChanges.push(...replacements.map((replacement) => ({
|
|
540
|
+
...replacement,
|
|
541
|
+
contentLabel: page.contentFile.contentLabel,
|
|
542
|
+
})));
|
|
543
|
+
|
|
544
|
+
const mapping = mappingByContentPath.get(contentPath);
|
|
545
|
+
if (mapping && preserveAliases) {
|
|
546
|
+
const aliasResult = addPageAliasToSource(updatedSource, mapping.oldPathname, page.contentFile.contentLabel);
|
|
547
|
+
updatedSource = aliasResult.source;
|
|
548
|
+
if (aliasResult.changed) {
|
|
549
|
+
aliasChanges.push({
|
|
550
|
+
alias: mapping.oldPathname,
|
|
551
|
+
contentLabel: mapping.virtualNode.contentLabel,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
updatedSources.set(contentPath, updatedSource);
|
|
557
|
+
if (updatedSource !== page.document.fullSource) {
|
|
558
|
+
const fileStat = await stat(contentPath);
|
|
559
|
+
fileChanges.push({
|
|
560
|
+
contentLabel: page.contentFile.contentLabel,
|
|
561
|
+
finalPath: mapping?.virtualNode.contentPath ?? contentPath,
|
|
562
|
+
mode: fileStat.mode,
|
|
563
|
+
originalPath: contentPath,
|
|
564
|
+
originalSource: page.document.fullSource,
|
|
565
|
+
updatedSource,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const virtualContentFiles = virtualNodes.filter((node) => node.kind === 'page');
|
|
571
|
+
const virtualPageDocuments = await Promise.all(virtualContentFiles.map(async (contentFile) => {
|
|
572
|
+
const originalContentFile = originalNodeByVirtualDirectory.get(contentFile.pageDirectory);
|
|
573
|
+
if (!originalContentFile) throw new Error(`Cannot map proposed content file ${contentFile.contentLabel} to its source.`);
|
|
574
|
+
const sourceText = updatedSources.get(originalContentFile.contentPath)
|
|
575
|
+
?? await readFile(originalContentFile.contentPath, 'utf8');
|
|
576
|
+
const { frontmatterBody } = splitSiteFile(sourceText, contentFile.contentLabel);
|
|
577
|
+
const document = await parsePageMarkdownSource(sourceText, { label: contentFile.contentLabel });
|
|
578
|
+
if (document.diagnostics.length > 0) {
|
|
579
|
+
throw new Error([
|
|
580
|
+
`Page move preflight found invalid Markdown in ${contentFile.contentLabel}.`,
|
|
581
|
+
...document.diagnostics.map((diagnostic) => `- line ${diagnostic.line}: ${diagnostic.message}`),
|
|
582
|
+
].join('\n'));
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
contentFile,
|
|
586
|
+
data: parseContentFrontmatter(frontmatterBody, contentFile.contentLabel),
|
|
587
|
+
document,
|
|
588
|
+
};
|
|
589
|
+
}));
|
|
590
|
+
const virtualStructure = {
|
|
591
|
+
categories: virtualNodes.filter((node) => node.kind === 'category'),
|
|
592
|
+
contentFiles: virtualContentFiles,
|
|
593
|
+
nodes: virtualNodes,
|
|
594
|
+
warnings: siteStructure.warnings,
|
|
595
|
+
};
|
|
596
|
+
const virtualGraph = createSiteLinkGraph({
|
|
597
|
+
pageDocuments: virtualPageDocuments,
|
|
598
|
+
publicFiles,
|
|
599
|
+
siteStructure: virtualStructure,
|
|
600
|
+
});
|
|
601
|
+
if (virtualGraph.diagnostics.length > 0) {
|
|
602
|
+
throw new Error([
|
|
603
|
+
'Page move preflight failed. No file was changed.',
|
|
604
|
+
...formatGraphDiagnostics(virtualGraph.diagnostics),
|
|
605
|
+
].join('\n'));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (mode === 'move') {
|
|
609
|
+
await access(sourceNode.nodeDir, constants.R_OK);
|
|
610
|
+
await access(path.dirname(sourceNode.nodeDir), constants.W_OK);
|
|
611
|
+
await access(destinationCollectionExists ? destinationCollectionDirectory : path.dirname(destinationCollectionDirectory), constants.W_OK);
|
|
612
|
+
}
|
|
613
|
+
for (const change of fileChanges) {
|
|
614
|
+
await access(change.originalPath, constants.R_OK);
|
|
615
|
+
await access(path.dirname(change.originalPath), constants.W_OK);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
aliasChanges,
|
|
620
|
+
destinationCollectionDirectory,
|
|
621
|
+
destinationCollectionExists,
|
|
622
|
+
destinationDirectory,
|
|
623
|
+
fileChanges,
|
|
624
|
+
from: source.pathname,
|
|
625
|
+
linkChanges,
|
|
626
|
+
mappings,
|
|
627
|
+
mode,
|
|
628
|
+
preserveAliases,
|
|
629
|
+
sourceFiles: graph.pages.map((page) => ({
|
|
630
|
+
contentLabel: page.contentFile.contentLabel,
|
|
631
|
+
contentPath: page.contentFile.contentPath,
|
|
632
|
+
source: page.document.fullSource,
|
|
633
|
+
})),
|
|
634
|
+
sourceDirectory: mode === 'move' ? sourceNode.nodeDir : null,
|
|
635
|
+
to: destination.pathname,
|
|
636
|
+
};
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
export const formatPageMovePlan = (plan, displayPath) => {
|
|
640
|
+
const lines = [
|
|
641
|
+
'Page move plan',
|
|
642
|
+
`Mode: ${plan.mode === 'move' ? 'move page directory' : 'reconcile an already moved page'}`,
|
|
643
|
+
];
|
|
644
|
+
if (plan.mode === 'move') {
|
|
645
|
+
lines.push(`Directory: ${displayPath(plan.sourceDirectory)} -> ${displayPath(plan.destinationDirectory)}`);
|
|
646
|
+
}
|
|
647
|
+
lines.push('', 'Page URLs:');
|
|
648
|
+
for (const mapping of plan.mappings) lines.push(`- ${mapping.oldPathname} -> ${mapping.newPathname}`);
|
|
649
|
+
lines.push('', 'Link updates:');
|
|
650
|
+
if (plan.linkChanges.length === 0) lines.push('- none');
|
|
651
|
+
for (const change of plan.linkChanges) {
|
|
652
|
+
lines.push(`- ${change.contentLabel} line ${change.line}: ${change.from} -> ${change.to}`);
|
|
653
|
+
}
|
|
654
|
+
lines.push('', 'Aliases:');
|
|
655
|
+
if (!plan.preserveAliases) lines.push('- skipped by --no-aliases');
|
|
656
|
+
else if (plan.aliasChanges.length === 0) lines.push('- none needed');
|
|
657
|
+
else for (const change of plan.aliasChanges) lines.push(`- ${change.contentLabel}: add ${change.alias}`);
|
|
658
|
+
return lines;
|
|
659
|
+
};
|
|
@@ -35,7 +35,7 @@ export const presentationPaletteMetadata = Object.freeze({
|
|
|
35
35
|
),
|
|
36
36
|
'vivid-night': palette(
|
|
37
37
|
'Vivid night',
|
|
38
|
-
'Indigo surfaces with
|
|
38
|
+
'Indigo-led surfaces with brighter cyan and blue accents.',
|
|
39
39
|
),
|
|
40
40
|
});
|
|
41
41
|
|