@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.
@@ -47,7 +47,9 @@
47
47
  * branch to get wrong.
48
48
  */
49
49
  import { markdownToMdast } from 'satteri';
50
+ import sanitizeHtml from 'sanitize-html';
50
51
  import { resolveLink } from "../src/lib/link-resolution.js";
52
+ import { classifyImageSource, } from "../src/lib/media.js";
51
53
  import { WITHHELD_LINK_TEXT, WITHHELD_ROUTE } from "../src/lib/route-path.js";
52
54
  /**
53
55
  * Node types that render as an anchor, so nothing inside one may open another.
@@ -106,7 +108,7 @@ const CONTAINS_LINK = new Set([
106
108
  * through the first definition for its identifier and rewritten inline, exactly
107
109
  * as `[s](drafts/secret.md)` would be; `tests/reference-links.test.ts` gates it.
108
110
  */
109
- function collectLinkNodes(node, into, nested, definitions, inside = false) {
111
+ function collectLinkNodes(node, into, nested, definitions, raw, inside = false) {
110
112
  if (CONTAINS_LINK.has(node.type)) {
111
113
  into.push(node);
112
114
  if (inside)
@@ -115,8 +117,15 @@ function collectLinkNodes(node, into, nested, definitions, inside = false) {
115
117
  else if (node.type === 'definition') {
116
118
  definitions.push(node);
117
119
  }
120
+ else if (node.type === 'html') {
121
+ // Raw HTML is the one image form the node walk cannot see: `<img>` written
122
+ // by hand is an `html` node, not an `image` node. Collected here so the
123
+ // producer can refuse a local one before a broken element ships; see
124
+ // `rawImageSources`.
125
+ raw.push(node);
126
+ }
118
127
  for (const child of ('children' in node ? node.children : [])) {
119
- collectLinkNodes(child, into, nested, definitions, inside || CONTAINS_LINK.has(node.type));
128
+ collectLinkNodes(child, into, nested, definitions, raw, inside || CONTAINS_LINK.has(node.type));
120
129
  }
121
130
  }
122
131
  /**
@@ -211,13 +220,75 @@ function labelSpan(node) {
211
220
  : { start: first.start, end: last.end };
212
221
  }
213
222
  /**
214
- * A label safe to place inside `[…]`, with the two delimiters escaped.
223
+ * Decoded text → Markdown inline source. **The only place that conversion
224
+ * happens**, for both an image's `alt` and its `title`.
225
+ *
226
+ * The rule, in one sentence: escape every decoded character that Markdown or
227
+ * the renderer can interpret, encode a line feed or carriage return as a numeric
228
+ * character reference, and emit everything else unchanged. The escaped set is
229
+ *
230
+ * | escaped | why leaving it raw changes the text |
231
+ * | --- | --- |
232
+ * | `\` | the escape introducer: **every** decoded backslash is emitted as `\\`, unconditionally |
233
+ * | `"` | the title's delimiter (a label never needs it, but one uniform set is the point) |
234
+ * | `` ` `` | a code span, which discards its delimiters |
235
+ * | `*`, `_` | emphasis and strong, which discard their markers |
236
+ * | `[`, `]` | a label's own delimiters, and nested link/image syntax |
237
+ * | `<` | raw HTML and autolinks, which reprocess the text as markup |
238
+ * | `&` | a character reference, which decodes the text a second time |
239
+ * | `~` | GFM strikethrough |
240
+ * | `\|` | a GFM table cell separator, when the image sits in a table |
241
+ * | `$` | the math feature's delimiters (`$$…$$`) |
242
+ *
243
+ * **A decoded backslash is always doubled; there is no conditional run logic.**
244
+ * The round-2 rule doubled a run only before punctuation, a line break, or the
245
+ * end of the label. That is wrong for a run before ordinary text: two decoded
246
+ * backslashes emitted raw re-parse as one, because each pair escapes itself.
247
+ * Emitting `2n` bytes for `n` decoded backslashes decodes back to `n` in every
248
+ * position — before a letter, a space, punctuation, a bracket, or the closing
249
+ * delimiter — so the uniform rule is also the simpler one. Measured before this
250
+ * change:
251
+ *
252
+ * ```
253
+ * alt "ends with \\" emitted raw "ends with \\" -> alt "ends with \"
254
+ * ```
255
+ *
256
+ * LF/CR cannot be backslash-escaped, and a raw one would let the next line start
257
+ * a block construct or a blank line end it, so both are emitted as `&#10;` /
258
+ * `&#13;`; the parser decodes them back to the same character. Everything else —
259
+ * letters, digits, spaces, `\u00a0`, `!`, `(`, `)`, `+`, `%`, `#`, `:`, `/`,
260
+ * `.`, `>` — is ordinary text in a label and a title, so it is emitted
261
+ * unchanged; an unescaped `!` cannot form an image because the `[` after it is
262
+ * escaped.
263
+ *
264
+ * The two fields this serves are an image's `alt` and its `title`, and they
265
+ * differ only in the title's surrounding quotes (added by {@link inlineTitle}).
266
+ * The title path used to carry a second copy of the escaping rule, and it
267
+ * decoded a second time for exactly the reason this function exists:
268
+ *
269
+ * ```
270
+ * title "literal &copy;" emitted raw inside quotes -> title "literal ©"
271
+ * ```
272
+ *
273
+ * **Compared with {@link escapeLabelEdits}, this is a different input, not a
274
+ * second rule.** That function receives *source* bytes, where the author's own
275
+ * escapes are already present, so it must add a byte only to an unescaped
276
+ * bracket; escaping anything else there would corrupt text that was already
277
+ * Markdown. This function receives decoded text, where every construct-forming
278
+ * character has to be reintroduced. There is no decoded text anywhere else in
279
+ * this module that is turned back into Markdown.
280
+ *
281
+ * The alternative — copy the label's source span — is what the split rewrite
282
+ * does for an anchor's label, and it is not available here: an image's `alt`
283
+ * arrives as the parser's decoded string with no span of its own inside the
284
+ * node, and recovering that span would need a second bracket-aware scanner in
285
+ * this function. The decoded form is the one the rewrite already has, so it is
286
+ * the one re-encoded.
215
287
  *
216
288
  * Text taken from one construct and written into another has to be re-escaped
217
- * for the construct it lands in, and this is the one place that happens: a
218
- * wikilink's display half and an image's `alt` are *not* link-label syntax, so
219
- * a `[` or `]` inside either is ordinary text there and a delimiter here.
220
- * Measured, all three from the same defect:
289
+ * for the construct it lands in: a wikilink's display half and an image's `alt`
290
+ * are *not* link-label syntax, so a `[` or `]` inside either is ordinary text
291
+ * there and a delimiter here. Measured, from the same defect:
221
292
  *
222
293
  * ```
223
294
  * ![[t|before ] after]] -> [before ] after](/t/) the link ends early
@@ -228,26 +299,49 @@ function labelSpan(node) {
228
299
  * back one image whose `alt` is `](t.md)` — and it is not a shape worth
229
300
  * special-casing, because escaping the delimiters fixes it and every other
230
301
  * spelling at once.
302
+ */
303
+ /**
304
+ * Every character that begins or participates in Markdown/HTML syntax.
231
305
  *
232
- * **Only `[` and `]`, and the set is measured rather than assumed.** Every
233
- * candidate was run through both rewrite paths and the shipped renderer
234
- * (`.tmp/probe-labels.mjs`); `(`, `)`, a backtick, and a lone backslash all
235
- * round-trip intact, because the parser closes a label at its `]` before a
236
- * destination begins and nothing else in a label is delimiter syntax. Escaping
237
- * more than the two characters that can break the construct would put visible
238
- * backslashes into a reader's text.
306
+ * A record rather than a `Set` because the table is static: each key names one
307
+ * construct above, and a reader can audit the whole rule in one place.
239
308
  */
240
- function escapeLabel(text) {
241
- return text.replace(/[[\]]/g, (character) => `\\${character}`);
309
+ const MARKDOWN_SYNTAX = {
310
+ '\\': true,
311
+ '"': true,
312
+ '`': true,
313
+ '*': true,
314
+ _: true,
315
+ '[': true,
316
+ ']': true,
317
+ '<': true,
318
+ '&': true,
319
+ '~': true,
320
+ '|': true,
321
+ $: true,
322
+ };
323
+ function serializeText(text) {
324
+ let result = '';
325
+ for (const character of text) {
326
+ if (character === '\n')
327
+ result += '&#10;';
328
+ else if (character === '\r')
329
+ result += '&#13;';
330
+ else if (MARKDOWN_SYNTAX[character] === true)
331
+ result += `\\${character}`;
332
+ else
333
+ result += character;
334
+ }
335
+ return result;
242
336
  }
243
337
  /**
244
338
  * The edits that make a label's own text safe to sit inside `[…]`.
245
339
  *
246
- * **This is the split rewrite's half of {@link escapeLabel}, and it existed as a
247
- * defect for as long as the split rewrite has.** An outer link is rewritten as
248
- * two edits *around* its label so a nested node can write into the middle
249
- * (see {@link labelSpan}) — which means the label's bytes are copied through
250
- * untouched, escaper included. Measured on `2405cb2` and still live at
340
+ * **This is the source-byte counterpart of {@link serializeText}, and it
341
+ * existed as a defect for as long as the split rewrite has.** An outer link is
342
+ * rewritten as two edits *around* its label so a nested node can write into the
343
+ * middle (see {@link labelSpan}) — which means the label's bytes are copied
344
+ * through untouched, escaper included. Measured on `2405cb2` and still live at
251
345
  * `ebd53ff`:
252
346
  *
253
347
  * ```
@@ -401,6 +495,76 @@ function spanOf(node) {
401
495
  return undefined;
402
496
  return { start: start.offset, end: end.offset, line: start.line };
403
497
  }
498
+ /**
499
+ * Whether a reference names a file other than a note.
500
+ *
501
+ * The one question that decides whether an `![[…]]` embed is the media
502
+ * pipeline's business or the note resolver's. A wikilink embed with no extension
503
+ * is Obsidian's transclusion syntax for a note (`![[beta]]`, `![[A note]]`); one
504
+ * carrying an extension names a file (`![[chart.png]]`, `![[report.pdf]]`), and
505
+ * Goal 0009's boundary is that a referenced file which is not a note must be
506
+ * published or fail. `.md` is excluded because `![[note.md]]` is still a note.
507
+ */
508
+ function namesNonNoteFile(reference) {
509
+ const cut = reference.indexOf('#');
510
+ const path = cut === -1 ? reference : reference.slice(0, cut);
511
+ const slash = path.lastIndexOf('/');
512
+ const base = slash === -1 ? path : path.slice(slash + 1);
513
+ const dot = base.lastIndexOf('.');
514
+ if (dot <= 0 || dot === base.length - 1)
515
+ return false;
516
+ return base.slice(dot).toLowerCase() !== '.md';
517
+ }
518
+ /**
519
+ * Every `src` a fragment of raw HTML gives an `<img>`, in document order.
520
+ *
521
+ * Parsed by `sanitize-html` — the same parser that later decides whether an
522
+ * element reaches the page — rather than matched with a regex, because the
523
+ * question is what a *browser* would fetch and a regex over `[^>]*` stops at the
524
+ * first `>` inside a quoted attribute. Measured shape: `<img alt="a > b"
525
+ * src="x.png">` is one tag with one src, and a search that truncates at `>` sees
526
+ * none.
527
+ *
528
+ * Returns `undefined` when the fragment mentions `<img` but the parser found no
529
+ * element, which is malformed raw HTML rather than a remote image. The caller
530
+ * treats that as a failure too: an `<img>` this build cannot read is not one it
531
+ * can certify as remote.
532
+ */
533
+ function rawImageSources(html) {
534
+ const sources = [];
535
+ sanitizeHtml(html, {
536
+ allowedTags: ['img'],
537
+ // Not a security decision — the renderer's sanitizer owns that. The
538
+ // attributes are only needed long enough for the transform to read `src`.
539
+ allowedAttributes: { img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', 'srcset', 'sizes'] },
540
+ // Schemes are read, not filtered, here.
541
+ allowedSchemes: [],
542
+ allowProtocolRelative: true,
543
+ transformTags: {
544
+ img: (tagName, attribs) => {
545
+ sources.push(typeof attribs['src'] === 'string' ? attribs['src'] : '');
546
+ return { tagName, attribs };
547
+ },
548
+ },
549
+ });
550
+ return sources.length === 0 ? undefined : sources;
551
+ }
552
+ /**
553
+ * An image title, quoted and serialised from the parser's decoded value.
554
+ *
555
+ * Only reached when an image is republished under its asset URL and the author
556
+ * wrote a title, inline or on the reference's definition. The parsed title is
557
+ * the author's text with character references and escapes already resolved, so
558
+ * it goes through the same {@link serializeText} the label does; the only
559
+ * title-specific part is the surrounding `"`. Whitespace is **not** collapsed
560
+ * or trimmed: the parser preserved it, and the old collapse changed titles like
561
+ * ` a b ` and turned a decoded `&copy;` into `©` a second time.
562
+ */
563
+ function inlineTitle(title) {
564
+ if (typeof title !== 'string' || title === '')
565
+ return '';
566
+ return ` "${serializeText(title)}"`;
567
+ }
404
568
  /**
405
569
  * Resolve every link in one note, rewriting the body and collecting the edges.
406
570
  *
@@ -414,16 +578,32 @@ function spanOf(node) {
414
578
  * hardcoded for the same reason `renderMarkdown` injects it: this module does
415
579
  * not own the route shape, and a later ticket moving notes under a prefix must
416
580
  * not have to edit a traversal.
581
+ * @param media The asset publisher. Required rather than defaulted, because a
582
+ * default that ignored images is the fail-open shape this module exists to
583
+ * avoid: every local image reference must either publish or be recorded as a
584
+ * failure, and the caller is the only one that can say which.
417
585
  */
418
- export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor) {
586
+ export async function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor, media) {
419
587
  const tree = markdownToMdast(markdown, { features: { wikilinks: true, gfm: true } });
420
588
  const nodes = [];
421
589
  const nestedNodes = new Set();
422
590
  const definitionNodes = [];
423
- collectLinkNodes(tree, nodes, nestedNodes, definitionNodes);
591
+ const rawNodes = [];
592
+ collectLinkNodes(tree, nodes, nestedNodes, definitionNodes, rawNodes);
424
593
  const definitions = definitionsById(definitionNodes);
425
594
  const edits = [];
426
595
  const findings = [];
596
+ const mediaFailures = [];
597
+ /**
598
+ * Definitions an image reference republished through, and the URL it became.
599
+ *
600
+ * The definition's own rewrite runs after the node loop, and without this it
601
+ * would resolve the *source path* against the corpus and find no note, leaving
602
+ * `[ref]: chart.png` in the body of the note that published it. No reader sees
603
+ * a definition, but the body handed onward would still carry the one path the
604
+ * asset pipeline replaced.
605
+ */
606
+ const publishedDefinitions = new Map();
427
607
  const outgoing = [];
428
608
  const seen = new Set();
429
609
  for (const node of nodes) {
@@ -431,10 +611,11 @@ export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor)
431
611
  // rewritten exactly as the inline form with that destination would be — see
432
612
  // {@link rewriteDefinition} for the definition's own half.
433
613
  let url;
614
+ let definition;
434
615
  if (node.type === 'link' || node.type === 'image')
435
616
  url = node.url;
436
617
  else if (node.type === 'linkReference' || node.type === 'imageReference') {
437
- const definition = definitions.get(node.identifier);
618
+ definition = definitions.get(node.identifier);
438
619
  // The parser only emits a reference node when a matching definition
439
620
  // exists, so this is unreachable; a reference with nothing to resolve is
440
621
  // left as authored rather than guessed at.
@@ -458,9 +639,54 @@ export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor)
458
639
  const written = markdown.slice(span.start, span.end);
459
640
  const label = displayText(node, markdown);
460
641
  const nested = nestedNodes.has(node);
461
- // **The single resolution.** Everything below reads this one value: the
462
- // replacement text, the edge, and the finding. There is no second call and
463
- // no second decision.
642
+ // **Image references are the media pipeline's, not the note resolver's.**
643
+ // Markdown image syntax is unambiguous; an `![[…]]` embed is the media
644
+ // pipeline's only when it names a file rather than a note (`![[chart.png]]`
645
+ // versus `![[beta]]`), which is what keeps Obsidian's note transclusion
646
+ // syntax — and its existing demote-to-link behaviour — intact. Handing an
647
+ // image to the note resolver would degrade it to text, to a `/private/` link,
648
+ // or to a link to a note the author meant to display; none of which is a
649
+ // picture.
650
+ //
651
+ // Decided before the resolver runs, and by the reference's own shape: the
652
+ // ownership question has nothing to do with which note a path might name, and
653
+ // asking it first means an image never pays for the five note-link tiers.
654
+ const isImage = node.type === 'image' || node.type === 'imageReference';
655
+ const mediaOwned = isImage && (!isWikilink || namesNonNoteFile(url));
656
+ if (mediaOwned) {
657
+ const outcome = await media.publish({ notePath: sourcePath, target: url, isWikilink });
658
+ const title = node.type === 'image' ? node.title : definition?.type === 'definition' ? definition.title : null;
659
+ if (outcome.kind === 'external')
660
+ continue;
661
+ if (outcome.kind === 'published') {
662
+ // The destination is replaced and the alt text kept — the author's words
663
+ // are the only description a reader without the image has. Written as an
664
+ // inline image even when the source was a reference, because the
665
+ // reference's destination is a source path this build never publishes.
666
+ edits.push({
667
+ start: span.start,
668
+ end: span.end,
669
+ text: `![${serializeText(label)}](${outcome.url}${inlineTitle(title)})`,
670
+ });
671
+ if (definition !== undefined)
672
+ publishedDefinitions.set(definition, outcome.url);
673
+ continue;
674
+ }
675
+ mediaFailures.push({
676
+ source: sourcePath,
677
+ line: span.line,
678
+ link: written,
679
+ reason: outcome.reason,
680
+ ...(outcome.detail === undefined ? {} : { detail: outcome.detail }),
681
+ });
682
+ // Degraded to its own alt text so the returned body is self-consistent.
683
+ // The caller stops the build; this value is never written.
684
+ edits.push({ start: span.start, end: span.end, text: serializeText(label) });
685
+ continue;
686
+ }
687
+ // **The single resolution, for everything that is not an image.** The
688
+ // replacement text, the edge, and the finding all read this one value. There
689
+ // is no second call and no second decision.
464
690
  const resolution = resolveLink(url, sourcePath, index, isWikilink);
465
691
  if (resolution.kind === 'external') {
466
692
  // Left byte-for-byte, including a wikilink that turned out to be a bare
@@ -478,18 +704,18 @@ export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor)
478
704
  // nothing nested to protect and is replaced whole.
479
705
  //
480
706
  // The whole-span form is also the only one that could escape its label
481
- // *through* {@link escapeLabel}: the split form leaves the label's own bytes
482
- // in place, which is what preserves a nested node's rewrite. So the split
483
- // form escapes its label separately, in edits scoped to the label's own text
484
- // nodes — see {@link escapeLabelEdits}, which is where that had been missing
485
- // for as long as the split form existed.
707
+ // *through* {@link serializeText}: the split form leaves the label's own
708
+ // bytes in place, which is what preserves a nested node's rewrite. So the
709
+ // split form escapes its label separately, in edits scoped to the label's
710
+ // own text nodes — see {@link escapeLabelEdits}, which is where that had
711
+ // been missing for as long as the split form existed.
486
712
  const inner = node.type === 'link' || node.type === 'linkReference' ? labelSpan(node) : undefined;
487
713
  const rewrite = (before, after, fallbackLabel = label) => {
488
714
  if (inner === undefined) {
489
715
  edits.push({
490
716
  start: span.start,
491
717
  end: span.end,
492
- text: before + escapeLabel(fallbackLabel) + after,
718
+ text: before + serializeText(fallbackLabel) + after,
493
719
  });
494
720
  return;
495
721
  }
@@ -577,7 +803,11 @@ export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor)
577
803
  // discover a missing image. An embed of a *file* — the ordinary
578
804
  // `![[diagram.png]]` — never reaches here: an image is not a published note,
579
805
  // so it left as `unpublished` above.
580
- const embedDemoted = node.type === 'image' || node.type === 'imageReference';
806
+ // The image node reaching this point is a wikilink embed of a note — the
807
+ // media branch above owns every file reference — and it is demoted to a
808
+ // link for the reason this branch states. `isImage` is the same test made
809
+ // before that branch, so control-flow narrowing cannot hide it.
810
+ const embedDemoted = isImage;
581
811
  const href = `${routeFor(resolution.slug)}${resolution.anchor}`;
582
812
  // The slug is the fallback name for a node with no label at all — `[](x)`
583
813
  // and an image whose alt is empty — because `[](/route/)` renders an anchor
@@ -617,24 +847,70 @@ export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor)
617
847
  // It contributes no edge and no finding: a definition renders nothing, and an
618
848
  // edge exists exactly where a rendered anchor does. An unresolved destination
619
849
  // names no file this build knows of, so it is left as written.
850
+ //
851
+ // A definition an image reference published through points at the asset URL,
852
+ // not at the withheld route: the source path it held is exactly what the
853
+ // pipeline replaced, and `resolveLink` cannot see a file the media map owns.
620
854
  for (const definition of definitionNodes) {
621
855
  if (definition.type !== 'definition')
622
856
  continue;
623
857
  const span = spanOf(definition);
624
858
  if (span === undefined)
625
859
  continue;
626
- const resolution = resolveLink(definition.url, sourcePath, index, false);
860
+ const published = publishedDefinitions.get(definition);
627
861
  let destination;
628
- if (resolution.kind === 'resolved' || resolution.kind === 'ambiguous') {
629
- destination = `${routeFor(resolution.slug)}${resolution.anchor}`;
862
+ if (published !== undefined)
863
+ destination = published;
864
+ else {
865
+ const resolution = resolveLink(definition.url, sourcePath, index, false);
866
+ if (resolution.kind === 'resolved' || resolution.kind === 'ambiguous') {
867
+ destination = `${routeFor(resolution.slug)}${resolution.anchor}`;
868
+ }
869
+ else if (resolution.kind === 'unpublished')
870
+ destination = WITHHELD_ROUTE;
871
+ else
872
+ continue;
630
873
  }
631
- else if (resolution.kind === 'unpublished')
632
- destination = WITHHELD_ROUTE;
633
- else
634
- continue;
635
874
  const text = rewriteDefinition(markdown.slice(span.start, span.end), destination);
636
875
  if (text !== undefined)
637
876
  edits.push({ start: span.start, end: span.end, text });
638
877
  }
639
- return { markdown: applyEdits(markdown, edits), outgoing, findings };
878
+ // **Raw-HTML `<img>` is refused unless it is remote or an inline raster.**
879
+ // The Markdown walk cannot see it — `<img src="chart.png">` is an `html` node
880
+ // — so without this it would bypass asset authorization entirely and ship a
881
+ // broken element. The accepted resolver covers Markdown images, reference-style
882
+ // images, and image wikilinks; an HTML form outside that set fails the build,
883
+ // which is what the goal requires rather than a silently dropped picture.
884
+ //
885
+ // Classification is {@link classifyImageSource} in `src/lib/media.ts`, the
886
+ // same function the renderer's `img` transform calls. It strips the characters
887
+ // at or below U+0020 that `sanitize-html` ignores before reading a scheme, so
888
+ // a leading space or tab cannot give the two sides different answers. Only
889
+ // `remote` is left alone: `refused` is `file:`/`javascript:`/`vbscript:`,
890
+ // which the producer must report, and `local` is a bare relative path, an
891
+ // all-whitespace value, or an empty src. `data:` is remote and keeps its
892
+ // previous treatment.
893
+ for (const node of rawNodes) {
894
+ if (node.type !== 'html')
895
+ continue;
896
+ // HTML comments are inert, and a comment is how a note *documents* markup
897
+ // without rendering it. A commented-out `<img>` asks the browser for nothing,
898
+ // so it is stripped before the detection — otherwise a note explaining how to
899
+ // embed an image would fail its own build.
900
+ const value = node.value.replace(/<!--[\s\S]*?-->/g, '');
901
+ if (!/<img[\s/>]/i.test(value))
902
+ continue;
903
+ const span = spanOf(node);
904
+ const sources = rawImageSources(value);
905
+ const offenders = sources === undefined ? [''] : sources.filter((source) => classifyImageSource(source) !== 'remote');
906
+ if (offenders.length === 0)
907
+ continue;
908
+ mediaFailures.push({
909
+ source: sourcePath,
910
+ line: span?.line ?? 1,
911
+ link: value.trim(),
912
+ reason: 'html-form',
913
+ });
914
+ }
915
+ return { markdown: applyEdits(markdown, edits), outgoing, findings, mediaFailures };
640
916
  }
@@ -348,14 +348,25 @@ const TEXT_NAMES = new Set(['_headers', '_redirects']);
348
348
  * File kinds whose bytes are not text, so a marker search over them reports
349
349
  * coincidences rather than residue.
350
350
  *
351
- * Mirrors the `binary` list in `.gitattributes`, plus `.jpeg` — which that file
352
- * omits because nothing in the tree uses the spelling, and which is listed here
353
- * because an unlisted extension would fail the scan as unclassified rather than
354
- * be treated as the image it is. These are not *unscanned by
355
- * accident* — they are declared, which is what separates them from a file kind
356
- * nobody has considered.
351
+ * Mirrors the `binary` list in `.gitattributes` exactly. Goal 0009 added `.gif`
352
+ * and `.avif` to both lists, and `.jpeg` to that file so the two now agree. An
353
+ * extension in neither set fails the scan as "neither declared text nor declared
354
+ * binary" — the fail-closed branch, which is why a new asset format has to be
355
+ * declared here in the same change that starts emitting it. These are not
356
+ * *unscanned by accident* — they are declared, which is what separates them from
357
+ * a file kind nobody has considered.
358
+ *
359
+ * A published image is either a passthrough of the author's bytes or a WebP
360
+ * encoded from them. A passthrough can contain any marker the author's file
361
+ * already contained; a transcode is metadata-free but its pixels are the
362
+ * author's. Neither is a text surface a residue marker search can interpret, so
363
+ * the bytes are declared binary here; credential scanning still runs over the
364
+ * same bytes through Gitleaks, which is a different rule set with its own
365
+ * false-positive policy.
357
366
  */
358
367
  const BINARY_EXTENSIONS = new Set([
368
+ '.avif',
369
+ '.gif',
359
370
  '.ico',
360
371
  '.jpg',
361
372
  '.jpeg',
@@ -19,6 +19,7 @@ import { snapshotFileName } from "../src/lib/snapshot.js";
19
19
  import { readBuildBinding } from "../src/lib/snapshot-reader.js";
20
20
  import { BuildFailure } from "./write-report.js";
21
21
  import { assertSnapshotRows } from "../src/lib/snapshot-contract.js";
22
+ import { ASSET_DIRECTORY, imageExtension, parseAssetUrl, sniffImageFormat } from "../src/lib/media.js";
22
23
  import { isGzip, declaresWal, journalSidecar } from "./snapshot-rows.js";
23
24
  import { readStagedWasm } from "./copy-wasm.js";
24
25
  const DIST = fileURLToPath(new URL('../dist', import.meta.url));
@@ -138,6 +139,57 @@ function snapshotOutput(root, workspace) {
138
139
  }
139
140
  return new Set([file]);
140
141
  }
142
+ /**
143
+ * The image assets the artifact declares, validated against their own bytes.
144
+ *
145
+ * **Exactness in both directions.** The returned set is what the generic loop
146
+ * accepts, so an asset file the artifact does not declare is an unexpected member
147
+ * — a stale file, a second copy, or something a future step wrote — and a declared
148
+ * file that is absent is a missing one. That is the difference between owning the
149
+ * namespace and allowing a directory: a blanket `/_assets/` rule would pass any
150
+ * bytes a build step dropped there.
151
+ *
152
+ * Each file is checked against the two things its URL claims: the digest prefix is
153
+ * the SHA-256 of the **emitted** bytes (Goal 0011 may have replaced the source with
154
+ * a WebP candidate), and the extension is the format those bytes actually are.
155
+ * A static host infers `Content-Type` from the extension, so a URL that disagrees
156
+ * with its own bytes would be served as the wrong type by every host that trusts
157
+ * the name — which is all of them.
158
+ *
159
+ * Nothing here reads the content directory or a source filename: a published asset
160
+ * is identified by its digest, and that is all this gate is given.
161
+ */
162
+ function assetOutput(root, artifact) {
163
+ const expected = new Set();
164
+ for (const { url } of artifact.assets ?? []) {
165
+ const parsed = parseAssetUrl(url);
166
+ if (parsed === undefined) {
167
+ throw new BuildFailure('output-inventory-asset-invalid', 'output inventory found an asset URL it cannot parse', url);
168
+ }
169
+ const file = `${ASSET_DIRECTORY}/${parsed.digest}-${parsed.fileName}`;
170
+ const path = join(root, ...file.split('/'));
171
+ let bytes;
172
+ try {
173
+ if (!lstatSync(path).isFile())
174
+ throw new Error('not a regular file');
175
+ bytes = readFileSync(path);
176
+ }
177
+ catch (error) {
178
+ throw new BuildFailure('output-inventory-asset-missing', 'output inventory is missing a declared image asset', `${file}: ${error instanceof Error ? error.message : String(error)}`);
179
+ }
180
+ const digest = createHash('sha256').update(bytes).digest('hex');
181
+ if (digest !== parsed.digest) {
182
+ throw new BuildFailure('output-inventory-asset-digest', 'an output image asset does not hash to the digest its URL names', `${file}: expected ${parsed.digest}, got ${digest}`);
183
+ }
184
+ const format = sniffImageFormat(bytes);
185
+ const extension = parsed.fileName.slice(parsed.fileName.lastIndexOf('.'));
186
+ if (format === undefined || imageExtension(format) !== extension) {
187
+ throw new BuildFailure('output-inventory-asset-format', 'an output image asset is not the format its URL extension names', `${file}: detected ${format ?? 'no supported image format'}, URL extension ${extension}`);
188
+ }
189
+ expected.add(file);
190
+ }
191
+ return expected;
192
+ }
141
193
  /**
142
194
  * The bound SQLite WASM member, validated against its own filename.
143
195
  *
@@ -256,6 +308,7 @@ export function assertOutputInventory(root, artifact, workspace) {
256
308
  ...PAGEFIND_RUNTIME,
257
309
  ...snapshotOutput(root, workspace),
258
310
  ...wasmOutput(root, workspace),
311
+ ...assetOutput(root, artifact),
259
312
  ]);
260
313
  const pagefind = pagefindFiles(root, actual);
261
314
  const unexpected = [];
@@ -18,6 +18,28 @@ import { routeKey, tagFacets as producerTagFacets, tagRoute } from "./routes.js"
18
18
  import { hydrateEntriesWithSnapshot, loadSnapshotRelations, snapshotMatchesEntries, } from "./snapshot-reader.js";
19
19
  export const artifact = loadArtifact();
20
20
  export const entries = artifact.entries;
21
+ /**
22
+ * The image URLs this build published, for anything that renders artifact Markdown.
23
+ *
24
+ * `renderMarkdown` refuses a local image `src` the artifact did not declare — the
25
+ * last gate over an image reference — so every caller rendering an *artifact*
26
+ * entry has to hand it this set. It is exported here rather than recomputed in
27
+ * each caller because the artifact is the only authority for it, and a second
28
+ * `new Set(...)` per page is both wasted work and a second place for the
29
+ * expression to drift.
30
+ */
31
+ export const publishedAssets = new Set((artifact.assets ?? []).map((asset) => asset.url));
32
+ /**
33
+ * The intrinsic pixel size of each published asset whose header declared one.
34
+ *
35
+ * The producer records this from the bytes it already authorized and emits; the
36
+ * renderer uses it for `width`/`height` attributes so the browser reserves
37
+ * layout space. An asset whose header was unreadable is absent, not zero — the
38
+ * renderer then emits no dimensions rather than a guess.
39
+ */
40
+ export const assetDimensions = new Map((artifact.assets ?? []).flatMap((asset) => asset.width === undefined
41
+ ? []
42
+ : [[asset.url, { width: asset.width, height: asset.height }]]));
21
43
  const bySlug = new Map(entries.map((entry) => [entry.slug, entry]));
22
44
  export function getEntry(slug) {
23
45
  return bySlug.get(slug);