@janga/norna 0.7.27 → 0.7.29

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