@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.
- package/README.md +1 -2
- package/package.json +5 -3
- package/schemas/config.schema.json +67 -67
- package/schemas/content-frontmatter.schema.json +9 -9
- package/schemas/page-theme.schema.json +237 -237
- package/schemas/root-theme.schema.json +237 -237
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -237
- package/scripts/generate-images.mjs +34 -82
- package/scripts/lib/editor-source-files.mjs +7 -1
- package/scripts/lib/image-dimensions.mjs +3 -72
- package/starters/basic/.github/workflows/deploy.yml +3 -6
- package/starters/basic/README.md +2 -0
- package/starters/basic/site/site-config/settings.yaml +2 -0
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
174
|
+
const convert = async (sourcePath, outputPath, width, sharp) => {
|
|
222
175
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
223
|
-
|
|
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 !==
|
|
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:
|
|
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 !==
|
|
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:
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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:
|
|
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' ?
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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
|
package/starters/basic/README.md
CHANGED