@janga/norna 0.7.28 → 0.7.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/astro.config.mjs +14 -2
- package/package.json +10 -8
- package/schemas/config.schema.json +80 -65
- package/schemas/content-frontmatter.schema.json +15 -8
- package/schemas/manifest.json +1 -1
- package/schemas/page-theme.schema.json +237 -237
- package/schemas/root-theme.schema.json +237 -237
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -237
- package/scripts/add-site-node.mjs +29 -100
- package/scripts/dev-local.mjs +34 -7
- package/scripts/generate-images.mjs +83 -65
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-routes.json +63 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +2 -0
- package/scripts/lib/editor-language-service.mjs +21 -18
- package/scripts/lib/editor-page-addresses.mjs +5 -3
- package/scripts/lib/editor-page-placement.mjs +28 -98
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +44 -9
- package/scripts/lib/editor-site-files.mjs +3 -2
- package/scripts/lib/editor-site-links.mjs +6 -4
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +10 -3
- package/scripts/lib/editor-source-files.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/norna-markdown-blocks.mjs +0 -57
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +5 -0
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +8 -22
- package/scripts/lib/page-move-apply.mjs +6 -4
- package/scripts/lib/page-move-plan.mjs +37 -19
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/project-config.mjs +3 -0
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +4 -0
- package/scripts/lib/schema-editor-metadata.mjs +4 -1
- package/scripts/lib/site-content.mjs +1 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-node-create.mjs +47 -34
- package/scripts/lib/site-paths.mjs +2 -2
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +14 -13
- package/scripts/lib/site-upgrade.mjs +2 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +1 -1
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/move-site-page.mjs +46 -133
- package/scripts/sync-content-sections.mjs +8 -10
- package/scripts/sync-site-public.mjs +44 -66
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/SearchPage.astro +2 -1
- package/src/components/SitePage.astro +1 -0
- package/src/components/SiteSection.astro +1 -0
- package/src/content.config.ts +1 -4
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/sectionContent.ts +64 -19
- package/src/lib/sitePages.ts +4 -30
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/[...slug].astro +2 -1
- package/src/styles/area-navigation.css +14 -5
- package/src/styles/content.css +6 -0
- package/src/styles/media.css +24 -0
- package/starters/basic/site/site-config/settings.yaml +1 -0
|
@@ -1,26 +1,14 @@
|
|
|
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
|
-
);
|
|
14
|
-
}
|
|
15
|
-
if (match[1] === '000') {
|
|
16
|
-
throw new Error(`Invalid ${label} "${pageDirectory}". Child page orders must be 001–999. The homepage is root/content.md in the site container.`);
|
|
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.`);
|
|
17
10
|
}
|
|
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('\\', '/');
|
|
@@ -30,7 +18,7 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
30
18
|
if (normalizedPath === homePageDirectory) {
|
|
31
19
|
return {
|
|
32
20
|
pageDirectory: homePageDirectory, pageDirectories: [], pageId: 'home',
|
|
33
|
-
pageIds: [], pageOrder: 0,
|
|
21
|
+
pageIds: [], pageOrder: 0, pagePath: '',
|
|
34
22
|
parentPagePath: null, depth: 0,
|
|
35
23
|
};
|
|
36
24
|
}
|
|
@@ -38,7 +26,7 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
38
26
|
|
|
39
27
|
if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
|
|
40
28
|
throw new Error(
|
|
41
|
-
`Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form
|
|
29
|
+
`Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form page-id/pages/child-id.`,
|
|
42
30
|
);
|
|
43
31
|
}
|
|
44
32
|
|
|
@@ -57,7 +45,6 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
57
45
|
}
|
|
58
46
|
|
|
59
47
|
const pageIds = pageDirectories.map(({ pageId }) => pageId);
|
|
60
|
-
const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
|
|
61
48
|
const currentPage = pageDirectories.at(-1);
|
|
62
49
|
|
|
63
50
|
return {
|
|
@@ -66,7 +53,6 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
66
53
|
pageId: currentPage.pageId,
|
|
67
54
|
pageIds,
|
|
68
55
|
pageOrder: currentPage.pageOrder,
|
|
69
|
-
pageOrders,
|
|
70
56
|
pagePath: pageIds.join('/'),
|
|
71
57
|
// Navigation projects the root's children as top-level entries.
|
|
72
58
|
parentPagePath: pageIds.length > 1
|
|
@@ -78,11 +64,11 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
78
64
|
|
|
79
65
|
export const encodePageDirectoryPath = (pageDirectoryPath) => {
|
|
80
66
|
const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
|
|
81
|
-
return pageDirectories.length ? pageDirectories.join(pageEntryPathSeparator) : '
|
|
67
|
+
return pageDirectories.length ? pageDirectories.join(pageEntryPathSeparator) : '~home';
|
|
82
68
|
};
|
|
83
69
|
|
|
84
70
|
export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
|
|
85
|
-
if (pageEntryPath === '
|
|
71
|
+
if (pageEntryPath === '~home') return homePageDirectory;
|
|
86
72
|
const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
|
|
87
73
|
if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
|
|
88
74
|
throw new Error(`Invalid ${label} "${pageEntryPath}".`);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { assertRenumberingAllowed } from './page-order.mjs';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { mkdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { getSiteLinkGraph } from './site-link-graph.mjs';
|
|
5
6
|
import { getSiteStructure } from './site-structure.mjs';
|
|
6
7
|
|
|
7
|
-
const writeFileAtomically = async (filePath, source, mode) => {
|
|
8
|
+
export const writeFileAtomically = async (filePath, source, mode) => {
|
|
8
9
|
const temporaryPath = path.join(
|
|
9
10
|
path.dirname(filePath),
|
|
10
11
|
`.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
|
|
@@ -31,7 +32,7 @@ const assertSourcesUnchanged = async (plan) => {
|
|
|
31
32
|
|
|
32
33
|
const assertFinalSite = async (siteRoot, generatedRoutes) => {
|
|
33
34
|
const structure = await getSiteStructure({ siteRoot });
|
|
34
|
-
const graph = await getSiteLinkGraph({ siteStructure: structure, publicDir: path.join(siteRoot, 'public'), generatedRoutes });
|
|
35
|
+
const graph = await getSiteLinkGraph({ siteRoot, siteStructure: structure, publicDir: path.join(siteRoot, 'public'), generatedRoutes });
|
|
35
36
|
const errors = graph.diagnostics.filter(({ severity }) => severity === 'error');
|
|
36
37
|
if (errors.length === 0) return;
|
|
37
38
|
throw new Error([
|
|
@@ -43,7 +44,8 @@ const assertFinalSite = async (siteRoot, generatedRoutes) => {
|
|
|
43
44
|
].join('\n'));
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
export const applyPageMovePlan = async (plan, { siteRoot, generatedRoutes, renameDirectory = rename }) => {
|
|
47
|
+
export const applyPageMovePlan = async (plan, { siteRoot, generatedRoutes, renameDirectory = rename, writeSource = writeFileAtomically, allowRenumber = false }) => {
|
|
48
|
+
assertRenumberingAllowed(plan.orderChanges ?? [], allowRenumber);
|
|
47
49
|
await assertSourcesUnchanged(plan);
|
|
48
50
|
let collectionCreated = false;
|
|
49
51
|
let directoryMoved = false;
|
|
@@ -67,7 +69,7 @@ export const applyPageMovePlan = async (plan, { siteRoot, generatedRoutes, renam
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
for (const change of plan.fileChanges) {
|
|
70
|
-
await
|
|
72
|
+
await writeSource(change.finalPath, change.updatedSource, change.mode);
|
|
71
73
|
fileWritten = true;
|
|
72
74
|
}
|
|
73
75
|
await assertFinalSite(siteRoot, generatedRoutes);
|
|
@@ -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 {
|
|
@@ -38,15 +41,6 @@ const replaceFilePathPrefix = (filePath, fromDirectory, toDirectory) => (
|
|
|
38
41
|
path.join(toDirectory, path.relative(fromDirectory, filePath))
|
|
39
42
|
);
|
|
40
43
|
|
|
41
|
-
const getNextOrder = (siblings) => {
|
|
42
|
-
const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
|
|
43
|
-
const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
|
|
44
|
-
if (nextOrder > 999) {
|
|
45
|
-
throw new Error('No automatic page order remains below 1000. Reorder the destination siblings or pass --order.');
|
|
46
|
-
}
|
|
47
|
-
return nextOrder;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
44
|
const getTargetSuffix = (target) => {
|
|
51
45
|
const queryIndex = target.indexOf('?');
|
|
52
46
|
const fragmentIndex = target.indexOf('#');
|
|
@@ -201,6 +195,11 @@ const getResolvedReferenceIntent = ({
|
|
|
201
195
|
};
|
|
202
196
|
}
|
|
203
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
|
+
|
|
204
203
|
if (resolution?.kind === 'public-file') {
|
|
205
204
|
return { kind: 'public-file', lookupPathname: target.pathname };
|
|
206
205
|
}
|
|
@@ -259,7 +258,7 @@ const getReferenceIntent = ({
|
|
|
259
258
|
const sourceMapping = mappingByCurrentPathname.get(reference.sourcePage.pathname);
|
|
260
259
|
if (!sourceMapping || sourceMapping.oldPathname === sourceMapping.currentPathname) return currentIntent;
|
|
261
260
|
|
|
262
|
-
const oldTarget = resolveInternalTarget(reference.targetSource, sourceMapping.oldPathname);
|
|
261
|
+
const oldTarget = resolveInternalTarget(reference.targetSource, sourceMapping.oldPathname, graph.siteUrl);
|
|
263
262
|
const oldIntent = getTargetIntent({
|
|
264
263
|
graph,
|
|
265
264
|
movedCurrentToNew,
|
|
@@ -311,8 +310,9 @@ const createReferenceChanges = ({ graph, mappings }) => {
|
|
|
311
310
|
|
|
312
311
|
const sourcePathname = movedCurrentToNew.get(reference.sourcePage.pathname)
|
|
313
312
|
?? reference.sourcePage.pathname;
|
|
314
|
-
const
|
|
315
|
-
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'
|
|
316
316
|
&& (intent.kind === 'public-file'
|
|
317
317
|
? resolvedAfterMove.pathname === intent.lookupPathname
|
|
318
318
|
: resolvedAfterMove.pageLookupPathname === intent.lookupPathname);
|
|
@@ -324,7 +324,7 @@ const createReferenceChanges = ({ graph, mappings }) => {
|
|
|
324
324
|
);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
const value = `${intent.lookupPathname}${getTargetSuffix(reference.targetSource)}`;
|
|
327
|
+
const value = `${reference.target.absolutePrefix ?? ''}${intent.kind === 'attachment' ? encodeAttachmentPath(intent.lookupPathname) : intent.lookupPathname}${getTargetSuffix(reference.targetSource)}`;
|
|
328
328
|
const key = `${reference.sourceContentFile.contentPath}:${reference.targetRange.start}:${reference.targetRange.end}`;
|
|
329
329
|
const existing = changeKeys.get(key);
|
|
330
330
|
if (existing && existing.to !== value) {
|
|
@@ -360,6 +360,7 @@ export const createPageMovePlan = async ({
|
|
|
360
360
|
graph,
|
|
361
361
|
generatedRoutes,
|
|
362
362
|
order = null,
|
|
363
|
+
siblingOrderChanges = [],
|
|
363
364
|
preserveAliases = true,
|
|
364
365
|
publicFiles,
|
|
365
366
|
sitePagesDir,
|
|
@@ -405,6 +406,7 @@ export const createPageMovePlan = async ({
|
|
|
405
406
|
let destinationCollectionDirectory = null;
|
|
406
407
|
let destinationCollectionExists = true;
|
|
407
408
|
let virtualNodes = siteStructure.nodes;
|
|
409
|
+
let orderChanges = siblingOrderChanges;
|
|
408
410
|
|
|
409
411
|
if (mode === 'move') {
|
|
410
412
|
const destinationParentPath = getParentPagePath(destination.pagePath);
|
|
@@ -419,22 +421,23 @@ export const createPageMovePlan = async ({
|
|
|
419
421
|
}
|
|
420
422
|
|
|
421
423
|
const destinationSiblings = siteStructure.nodes.filter((node) => (
|
|
422
|
-
node.parentPagePath === destinationParentPath
|
|
424
|
+
!node.isHome && node.parentPagePath === destinationParentPath
|
|
423
425
|
&& !isPathInSubtree(node.pagePath, source.pagePath)
|
|
424
426
|
));
|
|
425
427
|
const sameParent = sourceNode.parentPagePath === destinationParentPath;
|
|
426
|
-
|
|
427
|
-
if (
|
|
428
|
-
|
|
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;
|
|
429
433
|
}
|
|
430
|
-
|
|
431
434
|
const destinationSlug = destination.pagePath.split('/').at(-1);
|
|
432
435
|
destinationCollectionDirectory = destinationParent
|
|
433
436
|
? path.join(destinationParent.nodeDir, 'pages')
|
|
434
437
|
: sitePagesDir;
|
|
435
438
|
destinationDirectory = path.join(
|
|
436
439
|
destinationCollectionDirectory,
|
|
437
|
-
|
|
440
|
+
destinationSlug,
|
|
438
441
|
);
|
|
439
442
|
if (await exists(destinationDirectory)) {
|
|
440
443
|
throw new Error(`Destination directory ${destinationDirectory} already exists.`);
|
|
@@ -536,6 +539,11 @@ export const createPageMovePlan = async ({
|
|
|
536
539
|
}
|
|
537
540
|
}
|
|
538
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
|
+
}
|
|
539
547
|
updatedSources.set(contentPath, updatedSource);
|
|
540
548
|
if (updatedSource !== page.document.fullSource) {
|
|
541
549
|
const fileStat = await stat(contentPath);
|
|
@@ -576,7 +584,14 @@ export const createPageMovePlan = async ({
|
|
|
576
584
|
nodes: virtualNodes,
|
|
577
585
|
warnings: siteStructure.warnings,
|
|
578
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
|
+
}) };
|
|
579
592
|
const virtualGraph = createSiteLinkGraph({
|
|
593
|
+
siteUrl: graph.siteUrl,
|
|
594
|
+
attachments: virtualAttachments,
|
|
580
595
|
pageDocuments: virtualPageDocuments,
|
|
581
596
|
publicFiles,
|
|
582
597
|
siteStructure: virtualStructure,
|
|
@@ -602,6 +617,8 @@ export const createPageMovePlan = async ({
|
|
|
602
617
|
|
|
603
618
|
return {
|
|
604
619
|
aliasChanges,
|
|
620
|
+
generatedRoutes,
|
|
621
|
+
orderChanges,
|
|
605
622
|
destinationCollectionDirectory,
|
|
606
623
|
destinationCollectionExists,
|
|
607
624
|
destinationDirectory,
|
|
@@ -636,6 +653,7 @@ export const formatPageMovePlan = (plan, displayPath) => {
|
|
|
636
653
|
for (const change of plan.linkChanges) {
|
|
637
654
|
lines.push(`- ${change.contentLabel} line ${change.line}: ${change.from} -> ${change.to}`);
|
|
638
655
|
}
|
|
656
|
+
if (plan.orderChanges?.length) lines.push('', describeRenumbering(plan.orderChanges, displayPath));
|
|
639
657
|
lines.push('', 'Aliases:');
|
|
640
658
|
if (!plan.preserveAliases) lines.push('- skipped by --no-aliases');
|
|
641
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
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// One ordering contract for source discovery, placement and presentation.
|
|
2
|
+
export const pageOrderSpacing = 1000;
|
|
3
|
+
export const maximumPageOrder = Number.MAX_SAFE_INTEGER;
|
|
4
|
+
|
|
5
|
+
export const parsePageOrder = (value, label = 'navigation.order') => {
|
|
6
|
+
if (value === undefined || value === null) return null;
|
|
7
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
8
|
+
throw new Error(`${label} must be a positive safe integer (1–${maximumPageOrder}).`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const comparePageOrder = (left, right) => {
|
|
14
|
+
const a = left.pageOrder ?? null, b = right.pageOrder ?? null;
|
|
15
|
+
return (a === null ? b === null ? 0 : 1 : b === null ? -1 : a - b)
|
|
16
|
+
|| (left.pageId < right.pageId ? -1 : left.pageId > right.pageId ? 1 : 0);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Siblings exclude the inserted page and are already in presentation order.
|
|
20
|
+
export const planPageOrder = (siblings, index) => {
|
|
21
|
+
if (!Number.isInteger(index) || index < 0 || index > siblings.length) throw new Error('Invalid page insertion position.');
|
|
22
|
+
const before = index === 0 ? 0 : siblings[index - 1].pageOrder;
|
|
23
|
+
const after = siblings[index]?.pageOrder ?? null;
|
|
24
|
+
let order = null;
|
|
25
|
+
if (before !== null && before !== undefined) {
|
|
26
|
+
if (after !== null && after - before > 1) order = before + Math.floor((after - before) / 2);
|
|
27
|
+
else if (after === null && before <= maximumPageOrder - pageOrderSpacing) order = before + pageOrderSpacing;
|
|
28
|
+
}
|
|
29
|
+
if (order !== null) return { order, orderChanges: [] };
|
|
30
|
+
if (siblings.length + 1 > Math.floor(maximumPageOrder / pageOrderSpacing)) throw new Error('Too many siblings to assign safe page order values.');
|
|
31
|
+
const orderChanges = siblings.flatMap((node, position) => {
|
|
32
|
+
const newOrder = (position + (position >= index ? 2 : 1)) * pageOrderSpacing;
|
|
33
|
+
return node.pageOrder === newOrder ? [] : [{ node, oldOrder: node.pageOrder, newOrder }];
|
|
34
|
+
});
|
|
35
|
+
return { order: (index + 1) * pageOrderSpacing, orderChanges };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const describeRenumbering = (changes, displayPath = value => value) => changes.length ? [
|
|
39
|
+
`This placement requires new order values for ${changes.length} existing sibling page(s). Their content.md files will be updated:`,
|
|
40
|
+
...changes.map(change => `- ${displayPath(change.contentPath ?? change.node.contentPath)}: ${change.oldOrder ?? 'unset'} → ${change.newOrder}`),
|
|
41
|
+
].join('\n') : '';
|
|
42
|
+
|
|
43
|
+
export const assertRenumberingAllowed = (changes, allowRenumber) => {
|
|
44
|
+
if (changes.length && allowRenumber !== true) throw new Error('Renumbering existing sibling pages requires confirmation. Review the plan; use --allow-renumber for unattended commands. No files changed.');
|
|
45
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { parsePageOrder, assertRenumberingAllowed, describeRenumbering } from './page-order.mjs';
|
|
3
|
+
|
|
4
|
+
export const parsePlacementArguments = (args) => {
|
|
5
|
+
const options = { positionals: [], order: null, parent: null, slug: null, placement: null, target: null, preserveAliases: true };
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const flags = { '--help': 'help', '-h': 'help', '--dry-run': 'dryRun', '--write': 'write', '--allow-renumber': 'allowRenumber', '--no-aliases': 'noAliases', '--first': 'first', '--last': 'last' };
|
|
8
|
+
const values = { '--parent': 'parent', '--slug': 'slug', '--order': 'order', '--before': 'before', '--after': 'after' };
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const [name, ...tail] = args[i].split('=');
|
|
11
|
+
const key = flags[name] ?? values[name];
|
|
12
|
+
if (!key) {
|
|
13
|
+
if (args[i].startsWith('-')) throw new Error(`Unknown option: ${args[i]}`);
|
|
14
|
+
options.positionals.push(args[i]); continue;
|
|
15
|
+
}
|
|
16
|
+
if (seen.has(key)) throw new Error(`${name} may be specified only once.`);
|
|
17
|
+
seen.add(key);
|
|
18
|
+
if (flags[name]) {
|
|
19
|
+
if (tail.length) throw new Error(`${name} does not take a value.`);
|
|
20
|
+
options[key] = true;
|
|
21
|
+
} else {
|
|
22
|
+
const value = tail.length ? tail.join('=') : args[++i];
|
|
23
|
+
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`);
|
|
24
|
+
options[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (options.order !== null) {
|
|
28
|
+
if (!/^\d+$/.test(options.order)) throw new Error('--order must be a positive integer.');
|
|
29
|
+
options.order = parsePageOrder(Number(options.order), '--order');
|
|
30
|
+
}
|
|
31
|
+
const positions = ['before', 'after', 'first', 'last'].filter(key => seen.has(key));
|
|
32
|
+
if (positions.length > 1) throw new Error('Choose only one of --before, --after, --first or --last.');
|
|
33
|
+
options.placement = positions[0] ?? (options.parent !== null ? 'last' : null);
|
|
34
|
+
if ((options.before || options.after) && options.parent !== null) throw new Error('--before/--after determine the parent; do not combine them with --parent.');
|
|
35
|
+
if (positions.length && options.order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
36
|
+
if (options.dryRun && options.write) throw new Error('Choose --dry-run or --write, not both.');
|
|
37
|
+
options.target = options.before ?? options.after ?? options.parent ?? '/';
|
|
38
|
+
options.preserveAliases = !options.noAliases;
|
|
39
|
+
return options;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const confirmCliRenumbering = async (changes, options) => {
|
|
43
|
+
if (!changes.length) return true;
|
|
44
|
+
if (options.allowRenumber) return true;
|
|
45
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) assertRenumberingAllowed(changes, false);
|
|
46
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
47
|
+
try { return /^(y|yes)$/i.test((await prompt.question('Apply these order changes? [y/N] ')).trim()); }
|
|
48
|
+
finally { prompt.close(); }
|
|
49
|
+
};
|
|
50
|
+
export { describeRenumbering };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -34,6 +35,7 @@ const readSiteConfig = async () => {
|
|
|
34
35
|
throw error;
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
await assertSiteSourceFormat(siteDir);
|
|
37
39
|
return parseYamlConfig(source, siteConfigLabel, {
|
|
38
40
|
validateStructure: validateConfigYamlStructure,
|
|
39
41
|
});
|
|
@@ -423,6 +425,7 @@ export const projectConfig = Object.freeze({
|
|
|
423
425
|
url: siteUrl.href,
|
|
424
426
|
}),
|
|
425
427
|
...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
|
|
428
|
+
verificationFiles: Object.freeze(rawConfig.verificationFiles ?? []),
|
|
426
429
|
editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
|
|
427
430
|
search: Object.freeze({
|
|
428
431
|
enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { logoAssetFilenames, browserIconAssetFilenames, socialImageAssetFilenames } from './public-asset-conventions.mjs';
|
|
2
|
+
|
|
3
|
+
export const resourcePrefix = '/_norna/';
|
|
4
|
+
export const attachmentOutputPrefix = `${resourcePrefix}downloads/`;
|
|
5
|
+
export const sharedOutputPrefix = `${resourcePrefix}shared/`;
|
|
6
|
+
export const searchOutputDirectory = '_norna/search';
|
|
7
|
+
export const compiledOutputDirectory = '_norna/assets';
|
|
8
|
+
const technicalFiles = new Set(['robots.txt', 'CNAME', 'llms.txt']);
|
|
9
|
+
const siteImages = new Set([...logoAssetFilenames, ...browserIconAssetFilenames, ...socialImageAssetFilenames]);
|
|
10
|
+
const key = value => value.normalize('NFC').toLowerCase();
|
|
11
|
+
const reservedRoots = new Set(['_norna', '_astro', 'images', 'sitemap.xml', '404.html', '.well-known']);
|
|
12
|
+
const safeSegment = value => Boolean(value) && value === value.trim() && !/[\\/\x00-\x1f\x7f<>:"|?*#%]/.test(value) && !/^\.{1,2}$/.test(value) && !/[. ]$/.test(value);
|
|
13
|
+
|
|
14
|
+
export const isVerificationFilename = value => typeof value === 'string' && safeSegment(value)
|
|
15
|
+
&& !value.startsWith('.') && !reservedRoots.has(key(value))
|
|
16
|
+
&& ![...technicalFiles, ...siteImages].some(name => key(name) === key(value));
|
|
17
|
+
|
|
18
|
+
export const attachmentPublicationPath = (pagePathname, filename) => {
|
|
19
|
+
// Double hyphens and ~ cannot occur inside a page slug. Keep the complete
|
|
20
|
+
// page identity in one segment so extensionless files cannot shadow children.
|
|
21
|
+
const pageKey = pagePathname === '/' ? '~home' : pagePathname.slice(1, -1).split('/').join('--');
|
|
22
|
+
return `${attachmentOutputPrefix}${pageKey}/${filename}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const publicFilePublication = (relativePath, { verificationFiles = [] } = {}, { directory = false } = {}) => {
|
|
26
|
+
const segments = relativePath.split('/');
|
|
27
|
+
if (!segments.every(safeSegment)) throw new Error(`public/${relativePath} has an unsafe path. Use regular names without URL punctuation or parent-directory segments.`);
|
|
28
|
+
if (directory) {
|
|
29
|
+
if (segments[0] === '.well-known') return { pathname: '/' + relativePath, kind: 'public-directory' };
|
|
30
|
+
throw new Error(`public/${relativePath}/ is not a supported public folder. Put content images in the owning page’s images/ beside content.md. Only .well-known/ is supported here, for technical files.`);
|
|
31
|
+
}
|
|
32
|
+
if (technicalFiles.has(relativePath) || segments[0] === '.well-known' && segments.length > 1 || verificationFiles.includes(relativePath) && isVerificationFilename(relativePath)) {
|
|
33
|
+
return { pathname: '/' + relativePath, kind: 'technical-file' };
|
|
34
|
+
}
|
|
35
|
+
if (siteImages.has(relativePath)) {
|
|
36
|
+
return { pathname: sharedOutputPrefix + relativePath, kind: 'shared-image' };
|
|
37
|
+
}
|
|
38
|
+
const generated = ['_norna', '_astro', 'sitemap.xml', '404.html'].includes(key(segments[0]));
|
|
39
|
+
throw new Error(`public/${relativePath} ${generated ? 'conflicts with a generated resource area' : 'is not a supported public file'}. Put content images in the owning page’s images/ beside content.md and documents in its downloads/. Public images must use a recognized logo, favicon, apple-touch-icon or social-image filename. Declare exact root verification filenames in verificationFiles.`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const publicationIssues = ({ pages = [], publicFiles = [], attachments = [], images = [], generatedRoutes = [] } = {}) => {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const outputs = [];
|
|
45
|
+
const add = (pathname, label, owner) => outputs.push({ pathname, label, owner });
|
|
46
|
+
for (const page of pages) {
|
|
47
|
+
const label = page.contentFile?.contentLabel ?? page.contentLabel ?? page.pathname;
|
|
48
|
+
for (const route of [page.pathname, ...(page.aliases ?? [])]) {
|
|
49
|
+
if (!route) continue;
|
|
50
|
+
if (['/_norna', '/_astro', '/.well-known'].some(prefix => key(route) === prefix || key(route).startsWith(prefix + '/'))) {
|
|
51
|
+
issues.push({ severity: 'error', code: 'reserved-publication-path', message: `${label} claims ${route}, which belongs to Norna or technical public files.`, fix: 'Choose a page or alias outside the reserved area.' });
|
|
52
|
+
}
|
|
53
|
+
add(route + 'index.html', route === page.pathname ? label : `${label} (alias ${route})`, 'page');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const file of publicFiles) add(file.pathname, file.label ?? file.filePath ?? file.pathname, 'public');
|
|
57
|
+
for (const file of attachments) add(file.pathname, file.filePath ?? file.pathname, 'attachment');
|
|
58
|
+
for (const file of images) add(file.pathname, file.label, 'image');
|
|
59
|
+
for (const route of generatedRoutes) add(route.pathname.endsWith('/') ? route.pathname + 'index.html' : route.pathname, route.label ?? route.pathname, 'generated');
|
|
60
|
+
// 404 is always generated, including sites without a search page.
|
|
61
|
+
if (!generatedRoutes.some(route => route.pathname === '/404.html')) add('/404.html', 'Norna generated 404 page', 'generated');
|
|
62
|
+
const seen = [];
|
|
63
|
+
for (const output of outputs) {
|
|
64
|
+
const target = key(output.pathname);
|
|
65
|
+
const conflict = seen.find(candidate => target === candidate.key || target.startsWith(candidate.key + '/') || candidate.key.startsWith(target + '/'));
|
|
66
|
+
if (conflict) issues.push({ severity: 'error', code: output.owner === 'attachment' || conflict.owner === 'attachment' ? 'attachment-output-collision' : 'publication-collision',
|
|
67
|
+
message: `${output.label} publishes ${output.pathname}, which conflicts with ${conflict.label} at ${conflict.pathname}.`,
|
|
68
|
+
fix: 'Rename or relocate the conflicting source. Each published path must have one owner.' });
|
|
69
|
+
seen.push({ ...output, key: target });
|
|
70
|
+
}
|
|
71
|
+
return issues;
|
|
72
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isVerificationFilename } from './publication-paths.mjs';
|
|
1
2
|
import { z } from 'astro/zod';
|
|
2
3
|
import { localEditorNames } from './edit-source-link.mjs';
|
|
3
4
|
import { imagePresentationNames } from './image-presentation.mjs';
|
|
@@ -189,6 +190,7 @@ const themeSections = z.object({
|
|
|
189
190
|
backgroundPattern: backgroundPattern.optional().describe('Section background pattern. Alternating and accented create full-width bands with sections or top navigation; tree navigation requires uniform.'),
|
|
190
191
|
}).strict().describe('Defaults for page section presentation.');
|
|
191
192
|
const pageNavigation = z.object({
|
|
193
|
+
order: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional().describe('Sibling presentation order. Positive integer; lower values appear first. Omit to follow ordered siblings, sorted by slug. Duplicate values warn and use slug to break ties.'),
|
|
192
194
|
listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
|
|
193
195
|
}).strict();
|
|
194
196
|
const pageAlias = z.string().regex(
|
|
@@ -225,8 +227,10 @@ const sitewideFooter = z.object({
|
|
|
225
227
|
}).strict().describe('Site-wide footer content.');
|
|
226
228
|
|
|
227
229
|
const configShape = {
|
|
230
|
+
sourceFormat: z.literal(2).describe('Source format 2: page directories use slugs and sibling order lives in navigation.order frontmatter. Convert older sites before setting this value.'),
|
|
228
231
|
url: z.string().url().describe('Absolute public URL for the built site.'),
|
|
229
232
|
language: z.string().regex(supportedLocaleTagPattern).optional().describe('Site language tag selecting one complete built-in Norna interface language; the default is en.'),
|
|
233
|
+
verificationFiles: z.array(z.string().refine(isVerificationFilename, 'Use one verification filename outside Norna’s reserved and recognized paths.')).optional().describe('Exact filenames in public/ that verify ownership with an external service. Published unchanged at the site root. .well-known/ files need no declaration.'),
|
|
230
234
|
editLink: editLink.optional(),
|
|
231
235
|
navigation: configNavigation.optional(),
|
|
232
236
|
search: z.boolean().optional().default(false).describe('Generate a static site search page and Pagefind index. The default is false.'),
|
|
@@ -150,15 +150,17 @@ const addSitewideLogoHelp = (jsonSchema) => {
|
|
|
150
150
|
|
|
151
151
|
const addConfigHelp = (jsonSchema) => {
|
|
152
152
|
jsonSchema.markdownDescription = [
|
|
153
|
-
yamlExample('
|
|
153
|
+
yamlExample('sourceFormat: 2\nurl: https://example.com/'),
|
|
154
154
|
'`site-config/settings.yaml` contains the few technical settings shared by the complete site.',
|
|
155
155
|
documentationLink('Configuration reference', 'configuration.md'),
|
|
156
156
|
].join('\n\n');
|
|
157
|
+
addFieldHelp(jsonSchema, 'sourceFormat', 'sourceFormat: 2', 'pages.md', 'page-source-layout');
|
|
157
158
|
addHelp(jsonSchema, 'url', [
|
|
158
159
|
yamlExample('url: https://example.com/'),
|
|
159
160
|
'The absolute public URL. Its path also determines the base path for generated links and assets.',
|
|
160
161
|
documentationLink('Public URL reference', 'configuration.md', 'url'),
|
|
161
162
|
], ['https://example.com/', 'https://owner.github.io/repository-name/']);
|
|
163
|
+
addFieldHelp(jsonSchema, 'verificationFiles', 'verificationFiles:\n - google-example.html', 'public-files.md', 'verification-files');
|
|
162
164
|
addHelp(jsonSchema, 'language', [
|
|
163
165
|
yamlExample('language: en-GB'),
|
|
164
166
|
'Sets the language of the complete site and selects the matching built-in Norna interface text. Regional tags are preserved. The default is `en`; editorial Markdown is not translated.',
|
|
@@ -563,6 +565,7 @@ const addContentHelp = (jsonSchema) => {
|
|
|
563
565
|
'Optionally excludes an additional page from site navigation without removing its public URL.',
|
|
564
566
|
documentationLink('Site navigation reference', 'pages.md', 'navigation'),
|
|
565
567
|
]);
|
|
568
|
+
addFieldHelp(jsonSchema, 'navigation.order', 'navigation:\n order: 1000', 'pages.md', 'navigation');
|
|
566
569
|
addFieldHelp(jsonSchema, 'navigation.listed', 'navigation:\n listed: false', 'pages.md', 'navigation', [true, false]);
|
|
567
570
|
};
|
|
568
571
|
|
|
@@ -150,7 +150,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
150
150
|
} else if (fileKind === 'content' && key === 'description') {
|
|
151
151
|
fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
|
|
152
152
|
} else if (fileKind === 'content' && key === 'images') {
|
|
153
|
-
fix = 'Put local image references in image-stack or image-carousel
|
|
153
|
+
fix = 'Put local image references in the Markdown body, using images/name.jpg or an image-stack or image-carousel block.';
|
|
154
154
|
} else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
|
|
155
155
|
fix = `Move "${key}:" to site-config/site-theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
156
156
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
|