@wxxb789/anc 0.0.1 → 0.0.3
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 +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/mermaid-render.js +36 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal 0011's one encoder: a build-time WebP candidate for already-authorized
|
|
3
|
+
* raster bytes.
|
|
4
|
+
*
|
|
5
|
+
* This module is deliberately **not** a publication authority. It receives bytes
|
|
6
|
+
* the producer has already resolved, authorized and sniffed, and it returns
|
|
7
|
+
* either a byte buffer the build may publish or a bounded failure reason. It
|
|
8
|
+
* never reads a path, never sees a filename, and has no way to reach a source
|
|
9
|
+
* file the walk did not approve. `src/lib/media.ts` remains the module that
|
|
10
|
+
* identifies formats and public identity.
|
|
11
|
+
*
|
|
12
|
+
* ## The policy, exactly as `docs/core-design/media-pipeline.md` states it
|
|
13
|
+
*
|
|
14
|
+
* | Validated input | Candidate | Keep it only when |
|
|
15
|
+
* | --- | --- | --- |
|
|
16
|
+
* | JPEG | lossy WebP, quality 80, effort 4 | its bytes are smaller than the JPEG's |
|
|
17
|
+
* | 8-bit PNG | lossless WebP, effort 4 | its bytes are smaller than the PNG's; pixels are not compared here |
|
|
18
|
+
* | 16-bit or other-depth PNG | none | the original bytes are published unchanged |
|
|
19
|
+
* | WebP / AVIF | none | the original bytes are published unchanged |
|
|
20
|
+
* | animated GIF / animated WebP | none | ditto; no first frame is extracted |
|
|
21
|
+
* | static GIF | none | no policy row selects an encoder for it |
|
|
22
|
+
* | corrupt / over budget | none | the build fails; there is no fallback to the input |
|
|
23
|
+
*
|
|
24
|
+
* The comparison is **candidate emitted bytes vs source bytes**, never an
|
|
25
|
+
* estimate and never a perceptual score. Lossy quality is a qualification
|
|
26
|
+
* property (tests and the measurement corpus), not a runtime knob.
|
|
27
|
+
*
|
|
28
|
+
* ## Order of operations that must not be swapped
|
|
29
|
+
*
|
|
30
|
+
* `.autoOrient()` is applied before output. Sharp then strips source metadata on
|
|
31
|
+
* a transcode, so the published pixels are the displayed pixels and the emitted
|
|
32
|
+
* header carries the displayed size. Callers read dimensions from the *emitted*
|
|
33
|
+
* bytes with 0010's reader, which is why a rotated JPEG does not leak its coded
|
|
34
|
+
* frame into `width`/`height`.
|
|
35
|
+
*
|
|
36
|
+
* `.withIccProfile('srgb', { attach: false })` converts an embedded colour
|
|
37
|
+
* profile to sRGB and attaches none. Without it, stripping the ICC profile from a
|
|
38
|
+
* Display-P3 photo would change the colours a browser shows; with it, the
|
|
39
|
+
* conversion happens and the output stays metadata-free.
|
|
40
|
+
*
|
|
41
|
+
* ## Bounds
|
|
42
|
+
*
|
|
43
|
+
* Every operation runs under a byte cap, a decoded-pixel cap and a wall-clock
|
|
44
|
+
* deadline. `unlimited` is never set. The caps are chosen from the measured
|
|
45
|
+
* corpus and hostile controls recorded in goal 0011; they are finite because a
|
|
46
|
+
* build on a personal-notes host must fail on a decompression bomb rather than
|
|
47
|
+
* be killed by the operating system.
|
|
48
|
+
*/
|
|
49
|
+
import sharp, {} from 'sharp';
|
|
50
|
+
import { assertCompleteContainer, imageDimensions, isAnimatedPng, isPositiveDimension, pngCanUseLosslessWebp, } from "./media.js";
|
|
51
|
+
/**
|
|
52
|
+
* The fixed encoder settings for each format.
|
|
53
|
+
*
|
|
54
|
+
* The producer's cache is keyed by source digest alone because these are module
|
|
55
|
+
* constants for the lifetime of one build; changing a quality or effort here is
|
|
56
|
+
* a code change, not a per-build input. There is no author-facing knob and no
|
|
57
|
+
* configuration key.
|
|
58
|
+
*/
|
|
59
|
+
export const ENCODER_SETTINGS = Object.freeze({
|
|
60
|
+
/** Lossy WebP for JPEG photographs. */
|
|
61
|
+
lossy: Object.freeze({ quality: 80, effort: 4 }),
|
|
62
|
+
/** Lossless WebP for PNG screenshots, diagrams and alpha. */
|
|
63
|
+
lossless: Object.freeze({ effort: 4 }),
|
|
64
|
+
/** Converted-to-sRGB, metadata-free transcodes. */
|
|
65
|
+
colourspace: 'srgb',
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* libwebp's maximum supported dimension, `WEBP_MAX_DIMENSION` in
|
|
69
|
+
* `webp/encode.h`: 16383 pixels per side, inclusive.
|
|
70
|
+
*
|
|
71
|
+
* A source at or below this bound may have a WebP candidate; a valid image with
|
|
72
|
+
* either side above it cannot, so it must take the passthrough path rather than
|
|
73
|
+
* being mislabeled `corrupt` by the encoder. This is the library's format
|
|
74
|
+
* boundary, not a second project budget.
|
|
75
|
+
*/
|
|
76
|
+
export const WEBP_MAX_DIMENSION = 16_383;
|
|
77
|
+
/**
|
|
78
|
+
* Finite limits for one image, in the units each one bounds.
|
|
79
|
+
*
|
|
80
|
+
* - `maxSourceBytes` stops a absurdly large file before libvips parses it.
|
|
81
|
+
* - `maxPixels` is sharp's `limitInputPixels`; a single decoded frame above it
|
|
82
|
+
* is refused by libvips before allocation.
|
|
83
|
+
* - `maxAnimationPixels` bounds the stack of all frames when an animated file is
|
|
84
|
+
* decode-validated, because the full-animation check is the one place this
|
|
85
|
+
* build decodes more than the frame it displays.
|
|
86
|
+
* - The deadlines bound wall time per operation. libvips has no cancellable
|
|
87
|
+
* decode, so a deadline abandons the promise and fails the build; the worker's
|
|
88
|
+
* thread is left to finish, which is why the operation count is also bounded.
|
|
89
|
+
*
|
|
90
|
+
* Measured on the 4 vCPU / 8 GB qualification host rather than guessed; the goal
|
|
91
|
+
* record carries the throughput, peak-RSS and hostile-input results.
|
|
92
|
+
*/
|
|
93
|
+
export const MEDIA_LIMITS = Object.freeze({
|
|
94
|
+
maxSourceBytes: 32 * 1024 * 1024,
|
|
95
|
+
maxPixels: 50_000_000,
|
|
96
|
+
maxAnimationPixels: 50_000_000,
|
|
97
|
+
decodeTimeoutMs: 20_000,
|
|
98
|
+
encodeTimeoutMs: 20_000,
|
|
99
|
+
});
|
|
100
|
+
/** A rejected operation whose classification the caller needs. */
|
|
101
|
+
class MediaLimitError extends Error {
|
|
102
|
+
reason;
|
|
103
|
+
constructor(reason, message) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.reason = reason;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Sharp's error text for libvips' pixel-limit refusal, across versions. */
|
|
109
|
+
const PIXEL_LIMIT_ERROR = /pixel limit|exceeds.*limit/i;
|
|
110
|
+
/**
|
|
111
|
+
* Configure libvips once for this process.
|
|
112
|
+
*
|
|
113
|
+
* The cache is off because the producer already caches per source digest and a
|
|
114
|
+
* second, opaque cache inside libvips would only retain buffers; the thread count
|
|
115
|
+
* is explicit so a build's resource use is not whatever the host's CPU count
|
|
116
|
+
* happens to be. Both settings are process-wide and therefore not per-image
|
|
117
|
+
* budgets; every sharp consumer in this repository shares them.
|
|
118
|
+
*/
|
|
119
|
+
const SHARP_CONCURRENCY = 4;
|
|
120
|
+
sharp.cache(false);
|
|
121
|
+
sharp.concurrency(SHARP_CONCURRENCY);
|
|
122
|
+
/** Raise the timeout classification above the generic sharp wrapper. */
|
|
123
|
+
function classify(error) {
|
|
124
|
+
if (error instanceof MediaLimitError)
|
|
125
|
+
return error.reason;
|
|
126
|
+
if (error instanceof Error && PIXEL_LIMIT_ERROR.test(error.message))
|
|
127
|
+
return 'too-large';
|
|
128
|
+
return 'corrupt';
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Run one libvips operation under a deadline.
|
|
132
|
+
*
|
|
133
|
+
* `Promise.race` rather than an abort signal because sharp exposes none: the
|
|
134
|
+
* winning promise is settled and the loser's rejection is still observed by the
|
|
135
|
+
* race's own handler, so it cannot become an unhandled rejection. On timeout the
|
|
136
|
+
* build fails with a bounded reason; it does not fall back to publishing bytes
|
|
137
|
+
* this process could not read.
|
|
138
|
+
*/
|
|
139
|
+
async function withDeadline(work, milliseconds) {
|
|
140
|
+
const timeout = Promise.withResolvers();
|
|
141
|
+
const timer = setTimeout(() => timeout.reject(new MediaLimitError('timeout', 'operation exceeded its deadline')), milliseconds);
|
|
142
|
+
try {
|
|
143
|
+
return await Promise.race([work, timeout.promise]);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The constructor options every operation on untrusted bytes uses. */
|
|
150
|
+
function inputOptions(limits, animated = false) {
|
|
151
|
+
return {
|
|
152
|
+
// Sharp's strict default: a warning is a failure, not a partially decoded image.
|
|
153
|
+
failOn: 'warning',
|
|
154
|
+
limitInputPixels: limits.maxPixels,
|
|
155
|
+
// Never lifted: this is the decompression-bomb boundary.
|
|
156
|
+
unlimited: false,
|
|
157
|
+
// The bytes are a buffer, so sequential access is the truthful hint.
|
|
158
|
+
sequentialRead: true,
|
|
159
|
+
...(animated ? { animated: true } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Read a source's own header without trusting its filename. */
|
|
163
|
+
async function readMetadata(bytes, limits, animated) {
|
|
164
|
+
return withDeadline(sharp(bytes, inputOptions(limits, animated)).metadata(), limits.decodeTimeoutMs);
|
|
165
|
+
}
|
|
166
|
+
/** The displayed pixel size after `autoOrient`, from the coded frame and its tag. */
|
|
167
|
+
function displayedDimensions(metadata) {
|
|
168
|
+
const { width, height, orientation } = metadata;
|
|
169
|
+
if (!isPositiveDimension(width) || !isPositiveDimension(height)) {
|
|
170
|
+
throw new MediaLimitError('corrupt', 'the image header declares no positive pixel size');
|
|
171
|
+
}
|
|
172
|
+
const transposed = orientation !== undefined && orientation >= 5 && orientation <= 8;
|
|
173
|
+
return transposed ? { width: height, height: width } : { width, height };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Fully decode a passthrough image once, so a corrupt or truncated file fails
|
|
177
|
+
* the build instead of reaching a reader.
|
|
178
|
+
*
|
|
179
|
+
* A header read is not decode validation: measured, a JPEG truncated to 10% of
|
|
180
|
+
* its bytes still yields valid metadata. `stats()` decodes the visible frame. An
|
|
181
|
+
* animated file's first metadata read already used `animated: true`, so the
|
|
182
|
+
* stacked height and frame count are available without opening the file twice;
|
|
183
|
+
* the raw decode checks every frame under the separate total-pixel budget.
|
|
184
|
+
*/
|
|
185
|
+
async function validateDecode(bytes, metadata, limits) {
|
|
186
|
+
const pages = metadata.pages ?? 1;
|
|
187
|
+
if (pages > 1) {
|
|
188
|
+
const { width, height } = metadata;
|
|
189
|
+
if (!isPositiveDimension(width) || !isPositiveDimension(height)) {
|
|
190
|
+
throw new MediaLimitError('corrupt', 'the animated image header declares no positive pixel size');
|
|
191
|
+
}
|
|
192
|
+
// With `animated: true` sharp reports a stacked image: `height` is already
|
|
193
|
+
// the sum of every frame, so multiplying by `pages` would count the frames
|
|
194
|
+
// twice. Measured on animated WebP and GIF, both of which also expose
|
|
195
|
+
// `pageHeight`; the bound is the actual decoded surface.
|
|
196
|
+
if (width * height > limits.maxAnimationPixels) {
|
|
197
|
+
throw new MediaLimitError('too-large', 'the animated image exceeds the total decoded-pixel budget');
|
|
198
|
+
}
|
|
199
|
+
await withDeadline(sharp(bytes, inputOptions(limits, true)).raw().toBuffer(), limits.decodeTimeoutMs);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await withDeadline(sharp(bytes, inputOptions(limits)).stats(), limits.decodeTimeoutMs);
|
|
203
|
+
}
|
|
204
|
+
/** The one `.webp()` option object for a policy row. */
|
|
205
|
+
function webpOptions(format) {
|
|
206
|
+
return format === 'jpeg' ? ENCODER_SETTINGS.lossy : { lossless: true, ...ENCODER_SETTINGS.lossless };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Encode one validation-passing candidate and prove what came back is what the
|
|
210
|
+
* policy says it should be.
|
|
211
|
+
*
|
|
212
|
+
* Returning `undefined` means "no candidate": the encoder produced bytes but the
|
|
213
|
+
* output failed validation. That is a build failure, never a silent fallback,
|
|
214
|
+
* because a codec that returned something unreadable is exactly the case in which
|
|
215
|
+
* publishing the original would hide the defect.
|
|
216
|
+
*/
|
|
217
|
+
async function transcode(bytes, format, metadata, limits) {
|
|
218
|
+
const expected = displayedDimensions(metadata);
|
|
219
|
+
const encoded = await withDeadline(sharp(bytes, inputOptions(limits))
|
|
220
|
+
.autoOrient()
|
|
221
|
+
.withIccProfile(ENCODER_SETTINGS.colourspace, { attach: false })
|
|
222
|
+
.webp(webpOptions(format))
|
|
223
|
+
.toBuffer(), limits.encodeTimeoutMs);
|
|
224
|
+
if (encoded.length === 0)
|
|
225
|
+
return undefined;
|
|
226
|
+
const produced = await readMetadata(encoded, limits, false);
|
|
227
|
+
if (produced.format !== 'webp')
|
|
228
|
+
return undefined;
|
|
229
|
+
if (produced.orientation !== undefined && produced.orientation !== 1)
|
|
230
|
+
return undefined;
|
|
231
|
+
if (produced.pages !== undefined && produced.pages > 1)
|
|
232
|
+
return undefined;
|
|
233
|
+
if (produced.width !== expected.width || produced.height !== expected.height)
|
|
234
|
+
return undefined;
|
|
235
|
+
return encoded;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Prepare the one published representation of an authorized image.
|
|
239
|
+
*
|
|
240
|
+
* @param bytes The exact bytes the walk authorized; never re-read from a path.
|
|
241
|
+
* @param format The format `sniffImageFormat` detected, which the walk already
|
|
242
|
+
* used to classify the file; the encoder does not second-guess it.
|
|
243
|
+
*/
|
|
244
|
+
export async function prepareImage(bytes, format, limits = MEDIA_LIMITS) {
|
|
245
|
+
if (bytes.length > limits.maxSourceBytes) {
|
|
246
|
+
return { ok: false, reason: 'too-large' };
|
|
247
|
+
}
|
|
248
|
+
// A successful header read is not a complete container: measured, libvips
|
|
249
|
+
// decodes a 96x96 PNG with its last 20 bytes missing and a GIF truncated to
|
|
250
|
+
// 90% with a garbage tail. The structural gate runs before any sharp call, so
|
|
251
|
+
// an incomplete source never reaches libvips at all. GIF's one cross-check
|
|
252
|
+
// that needs decoded metadata is repeated after the header read below.
|
|
253
|
+
try {
|
|
254
|
+
assertCompleteContainer(format, bytes);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return { ok: false, reason: 'corrupt' };
|
|
258
|
+
}
|
|
259
|
+
// Animation is a property of the already-sniffed container, not a second
|
|
260
|
+
// decode: choosing it here makes the first metadata read sufficient.
|
|
261
|
+
const animated = format === 'gif' || format === 'webp' || format === 'avif';
|
|
262
|
+
let metadata;
|
|
263
|
+
try {
|
|
264
|
+
metadata = await readMetadata(bytes, limits, animated);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
return { ok: false, reason: classify(error) };
|
|
268
|
+
}
|
|
269
|
+
// The descriptor walk above counted frames, but only the decoder can say how
|
|
270
|
+
// many pages it accepted. Requiring the two numbers to agree catches a stream
|
|
271
|
+
// whose descriptors are structurally present but whose page table disagrees.
|
|
272
|
+
if (format === 'gif') {
|
|
273
|
+
try {
|
|
274
|
+
assertCompleteContainer(format, bytes, { pages: metadata.pages });
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return { ok: false, reason: 'corrupt' };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const sourceResult = () => ({
|
|
281
|
+
ok: true,
|
|
282
|
+
image: {
|
|
283
|
+
bytes,
|
|
284
|
+
format,
|
|
285
|
+
dimensions: imageDimensions(bytes, format),
|
|
286
|
+
transcoded: false,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const passthrough = async () => {
|
|
290
|
+
try {
|
|
291
|
+
await validateDecode(bytes, metadata, limits);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
return { ok: false, reason: classify(error) };
|
|
295
|
+
}
|
|
296
|
+
return sourceResult();
|
|
297
|
+
};
|
|
298
|
+
// The policy table has no encoding row for these formats, and an animation is
|
|
299
|
+
// preserved rather than flattened.
|
|
300
|
+
if (format === 'gif' || format === 'webp' || format === 'avif')
|
|
301
|
+
return passthrough();
|
|
302
|
+
const pages = metadata.pages ?? 1;
|
|
303
|
+
if (pages > 1)
|
|
304
|
+
return passthrough();
|
|
305
|
+
// APNG is a PNG whose animation sharp does not report as pages. Preserve the
|
|
306
|
+
// source exactly rather than publishing its first frame as a still WebP.
|
|
307
|
+
// A PNG whose depth or colour model WebP lossless cannot carry faithfully
|
|
308
|
+
// (16-bit and other depths) is likewise published unchanged, even when an
|
|
309
|
+
// 8-bit candidate would be smaller: that candidate is a visible reduction.
|
|
310
|
+
if (format === 'png' && (isAnimatedPng(bytes) || !pngCanUseLosslessWebp(bytes))) {
|
|
311
|
+
return passthrough();
|
|
312
|
+
}
|
|
313
|
+
// WebP cannot represent a side above libwebp's 16,383-pixel limit. The source
|
|
314
|
+
// is valid, so publish it unchanged instead of asking the encoder for a
|
|
315
|
+
// candidate that cannot exist; the metadata read above already supplies the
|
|
316
|
+
// displayed dimensions, so this does not decode again.
|
|
317
|
+
let sourceDimensions;
|
|
318
|
+
try {
|
|
319
|
+
sourceDimensions = displayedDimensions(metadata);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return { ok: false, reason: classify(error) };
|
|
323
|
+
}
|
|
324
|
+
if (sourceDimensions.width > WEBP_MAX_DIMENSION || sourceDimensions.height > WEBP_MAX_DIMENSION) {
|
|
325
|
+
return passthrough();
|
|
326
|
+
}
|
|
327
|
+
let candidate;
|
|
328
|
+
try {
|
|
329
|
+
candidate = await transcode(bytes, format, metadata, limits);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
return { ok: false, reason: classify(error) };
|
|
333
|
+
}
|
|
334
|
+
if (candidate === undefined)
|
|
335
|
+
return { ok: false, reason: 'encode-failed' };
|
|
336
|
+
// The only automatic comparison in this pass: emitted candidate bytes against
|
|
337
|
+
// the bytes the source would have published. A larger or equal candidate is
|
|
338
|
+
// discarded, not shipped for the sake of a `.webp` name.
|
|
339
|
+
if (candidate.length >= bytes.length)
|
|
340
|
+
return sourceResult();
|
|
341
|
+
// 0010's reader owns the pair the artifact publishes, and it reads the emitted
|
|
342
|
+
// bytes.
|
|
343
|
+
const emitted = imageDimensions(candidate, 'webp');
|
|
344
|
+
if (emitted === undefined)
|
|
345
|
+
return { ok: false, reason: 'encode-failed' };
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
image: {
|
|
349
|
+
bytes: candidate,
|
|
350
|
+
format: 'webp',
|
|
351
|
+
dimensions: emitted,
|
|
352
|
+
transcoded: true,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a published image is delivered from, when the build was explicitly
|
|
3
|
+
* opted into a managed image host.
|
|
4
|
+
*
|
|
5
|
+
* The artifact and the producer always speak the same-origin path
|
|
6
|
+
* (`/_assets/<sha256>-<name>`); that path is the asset's identity. This module
|
|
7
|
+
* is the only place the *delivery location* changes: when
|
|
8
|
+
* `anc build --release --media r2` sets {@link MEDIA_BASE_URL_VARIABLE} before
|
|
9
|
+
* Astro renders, `markdown.ts` prefixes each authorized asset with the
|
|
10
|
+
* configured public custom-domain origin. No other consumer sees the absolute
|
|
11
|
+
* form, so the output inventory, the artifact, the copy step, and the digest
|
|
12
|
+
* checks keep comparing the one stable path.
|
|
13
|
+
*
|
|
14
|
+
* ## Why an environment variable
|
|
15
|
+
*
|
|
16
|
+
* `astro.config.mjs` and its pages are evaluated by Astro after the CLI has
|
|
17
|
+
* changed the working directory to this package's root. The CLI already
|
|
18
|
+
* crosses that boundary with `PUBLISH_CONFIG_DIR`, `CONTENT_ARTIFACT`, and
|
|
19
|
+
* `SNAPSHOT_WORKSPACE`, and there is no function argument available for the
|
|
20
|
+
* same reason: nothing in this repository calls the page module directly. An
|
|
21
|
+
* environment variable is the seam, and it is set only for the opted-in build.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the value is validated again
|
|
24
|
+
*
|
|
25
|
+
* It comes from a configuration table the loader already validated, so a
|
|
26
|
+
* malformed value here is a defect rather than a user mistake. It is still
|
|
27
|
+
* checked: a renderer that concatenated an arbitrary string into `src` would
|
|
28
|
+
* be one environment variable away from emitting a `javascript:` URL or a
|
|
29
|
+
* non-HTTPS image, and the image sanitizer's scheme allowlist runs *before*
|
|
30
|
+
* this rewrite. Failing closed here is cheaper than teaching the sanitizer
|
|
31
|
+
* about a second pass.
|
|
32
|
+
*/
|
|
33
|
+
/** The environment variable naming the public media origin, when opted in. */
|
|
34
|
+
export const MEDIA_BASE_URL_VARIABLE = 'PUBLISH_MEDIA_BASE_URL';
|
|
35
|
+
/**
|
|
36
|
+
* The bare HTTPS origin images are delivered from, or `undefined` for the
|
|
37
|
+
* ordinary same-origin build.
|
|
38
|
+
*
|
|
39
|
+
* Empty is absent: a shell unsets a variable for one command by setting it
|
|
40
|
+
* empty, which is the same convention `artifact-source.ts` uses.
|
|
41
|
+
*/
|
|
42
|
+
export function mediaDeliveryOrigin() {
|
|
43
|
+
const value = process.env[MEDIA_BASE_URL_VARIABLE];
|
|
44
|
+
if (value === undefined || value === '')
|
|
45
|
+
return undefined;
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error('the media delivery origin is not an absolute URL');
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== 'https:' ||
|
|
54
|
+
url.username !== '' ||
|
|
55
|
+
url.password !== '' ||
|
|
56
|
+
url.pathname !== '/' ||
|
|
57
|
+
url.search !== '' ||
|
|
58
|
+
url.hash !== '') {
|
|
59
|
+
throw new Error('the media delivery origin must be a bare HTTPS origin with no credentials');
|
|
60
|
+
}
|
|
61
|
+
return url.origin;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The absolute HTTPS URL a reader fetches for a published asset path.
|
|
65
|
+
*
|
|
66
|
+
* `assetPath` is the artifact's canonical `/_assets/...` path. Concatenation
|
|
67
|
+
* rather than `new URL()` is deliberate: `URL` would normalize percent-encoded
|
|
68
|
+
* bytes into a second spelling, and the path's canonical encoding is what the
|
|
69
|
+
* object key, the staged filename, and the output inventory all agree on.
|
|
70
|
+
*/
|
|
71
|
+
export function deliveredMediaUrl(assetPath, origin) {
|
|
72
|
+
if (!assetPath.startsWith('/')) {
|
|
73
|
+
throw new Error('a delivered media path must be root-absolute');
|
|
74
|
+
}
|
|
75
|
+
return `${origin}${assetPath}`;
|
|
76
|
+
}
|