@rathnasgala2/template 3.1.0 → 3.2.1
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 +136 -43
- package/package.json +2 -2
- package/src/core/index.js +48 -8
- package/src/core/internal/appearance/base-layer.js +31 -2
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/media/content-images.js +262 -0
- package/src/core/internal/media/gif-probe.js +162 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +21 -0
- package/src/core/internal/media/pipeline.js +73 -40
- package/src/core/internal/media/sniff.js +15 -2
- package/src/core/internal/messages.js +10 -1
- package/src/core/internal/page-kinds.js +97 -25
- package/src/core/internal/skeleton.js +25 -4
- package/src/core/internal/source-inventory.js +4 -0
- package/src/modules/interactions/browser/gala-interactions.js +370 -37
- package/types/index.d.ts +31 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded GIF container validation and declared-dimension extraction.
|
|
3
|
+
*
|
|
4
|
+
* Scope decision (documented, not silently assumed, and identical in spirit
|
|
5
|
+
* to `webp-probe.js` and `avif-probe.js`): GIF is validated, never decoded
|
|
6
|
+
* or re-encoded. This renderer carries no GIF encoder, so an animated GIF
|
|
7
|
+
* cannot be resized without losing its animation; GIF is therefore admitted
|
|
8
|
+
* as a *source* reference (recorded, digest-verified, dimension-bounded)
|
|
9
|
+
* with no generated responsive derivative, and its original bytes pass
|
|
10
|
+
* through unmodified as the sole asset entry. Because the bytes are never
|
|
11
|
+
* rewritten, any comment or application extension the file carries is
|
|
12
|
+
* served as authored.
|
|
13
|
+
*
|
|
14
|
+
* What this module enforces, fully: the exact `GIF87a`/`GIF89a` signature;
|
|
15
|
+
* the logical screen's declared width/height, and every frame's declared
|
|
16
|
+
* width/height, within the shared dimension/pixel ceilings; every colour
|
|
17
|
+
* table, extension and image-data sub-block chain inside the buffer; a
|
|
18
|
+
* known block introducer for every block; at least one image; and the
|
|
19
|
+
* trailer as the file's exact last byte (no trailing payload, so a GIF
|
|
20
|
+
* cannot smuggle a second file past this pipeline). Animation is admitted.
|
|
21
|
+
*
|
|
22
|
+
* The walk is bounded: at most 4,096 top-level blocks are visited (the same
|
|
23
|
+
* container-structure bound `avif-probe.js` applies to boxes), and every
|
|
24
|
+
* sub-block chain advances by at least one byte per step, so a malformed or
|
|
25
|
+
* adversarial file cannot cause an out-of-bounds read or an unbounded walk.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { MediaPipelineError } from '../../errors.js';
|
|
29
|
+
import { assertDimensionsWithinCeiling } from './limits.js';
|
|
30
|
+
|
|
31
|
+
const MAX_BLOCKS_VISITED = 4096;
|
|
32
|
+
const HEADER_BYTES = 6;
|
|
33
|
+
const LOGICAL_SCREEN_DESCRIPTOR_BYTES = 7;
|
|
34
|
+
const IMAGE_DESCRIPTOR_BYTES = 9;
|
|
35
|
+
const EXTENSION_INTRODUCER = 0x21;
|
|
36
|
+
const IMAGE_SEPARATOR = 0x2c;
|
|
37
|
+
const TRAILER = 0x3b;
|
|
38
|
+
/** LZW codes are at most 12 bits wide, so the minimum code size is at most 11. */
|
|
39
|
+
const MAX_LZW_MINIMUM_CODE_SIZE = 11;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} message what is malformed
|
|
43
|
+
* @param {string} referencePath the source path, for error messages
|
|
44
|
+
* @returns {MediaPipelineError} a `MEDIA_FORMAT_INVALID` rejection
|
|
45
|
+
*/
|
|
46
|
+
function malformed(message, referencePath) {
|
|
47
|
+
return new MediaPipelineError('MEDIA_FORMAT_INVALID', message, referencePath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The byte length of a colour table whose packed field is `packed`, or `0`
|
|
52
|
+
* when its presence flag (bit 7) is clear.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} packed the descriptor's packed field
|
|
55
|
+
* @returns {number} the table's byte length
|
|
56
|
+
*/
|
|
57
|
+
function colorTableBytes(packed) {
|
|
58
|
+
return (packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Skip one data sub-block chain (size byte, that many bytes, repeated until
|
|
63
|
+
* a zero-size terminator), returning the offset just past the terminator.
|
|
64
|
+
*
|
|
65
|
+
* @param {Buffer} bytes the GIF bytes
|
|
66
|
+
* @param {number} offset the offset of the chain's first size byte
|
|
67
|
+
* @param {string} referencePath the source path, for error messages
|
|
68
|
+
* @returns {number} the offset after the chain's terminator
|
|
69
|
+
*/
|
|
70
|
+
function skipSubBlocks(bytes, offset, referencePath) {
|
|
71
|
+
let cursor = offset;
|
|
72
|
+
for (;;) {
|
|
73
|
+
if (cursor >= bytes.length) {
|
|
74
|
+
throw malformed('GIF data sub-blocks run past the end', referencePath);
|
|
75
|
+
}
|
|
76
|
+
const size = bytes[cursor];
|
|
77
|
+
cursor += 1;
|
|
78
|
+
if (size === 0) return cursor;
|
|
79
|
+
cursor += size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a GIF file and return its logical screen dimensions.
|
|
85
|
+
*
|
|
86
|
+
* @param {Buffer} bytes complete GIF file bytes
|
|
87
|
+
* @param {string} referencePath the source path, for error messages
|
|
88
|
+
* @returns {{width: number, height: number}} the declared dimensions
|
|
89
|
+
*/
|
|
90
|
+
export function probeGif(bytes, referencePath) {
|
|
91
|
+
const signature = bytes.subarray(0, HEADER_BYTES).toString('ascii');
|
|
92
|
+
if (
|
|
93
|
+
bytes.length < HEADER_BYTES + LOGICAL_SCREEN_DESCRIPTOR_BYTES + 1 ||
|
|
94
|
+
(signature !== 'GIF87a' && signature !== 'GIF89a')
|
|
95
|
+
) {
|
|
96
|
+
throw malformed('missing GIF87a/GIF89a signature', referencePath);
|
|
97
|
+
}
|
|
98
|
+
const width = bytes.readUInt16LE(HEADER_BYTES);
|
|
99
|
+
const height = bytes.readUInt16LE(HEADER_BYTES + 2);
|
|
100
|
+
assertDimensionsWithinCeiling(width, height, referencePath, 'GIF');
|
|
101
|
+
|
|
102
|
+
let cursor =
|
|
103
|
+
HEADER_BYTES +
|
|
104
|
+
LOGICAL_SCREEN_DESCRIPTOR_BYTES +
|
|
105
|
+
colorTableBytes(bytes[HEADER_BYTES + 4]);
|
|
106
|
+
let images = 0;
|
|
107
|
+
for (let visited = 0; visited < MAX_BLOCKS_VISITED; visited += 1) {
|
|
108
|
+
if (cursor >= bytes.length) {
|
|
109
|
+
throw malformed('GIF ends without a trailer', referencePath);
|
|
110
|
+
}
|
|
111
|
+
const introducer = bytes[cursor];
|
|
112
|
+
if (introducer === TRAILER) {
|
|
113
|
+
if (images === 0) {
|
|
114
|
+
throw malformed('GIF carries no image', referencePath);
|
|
115
|
+
}
|
|
116
|
+
if (cursor + 1 !== bytes.length) {
|
|
117
|
+
throw malformed(
|
|
118
|
+
'GIF has bytes after its trailer; no trailing payload is admitted',
|
|
119
|
+
referencePath,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { width, height };
|
|
123
|
+
}
|
|
124
|
+
if (introducer === EXTENSION_INTRODUCER) {
|
|
125
|
+
// Introducer, label, then the extension's own data sub-blocks.
|
|
126
|
+
cursor = skipSubBlocks(bytes, cursor + 2, referencePath);
|
|
127
|
+
} else if (introducer === IMAGE_SEPARATOR) {
|
|
128
|
+
if (cursor + IMAGE_DESCRIPTOR_BYTES + 1 > bytes.length) {
|
|
129
|
+
throw malformed('GIF image descriptor is truncated', referencePath);
|
|
130
|
+
}
|
|
131
|
+
assertDimensionsWithinCeiling(
|
|
132
|
+
bytes.readUInt16LE(cursor + 5),
|
|
133
|
+
bytes.readUInt16LE(cursor + 7),
|
|
134
|
+
referencePath,
|
|
135
|
+
'GIF frame',
|
|
136
|
+
);
|
|
137
|
+
cursor += IMAGE_DESCRIPTOR_BYTES + 1 + colorTableBytes(bytes[cursor + 9]);
|
|
138
|
+
const lzwMinimumCodeSize = bytes[cursor];
|
|
139
|
+
if (
|
|
140
|
+
lzwMinimumCodeSize === undefined ||
|
|
141
|
+
lzwMinimumCodeSize < 1 ||
|
|
142
|
+
lzwMinimumCodeSize > MAX_LZW_MINIMUM_CODE_SIZE
|
|
143
|
+
) {
|
|
144
|
+
throw malformed(
|
|
145
|
+
'GIF image data has no valid LZW minimum code size',
|
|
146
|
+
referencePath,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
cursor = skipSubBlocks(bytes, cursor + 1, referencePath);
|
|
150
|
+
images += 1;
|
|
151
|
+
} else {
|
|
152
|
+
throw malformed(
|
|
153
|
+
`unknown GIF block introducer 0x${introducer.toString(16)}`,
|
|
154
|
+
referencePath,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw malformed(
|
|
159
|
+
`GIF has more than ${MAX_BLOCKS_VISITED} blocks`,
|
|
160
|
+
referencePath,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
Binary file
|
|
@@ -40,6 +40,27 @@ export const MAX_IMAGE_PIXELS = 16_777_216;
|
|
|
40
40
|
/** Maximum admitted decoded RGBA8 output buffer size, in bytes. */
|
|
41
41
|
export const MAX_DECODED_RGBA_BYTES = 67_108_864;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The most bytes per pixel the `jpeg-js` decoder holds while decoding one
|
|
45
|
+
* image, all of which its own `maxMemoryUsageInMB` knob counts: per
|
|
46
|
+
* component (at most four, CMYK) every 8x8 coefficient block as a 32-bit
|
|
47
|
+
* integer per sample (4 bytes), one output-line byte and one interleaved
|
|
48
|
+
* byte per sample, plus the 4-byte RGBA output: (4 + 1 + 1) x 4 + 4 = 28,
|
|
49
|
+
* rounded up to 32 to leave room for MCU padding and table allocations.
|
|
50
|
+
*/
|
|
51
|
+
export const JPEG_DECODE_BYTES_PER_PIXEL = 32;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `jpeg-js` working-set ceiling: an image at the pixel ceiling, at the
|
|
55
|
+
* worst-case per-pixel cost above (536,870,912 bytes). The decoded RGBA8
|
|
56
|
+
* output itself stays bounded by {@link MAX_DECODED_RGBA_BYTES} through the
|
|
57
|
+
* pixel ceiling; this bound only stops the decoder's own intermediate
|
|
58
|
+
* buffers from being mistaken for the output buffer, which rejected every
|
|
59
|
+
* JPEG above roughly three to five megapixels.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_JPEG_DECODE_WORKING_SET_BYTES =
|
|
62
|
+
MAX_IMAGE_PIXELS * JPEG_DECODE_BYTES_PER_PIXEL;
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* Maximum admitted raster source byte length: the smaller of this
|
|
45
66
|
* renderer's raster-specific cap (16,777,216) and its general
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Every distinct image/font reference in the publication (publication
|
|
14
14
|
* default image, each author's avatar, each content record's hero and
|
|
15
|
-
* social image,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* social image, every content image a rendered body references (resolved
|
|
16
|
+
* by `content-images.js` before this pipeline runs), the appearance
|
|
17
|
+
* package's brand mark, wordmark and font assets) is collected once,
|
|
18
|
+
* deduplicated by path, processed once per distinct source digest, and
|
|
19
|
+
* turned into one or more content-addressed output files under
|
|
18
20
|
* `assets/media/` in the candidate output directory plus one
|
|
19
21
|
* `manifestAsset`-shaped row per output file. `manifestAsset` has no field
|
|
20
22
|
* for a separate source/transform/output digest triple or for width/height
|
|
@@ -44,6 +46,7 @@ import { decodePng, encodePng } from './png-codec.js';
|
|
|
44
46
|
import { applyExifOrientation, resizeRaster } from './resize.js';
|
|
45
47
|
import { MEDIA_TYPE_BY_FORMAT, sniffMediaFormat } from './sniff.js';
|
|
46
48
|
import { probeAvif } from './avif-probe.js';
|
|
49
|
+
import { probeGif } from './gif-probe.js';
|
|
47
50
|
import { probeWebp } from './webp-probe.js';
|
|
48
51
|
|
|
49
52
|
/** @type {Readonly<Record<string, string>>} media type -> file extension */
|
|
@@ -52,6 +55,7 @@ const EXTENSION_BY_MEDIA_TYPE = Object.freeze({
|
|
|
52
55
|
'image/jpeg': 'jpg',
|
|
53
56
|
'image/webp': 'webp',
|
|
54
57
|
'image/avif': 'avif',
|
|
58
|
+
'image/gif': 'gif',
|
|
55
59
|
'font/woff2': 'woff2',
|
|
56
60
|
});
|
|
57
61
|
|
|
@@ -59,16 +63,24 @@ const EXTENSION_BY_MEDIA_TYPE = Object.freeze({
|
|
|
59
63
|
* @typedef {{path: string, sourceDigest: string}} ImageReference
|
|
60
64
|
*/
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* One output file of a processed image: its output path and pixel size.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {{path: string, width: number, height: number}} MediaVariant
|
|
70
|
+
*/
|
|
71
|
+
|
|
62
72
|
/**
|
|
63
73
|
* Collect every distinct image reference in `buildInput`, deduplicated by
|
|
64
74
|
* path (rejecting a path reused with a conflicting digest).
|
|
65
75
|
*
|
|
66
76
|
* @param {import('../../../../types/index.d.ts').NormalizedBuildInput} buildInput
|
|
67
77
|
* the validated build input
|
|
78
|
+
* @param {readonly ImageReference[]} contentImages the assets rendered
|
|
79
|
+
* bodies reference, already resolved by `content-images.js`
|
|
68
80
|
* @returns {ImageReference[]} the distinct image references, in
|
|
69
81
|
* first-seen order
|
|
70
82
|
*/
|
|
71
|
-
function collectImageReferences(buildInput) {
|
|
83
|
+
function collectImageReferences(buildInput, contentImages) {
|
|
72
84
|
/** @type {Map<string, ImageReference>} */
|
|
73
85
|
const byPath = new Map();
|
|
74
86
|
/** @type {(ref: ImageReference | undefined) => void} */
|
|
@@ -91,6 +103,7 @@ function collectImageReferences(buildInput) {
|
|
|
91
103
|
if (record.frontmatter.hero) add(record.frontmatter.hero.file);
|
|
92
104
|
add(record.frontmatter.socialImage);
|
|
93
105
|
}
|
|
106
|
+
for (const reference of contentImages) add(reference);
|
|
94
107
|
add(buildInput.appearance.brandMark);
|
|
95
108
|
add(buildInput.appearance.wordmark);
|
|
96
109
|
|
|
@@ -192,9 +205,9 @@ function derivativePath(sourceDigest, variant, extension) {
|
|
|
192
205
|
*
|
|
193
206
|
* @param {Buffer} bytes verified source bytes
|
|
194
207
|
* @param {ImageReference} reference the reference these bytes back
|
|
195
|
-
* @returns {
|
|
196
|
-
*
|
|
197
|
-
*
|
|
208
|
+
* @returns {(PipelineOutputFile & {width: number, height: number})[]} the
|
|
209
|
+
* output files this reference produces, each with its pixel size: the
|
|
210
|
+
* canonical original first, then every derivative in ascending width
|
|
198
211
|
*/
|
|
199
212
|
function processImage(bytes, reference) {
|
|
200
213
|
const format = sniffMediaFormat(bytes);
|
|
@@ -213,24 +226,21 @@ function processImage(bytes, reference) {
|
|
|
213
226
|
);
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
if (format === 'webp' || format === 'avif') {
|
|
217
|
-
// Bounded container/dimension validation only
|
|
218
|
-
// avif-probe.js
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
: probeAvif(bytes, reference.path);
|
|
229
|
+
if (format === 'webp' || format === 'avif' || format === 'gif') {
|
|
230
|
+
// Bounded container/dimension validation only, and the original bytes
|
|
231
|
+
// pass through unmodified; see webp-probe.js, avif-probe.js and
|
|
232
|
+
// gif-probe.js module documentation for the documented scope decision.
|
|
233
|
+
const probe = { webp: probeWebp, avif: probeAvif, gif: probeGif }[format];
|
|
234
|
+
const size = probe(bytes, reference.path);
|
|
223
235
|
const extension = EXTENSION_BY_MEDIA_TYPE[MEDIA_TYPE_BY_FORMAT[format]];
|
|
224
|
-
return
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
height: size.height,
|
|
233
|
-
};
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
path: derivativePath(reference.sourceDigest, 'original', extension),
|
|
239
|
+
bytes,
|
|
240
|
+
width: size.width,
|
|
241
|
+
height: size.height,
|
|
242
|
+
},
|
|
243
|
+
];
|
|
234
244
|
}
|
|
235
245
|
|
|
236
246
|
/** @type {{width: number, height: number, rgba: Buffer}} */
|
|
@@ -250,11 +260,12 @@ function processImage(bytes, reference) {
|
|
|
250
260
|
extension = 'jpg';
|
|
251
261
|
}
|
|
252
262
|
|
|
253
|
-
/** @type {PipelineOutputFile[]} */
|
|
254
263
|
const outputs = [
|
|
255
264
|
{
|
|
256
265
|
path: derivativePath(reference.sourceDigest, 'original', extension),
|
|
257
266
|
bytes: encode(oriented),
|
|
267
|
+
width: oriented.width,
|
|
268
|
+
height: oriented.height,
|
|
258
269
|
},
|
|
259
270
|
];
|
|
260
271
|
|
|
@@ -264,9 +275,11 @@ function processImage(bytes, reference) {
|
|
|
264
275
|
outputs.push({
|
|
265
276
|
path: derivativePath(reference.sourceDigest, `${width}w`, extension),
|
|
266
277
|
bytes: encode(resized),
|
|
278
|
+
width: resized.width,
|
|
279
|
+
height: resized.height,
|
|
267
280
|
});
|
|
268
281
|
}
|
|
269
|
-
return
|
|
282
|
+
return outputs;
|
|
270
283
|
}
|
|
271
284
|
|
|
272
285
|
/**
|
|
@@ -275,16 +288,22 @@ function processImage(bytes, reference) {
|
|
|
275
288
|
*
|
|
276
289
|
* @param {import('../../../../types/index.d.ts').NormalizedBuildInput} buildInput
|
|
277
290
|
* the validated build input
|
|
278
|
-
* @param {{sourceDirectory: string}} options
|
|
279
|
-
* options
|
|
280
|
-
*
|
|
281
|
-
*
|
|
291
|
+
* @param {{sourceDirectory: string, contentImages?: readonly ImageReference[]}} options
|
|
292
|
+
* the media pipeline's own options: the read-only source root, and the
|
|
293
|
+
* assets rendered bodies reference (`content-images.js`)
|
|
294
|
+
* @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[], dimensions: Record<string, {width: number, height: number}>, variants: Record<string, MediaVariant[]>}>}
|
|
295
|
+
* the asset manifest rows, the output files backing them, the pixel
|
|
282
296
|
* dimensions of each image's canonical original keyed by its output path
|
|
283
297
|
* (`manifestAsset` has no width/height field, so this is the one place the
|
|
284
|
-
* renderer learns them, for `<img width height>` and structured data)
|
|
298
|
+
* renderer learns them, for `<img width height>` and structured data),
|
|
299
|
+
* and every output file of each image keyed by its source digest, in
|
|
300
|
+
* ascending width (for `srcset`)
|
|
285
301
|
*/
|
|
286
|
-
export async function processMedia(
|
|
287
|
-
|
|
302
|
+
export async function processMedia(
|
|
303
|
+
buildInput,
|
|
304
|
+
{ sourceDirectory, contentImages = [] },
|
|
305
|
+
) {
|
|
306
|
+
const imageReferences = collectImageReferences(buildInput, contentImages);
|
|
288
307
|
if (imageReferences.length > MAX_IMAGES_PER_PUBLICATION) {
|
|
289
308
|
throw new MediaPipelineError(
|
|
290
309
|
'MEDIA_RESOURCE_EXCEEDED',
|
|
@@ -305,19 +324,33 @@ export async function processMedia(buildInput, { sourceDirectory }) {
|
|
|
305
324
|
const files = [];
|
|
306
325
|
/** @type {Record<string, {width: number, height: number}>} */
|
|
307
326
|
const dimensions = {};
|
|
327
|
+
/** @type {Record<string, MediaVariant[]>} */
|
|
328
|
+
const variants = {};
|
|
308
329
|
let totalBytes = 0;
|
|
309
330
|
|
|
310
331
|
for (const reference of imageReferences) {
|
|
311
332
|
const bytes = await readAndVerify(sourceDirectory, reference);
|
|
312
|
-
|
|
313
|
-
|
|
333
|
+
// Output paths are content-addressed: two paths with identical bytes
|
|
334
|
+
// produce identical files, so each distinct digest is processed (and
|
|
335
|
+
// emitted) once.
|
|
336
|
+
if (Object.hasOwn(variants, reference.sourceDigest)) continue;
|
|
337
|
+
const outputs = processImage(bytes, reference);
|
|
338
|
+
for (const output of outputs) {
|
|
314
339
|
totalBytes += output.bytes.byteLength;
|
|
315
|
-
files.push(output);
|
|
340
|
+
files.push({ path: output.path, bytes: output.bytes });
|
|
316
341
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
342
|
+
const [original] = outputs;
|
|
343
|
+
dimensions[original.path] = {
|
|
344
|
+
width: original.width,
|
|
345
|
+
height: original.height,
|
|
320
346
|
};
|
|
347
|
+
variants[reference.sourceDigest] = outputs
|
|
348
|
+
.map(({ path: outputPath, width, height }) => ({
|
|
349
|
+
path: outputPath,
|
|
350
|
+
width,
|
|
351
|
+
height,
|
|
352
|
+
}))
|
|
353
|
+
.sort((a, b) => a.width - b.width);
|
|
321
354
|
}
|
|
322
355
|
|
|
323
356
|
for (const reference of fontReferences) {
|
|
@@ -364,5 +397,5 @@ export async function processMedia(buildInput, { sourceDirectory }) {
|
|
|
364
397
|
});
|
|
365
398
|
});
|
|
366
399
|
|
|
367
|
-
return { assets, files, dimensions };
|
|
400
|
+
return { assets, files, dimensions, variants };
|
|
368
401
|
}
|
|
@@ -60,6 +60,17 @@ function isAvif(bytes) {
|
|
|
60
60
|
return bytes.subarray(8, 12).toString('ascii') === 'avif';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* @param {Buffer} bytes candidate file bytes
|
|
65
|
+
* @returns {boolean} true when `bytes` starts with a `GIF87a` or `GIF89a`
|
|
66
|
+
* signature
|
|
67
|
+
*/
|
|
68
|
+
function isGif(bytes) {
|
|
69
|
+
if (bytes.length < 6) return false;
|
|
70
|
+
const signature = bytes.subarray(0, 6).toString('ascii');
|
|
71
|
+
return signature === 'GIF87a' || signature === 'GIF89a';
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* Detect whether `bytes` looks like SVG/XML markup, scanning only the first
|
|
65
76
|
* 4096 bytes (bounded: this classification exists purely to reject, never to
|
|
@@ -82,7 +93,7 @@ function looksLikeSvg(bytes) {
|
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/**
|
|
85
|
-
* @typedef {'png' | 'jpeg' | 'webp' | 'avif' | 'svg' | 'unknown'} SniffedFormat
|
|
96
|
+
* @typedef {'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'svg' | 'unknown'} SniffedFormat
|
|
86
97
|
*/
|
|
87
98
|
|
|
88
99
|
/**
|
|
@@ -97,13 +108,15 @@ export function sniffMediaFormat(bytes) {
|
|
|
97
108
|
if (isJpeg(bytes)) return 'jpeg';
|
|
98
109
|
if (isWebp(bytes)) return 'webp';
|
|
99
110
|
if (isAvif(bytes)) return 'avif';
|
|
111
|
+
if (isGif(bytes)) return 'gif';
|
|
100
112
|
return 'unknown';
|
|
101
113
|
}
|
|
102
114
|
|
|
103
|
-
/** @type {Readonly<Record<'png' | 'jpeg' | 'webp' | 'avif', string>>} */
|
|
115
|
+
/** @type {Readonly<Record<'png' | 'jpeg' | 'webp' | 'avif' | 'gif', string>>} */
|
|
104
116
|
export const MEDIA_TYPE_BY_FORMAT = Object.freeze({
|
|
105
117
|
png: 'image/png',
|
|
106
118
|
jpeg: 'image/jpeg',
|
|
107
119
|
webp: 'image/webp',
|
|
108
120
|
avif: 'image/avif',
|
|
121
|
+
gif: 'image/gif',
|
|
109
122
|
});
|
|
@@ -46,7 +46,7 @@ const CATALOGS = Object.freeze({
|
|
|
46
46
|
/** @type {(displayName: string) => string} */
|
|
47
47
|
byLineLabel: (displayName) => `By ${displayName}`,
|
|
48
48
|
footerCopyrightPrefix: '©',
|
|
49
|
-
footerAttributionLabel: '
|
|
49
|
+
footerAttributionLabel: 'Made with Galascribe',
|
|
50
50
|
errorPageHeading: 'Page not found',
|
|
51
51
|
errorPageBody:
|
|
52
52
|
'The page you were looking for could not be found. It may have been ' +
|
|
@@ -113,6 +113,15 @@ const CATALOGS = Object.freeze({
|
|
|
113
113
|
articleCountLabel: (count) =>
|
|
114
114
|
count === '1' ? '1 article' : `${count} articles`,
|
|
115
115
|
emptyListingLabel: 'No articles yet.',
|
|
116
|
+
editionsNavigationLabel: 'Editions',
|
|
117
|
+
editionOriginalLabel: 'Original',
|
|
118
|
+
editionQuickReadLabel: 'Quick read',
|
|
119
|
+
editionStandardLabel: 'Standard',
|
|
120
|
+
editionDeepDiveLabel: 'Deep dive',
|
|
121
|
+
editionNoticeLabel:
|
|
122
|
+
'Generated edition, reviewed by the author; the original is the reference',
|
|
123
|
+
/** @type {(title: string, edition: string) => string} */
|
|
124
|
+
editionPageTitle: (title, edition) => `${title} (${edition})`,
|
|
116
125
|
aboutPageTitle: 'About',
|
|
117
126
|
/** @type {(date: string) => string} */
|
|
118
127
|
updatedLabel: (date) => `Updated ${date}`,
|