@janga/norna 0.7.28 → 0.7.30
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 -6
- package/astro.config.mjs +14 -2
- package/package.json +13 -9
- 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 +97 -127
- 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 +7 -1
- package/scripts/lib/image-dimensions.mjs +3 -72
- 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/.github/workflows/deploy.yml +3 -6
- package/starters/basic/README.md +2 -0
- package/starters/basic/site/site-config/settings.yaml +3 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { publicFilePublication } from './publication-paths.mjs';
|
|
2
|
+
import { getSiteAttachments, resolveAttachment, parseLocalAttachmentTarget, attachmentOutputIssues } from './page-attachments.mjs';
|
|
3
|
+
import { readdir, readFile, lstat } from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import {
|
|
4
6
|
createPageAliasModel,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
toPosixPath,
|
|
12
14
|
} from './site-content.mjs';
|
|
13
15
|
import {
|
|
16
|
+
siteDir as defaultSiteDir,
|
|
14
17
|
sitePagesLabel,
|
|
15
18
|
sitePublicDir,
|
|
16
19
|
sitePublicLabel,
|
|
@@ -18,11 +21,15 @@ import {
|
|
|
18
21
|
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
19
22
|
import { getSiteStructure } from './site-structure.mjs';
|
|
20
23
|
import { createCategoryDestinationModel } from './category-destinations.mjs';
|
|
24
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
25
|
+
import { configSchema } from './schema-definitions.mjs';
|
|
21
26
|
|
|
22
27
|
const internalUrlOrigin = 'https://norna.invalid';
|
|
23
28
|
const externalSchemePattern = /^[a-z][a-z0-9+.-]*:/i;
|
|
24
29
|
|
|
25
|
-
const readPublicFiles = async (directory, relativeDirectory = '') => {
|
|
30
|
+
const readPublicFiles = async (directory, settings = {}, relativeDirectory = '') => {
|
|
31
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
32
|
+
if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error(`${directory} must be a regular public directory.`);
|
|
26
33
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
27
34
|
if (error?.code === 'ENOENT') return [];
|
|
28
35
|
throw error;
|
|
@@ -30,19 +37,22 @@ const readPublicFiles = async (directory, relativeDirectory = '') => {
|
|
|
30
37
|
const files = [];
|
|
31
38
|
|
|
32
39
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
40
|
+
if (entry.name === '.DS_Store' || entry.name.startsWith('._')) continue;
|
|
33
41
|
const relativePath = relativeDirectory
|
|
34
42
|
? path.join(relativeDirectory, entry.name)
|
|
35
43
|
: entry.name;
|
|
36
44
|
const entryPath = path.join(directory, entry.name);
|
|
37
45
|
if (entry.isDirectory()) {
|
|
38
|
-
|
|
46
|
+
publicFilePublication(toPosixPath(relativePath), settings, { directory: true });
|
|
47
|
+
files.push(...await readPublicFiles(entryPath, settings, relativePath));
|
|
39
48
|
} else if (entry.isFile()) {
|
|
40
49
|
files.push({
|
|
41
50
|
filePath: entryPath,
|
|
42
|
-
label:
|
|
43
|
-
|
|
51
|
+
label: entryPath,
|
|
52
|
+
relativePath: toPosixPath(relativePath),
|
|
53
|
+
...publicFilePublication(toPosixPath(relativePath), settings),
|
|
44
54
|
});
|
|
45
|
-
}
|
|
55
|
+
} else throw new Error(`${entryPath} must be a regular public file. Symbolic links are not supported.`);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
return files;
|
|
@@ -68,32 +78,47 @@ const getPageLookupPathname = (pathname) => {
|
|
|
68
78
|
|
|
69
79
|
const isExternalTarget = (target) => target.startsWith('//') || externalSchemePattern.test(target);
|
|
70
80
|
|
|
71
|
-
export const resolveInternalTarget = (target, sourcePathname) => {
|
|
72
|
-
|
|
81
|
+
export const resolveInternalTarget = (target, sourcePathname, siteUrl) => {
|
|
82
|
+
const absolute = isExternalTarget(target);
|
|
83
|
+
if (absolute && (!siteUrl || !/^(?:https?:)?\/\//i.test(target))) return { kind: 'external' };
|
|
73
84
|
|
|
74
85
|
let url;
|
|
86
|
+
let siteBase;
|
|
75
87
|
try {
|
|
76
|
-
|
|
88
|
+
siteBase = absolute ? new URL(siteUrl) : null;
|
|
89
|
+
url = new URL(target, siteBase ?? `${internalUrlOrigin}${sourcePathname}`);
|
|
77
90
|
} catch {
|
|
91
|
+
if (absolute) return { kind: 'external' };
|
|
78
92
|
return {
|
|
79
93
|
kind: 'invalid',
|
|
80
94
|
reason: 'The target is not a valid relative or site-relative URL.',
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
if (url.origin !== internalUrlOrigin) return { kind: 'external' };
|
|
98
|
+
if (url.origin !== (siteBase?.origin ?? internalUrlOrigin) || url.username || url.password) return { kind: 'external' };
|
|
85
99
|
|
|
86
100
|
try {
|
|
87
|
-
|
|
101
|
+
let pathname = decodePathname(url.pathname);
|
|
102
|
+
let absolutePrefix;
|
|
103
|
+
if (siteBase) {
|
|
104
|
+
const encodedBase = siteBase.pathname.replace(/\/?$/, '/');
|
|
105
|
+
const base = decodePathname(encodedBase);
|
|
106
|
+
if (pathname === base.slice(0, -1)) pathname = '/';
|
|
107
|
+
else if (pathname.startsWith(base)) pathname = '/' + pathname.slice(base.length);
|
|
108
|
+
else return { kind: 'external' };
|
|
109
|
+
absolutePrefix = (target.startsWith('//') ? `//${url.host}` : url.origin) + encodedBase.slice(0, -1);
|
|
110
|
+
}
|
|
88
111
|
const fragment = url.hash.length > 1 ? decodeFragment(url.hash.slice(1)) : '';
|
|
89
112
|
return {
|
|
90
113
|
kind: 'internal',
|
|
114
|
+
...(absolutePrefix ? { absolutePrefix } : {}),
|
|
91
115
|
fragment,
|
|
92
116
|
pageLookupPathname: getPageLookupPathname(pathname),
|
|
93
117
|
pathname,
|
|
94
118
|
query: url.search,
|
|
95
119
|
};
|
|
96
120
|
} catch (error) {
|
|
121
|
+
if (absolute) return { kind: 'external' };
|
|
97
122
|
return {
|
|
98
123
|
kind: 'invalid',
|
|
99
124
|
reason: error instanceof Error ? error.message : String(error),
|
|
@@ -146,9 +171,11 @@ const looksLikePublicFile = (pathname) => {
|
|
|
146
171
|
};
|
|
147
172
|
|
|
148
173
|
export const createSiteLinkGraph = ({
|
|
174
|
+
siteUrl,
|
|
149
175
|
siteStructure,
|
|
150
176
|
pageDocuments,
|
|
151
177
|
publicFiles = [],
|
|
178
|
+
attachments = { files: [], diagnostics: [] },
|
|
152
179
|
generatedRoutes,
|
|
153
180
|
}) => {
|
|
154
181
|
const activeGeneratedRoutes = generatedRoutes ?? generatedSiteRoutes;
|
|
@@ -193,15 +220,28 @@ export const createSiteLinkGraph = ({
|
|
|
193
220
|
|
|
194
221
|
const references = [];
|
|
195
222
|
const referencesByTarget = new Map();
|
|
196
|
-
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics
|
|
223
|
+
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics, ...attachments.diagnostics,
|
|
224
|
+
...attachmentOutputIssues({ files: attachments.files, pages, publicFiles, generatedRoutes: activeGeneratedRoutes })];
|
|
197
225
|
|
|
198
226
|
for (const page of pages) {
|
|
199
227
|
for (const sourceReference of page.document.links) {
|
|
200
|
-
const target = resolveInternalTarget(sourceReference.target, page.pathname);
|
|
228
|
+
const target = resolveInternalTarget(sourceReference.target, page.pathname, siteUrl);
|
|
201
229
|
if (target.kind === 'external') continue;
|
|
230
|
+
const attachment = target.kind === 'internal'
|
|
231
|
+
? resolveAttachment(sourceReference.target, page.pathname, attachments.files, target.pathname) : null;
|
|
232
|
+
// One origin/base path may also host independently built sites. Only
|
|
233
|
+
// adopt absolute URLs whose destination this source tree actually owns.
|
|
234
|
+
if (target.absolutePrefix && !attachment
|
|
235
|
+
&& !pagesByPathname.has(target.pageLookupPathname)
|
|
236
|
+
&& !aliasModel.aliasesByPathname.has(target.pageLookupPathname)
|
|
237
|
+
&& !publicFilesByPathname.has(target.pathname)
|
|
238
|
+
&& !categoriesByPathname.has(target.pageLookupPathname)
|
|
239
|
+
&& !generatedRoutesByPathname.has(target.pathname)
|
|
240
|
+
&& !generatedRoutesByPathname.has(target.pageLookupPathname)) continue;
|
|
202
241
|
|
|
203
242
|
const reference = {
|
|
204
243
|
...sourceReference,
|
|
244
|
+
sourceTarget: sourceReference.target,
|
|
205
245
|
sourceContentFile: page.contentFile,
|
|
206
246
|
sourcePage: page,
|
|
207
247
|
target,
|
|
@@ -222,6 +262,23 @@ export const createSiteLinkGraph = ({
|
|
|
222
262
|
if (!referencesByTarget.has(targetKey)) referencesByTarget.set(targetKey, []);
|
|
223
263
|
referencesByTarget.get(targetKey).push(reference);
|
|
224
264
|
|
|
265
|
+
const localAttachment = parseLocalAttachmentTarget(sourceReference.target);
|
|
266
|
+
if (attachment) {
|
|
267
|
+
const ambiguous = localAttachment?.prefix === '' && (publicFilesByPathname.has(target.pathname) || pagesByPathname.has(target.pageLookupPathname) || aliasModel.aliasesByPathname.has(target.pageLookupPathname));
|
|
268
|
+
if (ambiguous) {
|
|
269
|
+
reference.resolution = { kind: 'ambiguous-attachment' };
|
|
270
|
+
diagnostics.push(createIssue(reference, 'ambiguous-attachment', `Link "${sourceReference.target}" could identify a page/public file or a local attachment.`, `Use ${attachment.pathname} for the attachment or an explicit site-relative page/public link.`));
|
|
271
|
+
} else reference.resolution = { kind: 'attachment', file: attachment, pathname: attachment.pathname };
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (localAttachment?.prefix) {
|
|
276
|
+
const expectedPath = path.join(path.dirname(page.contentFile.contentPath), 'downloads', localAttachment.filename);
|
|
277
|
+
reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
|
|
278
|
+
diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" was not found at ${toPosixPath(expectedPath)}.`, 'Add the file to this page’s downloads/ folder or correct the link.'));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
225
282
|
const targetPage = target.pageLookupPathname
|
|
226
283
|
? pagesByPathname.get(target.pageLookupPathname)
|
|
227
284
|
: null;
|
|
@@ -304,13 +361,19 @@ export const createSiteLinkGraph = ({
|
|
|
304
361
|
continue;
|
|
305
362
|
}
|
|
306
363
|
|
|
364
|
+
if (target.pathname.startsWith('/_norna/downloads/') && !target.pathname.endsWith('/')) {
|
|
365
|
+
reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
|
|
366
|
+
diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" does not exist.`, 'Add the file to its owning page’s downloads/ or correct the link.'));
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
307
370
|
if (looksLikePublicFile(target.pathname)) {
|
|
308
371
|
reference.resolution = { kind: 'missing-public-file', pathname: target.pathname };
|
|
309
372
|
diagnostics.push(createIssue(
|
|
310
373
|
reference,
|
|
311
374
|
'missing-public-file',
|
|
312
|
-
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/.`,
|
|
313
|
-
|
|
375
|
+
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/ or the owning page’s downloads/.`,
|
|
376
|
+
'Use the published address of an allowed public image or technical file, or link to a page-owned attachment.',
|
|
314
377
|
));
|
|
315
378
|
continue;
|
|
316
379
|
}
|
|
@@ -327,6 +390,8 @@ export const createSiteLinkGraph = ({
|
|
|
327
390
|
}
|
|
328
391
|
|
|
329
392
|
return {
|
|
393
|
+
siteUrl,
|
|
394
|
+
attachments,
|
|
330
395
|
categoryModel,
|
|
331
396
|
aliasModel,
|
|
332
397
|
aliases: aliasModel.aliases,
|
|
@@ -342,7 +407,10 @@ export const createSiteLinkGraph = ({
|
|
|
342
407
|
};
|
|
343
408
|
|
|
344
409
|
export const getSiteLinkGraph = async (options = {}) => {
|
|
345
|
-
const
|
|
410
|
+
const siteRoot = options.siteRoot ?? defaultSiteDir;
|
|
411
|
+
const siteStructure = options.siteStructure ?? await getSiteStructure({ siteRoot });
|
|
412
|
+
const settingsPath = path.join(siteRoot, 'site-config/settings.yaml');
|
|
413
|
+
const settings = parseYamlConfig(await readFile(settingsPath, 'utf8'), settingsPath, { schema: configSchema });
|
|
346
414
|
const [pageDocuments, publicFiles] = await Promise.all([
|
|
347
415
|
Promise.all(siteStructure.contentFiles.map(async (contentFile) => {
|
|
348
416
|
const source = await readFile(contentFile.contentPath, 'utf8');
|
|
@@ -353,15 +421,17 @@ export const getSiteLinkGraph = async (options = {}) => {
|
|
|
353
421
|
document: await parsePageMarkdownSource(source, { label: contentFile.contentLabel }),
|
|
354
422
|
};
|
|
355
423
|
})),
|
|
356
|
-
readPublicFiles(options.publicDir ??
|
|
424
|
+
readPublicFiles(options.publicDir ?? path.join(siteRoot, 'public'), settings),
|
|
357
425
|
]);
|
|
358
426
|
|
|
359
427
|
return createSiteLinkGraph({
|
|
428
|
+
siteUrl: settings.url,
|
|
360
429
|
pageDocuments,
|
|
361
430
|
publicFiles,
|
|
362
431
|
siteStructure,
|
|
432
|
+
attachments: await getSiteAttachments(siteStructure),
|
|
363
433
|
generatedRoutes: options.generatedRoutes,
|
|
364
434
|
});
|
|
365
435
|
};
|
|
366
436
|
|
|
367
|
-
export const getSitePublicFiles = async (publicDir = sitePublicDir) => readPublicFiles(publicDir);
|
|
437
|
+
export const getSitePublicFiles = async (publicDir = sitePublicDir, settings = {}) => readPublicFiles(publicDir, settings);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { comparePageOrder, parsePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
|
|
2
|
+
import { pageOrderFileChanges } from './page-order-source.mjs';
|
|
3
|
+
import { writeFileAtomically } from './page-move-apply.mjs';
|
|
1
4
|
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
2
5
|
import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
6
|
import path from 'node:path';
|
|
@@ -9,17 +12,7 @@ import { siteSchema } from './schema-definitions.mjs';
|
|
|
9
12
|
import { readEditorLinkState } from './editor-site-links.mjs';
|
|
10
13
|
import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
|
|
11
14
|
import { parseYamlConfig } from './yaml-config.mjs';
|
|
12
|
-
|
|
13
|
-
const parseOrder = (value) => {
|
|
14
|
-
if (!/^\d{1,3}$/.test(value)) {
|
|
15
|
-
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
|
|
16
|
-
}
|
|
17
|
-
const order = Number.parseInt(value, 10);
|
|
18
|
-
if (order < 1 || order > 999) {
|
|
19
|
-
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
|
|
20
|
-
}
|
|
21
|
-
return order;
|
|
22
|
-
};
|
|
15
|
+
import { publicationIssues } from './publication-paths.mjs';
|
|
23
16
|
|
|
24
17
|
const normalizeParentPath = (value) => {
|
|
25
18
|
if (value === '/') return '';
|
|
@@ -33,15 +26,6 @@ const normalizeParentPath = (value) => {
|
|
|
33
26
|
return normalized;
|
|
34
27
|
};
|
|
35
28
|
|
|
36
|
-
const getNextOrder = (siblings) => {
|
|
37
|
-
const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
|
|
38
|
-
const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
|
|
39
|
-
if (nextOrder > 999) {
|
|
40
|
-
throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
|
|
41
|
-
}
|
|
42
|
-
return nextOrder;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
29
|
export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
|
|
46
30
|
|
|
47
31
|
const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
|
|
@@ -86,30 +70,40 @@ const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) =
|
|
|
86
70
|
// Planning never writes. Callers must present this same destination before applying.
|
|
87
71
|
export const planSiteNodeCreation = async ({
|
|
88
72
|
siteRoot, kind, title, slug = null, order = null, parentPath = '/',
|
|
89
|
-
invocationDirectory = siteRoot, metadata, sources = new Map(),
|
|
73
|
+
invocationDirectory = siteRoot, metadata, sources = new Map(), placement = null, targetPath = null,
|
|
90
74
|
}) => {
|
|
75
|
+
const request = { siteRoot, kind, title, slug, order, parentPath, invocationDirectory, metadata, placement, targetPath };
|
|
76
|
+
if (placement !== null && !['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first or last.');
|
|
77
|
+
if (placement !== null && order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
91
78
|
if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
|
|
92
79
|
siteRoot = path.resolve(siteRoot);
|
|
93
80
|
title = String(title ?? '').trim();
|
|
94
81
|
if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
|
|
95
82
|
const generatedSlug = slug === null;
|
|
96
83
|
slug ??= slugifyAsciiIdentifier(title);
|
|
97
|
-
if (!slug || !pageDirectoryPattern.test(
|
|
84
|
+
if (!slug || !pageDirectoryPattern.test(slug)) {
|
|
98
85
|
throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
|
|
99
86
|
}
|
|
100
87
|
|
|
101
88
|
const structure = await getSiteStructure({ siteRoot });
|
|
89
|
+
let relativeTarget = null;
|
|
90
|
+
if (placement === 'before' || placement === 'after') {
|
|
91
|
+
relativeTarget = structure.nodes.find(node => node.contentPath === targetPath || `/${node.pagePath}/` === targetPath);
|
|
92
|
+
if (!relativeTarget || relativeTarget.isHome) throw new Error('Choose an existing sibling page for before/after placement.');
|
|
93
|
+
parentPath = relativeTarget.parentPagePath ? `/${relativeTarget.parentPagePath}/` : '/';
|
|
94
|
+
}
|
|
102
95
|
const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
|
|
103
|
-
const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null));
|
|
96
|
+
const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null)).sort(comparePageOrder);
|
|
104
97
|
if (siblings.some((node) => node.pageId === slug)) {
|
|
105
98
|
throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
|
|
106
99
|
}
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const
|
|
100
|
+
const dirty = [...sources.keys()].filter(filename => filename === path.join(siteRoot, 'site-config/settings.yaml') || siblings.some(node => node.contentPath === filename));
|
|
101
|
+
if (dirty.length) throw new Error(`Save or undo these unsaved files before choosing page placement:\n${dirty.join('\n')}`);
|
|
102
|
+
const index = placement === 'first' ? 0 : relativeTarget ? siblings.indexOf(relativeTarget) + (placement === 'after' ? 1 : 0) : siblings.length;
|
|
103
|
+
const insertion = order === null ? planPageOrder(siblings, index) : { order: parsePageOrder(Number(order), '--order'), orderChanges: [] };
|
|
104
|
+
order = insertion.order;
|
|
105
|
+
const orderChanges = await pageOrderFileChanges(insertion.orderChanges);
|
|
106
|
+
const directoryName = slug;
|
|
113
107
|
const destination = path.join(parent.collectionDir, directoryName);
|
|
114
108
|
const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
|
|
115
109
|
const state = await readEditorLinkState({ siteRoot, sources });
|
|
@@ -127,14 +121,23 @@ export const planSiteNodeCreation = async ({
|
|
|
127
121
|
{ pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
|
|
128
122
|
categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
|
|
129
123
|
}
|
|
130
|
-
|
|
124
|
+
const publicationErrors = publicationIssues({ pages: [...state.graph.pages,
|
|
125
|
+
{ pathname: address, contentLabel: destination, aliases: metadata?.page?.aliases ?? [] }],
|
|
126
|
+
publicFiles: state.publicFiles, attachments: state.graph.attachments.files, generatedRoutes: state.generatedRoutes });
|
|
127
|
+
if (publicationErrors.length) throw new Error(publicationErrors.map(({ message }) => message).join('\n'));
|
|
128
|
+
metadata = { ...metadata, navigation: { ...metadata?.navigation, order } };
|
|
129
|
+
const fingerprint = JSON.stringify([destination, title, metadata, order, orderChanges, state.settings,
|
|
130
|
+
state.graph.pages.map(page => [page.contentFile.contentPath, page.document.fullSource]), state.publicFiles.map(file => file.filePath)]);
|
|
131
|
+
return { request, fingerprint, orderChanges, siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
|
|
131
132
|
collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
|
|
132
133
|
};
|
|
133
134
|
|
|
134
|
-
export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
|
|
135
|
+
export const createSiteNode = async (plan, { sources = new Map(), allowRenumber = false, writeSource = writeFileAtomically } = {}) => {
|
|
135
136
|
// Revalidate after the author has inspected the preview. Never change its placement.
|
|
136
|
-
const current = await planSiteNodeCreation({ ...plan, sources });
|
|
137
|
-
if (current.destination !== plan.destination) throw new Error('The site changed. Preview the new location before creating the node.');
|
|
137
|
+
const current = await planSiteNodeCreation({ ...(plan.request ?? plan), sources });
|
|
138
|
+
if (current.destination !== plan.destination || current.fingerprint !== plan.fingerprint) throw new Error('The site changed. Preview the new location before creating the node.');
|
|
139
|
+
assertRenumberingAllowed(current.orderChanges, allowRenumber);
|
|
140
|
+
const written = [];
|
|
138
141
|
let createdCollection = false;
|
|
139
142
|
try {
|
|
140
143
|
const info = await stat(current.collectionDir);
|
|
@@ -156,14 +159,24 @@ export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
|
|
|
156
159
|
const childDirectory = path.join(current.destination, 'images');
|
|
157
160
|
await mkdir(childDirectory);
|
|
158
161
|
created.push({ file: childDirectory, directory: true });
|
|
159
|
-
|
|
162
|
+
for (const change of current.orderChanges) {
|
|
163
|
+
await writeSource(change.originalPath, change.updatedSource, change.mode);
|
|
164
|
+
written.push(change);
|
|
165
|
+
}
|
|
166
|
+
return { ...current, sourcePath: filename, renumberedPages: current.orderChanges.length };
|
|
160
167
|
} catch (error) {
|
|
168
|
+
const restoreErrors = [];
|
|
169
|
+
for (const change of written.reverse()) {
|
|
170
|
+
try { await writeFileAtomically(change.originalPath, change.originalSource, change.mode); }
|
|
171
|
+
catch (failure) { restoreErrors.push(`${change.originalPath}: ${failure.message}`); }
|
|
172
|
+
}
|
|
161
173
|
// Remove only entries this operation created; never recursively remove an
|
|
162
174
|
// unexpected file written concurrently by another process.
|
|
163
175
|
for (const entry of created.reverse()) {
|
|
164
176
|
await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
|
|
165
177
|
}
|
|
166
178
|
if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
|
|
179
|
+
if (restoreErrors.length) throw new Error(`${error.message}\nRestoration was incomplete:\n${restoreErrors.join('\n')}`);
|
|
167
180
|
throw error;
|
|
168
181
|
}
|
|
169
182
|
};
|
|
@@ -155,8 +155,8 @@ export const astroDistDir = configuredStateDirectory
|
|
|
155
155
|
: path.join(siteProjectRoot, 'dist');
|
|
156
156
|
export const astroRootDir = siteStateDir;
|
|
157
157
|
export const astroCacheDir = path.join(astroRootDir, '.astro');
|
|
158
|
-
export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
|
|
159
|
-
export const originalImagesDir = path.join(astroPublicDir, 'images', 'original');
|
|
158
|
+
export const generatedImagesDir = path.join(astroPublicDir, '_norna', 'images', 'generated');
|
|
159
|
+
export const originalImagesDir = path.join(astroPublicDir, '_norna', 'images', 'original');
|
|
160
160
|
export const generatedImagesManifestPath = path.join(siteStateDir, 'generated-images.json');
|
|
161
161
|
|
|
162
162
|
export const engineRootLabel = getPathLabel(engineRoot);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { load } from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
export const assertSiteSourceFormat = async (siteRoot, readSource = filename => readFile(filename, 'utf8')) => {
|
|
6
|
+
const filename = path.join(siteRoot, 'site-config/settings.yaml');
|
|
7
|
+
const source = await readSource(filename);
|
|
8
|
+
let settings;
|
|
9
|
+
try { settings = load(source); }
|
|
10
|
+
catch (error) { throw new Error(`${filename} contains invalid YAML: ${error.reason ?? error.message}`); }
|
|
11
|
+
if (settings?.sourceFormat !== 2) throw new Error(`${filename} needs sourceFormat: 2 after converting the site: move numeric page-directory prefixes into navigation.order frontmatter and remove those prefixes. Keep this site on its previous engine until converted. No files changed.`);
|
|
12
|
+
};
|
|
@@ -2,6 +2,9 @@ import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
|
2
2
|
import { access, readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parsePageDirectoryPath } from './page-model.mjs';
|
|
5
|
+
import { comparePageOrder, parsePageOrder } from './page-order.mjs';
|
|
6
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
7
|
+
import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
|
|
5
8
|
import {
|
|
6
9
|
homePageDirectory,
|
|
7
10
|
siteDir as defaultSiteDir,
|
|
@@ -15,11 +18,6 @@ const readDirectory = async (directory) => readdir(directory, { withFileTypes: t
|
|
|
15
18
|
throw error;
|
|
16
19
|
});
|
|
17
20
|
|
|
18
|
-
const compareNodeMetadata = (left, right) => (
|
|
19
|
-
left.pageOrder - right.pageOrder
|
|
20
|
-
|| left.pageId.localeCompare(right.pageId, 'en')
|
|
21
|
-
);
|
|
22
|
-
|
|
23
21
|
const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
|
|
24
22
|
for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes']) {
|
|
25
23
|
if (await fileExists(path.join(siteDir, name))) {
|
|
@@ -28,7 +26,7 @@ const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
|
|
|
28
26
|
}
|
|
29
27
|
};
|
|
30
28
|
|
|
31
|
-
const assertUniqueSiblings = (nodes, pagesLabel) => {
|
|
29
|
+
const assertUniqueSiblings = (nodes, pagesLabel, warnings) => {
|
|
32
30
|
const byId = new Map();
|
|
33
31
|
const byOrder = new Map();
|
|
34
32
|
|
|
@@ -43,13 +41,11 @@ const assertUniqueSiblings = (nodes, pagesLabel) => {
|
|
|
43
41
|
}
|
|
44
42
|
byId.set(node.pageId, node);
|
|
45
43
|
|
|
44
|
+
if (node.pageOrder === null) continue;
|
|
46
45
|
const existingOrder = byOrder.get(node.pageOrder);
|
|
47
46
|
if (existingOrder) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`- ${existingOrder.nodeLabel}`,
|
|
51
|
-
`- ${node.nodeLabel}`,
|
|
52
|
-
].join('\n'));
|
|
47
|
+
const message = `${pagesLabel} contains duplicate sibling navigation.order ${node.pageOrder}: ${existingOrder.pageId} and ${node.pageId}. Slug determines their order. Choose different values to set an explicit order.`;
|
|
48
|
+
for (const sibling of [existingOrder, node]) warnings.push({ code: 'duplicate-page-order', message, label: sibling.contentLabel, path: sibling.contentPath });
|
|
53
49
|
}
|
|
54
50
|
byOrder.set(node.pageOrder, node);
|
|
55
51
|
}
|
|
@@ -75,6 +71,7 @@ export const getSiteStructure = async ({
|
|
|
75
71
|
};
|
|
76
72
|
try {
|
|
77
73
|
await assertLegacyStructureIsAbsent({ siteDir, siteDirLabel, sitePagesLabel });
|
|
74
|
+
await assertSiteSourceFormat(siteDir, readSource);
|
|
78
75
|
} catch (error) {
|
|
79
76
|
report(error);
|
|
80
77
|
}
|
|
@@ -131,16 +128,20 @@ export const getSiteStructure = async ({
|
|
|
131
128
|
imagesLabel: `${nodeLabel}/images`,
|
|
132
129
|
});
|
|
133
130
|
|
|
131
|
+
try {
|
|
132
|
+
const { frontmatterBody } = splitSiteFile(await readSource(contentPath), node.contentLabel);
|
|
133
|
+
node.pageOrder = parsePageOrder(parseContentFrontmatter(frontmatterBody, node.contentLabel).navigation?.order, `${node.contentLabel}: navigation.order`);
|
|
134
|
+
} catch (error) { report(error, node); }
|
|
134
135
|
siblingNodes.push(node);
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
try {
|
|
138
|
-
assertUniqueSiblings(siblingNodes, pagesLabel);
|
|
139
|
+
assertUniqueSiblings(siblingNodes, pagesLabel, warnings);
|
|
139
140
|
} catch (error) {
|
|
140
141
|
report(error);
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
for (const node of siblingNodes.sort(
|
|
144
|
+
for (const node of siblingNodes.sort(comparePageOrder)) {
|
|
144
145
|
nodes.push(node);
|
|
145
146
|
const childPagesDir = path.join(node.nodeDir, 'pages');
|
|
146
147
|
const childDirectories = (await readDirectory(childPagesDir)).filter((entry) => entry.isDirectory());
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
1
2
|
import { lstat } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { readThemePackage } from './theme-packages.mjs';
|
|
@@ -25,6 +26,7 @@ export const planSiteUpgrade = async (siteRoot) => {
|
|
|
25
26
|
for (const filename of [source.content, path.join(siteRoot, 'site-config', 'settings.yaml'), source.theme]) {
|
|
26
27
|
if (!(await inspect(filename))?.isFile()) throw new Error(`${filename} is required and must be a regular file.`);
|
|
27
28
|
}
|
|
29
|
+
await assertSiteSourceFormat(siteRoot);
|
|
28
30
|
await readThemePackage({ siteRoot, filename: source.theme });
|
|
29
31
|
return { siteRoot, moves: [] };
|
|
30
32
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publicFilePublication } from './publication-paths.mjs';
|
|
1
2
|
import { getPublicAssetInspection } from './logo-assets.mjs';
|
|
2
3
|
import {
|
|
3
4
|
socialImageAssetDefinitions,
|
|
@@ -13,7 +14,7 @@ export const getSocialImageAssets = () => getPublicAssetInspection().socialImage
|
|
|
13
14
|
.map((filename) => ({
|
|
14
15
|
...definitionsByFilename.get(filename),
|
|
15
16
|
filename,
|
|
16
|
-
href:
|
|
17
|
+
href: publicFilePublication(filename).pathname,
|
|
17
18
|
}));
|
|
18
19
|
|
|
19
20
|
export const getSocialImageAsset = () => {
|
|
@@ -11,7 +11,7 @@ export const sourceFileDefinitions = [
|
|
|
11
11
|
{ name: 'tree-theme.yaml', location: 'page', schemaKind: 'theme', rootSchemaKind: 'rootTheme', required: 'root', rootText: 'preset: documentation\n', text: '# Modify inherited settings. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page and its descendants. Without preset, modifies inherited values; preset selects a new base. Required on the root page.', removalEffect: 'This branch resumes the ancestor tree theme. Overrides on descendants remain; a descendant preset starts an independent base.' },
|
|
12
12
|
{ name: 'page-theme.yaml', location: 'page', schemaKind: 'pageTheme', text: '# Modify this page only. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page only. Without preset, modifies its tree theme; preset selects a new base. Descendants keep their tree theme.', removalEffect: 'This page will use its selected tree theme again. Other pages are unchanged.' },
|
|
13
13
|
];
|
|
14
|
-
export const sourcePageDirectoryPattern = pageDirectoryPattern.source
|
|
14
|
+
export const sourcePageDirectoryPattern = pageDirectoryPattern.source;
|
|
15
15
|
const schemas = { config: configSchema, contentFrontmatter: siteSchema, sitewideContent: sitewideSchema, theme: themeVisualSchema, rootTheme: rootThemeSchema, pageTheme: pageThemeSchema };
|
|
16
16
|
|
|
17
17
|
export const getSourcePageLocation = (siteRoot, directory) => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
|
|
2
2
|
|
|
3
|
+
export const managedImageFilenamePattern = '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])';
|
|
4
|
+
|
|
3
5
|
const stringSchema = (name, definition) => ({
|
|
4
6
|
type: 'string',
|
|
5
7
|
description: definition.description,
|
|
@@ -7,7 +9,7 @@ const stringSchema = (name, definition) => ({
|
|
|
7
9
|
...(definition.values ? { enum: Object.keys(definition.values) } : {}),
|
|
8
10
|
...(name === 'alt' ? {} : { minLength: 1 }),
|
|
9
11
|
pattern: name === 'image'
|
|
10
|
-
?
|
|
12
|
+
? managedImageFilenamePattern
|
|
11
13
|
: name === 'caption' || name === 'text'
|
|
12
14
|
? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
|
|
13
15
|
: name === 'alt'
|