@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,103 +1,32 @@
1
1
  import path from 'node:path';
2
2
  import { invocationRoot, siteDir, siteProjectRoot } from './lib/site-paths.mjs';
3
3
  import { createSiteNode, planSiteNodeCreation } from './lib/site-node-create.mjs';
4
-
5
- const usage = `
6
- Usage:
7
- norna page:add <title> [--parent <path>] [--slug <slug>] [--order <NNN>] [--dry-run]
8
-
9
- Parent selection:
10
- --parent / Add a top-level node
11
- --parent /guides/ Add a child below the guides node
12
- no --parent Use the site root, current node directory, or site/pages/
13
- `.trim();
14
-
15
- const parseValueOption = (rawArgs, index, name) => {
16
- const arg = rawArgs[index];
17
- if (arg === name) {
18
- const value = rawArgs[index + 1];
19
- if (!value || value.startsWith('-')) throw new Error(`${name} requires a value.\n${usage}`);
20
- return { consumed: 2, value };
21
- }
22
- if (arg.startsWith(`${name}=`)) {
23
- const value = arg.slice(name.length + 1);
24
- if (!value) throw new Error(`${name} requires a value.\n${usage}`);
25
- return { consumed: 1, value };
26
- }
27
- return null;
28
- };
29
-
30
- const parseArgs = (rawArgs) => {
31
- const options = {
32
- dryRun: false,
33
- help: false,
34
- order: null,
35
- parent: null,
36
- slug: null,
37
- titleParts: [],
38
- };
39
- const seen = new Set();
40
-
41
- for (let index = 0; index < rawArgs.length;) {
42
- const arg = rawArgs[index];
43
- if (arg === '-h' || arg === '--help') {
44
- options.help = true;
45
- index += 1;
46
- continue;
47
- }
48
- if (arg === '--dry-run') {
49
- if (seen.has('dryRun')) throw new Error(`--dry-run may be specified only once.\n${usage}`);
50
- seen.add('dryRun');
51
- options.dryRun = true;
52
- index += 1;
53
- continue;
54
- }
55
-
56
- let matched = false;
57
- for (const [name, key] of [['--order', 'order'], ['--parent', 'parent'], ['--slug', 'slug']]) {
58
- const parsed = parseValueOption(rawArgs, index, name);
59
- if (!parsed) continue;
60
- if (seen.has(key)) throw new Error(`${name} may be specified only once.\n${usage}`);
61
- seen.add(key);
62
- options[key] = parsed.value;
63
- index += parsed.consumed;
64
- matched = true;
65
- break;
66
- }
67
- if (matched) continue;
68
-
69
- if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
70
- options.titleParts.push(arg);
71
- index += 1;
72
- }
73
-
74
- return options;
75
- };
76
-
77
- const toDisplayPath = (filePath) => {
78
- const relativePath = path.relative(siteProjectRoot, filePath);
79
- return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
80
- ? relativePath
81
- : filePath).split(path.sep).join('/');
82
- };
83
-
84
- const [kind, ...rawArgs] = process.argv.slice(2);
85
- if (kind !== 'page') throw new Error(usage);
86
- const options = parseArgs(rawArgs);
87
- if (options.help) {
88
- console.log(usage);
89
- process.exit(0);
90
- }
91
-
92
- const title = options.titleParts.join(' ').trim();
93
- if (!title || /[\r\n]/.test(title)) throw new Error(`A one-line page title is required.\n${usage}`);
94
- const plan = await planSiteNodeCreation({
95
- siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
96
- parentPath: options.parent, invocationDirectory: invocationRoot,
97
- });
98
- const { destination, pagePath } = plan;
99
- const action = options.dryRun ? 'Would create' : 'Created';
100
- if (!options.dryRun) await createSiteNode(plan);
101
-
102
- console.log(`${action} page "${title}" at ${toDisplayPath(destination)}.`);
103
- console.log(`URL: /${pagePath}/`);
4
+ import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
5
+
6
+ const usage = `Usage:
7
+ norna page:add <title> [--parent <path>] [--first | --last] [--slug <slug>]
8
+ norna page:add <title> --before <page-url> | --after <page-url>
9
+
10
+ Options: --order <integer>, --dry-run, --allow-renumber
11
+ Without a placement, append under --parent or the current page/site directory.
12
+ Before/after infer the parent. --order cannot accompany a relative placement.`;
13
+ const display = filename => path.relative(siteProjectRoot, filename) || filename;
14
+ try {
15
+ const [kind, ...args] = process.argv.slice(2);
16
+ if (kind !== 'page') throw new Error(usage);
17
+ const options = parsePlacementArguments(args);
18
+ if (options.help) { console.log(usage); process.exit(0); }
19
+ if (options.write || options.noAliases) throw new Error('page:add uses --dry-run; --write and --no-aliases are page:move options.');
20
+ const title = options.positionals.join(' ').trim();
21
+ if (!title) throw new Error(`A one-line page title is required.\n${usage}`);
22
+ const plan = await planSiteNodeCreation({ siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
23
+ parentPath: options.parent, invocationDirectory: invocationRoot,
24
+ placement: options.order !== null ? null : options.placement, targetPath: options.target });
25
+ console.log(`Page: ${title}\nSource: ${display(plan.destination)}\nURL: ${plan.url}\nOrder: ${plan.order}`);
26
+ const renumbering = describeRenumbering(plan.orderChanges, display);
27
+ if (renumbering) console.log(renumbering);
28
+ if (options.dryRun) { console.log(`Would create page "${title}". Dry run only. No files changed.`); process.exit(0); }
29
+ if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
30
+ const result = await createSiteNode(plan, { allowRenumber: true });
31
+ console.log(`Created page "${title}" at ${display(plan.destination)}. ${result.renumberedPages} existing sibling page(s) renumbered.`);
32
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readDevServerIdentity, verifyDevServer } from './lib/dev-server-identity.mjs';
1
3
  import { execFile } from 'node:child_process';
2
4
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
5
  import net from 'node:net';
@@ -10,11 +12,14 @@ import {
10
12
  engineRoot,
11
13
  siteProjectRoot,
12
14
  siteStateDir,
15
+ siteDir,
13
16
  } from './lib/site-paths.mjs';
14
17
 
15
18
  const execFileAsync = promisify(execFile);
16
19
 
17
- const port = Number.parseInt(process.env.NORNA_DEV_PORT ?? '4321', 10);
20
+ const portValue = process.env.NORNA_DEV_PORT ?? '4321';
21
+ const port = /^\d+$/.test(portValue) ? Number(portValue) : NaN;
22
+ const serverToken = process.env.NORNA_DEV_START_TOKEN || randomUUID();
18
23
  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
19
24
  throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535.');
20
25
  }
@@ -107,6 +112,8 @@ const writeState = async (host) => {
107
112
  await writeFile(statePath, `${JSON.stringify({
108
113
  port,
109
114
  pid,
115
+ token: serverToken,
116
+ siteRoot: siteDir,
110
117
  host,
111
118
  mode: host === '0.0.0.0' ? 'lan' : 'local',
112
119
  url: localUrl,
@@ -367,6 +374,12 @@ const openBrowser = async () => {
367
374
 
368
375
  const stopServer = async ({ quiet = false } = {}) => {
369
376
  const state = await readState();
377
+ if (process.env.NORNA_DEV_EXPECT_TOKEN) {
378
+ if (state?.token !== process.env.NORNA_DEV_EXPECT_TOKEN || state.port !== port || !(await verifyDevServer(state, siteDir))) throw new Error('Cannot verify this site’s server. No process was stopped.');
379
+ if (!(await terminateTrackedProcess(state, state.host ?? localHost))) throw new Error('The verified preview server did not stop. Try Stop Preview Server again.');
380
+ await removeState();
381
+ return;
382
+ }
370
383
  let astroError;
371
384
  let astroOutput = '';
372
385
  try {
@@ -397,7 +410,7 @@ const stopServer = async ({ quiet = false } = {}) => {
397
410
  };
398
411
 
399
412
  const startServer = async ({ host = localHost, open = true, killBlockingPort = false } = {}) => {
400
- await stopServer({ quiet: true });
413
+ if (process.env.NORNA_DEV_NO_RESTART !== '1') await stopServer({ quiet: true });
401
414
 
402
415
  if (!(await isPortFree(host))) {
403
416
  if (!killBlockingPort) {
@@ -432,8 +445,24 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
432
445
  }
433
446
  const previousLog = await readDevLog();
434
447
  try {
435
- await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
448
+ await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)], { env: { ...process.env, NORNA_DEV_TOKEN: serverToken } });
449
+ // Record the process before probing rendered pages, so failed readiness
450
+ // checks can clean up only the server started by this request.
451
+ await writeState(host);
452
+ await waitForServer();
436
453
  } catch (error) {
454
+ let cleanupHint = '';
455
+ const identity = await readDevServerIdentity({ port, token: serverToken }, siteDir);
456
+ if (identity) {
457
+ const candidate = { port, ...identity };
458
+ try {
459
+ if (!(await terminateTrackedProcess(candidate, host))) throw new Error('The process did not stop.');
460
+ const recorded = await readState();
461
+ if (recorded?.token === serverToken) await removeState();
462
+ } catch (cleanup) {
463
+ cleanupHint = `Cleanup failed: ${cleanup.message} Use the site's dev:stop command after inspecting the log.`;
464
+ }
465
+ }
437
466
  const currentLog = await readDevLog();
438
467
  const newLog = currentLog.startsWith(previousLog)
439
468
  ? currentLog.slice(previousLog.length)
@@ -443,11 +472,9 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
443
472
  `Could not start the dev server at ${localUrl}.`,
444
473
  excerpt ? `Astro reported:\n${excerpt}` : 'Astro did not write a detailed startup error.',
445
474
  `Full log: ${logPath}`,
446
- ].join('\n\n'), { cause: error });
475
+ cleanupHint,
476
+ ].filter(Boolean).join('\n\n'), { cause: error });
447
477
  }
448
- await waitForServer();
449
-
450
- await writeState(host);
451
478
  if (open) {
452
479
  await openBrowser();
453
480
  } else {
@@ -1,7 +1,7 @@
1
1
  import { getPageImageSourceKey } from './lib/site-conventions.mjs';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { copyFile, mkdir, readdir, readFile, rm, stat, lstat, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import {
@@ -24,7 +24,7 @@ import {
24
24
 
25
25
  const execFileAsync = promisify(execFile);
26
26
 
27
- const imageOutputVersion = 2;
27
+ const imageOutputVersion = 3;
28
28
  const sourceHashSlugLength = 8;
29
29
  const widths = [480, 768, 1080, 1440, 1920];
30
30
 
@@ -86,7 +86,7 @@ const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLeng
86
86
 
87
87
  const getGeneratedPath = (sourceKey, sourceHash, width) => {
88
88
  const parsed = path.parse(sourceKey.replace(/^root\//, ''));
89
- return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
89
+ return path.join(generatedImagesDir, parsed.dir, `${parsed.base}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
90
90
  };
91
91
 
92
92
  const getOriginalPath = (sourceKey, sourceHash) => {
@@ -103,6 +103,8 @@ const getImageSourceKey = (contentFile, image) => getPageImageSourceKey(contentF
103
103
  const getReferencedImages = async (contentFile) => {
104
104
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
105
105
  const page = await parsePageMarkdown(body, { label: contentFile.contentLabel });
106
+ const imageError = page.diagnostics.find((issue) => issue.code === 'invalid-markdown-image');
107
+ if (imageError) throw new Error(imageError.message + ' ' + imageError.fix);
106
108
  const references = [];
107
109
 
108
110
  for (const region of page.regions) {
@@ -138,6 +140,7 @@ const getContentSourcePath = ({ image, sourceKey }) => {
138
140
  const getReferencedSources = async () => {
139
141
  const contentFiles = await getContentFiles();
140
142
  const seen = new Set();
143
+ const normalizedSources = new Map();
141
144
  const sources = [];
142
145
 
143
146
  for (const contentFile of contentFiles) {
@@ -152,7 +155,9 @@ const getReferencedSources = async () => {
152
155
  continue;
153
156
  }
154
157
 
155
- const fileStat = await stat(sourcePath).catch(() => null);
158
+ const fileStat = await lstat(sourcePath).catch(() => null);
159
+ const directoryStat = await lstat(contentFile.imagesDir).catch(() => null);
160
+ if (fileStat?.isSymbolicLink() || directoryStat?.isSymbolicLink()) fail(`Image ${sourcePath} must belong to a regular images/ directory; symbolic links are not supported.`);
156
161
  if (!fileStat?.isFile()) {
157
162
  fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
158
163
  }
@@ -161,6 +166,10 @@ const getReferencedSources = async () => {
161
166
  fail(`Image "${imageName}" is used in ${reference.sectionLabel} but is not stored directly in ${contentFile.imagesLabel}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
162
167
  }
163
168
 
169
+ const normalizedKey = reference.sourceKey.normalize('NFC').toLowerCase();
170
+ const previous = normalizedSources.get(normalizedKey);
171
+ if (previous) fail(`Image sources ${previous} and ${sourcePath} conflict after case/Unicode normalization. Rename one image.`);
172
+ normalizedSources.set(normalizedKey, sourcePath);
164
173
  seen.add(reference.sourceKey);
165
174
  sources.push({ sourcePath, sourceKey: reference.sourceKey, reference });
166
175
  }
@@ -203,13 +212,13 @@ const validateCarouselOrientations = async (manifest) => {
203
212
  }
204
213
  };
205
214
 
206
- const identify = async (sourcePath) => {
215
+ const identify = async (sourcePath, imageMagick) => {
207
216
  const output = await imageMagick.identify(sourcePath);
208
217
  const [width, height] = output.split(' ').map(Number);
209
218
  return { width, height };
210
219
  };
211
220
 
212
- const convert = async (sourcePath, outputPath, width) => {
221
+ const convert = async (sourcePath, outputPath, width, imageMagick) => {
213
222
  await mkdir(path.dirname(outputPath), { recursive: true });
214
223
  await imageMagick.convert(sourcePath, outputPath, width);
215
224
  };
@@ -288,12 +297,12 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
288
297
  };
289
298
  };
290
299
 
291
- const getReusableStaticEntry = async (previousEntry, sourceHash) => {
300
+ const getReusableStaticEntry = async (previousEntry, sourceHash, sourceKey) => {
292
301
  if (
293
302
  previousEntry?.kind !== 'static'
294
303
  || previousEntry?.sourceHash !== sourceHash
295
304
  || previousEntry?.outputVersion !== imageOutputVersion
296
- || typeof previousEntry?.src !== 'string'
305
+ || previousEntry?.src !== getPublicPath(getOriginalPath(sourceKey, sourceHash))
297
306
  || !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
298
307
  ) {
299
308
  return null;
@@ -349,80 +358,89 @@ const removeUnreferencedGeneratedFiles = async (manifest) => {
349
358
  }
350
359
  };
351
360
 
352
- await mkdir(generatedImagesDir, { recursive: true });
361
+ const main = async () => {
362
+ await mkdir(generatedImagesDir, { recursive: true });
353
363
 
354
- const previousManifest = await readManifest();
355
- const sources = await getReferencedSources();
356
- const manifest = {};
357
- const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
358
- const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
364
+ const previousManifest = await readManifest();
365
+ const sources = await getReferencedSources();
366
+ const manifest = {};
367
+ const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
368
+ const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
359
369
 
360
- if (sources.length === 0) {
361
- await removeUnreferencedGeneratedFiles(manifest);
362
- await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
363
- await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
364
- process.exit(0);
365
- }
370
+ if (sources.length === 0) {
371
+ await removeUnreferencedGeneratedFiles(manifest);
372
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
373
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
374
+ return;
375
+ }
376
+
377
+ const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
378
+ ? await getImageMagick()
379
+ : null;
366
380
 
367
- const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
368
- ? await getImageMagick()
369
- : null;
381
+ for (const { sourcePath, sourceKey } of sources) {
382
+ const sourceHash = await getSourceHash(sourcePath);
370
383
 
371
- for (const { sourcePath, sourceKey } of sources) {
372
- const sourceHash = await getSourceHash(sourcePath);
384
+ if (isStaticSource(sourcePath)) {
385
+ const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash, sourceKey);
373
386
 
374
- if (isStaticSource(sourcePath)) {
375
- const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash);
387
+ if (reusableEntry) {
388
+ manifest[sourceKey] = reusableEntry;
389
+ continue;
390
+ }
391
+
392
+ const outputPath = getOriginalPath(sourceKey, sourceHash);
393
+ await mkdir(path.dirname(outputPath), { recursive: true });
394
+ await copyFile(sourcePath, outputPath);
395
+
396
+ const dimensions = await readImageDimensions(sourcePath).catch(() => null);
397
+ manifest[sourceKey] = {
398
+ kind: 'static',
399
+ outputVersion: imageOutputVersion,
400
+ sourceHash,
401
+ src: getPublicPath(outputPath),
402
+ ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
403
+ };
404
+ continue;
405
+ }
406
+
407
+ const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
376
408
 
377
409
  if (reusableEntry) {
378
410
  manifest[sourceKey] = reusableEntry;
379
411
  continue;
380
412
  }
381
413
 
382
- const outputPath = getOriginalPath(sourceKey, sourceHash);
383
- await mkdir(path.dirname(outputPath), { recursive: true });
384
- await copyFile(sourcePath, outputPath);
414
+ if (!imageMagick) {
415
+ throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
416
+ }
417
+
418
+ const dimensions = await identify(sourcePath, imageMagick);
419
+ const variantWidths = getVariantWidths(dimensions);
420
+ const variants = getVariants(sourceKey, sourceHash, variantWidths);
421
+ for (const width of variantWidths) {
422
+ const outputPath = getGeneratedPath(sourceKey, sourceHash, width);
423
+ await convert(sourcePath, outputPath, width, imageMagick);
424
+ }
385
425
 
386
- const dimensions = await readImageDimensions(sourcePath).catch(() => null);
387
426
  manifest[sourceKey] = {
388
- kind: 'static',
389
427
  outputVersion: imageOutputVersion,
390
428
  sourceHash,
391
- src: getPublicPath(outputPath),
392
- ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
429
+ width: dimensions.width,
430
+ height: dimensions.height,
431
+ variants,
393
432
  };
394
- continue;
395
433
  }
396
434
 
397
- const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
398
-
399
- if (reusableEntry) {
400
- manifest[sourceKey] = reusableEntry;
401
- continue;
402
- }
403
-
404
- if (!imageMagick) {
405
- throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
406
- }
407
-
408
- const dimensions = await identify(sourcePath);
409
- const variantWidths = getVariantWidths(dimensions);
410
- const variants = getVariants(sourceKey, sourceHash, variantWidths);
411
- for (const width of variantWidths) {
412
- const outputPath = getGeneratedPath(sourceKey, sourceHash, width);
413
- await convert(sourcePath, outputPath, width);
414
- }
435
+ await validateCarouselOrientations(manifest);
436
+ await removeUnreferencedGeneratedFiles(manifest);
437
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
438
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
439
+ };
415
440
 
416
- manifest[sourceKey] = {
417
- outputVersion: imageOutputVersion,
418
- sourceHash,
419
- width: dimensions.width,
420
- height: dimensions.height,
421
- variants,
422
- };
441
+ try {
442
+ await main();
443
+ } catch (error) {
444
+ console.error(error.message);
445
+ process.exitCode = 1;
423
446
  }
424
-
425
- await validateCarouselOrientations(manifest);
426
- await removeUnreferencedGeneratedFiles(manifest);
427
- await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
428
- await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -1,3 +1,4 @@
1
+ import { searchOutputDirectory } from './lib/publication-paths.mjs';
1
2
  import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import * as pagefind from 'pagefind';
@@ -7,7 +8,7 @@ import {
7
8
  astroPublicDir,
8
9
  } from './lib/site-paths.mjs';
9
10
 
10
- const searchDirectoryName = 'pagefind';
11
+ const searchDirectoryName = searchOutputDirectory;
11
12
  const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
12
13
  const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
13
14
 
@@ -44,7 +45,7 @@ if (!projectConfig.search.enabled) {
44
45
  }
45
46
 
46
47
  await rm(localSearchDirectory, { force: true, recursive: true });
47
- await mkdir(astroPublicDir, { recursive: true });
48
+ await mkdir(path.dirname(localSearchDirectory), { recursive: true });
48
49
  await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
49
50
 
50
51
  console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { getSourcePageLocation } from './source-files.mjs';
4
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
5
+ import { readPageAttachments, resolveAttachment, attachmentHref } from './page-attachments.mjs';
6
+
7
+ export const rewriteAttachmentLinks = async (source, fileURL, siteRoot) => {
8
+ if (!fileURL) return source;
9
+ const contentPath = fileURLToPath(fileURL);
10
+ const location = getSourcePageLocation(siteRoot, path.dirname(contentPath));
11
+ if (!location || path.basename(contentPath) !== 'content.md') return source;
12
+ const { files } = await readPageAttachments({ ...location, contentPath });
13
+ if (!files.length) return source;
14
+ const document = await parsePageMarkdownSource(source);
15
+ const edits = new Map();
16
+ for (const link of document.links) {
17
+ const file = resolveAttachment(link.target, location.isHome ? '/' : `/${location.pagePath}/`, files);
18
+ if (!file || !link.targetRange) continue;
19
+ const href = attachmentHref(file, link.target);
20
+ edits.set(link.targetRange.start, { ...link.targetRange, text: link.yamlScalar ? JSON.stringify(href) : href });
21
+ }
22
+ return [...edits.values()].sort((a,b) => b.start-a.start).reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), source);
23
+ };
@@ -0,0 +1,30 @@
1
+ import { realpath } from 'node:fs/promises';
2
+
3
+ export const identityPath = '/.well-known/norna-dev';
4
+ export const readDevServerIdentity = async (state, siteRoot) => {
5
+ if (!state?.token || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535) return null;
6
+ try {
7
+ const response = await fetch(`http://127.0.0.1:${state.port}${identityPath}`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
8
+ if (!response.ok) return null;
9
+ const identity = await response.json();
10
+ return identity.token === state.token && Number.isInteger(identity.pid) && identity.pid > 0 && identity.siteRoot === await realpath(siteRoot) ? identity : null;
11
+ } catch { return null; }
12
+ };
13
+ export const verifyDevServer = async (state, siteRoot) => Number.isInteger(state?.pid) && state.pid > 0
14
+ && (await readDevServerIdentity(state, siteRoot))?.pid === state.pid;
15
+
16
+ export const devServerIdentityPlugin = siteRoot => ({
17
+ name: 'norna-dev-server-identity',
18
+ async configureServer(server) {
19
+ const canonicalRoot = await realpath(siteRoot);
20
+ return () => {
21
+ server.middlewares.stack.unshift({ route: '', handle(request, response, next) {
22
+ if (request.url !== identityPath || request.method !== 'GET' || !process.env.NORNA_DEV_TOKEN) return next();
23
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress)) return next();
24
+ response.setHeader('Content-Type', 'application/json');
25
+ response.setHeader('Cache-Control', 'no-store');
26
+ response.end(JSON.stringify({ siteRoot: canonicalRoot, token: process.env.NORNA_DEV_TOKEN, pid: process.pid }));
27
+ } });
28
+ };
29
+ },
30
+ });