@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
@@ -1,4 +1,6 @@
1
- import { readdir, readFile } from 'node:fs/promises';
1
+ import { publicFilePublication } from './publication-paths.mjs';
2
+ import { getSiteAttachments, resolveAttachment, parseLocalAttachmentTarget, attachmentOutputIssues } from './page-attachments.mjs';
3
+ import { readdir, readFile, lstat } from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
  import {
4
6
  createPageAliasModel,
@@ -11,6 +13,7 @@ import {
11
13
  toPosixPath,
12
14
  } from './site-content.mjs';
13
15
  import {
16
+ siteDir as defaultSiteDir,
14
17
  sitePagesLabel,
15
18
  sitePublicDir,
16
19
  sitePublicLabel,
@@ -18,11 +21,15 @@ import {
18
21
  import { getSiteNodePathname } from './site-page-urls.mjs';
19
22
  import { getSiteStructure } from './site-structure.mjs';
20
23
  import { createCategoryDestinationModel } from './category-destinations.mjs';
24
+ import { parseYamlConfig } from './yaml-config.mjs';
25
+ import { configSchema } from './schema-definitions.mjs';
21
26
 
22
27
  const internalUrlOrigin = 'https://norna.invalid';
23
28
  const externalSchemePattern = /^[a-z][a-z0-9+.-]*:/i;
24
29
 
25
- const readPublicFiles = async (directory, relativeDirectory = '') => {
30
+ const readPublicFiles = async (directory, settings = {}, relativeDirectory = '') => {
31
+ const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
32
+ if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error(`${directory} must be a regular public directory.`);
26
33
  const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
27
34
  if (error?.code === 'ENOENT') return [];
28
35
  throw error;
@@ -30,19 +37,22 @@ const readPublicFiles = async (directory, relativeDirectory = '') => {
30
37
  const files = [];
31
38
 
32
39
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
40
+ if (entry.name === '.DS_Store' || entry.name.startsWith('._')) continue;
33
41
  const relativePath = relativeDirectory
34
42
  ? path.join(relativeDirectory, entry.name)
35
43
  : entry.name;
36
44
  const entryPath = path.join(directory, entry.name);
37
45
  if (entry.isDirectory()) {
38
- files.push(...await readPublicFiles(entryPath, relativePath));
46
+ publicFilePublication(toPosixPath(relativePath), settings, { directory: true });
47
+ files.push(...await readPublicFiles(entryPath, settings, relativePath));
39
48
  } else if (entry.isFile()) {
40
49
  files.push({
41
50
  filePath: entryPath,
42
- label: `${sitePublicLabel}/${toPosixPath(relativePath)}`,
43
- pathname: `/${toPosixPath(relativePath)}`,
51
+ label: entryPath,
52
+ relativePath: toPosixPath(relativePath),
53
+ ...publicFilePublication(toPosixPath(relativePath), settings),
44
54
  });
45
- }
55
+ } else throw new Error(`${entryPath} must be a regular public file. Symbolic links are not supported.`);
46
56
  }
47
57
 
48
58
  return files;
@@ -68,32 +78,47 @@ const getPageLookupPathname = (pathname) => {
68
78
 
69
79
  const isExternalTarget = (target) => target.startsWith('//') || externalSchemePattern.test(target);
70
80
 
71
- export const resolveInternalTarget = (target, sourcePathname) => {
72
- if (isExternalTarget(target)) return { kind: 'external' };
81
+ export const resolveInternalTarget = (target, sourcePathname, siteUrl) => {
82
+ const absolute = isExternalTarget(target);
83
+ if (absolute && (!siteUrl || !/^(?:https?:)?\/\//i.test(target))) return { kind: 'external' };
73
84
 
74
85
  let url;
86
+ let siteBase;
75
87
  try {
76
- url = new URL(target, `${internalUrlOrigin}${sourcePathname}`);
88
+ siteBase = absolute ? new URL(siteUrl) : null;
89
+ url = new URL(target, siteBase ?? `${internalUrlOrigin}${sourcePathname}`);
77
90
  } catch {
91
+ if (absolute) return { kind: 'external' };
78
92
  return {
79
93
  kind: 'invalid',
80
94
  reason: 'The target is not a valid relative or site-relative URL.',
81
95
  };
82
96
  }
83
97
 
84
- if (url.origin !== internalUrlOrigin) return { kind: 'external' };
98
+ if (url.origin !== (siteBase?.origin ?? internalUrlOrigin) || url.username || url.password) return { kind: 'external' };
85
99
 
86
100
  try {
87
- const pathname = decodePathname(url.pathname);
101
+ let pathname = decodePathname(url.pathname);
102
+ let absolutePrefix;
103
+ if (siteBase) {
104
+ const encodedBase = siteBase.pathname.replace(/\/?$/, '/');
105
+ const base = decodePathname(encodedBase);
106
+ if (pathname === base.slice(0, -1)) pathname = '/';
107
+ else if (pathname.startsWith(base)) pathname = '/' + pathname.slice(base.length);
108
+ else return { kind: 'external' };
109
+ absolutePrefix = (target.startsWith('//') ? `//${url.host}` : url.origin) + encodedBase.slice(0, -1);
110
+ }
88
111
  const fragment = url.hash.length > 1 ? decodeFragment(url.hash.slice(1)) : '';
89
112
  return {
90
113
  kind: 'internal',
114
+ ...(absolutePrefix ? { absolutePrefix } : {}),
91
115
  fragment,
92
116
  pageLookupPathname: getPageLookupPathname(pathname),
93
117
  pathname,
94
118
  query: url.search,
95
119
  };
96
120
  } catch (error) {
121
+ if (absolute) return { kind: 'external' };
97
122
  return {
98
123
  kind: 'invalid',
99
124
  reason: error instanceof Error ? error.message : String(error),
@@ -146,9 +171,11 @@ const looksLikePublicFile = (pathname) => {
146
171
  };
147
172
 
148
173
  export const createSiteLinkGraph = ({
174
+ siteUrl,
149
175
  siteStructure,
150
176
  pageDocuments,
151
177
  publicFiles = [],
178
+ attachments = { files: [], diagnostics: [] },
152
179
  generatedRoutes,
153
180
  }) => {
154
181
  const activeGeneratedRoutes = generatedRoutes ?? generatedSiteRoutes;
@@ -193,15 +220,28 @@ export const createSiteLinkGraph = ({
193
220
 
194
221
  const references = [];
195
222
  const referencesByTarget = new Map();
196
- const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics];
223
+ const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics, ...attachments.diagnostics,
224
+ ...attachmentOutputIssues({ files: attachments.files, pages, publicFiles, generatedRoutes: activeGeneratedRoutes })];
197
225
 
198
226
  for (const page of pages) {
199
227
  for (const sourceReference of page.document.links) {
200
- const target = resolveInternalTarget(sourceReference.target, page.pathname);
228
+ const target = resolveInternalTarget(sourceReference.target, page.pathname, siteUrl);
201
229
  if (target.kind === 'external') continue;
230
+ const attachment = target.kind === 'internal'
231
+ ? resolveAttachment(sourceReference.target, page.pathname, attachments.files, target.pathname) : null;
232
+ // One origin/base path may also host independently built sites. Only
233
+ // adopt absolute URLs whose destination this source tree actually owns.
234
+ if (target.absolutePrefix && !attachment
235
+ && !pagesByPathname.has(target.pageLookupPathname)
236
+ && !aliasModel.aliasesByPathname.has(target.pageLookupPathname)
237
+ && !publicFilesByPathname.has(target.pathname)
238
+ && !categoriesByPathname.has(target.pageLookupPathname)
239
+ && !generatedRoutesByPathname.has(target.pathname)
240
+ && !generatedRoutesByPathname.has(target.pageLookupPathname)) continue;
202
241
 
203
242
  const reference = {
204
243
  ...sourceReference,
244
+ sourceTarget: sourceReference.target,
205
245
  sourceContentFile: page.contentFile,
206
246
  sourcePage: page,
207
247
  target,
@@ -222,6 +262,23 @@ export const createSiteLinkGraph = ({
222
262
  if (!referencesByTarget.has(targetKey)) referencesByTarget.set(targetKey, []);
223
263
  referencesByTarget.get(targetKey).push(reference);
224
264
 
265
+ const localAttachment = parseLocalAttachmentTarget(sourceReference.target);
266
+ if (attachment) {
267
+ const ambiguous = localAttachment?.prefix === '' && (publicFilesByPathname.has(target.pathname) || pagesByPathname.has(target.pageLookupPathname) || aliasModel.aliasesByPathname.has(target.pageLookupPathname));
268
+ if (ambiguous) {
269
+ reference.resolution = { kind: 'ambiguous-attachment' };
270
+ diagnostics.push(createIssue(reference, 'ambiguous-attachment', `Link "${sourceReference.target}" could identify a page/public file or a local attachment.`, `Use ${attachment.pathname} for the attachment or an explicit site-relative page/public link.`));
271
+ } else reference.resolution = { kind: 'attachment', file: attachment, pathname: attachment.pathname };
272
+ continue;
273
+ }
274
+
275
+ if (localAttachment?.prefix) {
276
+ const expectedPath = path.join(path.dirname(page.contentFile.contentPath), 'downloads', localAttachment.filename);
277
+ reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
278
+ diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" was not found at ${toPosixPath(expectedPath)}.`, 'Add the file to this page’s downloads/ folder or correct the link.'));
279
+ continue;
280
+ }
281
+
225
282
  const targetPage = target.pageLookupPathname
226
283
  ? pagesByPathname.get(target.pageLookupPathname)
227
284
  : null;
@@ -304,13 +361,19 @@ export const createSiteLinkGraph = ({
304
361
  continue;
305
362
  }
306
363
 
364
+ if (target.pathname.startsWith('/_norna/downloads/') && !target.pathname.endsWith('/')) {
365
+ reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
366
+ diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" does not exist.`, 'Add the file to its owning page’s downloads/ or correct the link.'));
367
+ continue;
368
+ }
369
+
307
370
  if (looksLikePublicFile(target.pathname)) {
308
371
  reference.resolution = { kind: 'missing-public-file', pathname: target.pathname };
309
372
  diagnostics.push(createIssue(
310
373
  reference,
311
374
  'missing-public-file',
312
- `Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/.`,
313
- `Add the file under ${sitePublicLabel}/ with the same relative path, or correct the link.`,
375
+ `Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/ or the owning page’s downloads/.`,
376
+ 'Use the published address of an allowed public image or technical file, or link to a page-owned attachment.',
314
377
  ));
315
378
  continue;
316
379
  }
@@ -327,6 +390,8 @@ export const createSiteLinkGraph = ({
327
390
  }
328
391
 
329
392
  return {
393
+ siteUrl,
394
+ attachments,
330
395
  categoryModel,
331
396
  aliasModel,
332
397
  aliases: aliasModel.aliases,
@@ -342,7 +407,10 @@ export const createSiteLinkGraph = ({
342
407
  };
343
408
 
344
409
  export const getSiteLinkGraph = async (options = {}) => {
345
- const siteStructure = options.siteStructure ?? await getSiteStructure();
410
+ const siteRoot = options.siteRoot ?? defaultSiteDir;
411
+ const siteStructure = options.siteStructure ?? await getSiteStructure({ siteRoot });
412
+ const settingsPath = path.join(siteRoot, 'site-config/settings.yaml');
413
+ const settings = parseYamlConfig(await readFile(settingsPath, 'utf8'), settingsPath, { schema: configSchema });
346
414
  const [pageDocuments, publicFiles] = await Promise.all([
347
415
  Promise.all(siteStructure.contentFiles.map(async (contentFile) => {
348
416
  const source = await readFile(contentFile.contentPath, 'utf8');
@@ -353,15 +421,17 @@ export const getSiteLinkGraph = async (options = {}) => {
353
421
  document: await parsePageMarkdownSource(source, { label: contentFile.contentLabel }),
354
422
  };
355
423
  })),
356
- readPublicFiles(options.publicDir ?? sitePublicDir),
424
+ readPublicFiles(options.publicDir ?? path.join(siteRoot, 'public'), settings),
357
425
  ]);
358
426
 
359
427
  return createSiteLinkGraph({
428
+ siteUrl: settings.url,
360
429
  pageDocuments,
361
430
  publicFiles,
362
431
  siteStructure,
432
+ attachments: await getSiteAttachments(siteStructure),
363
433
  generatedRoutes: options.generatedRoutes,
364
434
  });
365
435
  };
366
436
 
367
- export const getSitePublicFiles = async (publicDir = sitePublicDir) => readPublicFiles(publicDir);
437
+ export const getSitePublicFiles = async (publicDir = sitePublicDir, settings = {}) => readPublicFiles(publicDir, settings);
@@ -30,7 +30,6 @@ export const getListedSiteNavigationTree = (entries) => {
30
30
  const filterListed = (nodes) => nodes.flatMap((node) => {
31
31
  if (!node.node.isHome && !node.node.navigation.listed) return [];
32
32
  const children = filterListed(node.children);
33
- if (node.node.kind === 'category' && children.length === 0) return [];
34
33
  return [{ ...node, children }];
35
34
  });
36
35
 
@@ -48,9 +47,7 @@ export const getSequentialPageNavigation = (nodes, currentPagePath) => {
48
47
  ));
49
48
  if (!activeRoot) return { previous: null, next: null };
50
49
 
51
- const pages = flattenSiteNavigationTree([activeRoot])
52
- .filter(({ node }) => node.kind === 'page')
53
- .map(({ node }) => node);
50
+ const pages = flattenSiteNavigationTree([activeRoot]).map(({ node }) => node);
54
51
  const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
55
52
  if (currentIndex < 0) return { previous: null, next: null };
56
53
 
@@ -68,7 +65,5 @@ export const getDirectChildPages = (nodes, currentPagePath) => {
68
65
  ? nodes.filter(({ node }) => !node.isHome)
69
66
  : current.children;
70
67
 
71
- return children
72
- .filter(({ node }) => node.kind === 'page')
73
- .map(({ node }) => node);
68
+ return children.map(({ node }) => node);
74
69
  };
@@ -0,0 +1,182 @@
1
+ import { comparePageOrder, parsePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
2
+ import { pageOrderFileChanges } from './page-order-source.mjs';
3
+ import { writeFileAtomically } from './page-move-apply.mjs';
4
+ import { getSiteSourcePaths } from './site-conventions.mjs';
5
+ import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { dump as dumpYaml } from 'js-yaml';
8
+ import { slugifyAsciiIdentifier } from './heading-ids.mjs';
9
+ import { pageDirectoryPattern } from './page-model.mjs';
10
+ import { getSiteStructure } from './site-structure.mjs';
11
+ import { siteSchema } from './schema-definitions.mjs';
12
+ import { readEditorLinkState } from './editor-site-links.mjs';
13
+ import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
14
+ import { parseYamlConfig } from './yaml-config.mjs';
15
+ import { publicationIssues } from './publication-paths.mjs';
16
+
17
+ const normalizeParentPath = (value) => {
18
+ if (value === '/') return '';
19
+ if (value.includes('\\') || value.includes('?') || value.includes('#')) {
20
+ throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
21
+ }
22
+ const normalized = value.replace(/^\/+|\/+$/g, '');
23
+ if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
24
+ throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
25
+ }
26
+ return normalized;
27
+ };
28
+
29
+ export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
30
+
31
+ const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
32
+ if (error?.code === 'ENOENT') return path.resolve(directory);
33
+ throw error;
34
+ });
35
+
36
+ const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) => {
37
+ const sitePagesDir = getSiteSourcePaths(siteRoot).pages;
38
+ if (parent !== null) {
39
+ const parentPath = normalizeParentPath(parent);
40
+ if (!parentPath) {
41
+ return { collectionDir: sitePagesDir, node: null, pagePath: '' };
42
+ }
43
+ const node = nodes.find((candidate) => candidate.pagePath === parentPath);
44
+ if (!node) {
45
+ throw new Error(`Cannot find parent "${parent}". Use an existing page path, or / for the top level.`);
46
+ }
47
+ return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
48
+ }
49
+
50
+ const currentDirectory = await canonicalDirectory(invocationDirectory);
51
+ if (currentDirectory === await canonicalDirectory(sitePagesDir) || currentDirectory === await canonicalDirectory(siteRoot)) {
52
+ return { collectionDir: sitePagesDir, node: null, pagePath: '' };
53
+ }
54
+ let node = null;
55
+ for (const candidate of nodes) {
56
+ if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
57
+ node = candidate;
58
+ break;
59
+ }
60
+ }
61
+ if (!node) {
62
+ throw new Error([
63
+ 'Cannot infer where to add the node from the current directory.',
64
+ `Run the command from ${sitePagesDir} for a top-level node, from an existing page directory for a child, or pass --parent.`,
65
+ ].join('\n'));
66
+ }
67
+ return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
68
+ };
69
+
70
+ // Planning never writes. Callers must present this same destination before applying.
71
+ export const planSiteNodeCreation = async ({
72
+ siteRoot, kind, title, slug = null, order = null, parentPath = '/',
73
+ invocationDirectory = siteRoot, metadata, sources = new Map(), placement = null, targetPath = null,
74
+ }) => {
75
+ const request = { siteRoot, kind, title, slug, order, parentPath, invocationDirectory, metadata, placement, targetPath };
76
+ if (placement !== null && !['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first or last.');
77
+ if (placement !== null && order !== null) throw new Error('Do not combine --order with a placement selector.');
78
+ if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
79
+ siteRoot = path.resolve(siteRoot);
80
+ title = String(title ?? '').trim();
81
+ if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
82
+ const generatedSlug = slug === null;
83
+ slug ??= slugifyAsciiIdentifier(title);
84
+ if (!slug || !pageDirectoryPattern.test(slug)) {
85
+ throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
86
+ }
87
+
88
+ const structure = await getSiteStructure({ siteRoot });
89
+ let relativeTarget = null;
90
+ if (placement === 'before' || placement === 'after') {
91
+ relativeTarget = structure.nodes.find(node => node.contentPath === targetPath || `/${node.pagePath}/` === targetPath);
92
+ if (!relativeTarget || relativeTarget.isHome) throw new Error('Choose an existing sibling page for before/after placement.');
93
+ parentPath = relativeTarget.parentPagePath ? `/${relativeTarget.parentPagePath}/` : '/';
94
+ }
95
+ const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
96
+ const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null)).sort(comparePageOrder);
97
+ if (siblings.some((node) => node.pageId === slug)) {
98
+ throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
99
+ }
100
+ const dirty = [...sources.keys()].filter(filename => filename === path.join(siteRoot, 'site-config/settings.yaml') || siblings.some(node => node.contentPath === filename));
101
+ if (dirty.length) throw new Error(`Save or undo these unsaved files before choosing page placement:\n${dirty.join('\n')}`);
102
+ const index = placement === 'first' ? 0 : relativeTarget ? siblings.indexOf(relativeTarget) + (placement === 'after' ? 1 : 0) : siblings.length;
103
+ const insertion = order === null ? planPageOrder(siblings, index) : { order: parsePageOrder(Number(order), '--order'), orderChanges: [] };
104
+ order = insertion.order;
105
+ const orderChanges = await pageOrderFileChanges(insertion.orderChanges);
106
+ const directoryName = slug;
107
+ const destination = path.join(parent.collectionDir, directoryName);
108
+ const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
109
+ const state = await readEditorLinkState({ siteRoot, sources });
110
+ if (state.incomplete.length) throw new Error(`Page addresses could not be checked. ${state.incomplete.join('\n')}`);
111
+ const address = `/${pagePath}/`;
112
+ const owner = state.graph.aliasModel.identitiesByPathname.get(address);
113
+ if (owner?.kind === 'page-alias') {
114
+ const page = owner.source.page;
115
+ throw new Error(`${address} is a previous address for “${page.title}”, now at ${owner.source.targetPathname}. Choose another URL segment, or remove this previous address from “${page.title}” through Addresses and links… → Additional addresses….`);
116
+ }
117
+ if (owner) throw new Error(`Address ${address} is already in use by ${owner.label}. Choose another URL segment.`);
118
+ if (metadata !== undefined) {
119
+ metadata = parseYamlConfig(dumpYaml(metadata), 'Page information', { schema: siteSchema });
120
+ assertPageAliasModel(createPageAliasModel({ pages: [...state.graph.pages,
121
+ { pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
122
+ categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
123
+ }
124
+ const publicationErrors = publicationIssues({ pages: [...state.graph.pages,
125
+ { pathname: address, contentLabel: destination, aliases: metadata?.page?.aliases ?? [] }],
126
+ publicFiles: state.publicFiles, attachments: state.graph.attachments.files, generatedRoutes: state.generatedRoutes });
127
+ if (publicationErrors.length) throw new Error(publicationErrors.map(({ message }) => message).join('\n'));
128
+ metadata = { ...metadata, navigation: { ...metadata?.navigation, order } };
129
+ const fingerprint = JSON.stringify([destination, title, metadata, order, orderChanges, state.settings,
130
+ state.graph.pages.map(page => [page.contentFile.contentPath, page.document.fullSource]), state.publicFiles.map(file => file.filePath)]);
131
+ return { request, fingerprint, orderChanges, siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
132
+ collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
133
+ };
134
+
135
+ export const createSiteNode = async (plan, { sources = new Map(), allowRenumber = false, writeSource = writeFileAtomically } = {}) => {
136
+ // Revalidate after the author has inspected the preview. Never change its placement.
137
+ const current = await planSiteNodeCreation({ ...(plan.request ?? plan), sources });
138
+ if (current.destination !== plan.destination || current.fingerprint !== plan.fingerprint) throw new Error('The site changed. Preview the new location before creating the node.');
139
+ assertRenumberingAllowed(current.orderChanges, allowRenumber);
140
+ const written = [];
141
+ let createdCollection = false;
142
+ try {
143
+ const info = await stat(current.collectionDir);
144
+ if (!info.isDirectory()) throw new Error(`${current.collectionDir} exists but is not a directory.`);
145
+ } catch (error) {
146
+ if (error.code !== 'ENOENT') throw error;
147
+ await mkdir(current.collectionDir);
148
+ createdCollection = true;
149
+ }
150
+ const created = [];
151
+ try {
152
+ // Exclusive reservation also protects an empty destination created after planning.
153
+ await mkdir(current.destination);
154
+ created.push({ file: current.destination, directory: true });
155
+ const filename = path.join(current.destination, 'content.md');
156
+ const source = `${current.metadata && Object.keys(current.metadata).length ? `---\n${dumpYaml(current.metadata, { lineWidth: -1, noRefs: true })}---\n\n` : ''}# ${escapeMarkdownHeading(current.title)}\n\n## Introduction\n\nStart writing here.\n`;
157
+ await writeFile(filename, source, { flag: 'wx' });
158
+ created.push({ file: filename });
159
+ const childDirectory = path.join(current.destination, 'images');
160
+ await mkdir(childDirectory);
161
+ created.push({ file: childDirectory, directory: true });
162
+ for (const change of current.orderChanges) {
163
+ await writeSource(change.originalPath, change.updatedSource, change.mode);
164
+ written.push(change);
165
+ }
166
+ return { ...current, sourcePath: filename, renumberedPages: current.orderChanges.length };
167
+ } catch (error) {
168
+ const restoreErrors = [];
169
+ for (const change of written.reverse()) {
170
+ try { await writeFileAtomically(change.originalPath, change.originalSource, change.mode); }
171
+ catch (failure) { restoreErrors.push(`${change.originalPath}: ${failure.message}`); }
172
+ }
173
+ // Remove only entries this operation created; never recursively remove an
174
+ // unexpected file written concurrently by another process.
175
+ for (const entry of created.reverse()) {
176
+ await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
177
+ }
178
+ if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
179
+ if (restoreErrors.length) throw new Error(`${error.message}\nRestoration was incomplete:\n${restoreErrors.join('\n')}`);
180
+ throw error;
181
+ }
182
+ };
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { homePageDirectory } from './site-conventions.mjs';
4
+ import { getSiteSourcePaths, homePageDirectory, legacyHomePageDirectory } from './site-conventions.mjs';
5
5
 
6
6
  const siteDirectoryEnvName = 'NORNA_SITE_DIR';
7
7
  const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
@@ -32,9 +32,10 @@ if (hasStateDirectoryEnv && !configuredStateDirectory) {
32
32
 
33
33
  const hasSiteFilesInDirectory = (siteDir) => {
34
34
  return (
35
- existsSync(path.join(siteDir, 'config.yaml'))
35
+ (existsSync(path.join(siteDir, 'site-config', 'settings.yaml')) || existsSync(path.join(siteDir, 'config.yaml')))
36
36
  && (
37
- existsSync(path.join(siteDir, 'pages', homePageDirectory, 'content.md'))
37
+ existsSync(path.join(siteDir, 'pages', legacyHomePageDirectory, 'content.md'))
38
+ || existsSync(getSiteSourcePaths(siteDir).content)
38
39
  || existsSync(path.join(siteDir, 'content.md'))
39
40
  )
40
41
  );
@@ -63,6 +64,16 @@ const findSiteProjectRoot = (startDirectory, siteDirectory) => {
63
64
  }
64
65
  };
65
66
 
67
+ const findContainingSite = (startDirectory) => {
68
+ let current = path.resolve(startDirectory);
69
+ while (true) {
70
+ if (hasSiteFilesInDirectory(current)) return current;
71
+ const parent = path.dirname(current);
72
+ if (parent === current) return null;
73
+ current = parent;
74
+ }
75
+ };
76
+
66
77
  const hasSiteDirectoryEnv = Object.hasOwn(process.env, siteDirectoryEnvName);
67
78
  const configuredSiteDirectory = normalizeSiteDirectory(process.env[siteDirectoryEnvName]);
68
79
  const hasConfiguredSiteDirectory = hasSiteDirectoryEnv && configuredSiteDirectory !== '';
@@ -84,8 +95,9 @@ const resolveSitePaths = () => {
84
95
  };
85
96
  }
86
97
 
87
- if (!hasConfiguredSiteDirectory && hasSiteFilesInDirectory(invocationRoot)) {
88
- const siteDir = invocationRoot;
98
+ const containingSite = !hasConfiguredSiteDirectory && findContainingSite(invocationRoot);
99
+ if (containingSite) {
100
+ const siteDir = containingSite;
89
101
  const siteProjectRoot = path.dirname(siteDir);
90
102
 
91
103
  return {
@@ -126,11 +138,11 @@ const getPathLabel = (filePath) => {
126
138
  };
127
139
 
128
140
  export const siteDir = resolvedSitePaths.siteDir;
129
- export const siteConfigPath = path.join(siteDir, 'config.yaml');
130
- export const siteThemePath = path.join(siteDir, 'theme.yaml');
131
- export const sitewideContentPath = path.join(siteDir, 'sitewide-content.yaml');
132
- export const sitePagesDir = path.join(siteDir, 'pages');
133
- export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
141
+ export const siteConfigPath = path.join(siteDir, 'site-config', 'settings.yaml');
142
+ export const siteThemePath = getSiteSourcePaths(siteDir).theme;
143
+ export const sitewideContentPath = path.join(siteDir, 'site-config', 'shared-content.yaml');
144
+ export const sitePagesDir = getSiteSourcePaths(siteDir).pages;
145
+ export const siteHomePageDir = getSiteSourcePaths(siteDir).root;
134
146
  export const siteContentPath = path.join(siteHomePageDir, 'content.md');
135
147
  export const siteImagesDir = path.join(siteHomePageDir, 'images');
136
148
  export const sitePublicDir = path.join(siteDir, 'public');
@@ -143,8 +155,8 @@ export const astroDistDir = configuredStateDirectory
143
155
  : path.join(siteProjectRoot, 'dist');
144
156
  export const astroRootDir = siteStateDir;
145
157
  export const astroCacheDir = path.join(astroRootDir, '.astro');
146
- export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
147
- export const originalImagesDir = path.join(astroPublicDir, 'images', 'original');
158
+ export const generatedImagesDir = path.join(astroPublicDir, '_norna', 'images', 'generated');
159
+ export const originalImagesDir = path.join(astroPublicDir, '_norna', 'images', 'original');
148
160
  export const generatedImagesManifestPath = path.join(siteStateDir, 'generated-images.json');
149
161
 
150
162
  export const engineRootLabel = getPathLabel(engineRoot);
@@ -0,0 +1,12 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { load } from 'js-yaml';
4
+
5
+ export const assertSiteSourceFormat = async (siteRoot, readSource = filename => readFile(filename, 'utf8')) => {
6
+ const filename = path.join(siteRoot, 'site-config/settings.yaml');
7
+ const source = await readSource(filename);
8
+ let settings;
9
+ try { settings = load(source); }
10
+ catch (error) { throw new Error(`${filename} contains invalid YAML: ${error.reason ?? error.message}`); }
11
+ if (settings?.sourceFormat !== 2) throw new Error(`${filename} needs sourceFormat: 2 after converting the site: move numeric page-directory prefixes into navigation.order frontmatter and remove those prefixes. Keep this site on its previous engine until converted. No files changed.`);
12
+ };