@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.
@@ -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({ version: 1, entries: discovery.entries }, 'content directory');
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
  }