@wxxb789/anc 0.0.2 → 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/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
|
@@ -78,13 +78,14 @@
|
|
|
78
78
|
*/
|
|
79
79
|
import { spawnSync } from 'node:child_process';
|
|
80
80
|
import { createHash } from 'node:crypto';
|
|
81
|
-
import { readFile, readdir, mkdir, realpath, stat, writeFile } from 'node:fs/promises';
|
|
82
|
-
import { dirname, isAbsolute, matchesGlob, relative } from 'node:path';
|
|
81
|
+
import { readFile, readdir, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
82
|
+
import { basename, dirname, isAbsolute, join, matchesGlob, relative } from 'node:path';
|
|
83
83
|
import { parse as parseYaml } from 'yaml';
|
|
84
84
|
import { FIELD_LIMITS, RESERVED_SLUGS, contentLimitIssues, contentPrivacyIssues, isLanguageTag, isSafeTagOrAlias, isSlug, validateArtifact, } from "../src/lib/schema.js";
|
|
85
85
|
import { indexCorpus } from "../src/lib/link-resolution.js";
|
|
86
|
+
import { assetFileName, assetUrlFor, looksLikeSvg, parseAssetUrl, resolveImageTarget, sniffImageFormat, } from "../src/lib/media.js";
|
|
86
87
|
import { SLUG_MAX_BYTES, compareSlugs, slugSegment, utf8Length } from "../src/lib/route-path.js";
|
|
87
|
-
import { BuildFailure, bySourceThenLine } from "./write-report.js";
|
|
88
|
+
import { BuildFailure, bySourceThenLine, } from "./write-report.js";
|
|
88
89
|
/**
|
|
89
90
|
* Names the walk never descends into or enumerates, tested against one path
|
|
90
91
|
* segment.
|
|
@@ -930,6 +931,16 @@ function excludes(relativePath, patterns, matched) {
|
|
|
930
931
|
export async function discover(contentDirectory, options = {}) {
|
|
931
932
|
const patterns = options.exclude ?? [];
|
|
932
933
|
const matched = patterns.map(() => false);
|
|
934
|
+
/**
|
|
935
|
+
* Every non-Markdown file, with what this build may do with it.
|
|
936
|
+
*
|
|
937
|
+
* Recorded here rather than derived again where an image is resolved, because
|
|
938
|
+
* the exclusion verdict is a property of this walk: the pattern list, the
|
|
939
|
+
* zero-match bookkeeping, and the symlink escape set all live in this function,
|
|
940
|
+
* and a second pass over the same questions is a second answer waiting to
|
|
941
|
+
* disagree.
|
|
942
|
+
*/
|
|
943
|
+
const media = new Map();
|
|
933
944
|
/** File links whose target resolves outside the content root: dropped unread. */
|
|
934
945
|
const escaped = new Set();
|
|
935
946
|
const paths = await walk(contentDirectory, escaped);
|
|
@@ -957,6 +968,19 @@ export async function discover(contentDirectory, options = {}) {
|
|
|
957
968
|
// verdict was." A file dropped for another reason is still discovered.
|
|
958
969
|
const excluded = excludes(path, patterns, matched);
|
|
959
970
|
if (!path.endsWith('.md')) {
|
|
971
|
+
// Recorded before the drop, and with the exclusion and escape verdicts the
|
|
972
|
+
// note branches below compute. A file that is not a note is still a file
|
|
973
|
+
// the author wrote, and an image referenced from a published note is
|
|
974
|
+
// published by *this* verdict and no other.
|
|
975
|
+
//
|
|
976
|
+
// An escape is tested first: a symlink whose target lies outside the
|
|
977
|
+
// content root would otherwise look like an ordinary non-Markdown file,
|
|
978
|
+
// and on Windows a file symlink reports `isFile() === false` under
|
|
979
|
+
// `withFileTypes` — which is the measurement `walk` records.
|
|
980
|
+
media.set(path.normalize('NFC'), {
|
|
981
|
+
path,
|
|
982
|
+
verdict: escaped.has(path) ? 'escaping' : excluded ? 'excluded' : 'available',
|
|
983
|
+
});
|
|
960
984
|
dropped.push({ path, reason: 'not-markdown' });
|
|
961
985
|
continue;
|
|
962
986
|
}
|
|
@@ -1104,6 +1128,9 @@ export async function discover(contentDirectory, options = {}) {
|
|
|
1104
1128
|
paths,
|
|
1105
1129
|
pathBySlug,
|
|
1106
1130
|
declaredTitles,
|
|
1131
|
+
assetRoot: contentDirectory,
|
|
1132
|
+
media,
|
|
1133
|
+
assets: [],
|
|
1107
1134
|
};
|
|
1108
1135
|
}
|
|
1109
1136
|
const defaultRouteForSlug = (slug) => `/${slug}/`;
|
|
@@ -1116,6 +1143,177 @@ const defaultRouteForSlug = (slug) => `/${slug}/`;
|
|
|
1116
1143
|
* long-running process holding many discoveries does not retain them.
|
|
1117
1144
|
*/
|
|
1118
1145
|
const resolved = new WeakSet();
|
|
1146
|
+
/**
|
|
1147
|
+
* Reason labels, in the order the public summary counts them.
|
|
1148
|
+
*
|
|
1149
|
+
* A `Record` rather than a list of pairs, so a new `MediaFailureReason` cannot
|
|
1150
|
+
* reach the count and the private detail while silently disappearing from the
|
|
1151
|
+
* public breakdown. `Object.entries` preserves this insertion order.
|
|
1152
|
+
*/
|
|
1153
|
+
const MEDIA_REASON_LABELS = {
|
|
1154
|
+
missing: 'missing',
|
|
1155
|
+
excluded: 'excluded',
|
|
1156
|
+
outside: 'outside the content root',
|
|
1157
|
+
unsupported: 'unsupported file type',
|
|
1158
|
+
'html-form': 'unsupported HTML image form',
|
|
1159
|
+
corrupt: 'corrupt or undecodable',
|
|
1160
|
+
'too-large': 'over the size limit',
|
|
1161
|
+
timeout: 'exceeded the processing deadline',
|
|
1162
|
+
'encode-failed': 'encoder failure',
|
|
1163
|
+
'svg-invalid': 'malformed SVG',
|
|
1164
|
+
'svg-doctype': 'SVG doctype or entity declaration',
|
|
1165
|
+
'svg-xinclude': 'SVG XInclude',
|
|
1166
|
+
'svg-script': 'active SVG content',
|
|
1167
|
+
'svg-foreign': 'SVG foreign content',
|
|
1168
|
+
'svg-unsafe': 'unsupported SVG construct',
|
|
1169
|
+
'svg-animation': 'SVG animation',
|
|
1170
|
+
'svg-external': 'external SVG reference',
|
|
1171
|
+
'svg-data': 'embedded SVG data asset',
|
|
1172
|
+
'svg-css': 'unsafe SVG CSS',
|
|
1173
|
+
'svg-render': 'SVG rasterizer failure',
|
|
1174
|
+
'svg-workload': 'SVG over the work budget',
|
|
1175
|
+
'svg-font': 'SVG text with no host font coverage',
|
|
1176
|
+
'svg-empty': 'SVG with no visible content',
|
|
1177
|
+
};
|
|
1178
|
+
/**
|
|
1179
|
+
* The one function that reads image bytes, and the only writer of the asset set.
|
|
1180
|
+
*
|
|
1181
|
+
* Built per build and shared across notes, so deduplication is a property of the
|
|
1182
|
+
* build rather than of a note. There are three caches, each with one job:
|
|
1183
|
+
*
|
|
1184
|
+
* - **by path**, so `../img/a.png` from one note and `img/a.png` from another are
|
|
1185
|
+
* the same resolved file and are read once;
|
|
1186
|
+
* - **by source digest**, so two files with identical bytes transcode once. The
|
|
1187
|
+
* encoder settings are module constants for the build's lifetime, so they are
|
|
1188
|
+
* not part of the key. This is Goal 0011's "private per-build in-memory cache";
|
|
1189
|
+
* its lifetime is the build and it is not a public index or a durable contract;
|
|
1190
|
+
* - **by emitted digest**, so two different sources that encode to the same bytes
|
|
1191
|
+
* publish one asset. Identity is the bytes that leave this build, not the bytes
|
|
1192
|
+
* that arrived.
|
|
1193
|
+
*
|
|
1194
|
+
* Authorization happens **before** the read: an excluded file, a symlink whose
|
|
1195
|
+
* target is outside the tree, and a name the walk never found are refused without
|
|
1196
|
+
* ever being opened, and the codec is only reached after the sniff. That ordering
|
|
1197
|
+
* is the publication boundary: a codec is never a publication authority, and the
|
|
1198
|
+
* pixels of a withheld image are never in this process's memory.
|
|
1199
|
+
*
|
|
1200
|
+
* A failure is cached too, so an `unsupported` or corrupt file is processed once
|
|
1201
|
+
* however many notes embed it. Every reference still gets its own report row,
|
|
1202
|
+
* because a reference has a line and a spelling and the two are what an author
|
|
1203
|
+
* edits.
|
|
1204
|
+
*/
|
|
1205
|
+
function mediaPublisher(discovery) {
|
|
1206
|
+
const byPath = new Map();
|
|
1207
|
+
const bySource = new Map();
|
|
1208
|
+
const byEmitted = new Map();
|
|
1209
|
+
const assets = [];
|
|
1210
|
+
/** Publish one file the resolver has already found inside the content root. */
|
|
1211
|
+
const publishLocal = async (resolvedPath) => {
|
|
1212
|
+
const entry = discovery.media.get(resolvedPath.normalize('NFC'));
|
|
1213
|
+
if (entry === undefined)
|
|
1214
|
+
return { kind: 'failed', reason: 'missing' };
|
|
1215
|
+
if (entry.verdict === 'excluded')
|
|
1216
|
+
return { kind: 'failed', reason: 'excluded' };
|
|
1217
|
+
if (entry.verdict === 'escaping')
|
|
1218
|
+
return { kind: 'failed', reason: 'outside' };
|
|
1219
|
+
// `media-codec` imports sharp, a per-platform native binding. Load it only
|
|
1220
|
+
// when an authorized local reference actually reaches this boundary; a
|
|
1221
|
+
// corpus with no images must not evaluate it. `resolve-links` below documents
|
|
1222
|
+
// the same class of failure for its parser.
|
|
1223
|
+
const { MEDIA_LIMITS, prepareImage } = await import("../src/lib/media-codec.js");
|
|
1224
|
+
// The codec enforces this after the bytes are in memory; this check keeps a
|
|
1225
|
+
// multi-gigabyte source from being allocated at all. `stat` follows the same
|
|
1226
|
+
// resolved path the read would, and a failure here is the same "not there"
|
|
1227
|
+
// answer. A file replaced between this call and the read is caught by the
|
|
1228
|
+
// codec's byte-length limit and by the staging digest check.
|
|
1229
|
+
const absolute = join(discovery.assetRoot, entry.path);
|
|
1230
|
+
let size;
|
|
1231
|
+
try {
|
|
1232
|
+
size = (await stat(absolute)).size;
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return { kind: 'failed', reason: 'missing' };
|
|
1236
|
+
}
|
|
1237
|
+
if (size > MEDIA_LIMITS.maxSourceBytes)
|
|
1238
|
+
return { kind: 'failed', reason: 'too-large' };
|
|
1239
|
+
let bytes;
|
|
1240
|
+
try {
|
|
1241
|
+
bytes = await readFile(absolute);
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// The walk saw it and the read did not: it was removed between the two,
|
|
1245
|
+
// or the process lacks permission. "Not there" is the honest answer and
|
|
1246
|
+
// it fails the build like any other missing image.
|
|
1247
|
+
return { kind: 'failed', reason: 'missing' };
|
|
1248
|
+
}
|
|
1249
|
+
const format = sniffImageFormat(bytes);
|
|
1250
|
+
// Goal 0012: an SVG is a build-only source. The signature selects the policy
|
|
1251
|
+
// and rasterizer, which run on these same authorized bytes; the XML is never
|
|
1252
|
+
// staged or published, and a conversion failure never falls back to it.
|
|
1253
|
+
const svg = format === undefined && looksLikeSvg(bytes);
|
|
1254
|
+
// The source digest answers "have I already processed these exact bytes?".
|
|
1255
|
+
// The emitted digest is what the URL will name.
|
|
1256
|
+
const sourceDigest = createHash('sha256').update(bytes).digest('hex');
|
|
1257
|
+
let prepared = bySource.get(sourceDigest);
|
|
1258
|
+
if (prepared === undefined) {
|
|
1259
|
+
if (svg) {
|
|
1260
|
+
prepared = import("../src/lib/svg-render.js").then(({ prepareSvg }) => prepareSvg(bytes));
|
|
1261
|
+
}
|
|
1262
|
+
else if (format !== undefined) {
|
|
1263
|
+
prepared = prepareImage(bytes, format);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
return { kind: 'failed', reason: 'unsupported' };
|
|
1267
|
+
}
|
|
1268
|
+
bySource.set(sourceDigest, prepared);
|
|
1269
|
+
}
|
|
1270
|
+
const result = await prepared;
|
|
1271
|
+
if (!result.ok) {
|
|
1272
|
+
return {
|
|
1273
|
+
kind: 'failed',
|
|
1274
|
+
reason: result.reason,
|
|
1275
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
const emitted = result.image;
|
|
1279
|
+
const digest = createHash('sha256').update(emitted.bytes).digest('hex');
|
|
1280
|
+
const existing = byEmitted.get(digest);
|
|
1281
|
+
if (existing !== undefined)
|
|
1282
|
+
return { kind: 'published', url: existing };
|
|
1283
|
+
// Identity is the emitted digest; the name is decoration. Two sources whose
|
|
1284
|
+
// published bytes are identical therefore share one asset under the first
|
|
1285
|
+
// name the deterministic walk order reached, which is what makes the URL
|
|
1286
|
+
// stable across builds even when a transcode changed the extension.
|
|
1287
|
+
const url = assetUrlFor(digest, assetFileName(basename(entry.path), emitted.format));
|
|
1288
|
+
byEmitted.set(digest, url);
|
|
1289
|
+
assets.push({
|
|
1290
|
+
url,
|
|
1291
|
+
path: entry.path,
|
|
1292
|
+
sourceDigest,
|
|
1293
|
+
digest,
|
|
1294
|
+
bytes: emitted.bytes,
|
|
1295
|
+
...(emitted.dimensions === undefined ? {} : { dimensions: emitted.dimensions }),
|
|
1296
|
+
...(emitted.displayDimensions === undefined ? {} : { displayDimensions: emitted.displayDimensions }),
|
|
1297
|
+
});
|
|
1298
|
+
return { kind: 'published', url };
|
|
1299
|
+
};
|
|
1300
|
+
const publisher = {
|
|
1301
|
+
publish(request) {
|
|
1302
|
+
const resolved = resolveImageTarget(request.target, request.notePath, (key) => discovery.media.get(key)?.path, request.isWikilink);
|
|
1303
|
+
if (resolved.kind === 'external')
|
|
1304
|
+
return Promise.resolve({ kind: 'external' });
|
|
1305
|
+
if (resolved.kind !== 'local')
|
|
1306
|
+
return Promise.resolve({ kind: 'failed', reason: resolved.kind });
|
|
1307
|
+
const cached = byPath.get(resolved.path);
|
|
1308
|
+
if (cached !== undefined)
|
|
1309
|
+
return cached;
|
|
1310
|
+
const outcome = publishLocal(resolved.path);
|
|
1311
|
+
byPath.set(resolved.path, outcome);
|
|
1312
|
+
return outcome;
|
|
1313
|
+
},
|
|
1314
|
+
};
|
|
1315
|
+
return { publisher, assets };
|
|
1316
|
+
}
|
|
1119
1317
|
/**
|
|
1120
1318
|
* Resolve every link in every published note, in one traversal per note.
|
|
1121
1319
|
*
|
|
@@ -1166,6 +1364,8 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1166
1364
|
const slugByPath = new Map([...pathBySlug].map(([slug, path]) => [path, slug]));
|
|
1167
1365
|
const corpus = allPaths.map((path) => ({ path, slug: slugByPath.get(path) }));
|
|
1168
1366
|
const index = indexCorpus(corpus);
|
|
1367
|
+
const { publisher, assets } = mediaPublisher(discovery);
|
|
1368
|
+
const mediaFailures = [];
|
|
1169
1369
|
// **Imported here rather than at the top of the file, and this is measured
|
|
1170
1370
|
// rather than stylistic.** `resolve-links.ts` imports `satteri`, whose parser
|
|
1171
1371
|
// is a native binding installed per platform — a win32 install carries
|
|
@@ -1173,7 +1373,10 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1173
1373
|
// gate loads *this module* under WSL to compare the two walks, and a static
|
|
1174
1374
|
// import made that load throw `Cannot find native binding` before discovery
|
|
1175
1375
|
// ran at all, turning a green gate red on a property this ticket does not
|
|
1176
|
-
// touch. The walk needs no parser; only the traversal does.
|
|
1376
|
+
// touch. The walk needs no parser; only the traversal does. `media-codec.ts`
|
|
1377
|
+
// is the second instance of the same class: it imports `sharp`, whose native
|
|
1378
|
+
// binding is likewise installed per platform, so it is imported inside the
|
|
1379
|
+
// media boundary rather than here or at module scope.
|
|
1177
1380
|
const { resolveLinksIn } = await import("./resolve-links.js");
|
|
1178
1381
|
const findings = [];
|
|
1179
1382
|
for (const entry of entries) {
|
|
@@ -1186,7 +1389,7 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1186
1389
|
continue;
|
|
1187
1390
|
let result;
|
|
1188
1391
|
try {
|
|
1189
|
-
result = resolveLinksIn(entry.markdown, path, index, entry.slug, routeForSlug);
|
|
1392
|
+
result = await resolveLinksIn(entry.markdown, path, index, entry.slug, routeForSlug, publisher);
|
|
1190
1393
|
}
|
|
1191
1394
|
catch (error) {
|
|
1192
1395
|
// `applyEdits` refuses overlapping spans, which is a rewritten body that
|
|
@@ -1206,6 +1409,7 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1206
1409
|
// The *page* still renders links in document order: this array is the edge
|
|
1207
1410
|
// set, not the body, and the body is `result.markdown` above.
|
|
1208
1411
|
entry.outgoing = [...result.outgoing].sort(compareSlugs);
|
|
1412
|
+
mediaFailures.push(...result.mediaFailures);
|
|
1209
1413
|
// Re-derived from the rewritten body. A title and an excerpt are
|
|
1210
1414
|
// projections of what was published, and taking them from the pre-traversal
|
|
1211
1415
|
// text put an unresolved `[[wikilink]]` into the excerpt of a body that no
|
|
@@ -1216,6 +1420,31 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1216
1420
|
entry.excerpt = excerptFor(entry.markdown);
|
|
1217
1421
|
findings.push(...result.findings);
|
|
1218
1422
|
}
|
|
1423
|
+
// **The asset set is published only when every reference resolved.** The
|
|
1424
|
+
// traversal has already degraded the failures to their alt text so the bodies
|
|
1425
|
+
// above are self-consistent, but nothing from this run is written: a build that
|
|
1426
|
+
// shipped some images and dropped others would be a site whose pictures
|
|
1427
|
+
// silently changed.
|
|
1428
|
+
//
|
|
1429
|
+
// The stream carries a count and the reason labels — integers and literals of
|
|
1430
|
+
// this module's own table. The file names, and the note that wrote each
|
|
1431
|
+
// reference, are in the report, because an excluded image's directory is
|
|
1432
|
+
// exactly the disclosure the private half exists for: a workflow log on a
|
|
1433
|
+
// public notes repository is world-readable and retained.
|
|
1434
|
+
if (mediaFailures.length > 0) {
|
|
1435
|
+
mediaFailures.sort(bySourceThenLine);
|
|
1436
|
+
const counts = Object.entries(MEDIA_REASON_LABELS).flatMap(([reason, label]) => {
|
|
1437
|
+
const count = mediaFailures.filter((row) => row.reason === reason).length;
|
|
1438
|
+
return count === 0 ? [] : [`${count} ${label}`];
|
|
1439
|
+
}).join(', ');
|
|
1440
|
+
throw new BuildFailure('image-not-publishable', `${mediaFailures.length} image reference${mediaFailures.length === 1 ? '' : 's'} in published notes ` +
|
|
1441
|
+
`cannot be published (${counts}). An image must name a file this build may publish — PNG, JPEG, ` +
|
|
1442
|
+
'GIF, WebP, or AVIF, or a self-contained static SVG — inside the content root. The build stops ' +
|
|
1443
|
+
'rather than shipping a broken picture.', mediaFailures
|
|
1444
|
+
.map((row) => `${row.source}:${row.line} → ${row.link} (${row.reason}${row.detail === undefined ? '' : `: ${row.detail}`})`)
|
|
1445
|
+
.join('\n'));
|
|
1446
|
+
}
|
|
1447
|
+
discovery.assets = assets;
|
|
1219
1448
|
// **The mechanical inverse, and why it is here rather than left to TK-28.**
|
|
1220
1449
|
// `checkCorpus` requires `backlinks` to be the exact inverse of `outgoing`
|
|
1221
1450
|
// and computes precisely this list to compare against. So the moment
|
|
@@ -1244,6 +1473,59 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1244
1473
|
// disk.
|
|
1245
1474
|
return findings.sort(bySourceThenLine);
|
|
1246
1475
|
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Stage the emitted asset bytes beside the artifact, under the names their URLs
|
|
1478
|
+
* publish.
|
|
1479
|
+
*
|
|
1480
|
+
* **A sibling directory rather than source paths in the artifact.** The artifact
|
|
1481
|
+
* is the producer's IR and it carries only public URLs; the bytes it names live in
|
|
1482
|
+
* `<artifact directory>/assets/<digest>-<name>`, which is what lets the emission
|
|
1483
|
+
* step and the output inventory run without knowing the content directory — and
|
|
1484
|
+
* therefore without a second way to reach a file the walk authorized. The name is
|
|
1485
|
+
* the published one, so a reader can correlate a served URL with the file that
|
|
1486
|
+
* produced it.
|
|
1487
|
+
*
|
|
1488
|
+
* **The bytes written are the bytes the encoder produced**, not a second read of
|
|
1489
|
+
* the source. That is Goal 0011's identity rule: re-reading the source would
|
|
1490
|
+
* either re-encode (a second, potentially disagreeing candidate) or write the
|
|
1491
|
+
* original under a WebP name. The source is still read once, only to prove the
|
|
1492
|
+
* file did not change between encoding and staging — a source edited mid-build
|
|
1493
|
+
* would otherwise publish a URL that no longer describes its content, the one
|
|
1494
|
+
* property a content-addressed path cannot tolerate.
|
|
1495
|
+
*/
|
|
1496
|
+
async function stageAssets(discovery, destination, assets) {
|
|
1497
|
+
const directory = join(dirname(destination), 'assets');
|
|
1498
|
+
// Removed rather than merged: a stale asset from an earlier build into the same
|
|
1499
|
+
// directory would sit beside the current set, and the emission step — which
|
|
1500
|
+
// requires the source directory to match the artifact exactly — would refuse the
|
|
1501
|
+
// build for a file nothing references.
|
|
1502
|
+
await rm(directory, { recursive: true, force: true });
|
|
1503
|
+
if (assets.length === 0)
|
|
1504
|
+
return;
|
|
1505
|
+
await mkdir(directory, { recursive: true });
|
|
1506
|
+
for (const asset of assets) {
|
|
1507
|
+
const parsed = parseAssetUrl(asset.url);
|
|
1508
|
+
if (parsed === undefined) {
|
|
1509
|
+
throw new Error(`the producer built an asset URL this build cannot parse: ${asset.url}`);
|
|
1510
|
+
}
|
|
1511
|
+
const source = await readFile(join(discovery.assetRoot, ...asset.path.split('/')));
|
|
1512
|
+
const sourceDigest = createHash('sha256').update(source).digest('hex');
|
|
1513
|
+
if (sourceDigest !== asset.sourceDigest) {
|
|
1514
|
+
// The source name is in the report's half of this failure; the stream gets
|
|
1515
|
+
// the sentence. An image that changed mid-build is a fault of the corpus or
|
|
1516
|
+
// the machine, never of a reader.
|
|
1517
|
+
throw new BuildFailure('asset-source-changed', 'a referenced image changed while the build ran, so its content address would name the wrong bytes', `${asset.path}: expected ${asset.sourceDigest}, found ${sourceDigest}`);
|
|
1518
|
+
}
|
|
1519
|
+
const digest = createHash('sha256').update(asset.bytes).digest('hex');
|
|
1520
|
+
if (digest !== asset.digest) {
|
|
1521
|
+
// A producer defect rather than a corpus fault, and unreachable unless a
|
|
1522
|
+
// later step mutates the buffer. Stated as a failure rather than trusted,
|
|
1523
|
+
// because the URL would otherwise name bytes nobody wrote.
|
|
1524
|
+
throw new BuildFailure('asset-emitted-digest', 'a published image does not hash to the digest its own content address names', `${asset.path}: expected ${asset.digest}, found ${digest}`);
|
|
1525
|
+
}
|
|
1526
|
+
await writeFile(join(directory, `${parsed.digest}-${parsed.fileName}`), asset.bytes);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1247
1529
|
/**
|
|
1248
1530
|
* Validate what discovery produced, and write it where the build will read it.
|
|
1249
1531
|
*
|
|
@@ -1253,6 +1535,11 @@ export async function resolveCorpusLinks(discovery, routeForSlug = defaultRouteF
|
|
|
1253
1535
|
* artifact. A callback would have hidden that ordering inside this module,
|
|
1254
1536
|
* where nothing depends on it.
|
|
1255
1537
|
*
|
|
1538
|
+
* **The artifact and its assets are written together.** `assets` is a sorted list
|
|
1539
|
+
* of public URLs and the bytes are staged beside the file, so the pair is
|
|
1540
|
+
* self-contained: the emission step needs no content directory, and a fixture
|
|
1541
|
+
* artifact plus its `assets/` directory can be committed as one thing.
|
|
1542
|
+
*
|
|
1256
1543
|
* @param options.includeEdges When false, `outgoing`/`backlinks` are validated
|
|
1257
1544
|
* against the in-memory producer result but not written: the packaged target's
|
|
1258
1545
|
* relation authority is the finalized snapshot, and the caller builds that
|
|
@@ -1272,15 +1559,32 @@ export async function writeArtifact(discovery, destination, options = {}) {
|
|
|
1272
1559
|
// is a literal of this tool's own source, identical in every run.
|
|
1273
1560
|
throw new BuildFailure('no-markdown-found', 'no Markdown found — this build reads *.md from the directory named by --content');
|
|
1274
1561
|
}
|
|
1562
|
+
// Sorted because the contract requires it and because the asset list is a set:
|
|
1563
|
+
// two builds of one corpus must serialize the same bytes, and discovery order is
|
|
1564
|
+
// a property of the walk rather than of the site.
|
|
1565
|
+
const published = [...discovery.assets].sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
1275
1566
|
// A source literal, not the content directory: `ContentValidationError`
|
|
1276
1567
|
// prefixes its message with this, and that message reaches a stream.
|
|
1277
|
-
const artifact = validateArtifact({
|
|
1568
|
+
const artifact = validateArtifact({
|
|
1569
|
+
version: 1,
|
|
1570
|
+
...(published.length === 0
|
|
1571
|
+
? {}
|
|
1572
|
+
: {
|
|
1573
|
+
assets: published.map((asset) => ({
|
|
1574
|
+
url: asset.url,
|
|
1575
|
+
...(asset.displayDimensions ?? asset.dimensions),
|
|
1576
|
+
})),
|
|
1577
|
+
}),
|
|
1578
|
+
entries: discovery.entries,
|
|
1579
|
+
}, 'content directory');
|
|
1278
1580
|
const serialized = options.includeEdges === false
|
|
1279
1581
|
? {
|
|
1280
1582
|
version: artifact.version,
|
|
1583
|
+
...(artifact.assets === undefined ? {} : { assets: artifact.assets }),
|
|
1281
1584
|
entries: artifact.entries.map(({ outgoing: _outgoing, backlinks: _backlinks, ...entry }) => entry),
|
|
1282
1585
|
}
|
|
1283
1586
|
: artifact;
|
|
1284
1587
|
await mkdir(dirname(destination), { recursive: true });
|
|
1285
1588
|
await writeFile(destination, `${JSON.stringify(serialized, null, 2)}\n`, 'utf8');
|
|
1589
|
+
await stageAssets(discovery, destination, published);
|
|
1286
1590
|
}
|