@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
package/scripts/resolve-links.js
CHANGED
|
@@ -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
|
-
*
|
|
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 ` ` /
|
|
258
|
+
* ` `; 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 ©" 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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
-
*
|
|
233
|
-
*
|
|
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
|
-
|
|
241
|
-
|
|
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 += ' ';
|
|
328
|
+
else if (character === '\r')
|
|
329
|
+
result += ' ';
|
|
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
|
|
247
|
-
* defect for as long as the split rewrite has.** An outer link is
|
|
248
|
-
* two edits *around* its label so a nested node can write into the
|
|
249
|
-
* (see {@link labelSpan}) — which means the label's bytes are copied
|
|
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 `©` 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
|
-
|
|
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
|
-
|
|
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
|
-
// **
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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: `})`,
|
|
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
|
|
482
|
-
// in place, which is what preserves a nested node's rewrite. So the
|
|
483
|
-
// form escapes its label separately, in edits scoped to the label's
|
|
484
|
-
// nodes — see {@link escapeLabelEdits}, which is where that had
|
|
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 +
|
|
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
|
-
|
|
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
|
|
860
|
+
const published = publishedDefinitions.get(definition);
|
|
627
861
|
let destination;
|
|
628
|
-
if (
|
|
629
|
-
destination =
|
|
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
|
-
|
|
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
|
}
|
package/scripts/scan-residue.js
CHANGED
|
@@ -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
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
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 = [];
|
package/src/lib/content.js
CHANGED
|
@@ -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);
|