@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.
Files changed (80) hide show
  1. package/README.md +8 -6
  2. package/astro.config.mjs +14 -2
  3. package/package.json +13 -9
  4. package/schemas/config.schema.json +80 -65
  5. package/schemas/content-frontmatter.schema.json +15 -8
  6. package/schemas/manifest.json +1 -1
  7. package/schemas/page-theme.schema.json +237 -237
  8. package/schemas/root-theme.schema.json +237 -237
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +237 -237
  11. package/scripts/add-site-node.mjs +29 -100
  12. package/scripts/dev-local.mjs +34 -7
  13. package/scripts/generate-images.mjs +97 -127
  14. package/scripts/generate-search-index.mjs +3 -2
  15. package/scripts/lib/attachment-render.mjs +23 -0
  16. package/scripts/lib/dev-server-identity.mjs +30 -0
  17. package/scripts/lib/documentation-routes.json +63 -62
  18. package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
  19. package/scripts/lib/editor-attachments.mjs +69 -0
  20. package/scripts/lib/editor-file-policy.mjs +2 -0
  21. package/scripts/lib/editor-language-service.mjs +21 -18
  22. package/scripts/lib/editor-page-addresses.mjs +5 -3
  23. package/scripts/lib/editor-page-placement.mjs +28 -98
  24. package/scripts/lib/editor-resource-actions.mjs +216 -0
  25. package/scripts/lib/editor-site-editing-tree.mjs +44 -9
  26. package/scripts/lib/editor-site-files.mjs +3 -2
  27. package/scripts/lib/editor-site-links.mjs +6 -4
  28. package/scripts/lib/editor-site-preview.mjs +149 -0
  29. package/scripts/lib/editor-site-tree.mjs +10 -3
  30. package/scripts/lib/editor-source-files.mjs +7 -1
  31. package/scripts/lib/image-dimensions.mjs +3 -72
  32. package/scripts/lib/logo-assets.mjs +2 -1
  33. package/scripts/lib/markdown-images.mjs +59 -0
  34. package/scripts/lib/markdown-links.mjs +29 -9
  35. package/scripts/lib/norna-markdown-blocks.mjs +0 -57
  36. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  37. package/scripts/lib/page-aliases.mjs +5 -0
  38. package/scripts/lib/page-attachments.mjs +59 -0
  39. package/scripts/lib/page-markdown.mjs +11 -7
  40. package/scripts/lib/page-model.mjs +8 -22
  41. package/scripts/lib/page-move-apply.mjs +6 -4
  42. package/scripts/lib/page-move-plan.mjs +37 -19
  43. package/scripts/lib/page-order-source.mjs +22 -0
  44. package/scripts/lib/page-order.mjs +45 -0
  45. package/scripts/lib/page-placement-cli.mjs +50 -0
  46. package/scripts/lib/project-config.mjs +3 -0
  47. package/scripts/lib/publication-paths.mjs +72 -0
  48. package/scripts/lib/schema-definitions.mjs +4 -0
  49. package/scripts/lib/schema-editor-metadata.mjs +4 -1
  50. package/scripts/lib/site-content.mjs +1 -1
  51. package/scripts/lib/site-link-graph.mjs +88 -18
  52. package/scripts/lib/site-node-create.mjs +47 -34
  53. package/scripts/lib/site-paths.mjs +2 -2
  54. package/scripts/lib/site-source-format.mjs +12 -0
  55. package/scripts/lib/site-structure.mjs +14 -13
  56. package/scripts/lib/site-upgrade.mjs +2 -0
  57. package/scripts/lib/social-image-assets.mjs +2 -1
  58. package/scripts/lib/source-files.mjs +1 -1
  59. package/scripts/lib/structured-blocks.mjs +3 -1
  60. package/scripts/move-site-page.mjs +46 -133
  61. package/scripts/sync-content-sections.mjs +8 -10
  62. package/scripts/sync-site-public.mjs +44 -66
  63. package/src/components/ContentBlocks.astro +2 -1
  64. package/src/components/ImageStack.astro +7 -5
  65. package/src/components/SearchPage.astro +2 -1
  66. package/src/components/SitePage.astro +1 -0
  67. package/src/components/SiteSection.astro +1 -0
  68. package/src/content.config.ts +1 -4
  69. package/src/lib/imagePresentation.ts +3 -0
  70. package/src/lib/markdownImages.ts +101 -0
  71. package/src/lib/sectionContent.ts +64 -19
  72. package/src/lib/sitePages.ts +4 -30
  73. package/src/lib/sitePublicAssets.ts +2 -1
  74. package/src/pages/[...slug].astro +2 -1
  75. package/src/styles/area-navigation.css +14 -5
  76. package/src/styles/content.css +6 -0
  77. package/src/styles/media.css +24 -0
  78. package/starters/basic/.github/workflows/deploy.yml +3 -6
  79. package/starters/basic/README.md +2 -0
  80. package/starters/basic/site/site-config/settings.yaml +3 -0
@@ -1,3 +1,4 @@
1
+ import { extractMarkdownImageReferences, markdownImageDiagnostic } from './markdown-images.mjs';
1
2
  import { getCodeFenceMetadataDiagnostics } from './code-fence-metadata.mjs';
2
3
  import { parseContentTabs } from './content-tabs.mjs';
3
4
  import { getDetailsHeadingDiagnostics } from './details-headings.mjs';
@@ -11,7 +12,6 @@ import {
11
12
  } from './markdown-links.mjs';
12
13
  import {
13
14
  extractInlineNoteDiagnostics,
14
- extractMarkdownImageReferences,
15
15
  extractNornaMarkdownBlockDiagnostics,
16
16
  getNornaBlockImageReferences,
17
17
  nornaBlockTypes,
@@ -191,6 +191,7 @@ const createRegion = ({
191
191
  tableErrors,
192
192
  tabResult,
193
193
  pageNotes,
194
+ imageReferences,
194
195
  }) => {
195
196
  const endOffset = nextHeading?.index ?? source.length;
196
197
  const markdown = source.slice(heading.index, endOffset).trimEnd();
@@ -201,10 +202,7 @@ const createRegion = ({
201
202
  });
202
203
  const inRegion = (offset) => offset >= heading.index && offset < endOffset;
203
204
  const fieldNoteErrors = getStructuredSidenoteDiagnostics(blockResult.blocks, { label, regionOffset: heading.index });
204
- const markdownImages = extractMarkdownImageReferences(markdown).map((reference) => ({
205
- ...reference,
206
- line: regionLineOffset + reference.line,
207
- }));
205
+ const markdownImages = imageReferences.filter((reference) => inRegion(reference.range.start));
208
206
  const regionHeadings = headings.filter((candidate) => (
209
207
  candidate.index >= heading.index
210
208
  && candidate.index < endOffset
@@ -226,7 +224,7 @@ const createRegion = ({
226
224
  kind: heading.depth === 1 ? 'page-intro' : 'section',
227
225
  bodyLine: heading.line,
228
226
  line: lineOffset + heading.line,
229
- managedImages: getNornaBlockImageReferences(blockResult.blocks),
227
+ managedImages: [...getNornaBlockImageReferences(blockResult.blocks), ...markdownImages.filter((reference) => reference.kind === 'managed')],
230
228
  markdown,
231
229
  markdownImages,
232
230
  notes: pageNotes.notes.filter((note) => inRegion(note.referenceOffset)),
@@ -246,6 +244,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
246
244
  const tabResult = parseContentTabs(source, { label, lineOffset });
247
245
  const pageNotes = extractInlineNoteDiagnostics(source, { label, lineOffset, tabResult });
248
246
  const { headings, tree } = await getMarkdownHeadings(tabResult.maskedSource);
247
+ const imageReferences = extractMarkdownImageReferences({ source, tree, lineOffset });
249
248
  const detailsDiagnostics = getDetailsHeadingDiagnostics(tree, { source: tabResult.maskedSource, label, lineOffset });
250
249
  const calloutErrors = getSemanticCalloutDiagnostics(tree, { label, lineOffset });
251
250
  const codeFenceErrors = getCodeFenceMetadataDiagnostics(tree, {
@@ -271,6 +270,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
271
270
  pageNotes,
272
271
  tableErrors,
273
272
  tabResult,
273
+ imageReferences,
274
274
  }));
275
275
  const headingIssues = getHeadingIdentifierIssues(headings);
276
276
  const headingDiagnostics = getHeadingDiagnostics(headings, lineOffset);
@@ -346,6 +346,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
346
346
  return {
347
347
  blocks,
348
348
  diagnostics: [
349
+ ...imageReferences.filter((reference) => reference.kind === 'invalid').map((reference) => markdownImageDiagnostic(reference, label)),
349
350
  ...detailsDiagnostics,
350
351
  ...structureDiagnostics,
351
352
  ...headingDiagnostics,
@@ -363,7 +364,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
363
364
  lineOffset,
364
365
  links,
365
366
  managedImages: regions.flatMap((region) => region.managedImages),
366
- markdownImages: regions.flatMap((region) => region.markdownImages),
367
+ markdownImages: imageReferences,
367
368
  navigationHeadings,
368
369
  notes: regions.flatMap((region) => region.notes),
369
370
  noteDiagnostics,
@@ -395,8 +396,11 @@ export const parsePageMarkdownSource = async (source, options = {}) => {
395
396
  };
396
397
  };
397
398
 
399
+ const mapImage = (reference) => reference.blockType === 'markdown-image' ? { ...reference, range: toOriginalRange(reference.range), targetRange: toOriginalRange(reference.targetRange) } : reference;
398
400
  return {
399
401
  ...model,
402
+ markdownImages: model.markdownImages.map(mapImage),
403
+ managedImages: model.managedImages.map(mapImage),
400
404
  bodyOffset: normalization.originalOffsets[split.bodyOffset] ?? split.bodyOffset,
401
405
  frontmatter: split.frontmatter,
402
406
  frontmatterUnclosed: split.frontmatterUnclosed,
@@ -1,26 +1,14 @@
1
1
  import { homePageDirectory } from './site-conventions.mjs';
2
2
 
3
- export const pageDirectoryPattern = /^(\d{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
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
- const match = pageDirectory.match(pageDirectoryPattern);
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, pageOrders: [], pagePath: '',
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 NNN-page-id/pages/NNN-child-id.`,
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) : 'root';
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 === 'root') return homePageDirectory;
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 writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
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 resolvedAfterMove = resolveInternalTarget(reference.targetSource, sourcePathname);
315
- const stillResolves = resolvedAfterMove.kind === 'internal'
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
- destinationOrder = order ?? (sameParent ? sourceNode.pageOrder : getNextOrder(destinationSiblings));
427
- if (destinationSiblings.some((node) => node.pageOrder === destinationOrder)) {
428
- throw new Error(`Destination order ${String(destinationOrder).padStart(3, '0')} is already used below ${destinationParentPath ? `/${destinationParentPath}/` : '/'}.`);
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
- `${String(destinationOrder).padStart(3, '0')}-${destinationSlug}`,
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('url: https://example.com/'),
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 blocks in the Markdown body.';
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') {