@rathnasgala2/template 3.1.0 → 3.2.0

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.
@@ -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
+ }
@@ -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, the appearance package's brand mark, wordmark and font
16
- * assets) is collected once, deduplicated by path, processed independently,
17
- * and turned into one or more content-addressed output files under
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 {{files: PipelineOutputFile[], width: number, height: number}}
196
- * the output files this reference produces and the canonical original's
197
- * pixel dimensions
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; see webp-probe.js and
218
- // avif-probe.js module documentation for the documented scope decision.
219
- const size =
220
- format === 'webp'
221
- ? probeWebp(bytes, reference.path)
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
- files: [
226
- {
227
- path: derivativePath(reference.sourceDigest, 'original', extension),
228
- bytes,
229
- },
230
- ],
231
- width: size.width,
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 { files: outputs, width: oriented.width, height: oriented.height };
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 the media pipeline's own
279
- * options
280
- * @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[], dimensions: Record<string, {width: number, height: number}>}>}
281
- * the asset manifest rows, the output files backing them, and the pixel
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(buildInput, { sourceDirectory }) {
287
- const imageReferences = collectImageReferences(buildInput);
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
- const processed = processImage(bytes, reference);
313
- for (const output of processed.files) {
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
- dimensions[processed.files[0].path] = {
318
- width: processed.width,
319
- height: processed.height,
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: 'Published with the Galascribe template renderer.',
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}`,