@janga/norna 0.7.28 → 0.7.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +8 -6
  2. package/astro.config.mjs +14 -2
  3. package/package.json +13 -9
  4. package/schemas/config.schema.json +80 -65
  5. package/schemas/content-frontmatter.schema.json +15 -8
  6. package/schemas/manifest.json +1 -1
  7. package/schemas/page-theme.schema.json +237 -237
  8. package/schemas/root-theme.schema.json +237 -237
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +237 -237
  11. package/scripts/add-site-node.mjs +29 -100
  12. package/scripts/dev-local.mjs +34 -7
  13. package/scripts/generate-images.mjs +97 -127
  14. package/scripts/generate-search-index.mjs +3 -2
  15. package/scripts/lib/attachment-render.mjs +23 -0
  16. package/scripts/lib/dev-server-identity.mjs +30 -0
  17. package/scripts/lib/documentation-routes.json +63 -62
  18. package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
  19. package/scripts/lib/editor-attachments.mjs +69 -0
  20. package/scripts/lib/editor-file-policy.mjs +2 -0
  21. package/scripts/lib/editor-language-service.mjs +21 -18
  22. package/scripts/lib/editor-page-addresses.mjs +5 -3
  23. package/scripts/lib/editor-page-placement.mjs +28 -98
  24. package/scripts/lib/editor-resource-actions.mjs +216 -0
  25. package/scripts/lib/editor-site-editing-tree.mjs +44 -9
  26. package/scripts/lib/editor-site-files.mjs +3 -2
  27. package/scripts/lib/editor-site-links.mjs +6 -4
  28. package/scripts/lib/editor-site-preview.mjs +149 -0
  29. package/scripts/lib/editor-site-tree.mjs +10 -3
  30. package/scripts/lib/editor-source-files.mjs +7 -1
  31. package/scripts/lib/image-dimensions.mjs +3 -72
  32. package/scripts/lib/logo-assets.mjs +2 -1
  33. package/scripts/lib/markdown-images.mjs +59 -0
  34. package/scripts/lib/markdown-links.mjs +29 -9
  35. package/scripts/lib/norna-markdown-blocks.mjs +0 -57
  36. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  37. package/scripts/lib/page-aliases.mjs +5 -0
  38. package/scripts/lib/page-attachments.mjs +59 -0
  39. package/scripts/lib/page-markdown.mjs +11 -7
  40. package/scripts/lib/page-model.mjs +8 -22
  41. package/scripts/lib/page-move-apply.mjs +6 -4
  42. package/scripts/lib/page-move-plan.mjs +37 -19
  43. package/scripts/lib/page-order-source.mjs +22 -0
  44. package/scripts/lib/page-order.mjs +45 -0
  45. package/scripts/lib/page-placement-cli.mjs +50 -0
  46. package/scripts/lib/project-config.mjs +3 -0
  47. package/scripts/lib/publication-paths.mjs +72 -0
  48. package/scripts/lib/schema-definitions.mjs +4 -0
  49. package/scripts/lib/schema-editor-metadata.mjs +4 -1
  50. package/scripts/lib/site-content.mjs +1 -1
  51. package/scripts/lib/site-link-graph.mjs +88 -18
  52. package/scripts/lib/site-node-create.mjs +47 -34
  53. package/scripts/lib/site-paths.mjs +2 -2
  54. package/scripts/lib/site-source-format.mjs +12 -0
  55. package/scripts/lib/site-structure.mjs +14 -13
  56. package/scripts/lib/site-upgrade.mjs +2 -0
  57. package/scripts/lib/social-image-assets.mjs +2 -1
  58. package/scripts/lib/source-files.mjs +1 -1
  59. package/scripts/lib/structured-blocks.mjs +3 -1
  60. package/scripts/move-site-page.mjs +46 -133
  61. package/scripts/sync-content-sections.mjs +8 -10
  62. package/scripts/sync-site-public.mjs +44 -66
  63. package/src/components/ContentBlocks.astro +2 -1
  64. package/src/components/ImageStack.astro +7 -5
  65. package/src/components/SearchPage.astro +2 -1
  66. package/src/components/SitePage.astro +1 -0
  67. package/src/components/SiteSection.astro +1 -0
  68. package/src/content.config.ts +1 -4
  69. package/src/lib/imagePresentation.ts +3 -0
  70. package/src/lib/markdownImages.ts +101 -0
  71. package/src/lib/sectionContent.ts +64 -19
  72. package/src/lib/sitePages.ts +4 -30
  73. package/src/lib/sitePublicAssets.ts +2 -1
  74. package/src/pages/[...slug].astro +2 -1
  75. package/src/styles/area-navigation.css +14 -5
  76. package/src/styles/content.css +6 -0
  77. package/src/styles/media.css +24 -0
  78. package/starters/basic/.github/workflows/deploy.yml +3 -6
  79. package/starters/basic/README.md +2 -0
  80. package/starters/basic/site/site-config/settings.yaml +3 -0
@@ -1,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,13 +1,10 @@
1
1
  import { getPageImageSourceKey } from './lib/site-conventions.mjs';
2
- import { execFile } from 'node:child_process';
3
2
  import { createHash } from 'node:crypto';
4
- import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { copyFile, mkdir, readdir, readFile, rm, stat, lstat, writeFile } from 'node:fs/promises';
5
4
  import path from 'node:path';
6
- import { promisify } from 'node:util';
7
5
  import {
8
6
  getContentFiles,
9
7
  readSiteFile,
10
- rasterImageExtensions,
11
8
  staticImageExtensions,
12
9
  supportedImageExtensions,
13
10
  toPosixPath,
@@ -22,61 +19,16 @@ import {
22
19
  siteDir,
23
20
  } from './lib/site-paths.mjs';
24
21
 
25
- const execFileAsync = promisify(execFile);
26
-
27
- const imageOutputVersion = 2;
22
+ // Bump only when the raster recipe changes, not on ordinary engine releases.
23
+ const rasterOutputVersion = 4;
24
+ const staticOutputVersion = 3;
28
25
  const sourceHashSlugLength = 8;
29
26
  const widths = [480, 768, 1080, 1440, 1920];
30
27
 
31
- const run = async (command, args) => {
32
- const { stdout } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 * 10 });
33
- return stdout.trim();
34
- };
35
-
36
- const canRun = async (command, args = ['-version']) => {
37
- try {
38
- await run(command, args);
39
- return true;
40
- } catch {
41
- return false;
42
- }
43
- };
44
-
45
- const getImageMagick = async () => {
46
- if (await canRun('magick')) {
47
- return {
48
- identify: (sourcePath) => run('magick', ['identify', '-format', '%w %h', sourcePath]),
49
- convert: (sourcePath, outputPath, width) => run('magick', [
50
- sourcePath,
51
- '-auto-orient',
52
- '-resize',
53
- `${width}x`,
54
- '-strip',
55
- '-quality',
56
- '82',
57
- outputPath,
58
- ]),
59
- };
60
- }
61
-
62
- if (await canRun('identify') && await canRun('convert')) {
63
- return {
64
- identify: (sourcePath) => run('identify', ['-format', '%w %h', sourcePath]),
65
- convert: (sourcePath, outputPath, width) => run('convert', [
66
- sourcePath,
67
- '-auto-orient',
68
- '-resize',
69
- `${width}x`,
70
- '-strip',
71
- '-quality',
72
- '82',
73
- outputPath,
74
- ]),
75
- };
76
- }
77
-
78
- throw new Error('ImageMagick is missing. Install either the "magick" command or both "identify" and "convert".');
79
- };
28
+ let sharpPromise;
29
+ const getSharp = () => sharpPromise ??= import('sharp').then(({ default: sharp }) => sharp).catch(() => {
30
+ throw new Error('Norna could not load Sharp for image processing. Run npm install --include=optional in the project to install the native image dependencies for this computer.');
31
+ });
80
32
 
81
33
  const toPublicPath = (filePath) => filePath.split(path.sep).join('/');
82
34
  const getPublicPath = (filePath) => `/${toPublicPath(path.relative(astroPublicDir, filePath))}`;
@@ -86,7 +38,7 @@ const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLeng
86
38
 
87
39
  const getGeneratedPath = (sourceKey, sourceHash, width) => {
88
40
  const parsed = path.parse(sourceKey.replace(/^root\//, ''));
89
- return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
41
+ return path.join(generatedImagesDir, parsed.dir, `${parsed.base}-${getSourceHashSlug(sourceHash)}-v${rasterOutputVersion}-${width}.webp`);
90
42
  };
91
43
 
92
44
  const getOriginalPath = (sourceKey, sourceHash) => {
@@ -103,6 +55,8 @@ const getImageSourceKey = (contentFile, image) => getPageImageSourceKey(contentF
103
55
  const getReferencedImages = async (contentFile) => {
104
56
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
105
57
  const page = await parsePageMarkdown(body, { label: contentFile.contentLabel });
58
+ const imageError = page.diagnostics.find((issue) => issue.code === 'invalid-markdown-image');
59
+ if (imageError) throw new Error(imageError.message + ' ' + imageError.fix);
106
60
  const references = [];
107
61
 
108
62
  for (const region of page.regions) {
@@ -138,6 +92,7 @@ const getContentSourcePath = ({ image, sourceKey }) => {
138
92
  const getReferencedSources = async () => {
139
93
  const contentFiles = await getContentFiles();
140
94
  const seen = new Set();
95
+ const normalizedSources = new Map();
141
96
  const sources = [];
142
97
 
143
98
  for (const contentFile of contentFiles) {
@@ -152,7 +107,9 @@ const getReferencedSources = async () => {
152
107
  continue;
153
108
  }
154
109
 
155
- const fileStat = await stat(sourcePath).catch(() => null);
110
+ const fileStat = await lstat(sourcePath).catch(() => null);
111
+ const directoryStat = await lstat(contentFile.imagesDir).catch(() => null);
112
+ if (fileStat?.isSymbolicLink() || directoryStat?.isSymbolicLink()) fail(`Image ${sourcePath} must belong to a regular images/ directory; symbolic links are not supported.`);
156
113
  if (!fileStat?.isFile()) {
157
114
  fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
158
115
  }
@@ -161,6 +118,10 @@ const getReferencedSources = async () => {
161
118
  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
119
  }
163
120
 
121
+ const normalizedKey = reference.sourceKey.normalize('NFC').toLowerCase();
122
+ const previous = normalizedSources.get(normalizedKey);
123
+ if (previous) fail(`Image sources ${previous} and ${sourcePath} conflict after case/Unicode normalization. Rename one image.`);
124
+ normalizedSources.set(normalizedKey, sourcePath);
164
125
  seen.add(reference.sourceKey);
165
126
  sources.push({ sourcePath, sourceKey: reference.sourceKey, reference });
166
127
  }
@@ -203,15 +164,18 @@ const validateCarouselOrientations = async (manifest) => {
203
164
  }
204
165
  };
205
166
 
206
- const identify = async (sourcePath) => {
207
- const output = await imageMagick.identify(sourcePath);
208
- const [width, height] = output.split(' ').map(Number);
209
- return { width, height };
167
+ const identify = async (sourcePath, sharp) => {
168
+ const { autoOrient: dimensions } = await sharp(sourcePath).metadata();
169
+ if (!dimensions || !Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)
170
+ || dimensions.width <= 0 || dimensions.height <= 0) throw new Error('The image has no usable dimensions.');
171
+ return dimensions;
210
172
  };
211
173
 
212
- const convert = async (sourcePath, outputPath, width) => {
174
+ const convert = async (sourcePath, outputPath, width, sharp) => {
213
175
  await mkdir(path.dirname(outputPath), { recursive: true });
214
- await imageMagick.convert(sourcePath, outputPath, width);
176
+ // Sharp converts to sRGB and strips metadata by default.
177
+ await sharp(sourcePath).autoOrient().resize({ width, withoutEnlargement: true })
178
+ .webp({ quality: 82 }).toFile(outputPath);
215
179
  };
216
180
 
217
181
  const getHash = (value) => createHash('sha256').update(value).digest('hex');
@@ -266,7 +230,7 @@ const hasGeneratedVariants = async (variants) => {
266
230
  const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
267
231
  if (
268
232
  previousEntry?.sourceHash !== sourceHash
269
- || previousEntry?.outputVersion !== imageOutputVersion
233
+ || previousEntry?.outputVersion !== rasterOutputVersion
270
234
  || !Number.isFinite(previousEntry?.width)
271
235
  || !Number.isFinite(previousEntry?.height)
272
236
  ) {
@@ -280,7 +244,7 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
280
244
  }
281
245
 
282
246
  return {
283
- outputVersion: imageOutputVersion,
247
+ outputVersion: rasterOutputVersion,
284
248
  sourceHash,
285
249
  width: previousEntry.width,
286
250
  height: previousEntry.height,
@@ -288,12 +252,12 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
288
252
  };
289
253
  };
290
254
 
291
- const getReusableStaticEntry = async (previousEntry, sourceHash) => {
255
+ const getReusableStaticEntry = async (previousEntry, sourceHash, sourceKey) => {
292
256
  if (
293
257
  previousEntry?.kind !== 'static'
294
258
  || previousEntry?.sourceHash !== sourceHash
295
- || previousEntry?.outputVersion !== imageOutputVersion
296
- || typeof previousEntry?.src !== 'string'
259
+ || previousEntry?.outputVersion !== staticOutputVersion
260
+ || previousEntry?.src !== getPublicPath(getOriginalPath(sourceKey, sourceHash))
297
261
  || !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
298
262
  ) {
299
263
  return null;
@@ -349,80 +313,86 @@ const removeUnreferencedGeneratedFiles = async (manifest) => {
349
313
  }
350
314
  };
351
315
 
352
- await mkdir(generatedImagesDir, { recursive: true });
316
+ const main = async () => {
317
+ await mkdir(generatedImagesDir, { recursive: true });
353
318
 
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());
319
+ const previousManifest = await readManifest();
320
+ const sources = await getReferencedSources();
321
+ const manifest = {};
322
+ const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
359
323
 
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
- }
324
+ if (sources.length === 0) {
325
+ await removeUnreferencedGeneratedFiles(manifest);
326
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
327
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
328
+ return;
329
+ }
366
330
 
367
- const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
368
- ? await getImageMagick()
369
- : null;
331
+ for (const { sourcePath, sourceKey } of sources) {
332
+ const sourceHash = await getSourceHash(sourcePath);
370
333
 
371
- for (const { sourcePath, sourceKey } of sources) {
372
- const sourceHash = await getSourceHash(sourcePath);
334
+ if (isStaticSource(sourcePath)) {
335
+ const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash, sourceKey);
373
336
 
374
- if (isStaticSource(sourcePath)) {
375
- const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash);
337
+ if (reusableEntry) {
338
+ manifest[sourceKey] = reusableEntry;
339
+ continue;
340
+ }
341
+
342
+ const outputPath = getOriginalPath(sourceKey, sourceHash);
343
+ await mkdir(path.dirname(outputPath), { recursive: true });
344
+ await copyFile(sourcePath, outputPath);
345
+
346
+ const dimensions = await readImageDimensions(sourcePath).catch(() => null);
347
+ manifest[sourceKey] = {
348
+ kind: 'static',
349
+ outputVersion: staticOutputVersion,
350
+ sourceHash,
351
+ src: getPublicPath(outputPath),
352
+ ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
353
+ };
354
+ continue;
355
+ }
356
+
357
+ const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
376
358
 
377
359
  if (reusableEntry) {
378
360
  manifest[sourceKey] = reusableEntry;
379
361
  continue;
380
362
  }
381
363
 
382
- const outputPath = getOriginalPath(sourceKey, sourceHash);
383
- await mkdir(path.dirname(outputPath), { recursive: true });
384
- await copyFile(sourcePath, outputPath);
364
+ const sharp = await getSharp();
365
+ let dimensions;
366
+ let variants;
367
+ try {
368
+ dimensions = await identify(sourcePath, sharp);
369
+ const variantWidths = getVariantWidths(dimensions);
370
+ variants = getVariants(sourceKey, sourceHash, variantWidths);
371
+ for (const width of variantWidths) {
372
+ await convert(sourcePath, getGeneratedPath(sourceKey, sourceHash, width), width, sharp);
373
+ }
374
+ } catch (error) {
375
+ throw new Error(`Cannot process image ${sourcePath}: ${error.message}`);
376
+ }
385
377
 
386
- const dimensions = await readImageDimensions(sourcePath).catch(() => null);
387
378
  manifest[sourceKey] = {
388
- kind: 'static',
389
- outputVersion: imageOutputVersion,
379
+ outputVersion: rasterOutputVersion,
390
380
  sourceHash,
391
- src: getPublicPath(outputPath),
392
- ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
381
+ width: dimensions.width,
382
+ height: dimensions.height,
383
+ variants,
393
384
  };
394
- continue;
395
- }
396
-
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
385
  }
407
386
 
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
- }
387
+ await validateCarouselOrientations(manifest);
388
+ await removeUnreferencedGeneratedFiles(manifest);
389
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
390
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
391
+ };
415
392
 
416
- manifest[sourceKey] = {
417
- outputVersion: imageOutputVersion,
418
- sourceHash,
419
- width: dimensions.width,
420
- height: dimensions.height,
421
- variants,
422
- };
393
+ try {
394
+ await main();
395
+ } catch (error) {
396
+ console.error(error.message);
397
+ process.exitCode = 1;
423
398
  }
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
+ });