@janga/norna 0.7.29 → 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.
@@ -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
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 = 3;
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.base}-${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) => {
@@ -212,15 +164,18 @@ const validateCarouselOrientations = async (manifest) => {
212
164
  }
213
165
  };
214
166
 
215
- const identify = async (sourcePath, imageMagick) => {
216
- const output = await imageMagick.identify(sourcePath);
217
- const [width, height] = output.split(' ').map(Number);
218
- 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;
219
172
  };
220
173
 
221
- const convert = async (sourcePath, outputPath, width, imageMagick) => {
174
+ const convert = async (sourcePath, outputPath, width, sharp) => {
222
175
  await mkdir(path.dirname(outputPath), { recursive: true });
223
- 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);
224
179
  };
225
180
 
226
181
  const getHash = (value) => createHash('sha256').update(value).digest('hex');
@@ -275,7 +230,7 @@ const hasGeneratedVariants = async (variants) => {
275
230
  const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
276
231
  if (
277
232
  previousEntry?.sourceHash !== sourceHash
278
- || previousEntry?.outputVersion !== imageOutputVersion
233
+ || previousEntry?.outputVersion !== rasterOutputVersion
279
234
  || !Number.isFinite(previousEntry?.width)
280
235
  || !Number.isFinite(previousEntry?.height)
281
236
  ) {
@@ -289,7 +244,7 @@ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
289
244
  }
290
245
 
291
246
  return {
292
- outputVersion: imageOutputVersion,
247
+ outputVersion: rasterOutputVersion,
293
248
  sourceHash,
294
249
  width: previousEntry.width,
295
250
  height: previousEntry.height,
@@ -301,7 +256,7 @@ const getReusableStaticEntry = async (previousEntry, sourceHash, sourceKey) => {
301
256
  if (
302
257
  previousEntry?.kind !== 'static'
303
258
  || previousEntry?.sourceHash !== sourceHash
304
- || previousEntry?.outputVersion !== imageOutputVersion
259
+ || previousEntry?.outputVersion !== staticOutputVersion
305
260
  || previousEntry?.src !== getPublicPath(getOriginalPath(sourceKey, sourceHash))
306
261
  || !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
307
262
  ) {
@@ -365,7 +320,6 @@ const main = async () => {
365
320
  const sources = await getReferencedSources();
366
321
  const manifest = {};
367
322
  const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
368
- const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
369
323
 
370
324
  if (sources.length === 0) {
371
325
  await removeUnreferencedGeneratedFiles(manifest);
@@ -374,10 +328,6 @@ const main = async () => {
374
328
  return;
375
329
  }
376
330
 
377
- const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
378
- ? await getImageMagick()
379
- : null;
380
-
381
331
  for (const { sourcePath, sourceKey } of sources) {
382
332
  const sourceHash = await getSourceHash(sourcePath);
383
333
 
@@ -396,7 +346,7 @@ const main = async () => {
396
346
  const dimensions = await readImageDimensions(sourcePath).catch(() => null);
397
347
  manifest[sourceKey] = {
398
348
  kind: 'static',
399
- outputVersion: imageOutputVersion,
349
+ outputVersion: staticOutputVersion,
400
350
  sourceHash,
401
351
  src: getPublicPath(outputPath),
402
352
  ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
@@ -411,20 +361,22 @@ const main = async () => {
411
361
  continue;
412
362
  }
413
363
 
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);
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}`);
424
376
  }
425
377
 
426
378
  manifest[sourceKey] = {
427
- outputVersion: imageOutputVersion,
379
+ outputVersion: rasterOutputVersion,
428
380
  sourceHash,
429
381
  width: dimensions.width,
430
382
  height: dimensions.height,
@@ -7,6 +7,12 @@ import { checkedPath } from './editor-site-files.mjs';
7
7
  import { escapeMarkdownHeading } from './site-node-create.mjs';
8
8
 
9
9
  export const siteTreeEditingApiVersion = 1;
10
+ const sourceFormatHeader = [
11
+ '# Norna increments this version for breaking changes to the site source format.',
12
+ '# Change it only as part of a documented format conversion.',
13
+ 'sourceFormat: 2',
14
+ '',
15
+ ].join('\n');
10
16
  const stat = (filename) => lstat(filename).catch((error) => {
11
17
  if (error.code === 'ENOENT') return null;
12
18
  throw error;
@@ -54,7 +60,7 @@ export const planEditorSourceFileCreation = async ({ siteRoot, directory, filena
54
60
  if (definition.input) {
55
61
  if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) throw new Error('Enter a non-empty, single line of text.');
56
62
  text = definition.input === 'title' ? `# ${escapeMarkdownHeading(value.trim())}\n`
57
- : `${definition.schemaKind === 'config' ? 'sourceFormat: 2\n' : ''}${definition.input}: ${JSON.stringify(value.trim())}\n`;
63
+ : `${definition.schemaKind === 'config' ? sourceFormatHeader : ''}${definition.input}: ${JSON.stringify(value.trim())}\n`;
58
64
  }
59
65
  if (definition.schema) parseYamlConfig(text, filename, { schema: definition.schema });
60
66
  const identity = async (location) => { const entry = await stat(location); return entry ? [entry.dev, entry.ino] : null; };
@@ -1,73 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
5
- const jpegStartOfFrameMarkers = new Set([
6
- 0xc0,
7
- 0xc1,
8
- 0xc2,
9
- 0xc3,
10
- 0xc5,
11
- 0xc6,
12
- 0xc7,
13
- 0xc9,
14
- 0xca,
15
- 0xcb,
16
- 0xcd,
17
- 0xce,
18
- 0xcf,
19
- ]);
20
-
21
- const isPng = (buffer) => buffer.length >= 24 && buffer.subarray(0, 8).equals(pngSignature);
22
-
23
- const readPngDimensions = (buffer) => ({
24
- width: buffer.readUInt32BE(16),
25
- height: buffer.readUInt32BE(20),
26
- });
27
-
28
- const readJpegDimensions = (buffer) => {
29
- if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
30
- return null;
31
- }
32
-
33
- let offset = 2;
34
-
35
- while (offset + 9 < buffer.length) {
36
- while (buffer[offset] === 0xff) {
37
- offset += 1;
38
- }
39
-
40
- const marker = buffer[offset];
41
- offset += 1;
42
-
43
- if (marker === 0xd9 || marker === 0xda) {
44
- return null;
45
- }
46
-
47
- if (offset + 2 > buffer.length) {
48
- return null;
49
- }
50
-
51
- const segmentLength = buffer.readUInt16BE(offset);
52
- if (segmentLength < 2 || offset + segmentLength > buffer.length) {
53
- return null;
54
- }
55
-
56
- if (jpegStartOfFrameMarkers.has(marker)) {
57
- if (segmentLength < 7) return null;
58
-
59
- return {
60
- height: buffer.readUInt16BE(offset + 3),
61
- width: buffer.readUInt16BE(offset + 5),
62
- };
63
- }
64
-
65
- offset += segmentLength;
66
- }
67
-
68
- return null;
69
- };
70
-
71
4
  const parseSvgLength = (value) => {
72
5
  const match = String(value ?? '').trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
73
6
  return match ? Number(match[1]) : null;
@@ -96,9 +29,7 @@ export const readImageDimensions = async (filePath) => {
96
29
  return readSvgDimensions(buffer.toString('utf8'));
97
30
  }
98
31
 
99
- if (isPng(buffer)) {
100
- return readPngDimensions(buffer);
101
- }
102
-
103
- return readJpegDimensions(buffer);
32
+ const { default: sharp } = await import('sharp');
33
+ const { autoOrient } = await sharp(buffer).metadata();
34
+ return autoOrient ?? null;
104
35
  };
@@ -27,16 +27,13 @@ jobs:
27
27
  node-version: 24.18.0
28
28
  cache: npm
29
29
 
30
- - name: Install image tools
31
- run: sudo apt-get update && sudo apt-get install -y imagemagick
32
-
33
30
  - name: Restore generated images
34
31
  uses: actions/cache@v4
35
32
  with:
36
- path: site/.norna/public/images/generated
37
- key: norna-images-${{ hashFiles('site/.norna/generated-images.json') }}
33
+ path: site/.norna/public/_norna/images
34
+ key: norna-images-v4-${{ hashFiles('site/.norna/generated-images.json') }}
38
35
  restore-keys: |
39
- norna-images-
36
+ norna-images-v4-
40
37
 
41
38
  - name: Install dependencies
42
39
  run: npm ci
@@ -9,6 +9,8 @@ npm install
9
9
  npm run norna:dev
10
10
  ```
11
11
 
12
+ The npm installation includes image processing for JPEG and PNG files.
13
+
12
14
  Open the address printed by the development server. Stop it later with
13
15
  `npm run norna:dev:stop`.
14
16
 
@@ -1,2 +1,4 @@
1
+ # Norna increments this version for breaking changes to the site source format.
2
+ # Change it only as part of a documented format conversion.
1
3
  sourceFormat: 2
2
4
  url: https://example.com/