@janga/norna 0.7.28 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +8 -5
  2. package/astro.config.mjs +14 -2
  3. package/package.json +10 -8
  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 +83 -65
  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 +1 -1
  31. package/scripts/lib/logo-assets.mjs +2 -1
  32. package/scripts/lib/markdown-images.mjs +59 -0
  33. package/scripts/lib/markdown-links.mjs +29 -9
  34. package/scripts/lib/norna-markdown-blocks.mjs +0 -57
  35. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  36. package/scripts/lib/page-aliases.mjs +5 -0
  37. package/scripts/lib/page-attachments.mjs +59 -0
  38. package/scripts/lib/page-markdown.mjs +11 -7
  39. package/scripts/lib/page-model.mjs +8 -22
  40. package/scripts/lib/page-move-apply.mjs +6 -4
  41. package/scripts/lib/page-move-plan.mjs +37 -19
  42. package/scripts/lib/page-order-source.mjs +22 -0
  43. package/scripts/lib/page-order.mjs +45 -0
  44. package/scripts/lib/page-placement-cli.mjs +50 -0
  45. package/scripts/lib/project-config.mjs +3 -0
  46. package/scripts/lib/publication-paths.mjs +72 -0
  47. package/scripts/lib/schema-definitions.mjs +4 -0
  48. package/scripts/lib/schema-editor-metadata.mjs +4 -1
  49. package/scripts/lib/site-content.mjs +1 -1
  50. package/scripts/lib/site-link-graph.mjs +88 -18
  51. package/scripts/lib/site-node-create.mjs +47 -34
  52. package/scripts/lib/site-paths.mjs +2 -2
  53. package/scripts/lib/site-source-format.mjs +12 -0
  54. package/scripts/lib/site-structure.mjs +14 -13
  55. package/scripts/lib/site-upgrade.mjs +2 -0
  56. package/scripts/lib/social-image-assets.mjs +2 -1
  57. package/scripts/lib/source-files.mjs +1 -1
  58. package/scripts/lib/structured-blocks.mjs +3 -1
  59. package/scripts/move-site-page.mjs +46 -133
  60. package/scripts/sync-content-sections.mjs +8 -10
  61. package/scripts/sync-site-public.mjs +44 -66
  62. package/src/components/ContentBlocks.astro +2 -1
  63. package/src/components/ImageStack.astro +7 -5
  64. package/src/components/SearchPage.astro +2 -1
  65. package/src/components/SitePage.astro +1 -0
  66. package/src/components/SiteSection.astro +1 -0
  67. package/src/content.config.ts +1 -4
  68. package/src/lib/imagePresentation.ts +3 -0
  69. package/src/lib/markdownImages.ts +101 -0
  70. package/src/lib/sectionContent.ts +64 -19
  71. package/src/lib/sitePages.ts +4 -30
  72. package/src/lib/sitePublicAssets.ts +2 -1
  73. package/src/pages/[...slug].astro +2 -1
  74. package/src/styles/area-navigation.css +14 -5
  75. package/src/styles/content.css +6 -0
  76. package/src/styles/media.css +24 -0
  77. package/starters/basic/site/site-config/settings.yaml +1 -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);
@@ -1,3 +1,6 @@
1
+ import { comparePageOrder, parsePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
2
+ import { pageOrderFileChanges } from './page-order-source.mjs';
3
+ import { writeFileAtomically } from './page-move-apply.mjs';
1
4
  import { getSiteSourcePaths } from './site-conventions.mjs';
2
5
  import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
6
  import path from 'node:path';
@@ -9,17 +12,7 @@ import { siteSchema } from './schema-definitions.mjs';
9
12
  import { readEditorLinkState } from './editor-site-links.mjs';
10
13
  import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
11
14
  import { parseYamlConfig } from './yaml-config.mjs';
12
-
13
- const parseOrder = (value) => {
14
- if (!/^\d{1,3}$/.test(value)) {
15
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
16
- }
17
- const order = Number.parseInt(value, 10);
18
- if (order < 1 || order > 999) {
19
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
20
- }
21
- return order;
22
- };
15
+ import { publicationIssues } from './publication-paths.mjs';
23
16
 
24
17
  const normalizeParentPath = (value) => {
25
18
  if (value === '/') return '';
@@ -33,15 +26,6 @@ const normalizeParentPath = (value) => {
33
26
  return normalized;
34
27
  };
35
28
 
36
- const getNextOrder = (siblings) => {
37
- const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
38
- const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
39
- if (nextOrder > 999) {
40
- throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
41
- }
42
- return nextOrder;
43
- };
44
-
45
29
  export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
46
30
 
47
31
  const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
@@ -86,30 +70,40 @@ const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) =
86
70
  // Planning never writes. Callers must present this same destination before applying.
87
71
  export const planSiteNodeCreation = async ({
88
72
  siteRoot, kind, title, slug = null, order = null, parentPath = '/',
89
- invocationDirectory = siteRoot, metadata, sources = new Map(),
73
+ invocationDirectory = siteRoot, metadata, sources = new Map(), placement = null, targetPath = null,
90
74
  }) => {
75
+ const request = { siteRoot, kind, title, slug, order, parentPath, invocationDirectory, metadata, placement, targetPath };
76
+ if (placement !== null && !['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first or last.');
77
+ if (placement !== null && order !== null) throw new Error('Do not combine --order with a placement selector.');
91
78
  if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
92
79
  siteRoot = path.resolve(siteRoot);
93
80
  title = String(title ?? '').trim();
94
81
  if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
95
82
  const generatedSlug = slug === null;
96
83
  slug ??= slugifyAsciiIdentifier(title);
97
- if (!slug || !pageDirectoryPattern.test(`010-${slug}`)) {
84
+ if (!slug || !pageDirectoryPattern.test(slug)) {
98
85
  throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
99
86
  }
100
87
 
101
88
  const structure = await getSiteStructure({ siteRoot });
89
+ let relativeTarget = null;
90
+ if (placement === 'before' || placement === 'after') {
91
+ relativeTarget = structure.nodes.find(node => node.contentPath === targetPath || `/${node.pagePath}/` === targetPath);
92
+ if (!relativeTarget || relativeTarget.isHome) throw new Error('Choose an existing sibling page for before/after placement.');
93
+ parentPath = relativeTarget.parentPagePath ? `/${relativeTarget.parentPagePath}/` : '/';
94
+ }
102
95
  const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
103
- const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null));
96
+ const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null)).sort(comparePageOrder);
104
97
  if (siblings.some((node) => node.pageId === slug)) {
105
98
  throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
106
99
  }
107
- order = order === null ? getNextOrder(siblings) : parseOrder(String(order));
108
- if (siblings.some((node) => node.pageOrder === order)) {
109
- throw new Error(`Cannot use order ${String(order).padStart(3, '0')} below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling already uses it.`);
110
- }
111
-
112
- const directoryName = `${String(order).padStart(3, '0')}-${slug}`;
100
+ const dirty = [...sources.keys()].filter(filename => filename === path.join(siteRoot, 'site-config/settings.yaml') || siblings.some(node => node.contentPath === filename));
101
+ if (dirty.length) throw new Error(`Save or undo these unsaved files before choosing page placement:\n${dirty.join('\n')}`);
102
+ const index = placement === 'first' ? 0 : relativeTarget ? siblings.indexOf(relativeTarget) + (placement === 'after' ? 1 : 0) : siblings.length;
103
+ const insertion = order === null ? planPageOrder(siblings, index) : { order: parsePageOrder(Number(order), '--order'), orderChanges: [] };
104
+ order = insertion.order;
105
+ const orderChanges = await pageOrderFileChanges(insertion.orderChanges);
106
+ const directoryName = slug;
113
107
  const destination = path.join(parent.collectionDir, directoryName);
114
108
  const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
115
109
  const state = await readEditorLinkState({ siteRoot, sources });
@@ -127,14 +121,23 @@ export const planSiteNodeCreation = async ({
127
121
  { pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
128
122
  categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
129
123
  }
130
- return { siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
124
+ const publicationErrors = publicationIssues({ pages: [...state.graph.pages,
125
+ { pathname: address, contentLabel: destination, aliases: metadata?.page?.aliases ?? [] }],
126
+ publicFiles: state.publicFiles, attachments: state.graph.attachments.files, generatedRoutes: state.generatedRoutes });
127
+ if (publicationErrors.length) throw new Error(publicationErrors.map(({ message }) => message).join('\n'));
128
+ metadata = { ...metadata, navigation: { ...metadata?.navigation, order } };
129
+ const fingerprint = JSON.stringify([destination, title, metadata, order, orderChanges, state.settings,
130
+ state.graph.pages.map(page => [page.contentFile.contentPath, page.document.fullSource]), state.publicFiles.map(file => file.filePath)]);
131
+ return { request, fingerprint, orderChanges, siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
131
132
  collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
132
133
  };
133
134
 
134
- export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
135
+ export const createSiteNode = async (plan, { sources = new Map(), allowRenumber = false, writeSource = writeFileAtomically } = {}) => {
135
136
  // Revalidate after the author has inspected the preview. Never change its placement.
136
- const current = await planSiteNodeCreation({ ...plan, sources });
137
- if (current.destination !== plan.destination) throw new Error('The site changed. Preview the new location before creating the node.');
137
+ const current = await planSiteNodeCreation({ ...(plan.request ?? plan), sources });
138
+ if (current.destination !== plan.destination || current.fingerprint !== plan.fingerprint) throw new Error('The site changed. Preview the new location before creating the node.');
139
+ assertRenumberingAllowed(current.orderChanges, allowRenumber);
140
+ const written = [];
138
141
  let createdCollection = false;
139
142
  try {
140
143
  const info = await stat(current.collectionDir);
@@ -156,14 +159,24 @@ export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
156
159
  const childDirectory = path.join(current.destination, 'images');
157
160
  await mkdir(childDirectory);
158
161
  created.push({ file: childDirectory, directory: true });
159
- return { ...current, sourcePath: filename };
162
+ for (const change of current.orderChanges) {
163
+ await writeSource(change.originalPath, change.updatedSource, change.mode);
164
+ written.push(change);
165
+ }
166
+ return { ...current, sourcePath: filename, renumberedPages: current.orderChanges.length };
160
167
  } catch (error) {
168
+ const restoreErrors = [];
169
+ for (const change of written.reverse()) {
170
+ try { await writeFileAtomically(change.originalPath, change.originalSource, change.mode); }
171
+ catch (failure) { restoreErrors.push(`${change.originalPath}: ${failure.message}`); }
172
+ }
161
173
  // Remove only entries this operation created; never recursively remove an
162
174
  // unexpected file written concurrently by another process.
163
175
  for (const entry of created.reverse()) {
164
176
  await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
165
177
  }
166
178
  if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
179
+ if (restoreErrors.length) throw new Error(`${error.message}\nRestoration was incomplete:\n${restoreErrors.join('\n')}`);
167
180
  throw error;
168
181
  }
169
182
  };
@@ -155,8 +155,8 @@ export const astroDistDir = configuredStateDirectory
155
155
  : path.join(siteProjectRoot, 'dist');
156
156
  export const astroRootDir = siteStateDir;
157
157
  export const astroCacheDir = path.join(astroRootDir, '.astro');
158
- export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
159
- export const originalImagesDir = path.join(astroPublicDir, 'images', 'original');
158
+ export const generatedImagesDir = path.join(astroPublicDir, '_norna', 'images', 'generated');
159
+ export const originalImagesDir = path.join(astroPublicDir, '_norna', 'images', 'original');
160
160
  export const generatedImagesManifestPath = path.join(siteStateDir, 'generated-images.json');
161
161
 
162
162
  export const engineRootLabel = getPathLabel(engineRoot);
@@ -0,0 +1,12 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { load } from 'js-yaml';
4
+
5
+ export const assertSiteSourceFormat = async (siteRoot, readSource = filename => readFile(filename, 'utf8')) => {
6
+ const filename = path.join(siteRoot, 'site-config/settings.yaml');
7
+ const source = await readSource(filename);
8
+ let settings;
9
+ try { settings = load(source); }
10
+ catch (error) { throw new Error(`${filename} contains invalid YAML: ${error.reason ?? error.message}`); }
11
+ if (settings?.sourceFormat !== 2) throw new Error(`${filename} needs sourceFormat: 2 after converting the site: move numeric page-directory prefixes into navigation.order frontmatter and remove those prefixes. Keep this site on its previous engine until converted. No files changed.`);
12
+ };
@@ -2,6 +2,9 @@ import { getSiteSourcePaths } from './site-conventions.mjs';
2
2
  import { access, readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { parsePageDirectoryPath } from './page-model.mjs';
5
+ import { comparePageOrder, parsePageOrder } from './page-order.mjs';
6
+ import { assertSiteSourceFormat } from './site-source-format.mjs';
7
+ import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
5
8
  import {
6
9
  homePageDirectory,
7
10
  siteDir as defaultSiteDir,
@@ -15,11 +18,6 @@ const readDirectory = async (directory) => readdir(directory, { withFileTypes: t
15
18
  throw error;
16
19
  });
17
20
 
18
- const compareNodeMetadata = (left, right) => (
19
- left.pageOrder - right.pageOrder
20
- || left.pageId.localeCompare(right.pageId, 'en')
21
- );
22
-
23
21
  const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
24
22
  for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes']) {
25
23
  if (await fileExists(path.join(siteDir, name))) {
@@ -28,7 +26,7 @@ const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
28
26
  }
29
27
  };
30
28
 
31
- const assertUniqueSiblings = (nodes, pagesLabel) => {
29
+ const assertUniqueSiblings = (nodes, pagesLabel, warnings) => {
32
30
  const byId = new Map();
33
31
  const byOrder = new Map();
34
32
 
@@ -43,13 +41,11 @@ const assertUniqueSiblings = (nodes, pagesLabel) => {
43
41
  }
44
42
  byId.set(node.pageId, node);
45
43
 
44
+ if (node.pageOrder === null) continue;
46
45
  const existingOrder = byOrder.get(node.pageOrder);
47
46
  if (existingOrder) {
48
- throw new Error([
49
- `${pagesLabel} contains duplicate sibling order "${String(node.pageOrder).padStart(3, '0')}".`,
50
- `- ${existingOrder.nodeLabel}`,
51
- `- ${node.nodeLabel}`,
52
- ].join('\n'));
47
+ const message = `${pagesLabel} contains duplicate sibling navigation.order ${node.pageOrder}: ${existingOrder.pageId} and ${node.pageId}. Slug determines their order. Choose different values to set an explicit order.`;
48
+ for (const sibling of [existingOrder, node]) warnings.push({ code: 'duplicate-page-order', message, label: sibling.contentLabel, path: sibling.contentPath });
53
49
  }
54
50
  byOrder.set(node.pageOrder, node);
55
51
  }
@@ -75,6 +71,7 @@ export const getSiteStructure = async ({
75
71
  };
76
72
  try {
77
73
  await assertLegacyStructureIsAbsent({ siteDir, siteDirLabel, sitePagesLabel });
74
+ await assertSiteSourceFormat(siteDir, readSource);
78
75
  } catch (error) {
79
76
  report(error);
80
77
  }
@@ -131,16 +128,20 @@ export const getSiteStructure = async ({
131
128
  imagesLabel: `${nodeLabel}/images`,
132
129
  });
133
130
 
131
+ try {
132
+ const { frontmatterBody } = splitSiteFile(await readSource(contentPath), node.contentLabel);
133
+ node.pageOrder = parsePageOrder(parseContentFrontmatter(frontmatterBody, node.contentLabel).navigation?.order, `${node.contentLabel}: navigation.order`);
134
+ } catch (error) { report(error, node); }
134
135
  siblingNodes.push(node);
135
136
  }
136
137
 
137
138
  try {
138
- assertUniqueSiblings(siblingNodes, pagesLabel);
139
+ assertUniqueSiblings(siblingNodes, pagesLabel, warnings);
139
140
  } catch (error) {
140
141
  report(error);
141
142
  }
142
143
 
143
- for (const node of siblingNodes.sort(compareNodeMetadata)) {
144
+ for (const node of siblingNodes.sort(comparePageOrder)) {
144
145
  nodes.push(node);
145
146
  const childPagesDir = path.join(node.nodeDir, 'pages');
146
147
  const childDirectories = (await readDirectory(childPagesDir)).filter((entry) => entry.isDirectory());
@@ -1,3 +1,4 @@
1
+ import { assertSiteSourceFormat } from './site-source-format.mjs';
1
2
  import { lstat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { readThemePackage } from './theme-packages.mjs';
@@ -25,6 +26,7 @@ export const planSiteUpgrade = async (siteRoot) => {
25
26
  for (const filename of [source.content, path.join(siteRoot, 'site-config', 'settings.yaml'), source.theme]) {
26
27
  if (!(await inspect(filename))?.isFile()) throw new Error(`${filename} is required and must be a regular file.`);
27
28
  }
29
+ await assertSiteSourceFormat(siteRoot);
28
30
  await readThemePackage({ siteRoot, filename: source.theme });
29
31
  return { siteRoot, moves: [] };
30
32
  };
@@ -1,3 +1,4 @@
1
+ import { publicFilePublication } from './publication-paths.mjs';
1
2
  import { getPublicAssetInspection } from './logo-assets.mjs';
2
3
  import {
3
4
  socialImageAssetDefinitions,
@@ -13,7 +14,7 @@ export const getSocialImageAssets = () => getPublicAssetInspection().socialImage
13
14
  .map((filename) => ({
14
15
  ...definitionsByFilename.get(filename),
15
16
  filename,
16
- href: `/${filename}`,
17
+ href: publicFilePublication(filename).pathname,
17
18
  }));
18
19
 
19
20
  export const getSocialImageAsset = () => {
@@ -11,7 +11,7 @@ export const sourceFileDefinitions = [
11
11
  { name: 'tree-theme.yaml', location: 'page', schemaKind: 'theme', rootSchemaKind: 'rootTheme', required: 'root', rootText: 'preset: documentation\n', text: '# Modify inherited settings. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page and its descendants. Without preset, modifies inherited values; preset selects a new base. Required on the root page.', removalEffect: 'This branch resumes the ancestor tree theme. Overrides on descendants remain; a descendant preset starts an independent base.' },
12
12
  { name: 'page-theme.yaml', location: 'page', schemaKind: 'pageTheme', text: '# Modify this page only. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page only. Without preset, modifies its tree theme; preset selects a new base. Descendants keep their tree theme.', removalEffect: 'This page will use its selected tree theme again. Other pages are unchanged.' },
13
13
  ];
14
- export const sourcePageDirectoryPattern = pageDirectoryPattern.source.replace('^', '^(?!000-)');
14
+ export const sourcePageDirectoryPattern = pageDirectoryPattern.source;
15
15
  const schemas = { config: configSchema, contentFrontmatter: siteSchema, sitewideContent: sitewideSchema, theme: themeVisualSchema, rootTheme: rootThemeSchema, pageTheme: pageThemeSchema };
16
16
 
17
17
  export const getSourcePageLocation = (siteRoot, directory) => {
@@ -1,5 +1,7 @@
1
1
  import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
2
2
 
3
+ export const managedImageFilenamePattern = '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])';
4
+
3
5
  const stringSchema = (name, definition) => ({
4
6
  type: 'string',
5
7
  description: definition.description,
@@ -7,7 +9,7 @@ const stringSchema = (name, definition) => ({
7
9
  ...(definition.values ? { enum: Object.keys(definition.values) } : {}),
8
10
  ...(name === 'alt' ? {} : { minLength: 1 }),
9
11
  pattern: name === 'image'
10
- ? '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])'
12
+ ? managedImageFilenamePattern
11
13
  : name === 'caption' || name === 'text'
12
14
  ? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
13
15
  : name === 'alt'