@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/src/lib/markdown.js
CHANGED
|
@@ -32,6 +32,8 @@ import { MATH_MODE } from "./math-mode.js";
|
|
|
32
32
|
import { RENDERED_DIAGRAM, RENDERED_MARKER, RENDERED_MATH } from "./rendered-marker.js";
|
|
33
33
|
import { NAV_LANGUAGE, translate } from "./translations.js";
|
|
34
34
|
import { slugFromSegment } from "./route-path.js";
|
|
35
|
+
import { classifyImageSource } from "./media.js";
|
|
36
|
+
import { deliveredMediaUrl, mediaDeliveryOrigin } from "./media-delivery.js";
|
|
35
37
|
/**
|
|
36
38
|
* Today's route shape, which the producer already emits. Keeping it as the
|
|
37
39
|
* default means rendering is unchanged until TK-04 injects its own mapping.
|
|
@@ -937,6 +939,94 @@ function asClassList(value) {
|
|
|
937
939
|
return value.filter((item) => typeof item === 'string');
|
|
938
940
|
return typeof value === 'string' ? value.split(/\s+/) : [];
|
|
939
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* A standalone, captioned image becomes `figure > img + figcaption`.
|
|
944
|
+
*
|
|
945
|
+
* **Only the title becomes a caption.** `alt` is the accessible alternative and
|
|
946
|
+
* stays on the image; ordinary alt text never turns into reader-visible prose.
|
|
947
|
+
* A title is the author's explicit visible label, which is what Markdown's
|
|
948
|
+
* `` and a reference definition's title already mean.
|
|
949
|
+
*
|
|
950
|
+
* The shape is deliberately narrow:
|
|
951
|
+
*
|
|
952
|
+
* - a paragraph **or list item** must contain exactly one meaningful child, and
|
|
953
|
+
* it must be an `img` — so `Prose  prose` keeps its inline image, and
|
|
954
|
+
* a link-wrapped image (`[](target)`) is not promoted because its
|
|
955
|
+
* child is the anchor;
|
|
956
|
+
* - the image must have a non-empty title; an uncaptioned image keeps the exact
|
|
957
|
+
* container it had, so no empty `<figcaption>` and no invented "Figure 1";
|
|
958
|
+
* - a decorative `alt=""` image with no title is therefore untouched, and a
|
|
959
|
+
* title is authored content rather than something derived from `alt`.
|
|
960
|
+
*
|
|
961
|
+
* A tight list item (`- `) has the image as the item's direct
|
|
962
|
+
* child, while a loose one carries a paragraph; both must become the same
|
|
963
|
+
* figure. Promoting a paragraph that is already inside a list item and then
|
|
964
|
+
* re-checking the item is safe because the item's meaningful child is then the
|
|
965
|
+
* promoted `figure`, not an `img`, so it is not wrapped twice.
|
|
966
|
+
*
|
|
967
|
+
* A blockquote's own paragraph is promoted too: a figure is a block, so it
|
|
968
|
+
* remains valid there. A table cell holds no paragraph here, and a fenced code
|
|
969
|
+
* block's content is text, so neither can reach this visitor with an element
|
|
970
|
+
* `img` child.
|
|
971
|
+
*/
|
|
972
|
+
function imageFigurePlugin() {
|
|
973
|
+
const titledOnlyImage = (node) => {
|
|
974
|
+
const meaningful = (node.children ?? []).filter((child) => !(child.type === 'text' && /^\s*$/.test(child.value)));
|
|
975
|
+
if (meaningful.length !== 1)
|
|
976
|
+
return undefined;
|
|
977
|
+
const image = meaningful[0];
|
|
978
|
+
if (image.type !== 'element' || image.tagName !== 'img')
|
|
979
|
+
return undefined;
|
|
980
|
+
const title = image.properties?.['title'];
|
|
981
|
+
if (typeof title !== 'string' || title.trim() === '')
|
|
982
|
+
return undefined;
|
|
983
|
+
return { image, title };
|
|
984
|
+
};
|
|
985
|
+
const figureFor = (image, title) => {
|
|
986
|
+
const { title: _caption, ...properties } = image.properties ?? {};
|
|
987
|
+
return {
|
|
988
|
+
type: 'element',
|
|
989
|
+
tagName: 'figure',
|
|
990
|
+
properties: { className: ['image-figure'] },
|
|
991
|
+
children: [
|
|
992
|
+
{ ...image, properties },
|
|
993
|
+
{
|
|
994
|
+
type: 'element',
|
|
995
|
+
tagName: 'figcaption',
|
|
996
|
+
properties: {},
|
|
997
|
+
children: [{ type: 'text', value: title }],
|
|
998
|
+
},
|
|
999
|
+
],
|
|
1000
|
+
};
|
|
1001
|
+
};
|
|
1002
|
+
return {
|
|
1003
|
+
name: 'anc-image-figures',
|
|
1004
|
+
element: [
|
|
1005
|
+
{
|
|
1006
|
+
filter: ['p'],
|
|
1007
|
+
visit(node, ctx) {
|
|
1008
|
+
const found = titledOnlyImage(node);
|
|
1009
|
+
if (found === undefined)
|
|
1010
|
+
return;
|
|
1011
|
+
ctx.replaceNode(node, figureFor(found.image, found.title));
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
filter: ['li'],
|
|
1016
|
+
visit(node) {
|
|
1017
|
+
const found = titledOnlyImage(node);
|
|
1018
|
+
if (found === undefined)
|
|
1019
|
+
return;
|
|
1020
|
+
const figure = figureFor(found.image, found.title);
|
|
1021
|
+
return {
|
|
1022
|
+
...node,
|
|
1023
|
+
children: (node.children ?? []).map((child) => (child === found.image ? figure : child)),
|
|
1024
|
+
};
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
],
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
940
1030
|
/**
|
|
941
1031
|
* The fence info string satteri records on a code node, or `undefined` for an
|
|
942
1032
|
* unlabelled fence. hast's `ElementData` is an open record with no declared
|
|
@@ -967,7 +1057,8 @@ const POLICY = {
|
|
|
967
1057
|
/**
|
|
968
1058
|
* satteri and this module's own plugins emit only: `a blockquote code del em
|
|
969
1059
|
* h1`–`h6 hr img input li ol p pre section strong sup table tbody td th thead
|
|
970
|
-
* tr ul`, plus `figure`
|
|
1060
|
+
* tr ul`, plus `figure`/`figcaption` from the code-block, diagram, and image
|
|
1061
|
+
* transforms, and `span` from the math source path.
|
|
971
1062
|
*
|
|
972
1063
|
* The remainder are reachable only through raw HTML in a note body. They are
|
|
973
1064
|
* kept because requirements 15.1 promises a "safe HTML subset", and because a
|
|
@@ -998,7 +1089,7 @@ const POLICY = {
|
|
|
998
1089
|
'href', 'title', 'class', 'rel', 'aria-label', 'aria-describedby',
|
|
999
1090
|
'data-footnote-ref', 'data-footnote-backref', 'id',
|
|
1000
1091
|
],
|
|
1001
|
-
img: ['src', 'alt', 'title'],
|
|
1092
|
+
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
|
|
1002
1093
|
h1: ['id'],
|
|
1003
1094
|
h2: ['id', 'class'],
|
|
1004
1095
|
h3: ['id'],
|
|
@@ -1016,7 +1107,6 @@ const POLICY = {
|
|
|
1016
1107
|
section: ['class', 'data-footnotes'],
|
|
1017
1108
|
blockquote: ['class', 'cite', 'data-callout'],
|
|
1018
1109
|
figure: ['class', 'data-code-language', 'data-diagram'],
|
|
1019
|
-
figcaption: ['class'],
|
|
1020
1110
|
div: ['class', 'tabindex'],
|
|
1021
1111
|
input: ['type', 'checked', 'disabled', 'aria-label'],
|
|
1022
1112
|
th: ['colspan', 'rowspan', 'scope', 'data-align'],
|
|
@@ -1051,10 +1141,10 @@ const POLICY = {
|
|
|
1051
1141
|
section: ['footnotes'],
|
|
1052
1142
|
blockquote: ['callout', 'callout-*'],
|
|
1053
1143
|
// `diagram` is the figure a rendered diagram sits in; `code-block` the one a
|
|
1054
|
-
// fence sits in
|
|
1055
|
-
// diagram
|
|
1056
|
-
|
|
1057
|
-
|
|
1144
|
+
// fence sits in; `image-figure` a standalone image with an authored caption.
|
|
1145
|
+
// `diagram-canvas` and `diagram-source` are the two shapes a diagram takes,
|
|
1146
|
+
// one per mode.
|
|
1147
|
+
figure: ['code-block', 'diagram', 'image-figure'],
|
|
1058
1148
|
div: ['diagram-canvas'],
|
|
1059
1149
|
pre: ['diagram-source'],
|
|
1060
1150
|
strong: ['callout-title'],
|
|
@@ -1080,6 +1170,50 @@ const POLICY = {
|
|
|
1080
1170
|
'iframe', 'object', 'embed', 'form', 'svg', 'math', 'title',
|
|
1081
1171
|
],
|
|
1082
1172
|
};
|
|
1173
|
+
/**
|
|
1174
|
+
* Whether `sanitize-html` will keep this `src` after the transforms run.
|
|
1175
|
+
*
|
|
1176
|
+
* This mirrors the policy the sanitizer actually applies, for the one decision
|
|
1177
|
+
* that must not be made before it: which image is the article's lead. A
|
|
1178
|
+
* transform runs before the scheme allowlist, so treating any present `src` as
|
|
1179
|
+
* surviving spent the eager image on a `javascript:`, `file:`, `mailto:`, `ftp:`
|
|
1180
|
+
* or protocol-relative URL the sanitizer then removed, leaving the real first
|
|
1181
|
+
* image lazy.
|
|
1182
|
+
*
|
|
1183
|
+
* The predicates are the policy's own (`allowedSchemesByTag.img`,
|
|
1184
|
+
* `allowProtocolRelative`, and the safe-`data:` test the transform applies),
|
|
1185
|
+
* and the laundering is the sanitizer's: remove every character at or below
|
|
1186
|
+
* U+0020, then remove complete HTML comments before reading a scheme.
|
|
1187
|
+
*/
|
|
1188
|
+
function imageSourceSurvivesPolicy(source) {
|
|
1189
|
+
if (source === undefined)
|
|
1190
|
+
return false;
|
|
1191
|
+
// The transform turns an unsafe `data:` URI into an empty `src`, which the
|
|
1192
|
+
// sanitizer then drops because `src` is a non-boolean attribute.
|
|
1193
|
+
if (/^data:/i.test(source) && !SAFE_IMAGE_DATA_URI.test(source))
|
|
1194
|
+
return false;
|
|
1195
|
+
let href = Array.from(source, (character) => (character <= ' ' ? '' : character)).join('');
|
|
1196
|
+
for (;;) {
|
|
1197
|
+
const first = href.indexOf('<!--');
|
|
1198
|
+
if (first === -1)
|
|
1199
|
+
break;
|
|
1200
|
+
const last = href.indexOf('-->', first + 4);
|
|
1201
|
+
if (last === -1)
|
|
1202
|
+
break;
|
|
1203
|
+
href = href.slice(0, first) + href.slice(last + 3);
|
|
1204
|
+
}
|
|
1205
|
+
if (href === '')
|
|
1206
|
+
return false;
|
|
1207
|
+
const allowProtocolRelative = POLICY.allowProtocolRelative !== false;
|
|
1208
|
+
if (!allowProtocolRelative && /^[/\\]{2}/.test(href))
|
|
1209
|
+
return false;
|
|
1210
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9.\-+]*):/.exec(href)?.[1]?.toLowerCase();
|
|
1211
|
+
if (scheme === undefined)
|
|
1212
|
+
return true;
|
|
1213
|
+
const byTag = typeof POLICY.allowedSchemesByTag === 'object' ? POLICY.allowedSchemesByTag : undefined;
|
|
1214
|
+
const allowed = byTag?.['img'] ?? (Array.isArray(POLICY.allowedSchemes) ? POLICY.allowedSchemes : []);
|
|
1215
|
+
return allowed.includes(scheme);
|
|
1216
|
+
}
|
|
1083
1217
|
/**
|
|
1084
1218
|
* The rendering policy's terminal step. Transforms that must observe *every*
|
|
1085
1219
|
* anchor and image live here rather than in a hast plugin, because raw HTML in
|
|
@@ -1088,7 +1222,7 @@ const POLICY = {
|
|
|
1088
1222
|
* `<a href="/other/">` written as raw HTML. `sanitize-html` parses the final
|
|
1089
1223
|
* HTML, so it sees all of them, and its allowlist runs after `transformTags`.
|
|
1090
1224
|
*/
|
|
1091
|
-
function sanitize(html, routeForSlug, generatedIds, rawIds) {
|
|
1225
|
+
function sanitize(html, routeForSlug, generatedIds, rawIds, publishedAssets, assetDimensions) {
|
|
1092
1226
|
// Each generated id survives exactly once. `headingPlugin` has already made
|
|
1093
1227
|
// the generated set disjoint from every raw-HTML id, so this no longer
|
|
1094
1228
|
// arbitrates a collision — it only stops a raw duplicate of a generated id
|
|
@@ -1097,8 +1231,32 @@ function sanitize(html, routeForSlug, generatedIds, rawIds) {
|
|
|
1097
1231
|
const usedFootnoteIds = new Set();
|
|
1098
1232
|
// One deny token per raw-HTML claim on that id; see the note below.
|
|
1099
1233
|
const denials = new Map(rawIds);
|
|
1234
|
+
// The first image that actually keeps a `src` is the article's lead, and the
|
|
1235
|
+
// rest are below it. This closure advances in parse order, which is document
|
|
1236
|
+
// order for the one sanitizer pass; an `<img>` with no surviving source is not
|
|
1237
|
+
// a content image and does not spend the lead.
|
|
1238
|
+
let leadImagePending = true;
|
|
1239
|
+
// A minimal open-element stack, used by the one structural rule the allowlist
|
|
1240
|
+
// cannot express: a `figcaption` outside a `figure` is a shape this renderer
|
|
1241
|
+
// never emits, so a raw-HTML one is renamed to an inert `span` rather than
|
|
1242
|
+
// left claiming caption semantics. `onOpenTag` runs before the tag's own
|
|
1243
|
+
// transform, so the current tag is the stack's last entry and its parent is
|
|
1244
|
+
// the one before it.
|
|
1245
|
+
const openTags = [];
|
|
1246
|
+
// The public custom-domain origin, when this build was explicitly opted into
|
|
1247
|
+
// a managed image host (`anc build --release --media r2`). `undefined` is the
|
|
1248
|
+
// ordinary same-origin build, and then every image `src` below is untouched.
|
|
1249
|
+
// Read once per render, not per image: the environment cannot change while
|
|
1250
|
+
// one note is being rendered.
|
|
1251
|
+
const deliveryOrigin = mediaDeliveryOrigin();
|
|
1100
1252
|
return sanitizeHtml(html, {
|
|
1101
1253
|
...POLICY,
|
|
1254
|
+
onOpenTag: (name) => openTags.push(name),
|
|
1255
|
+
onCloseTag: (name) => {
|
|
1256
|
+
const index = openTags.lastIndexOf(name);
|
|
1257
|
+
if (index !== -1)
|
|
1258
|
+
openTags.length = index;
|
|
1259
|
+
},
|
|
1102
1260
|
transformTags: {
|
|
1103
1261
|
// Element ids are page-global names, and the layout owns hooks such as
|
|
1104
1262
|
// `#search-toggle`. Only ids this pipeline generated survive, and each
|
|
@@ -1171,20 +1329,107 @@ function sanitize(html, routeForSlug, generatedIds, rawIds) {
|
|
|
1171
1329
|
// only for the base64 raster types TK-01's artifact scanner allowlists.
|
|
1172
1330
|
// The scheme allowlist alone is too coarse: it would also admit
|
|
1173
1331
|
// `data:image/svg+xml`, which can carry a script, and `data:text/html`.
|
|
1332
|
+
//
|
|
1333
|
+
// **A local `src` must name an asset this build published.** The producer
|
|
1334
|
+
// rewrites every image reference it authorizes to a `/_assets/<digest>-<name>`
|
|
1335
|
+
// URL, and refuses an unauthorized one, so anything else local here is a
|
|
1336
|
+
// reference that bypassed the pipeline — a raw-HTML `<img>` the producer
|
|
1337
|
+
// missed, or an artifact path that names no emitted file. Shipping it would
|
|
1338
|
+
// be a broken picture and, in the second case, a request for a file this
|
|
1339
|
+
// build never wrote. It throws instead, which stops the build; the message
|
|
1340
|
+
// names the source for the report and never reaches a stream, because a
|
|
1341
|
+
// plain `Error` is not disclosure-checked.
|
|
1342
|
+
//
|
|
1343
|
+
// Remote and `data:` sources keep their existing treatment: they are never
|
|
1344
|
+
// fetched, never mirrored, and their forms did not change in this goal.
|
|
1174
1345
|
img: (tagName, attribs) => {
|
|
1175
1346
|
const source = attribs['src'];
|
|
1347
|
+
// **The producer and this transform share one classifier.** See
|
|
1348
|
+
// {@link classifyImageSource} in `media.ts`: it strips every character
|
|
1349
|
+
// at or below U+0020 before reading a scheme, the same way
|
|
1350
|
+
// `sanitize-html`'s `launder` does. Testing the raw bytes here used to
|
|
1351
|
+
// call `<img src=" https://…">` local while the producer called it an
|
|
1352
|
+
// unpublished image, so one URL got two answers; the shared function
|
|
1353
|
+
// makes both sides read it as remote. `refused` and `remote` are both
|
|
1354
|
+
// non-local here, which keeps the sanitizer's scheme policy deciding
|
|
1355
|
+
// the fate of `file:`/`javascript:`/`vbscript:` and protocol-relative
|
|
1356
|
+
// values exactly as before. `data:` keeps its separate body below.
|
|
1357
|
+
const external = source !== undefined && classifyImageSource(source) !== 'local';
|
|
1358
|
+
// An `<img>` with no `src` at all is not a reference to anything, and the
|
|
1359
|
+
// producer refuses that form outright (`html-form`); the renderer leaves
|
|
1360
|
+
// the element as the allowlist always has. A *present* src that is not
|
|
1361
|
+
// remote has to name a published asset.
|
|
1362
|
+
if (source !== undefined && !external && !publishedAssets.has(source)) {
|
|
1363
|
+
throw new Error('a local image reference reached the renderer without being published: ' +
|
|
1364
|
+
JSON.stringify(source) +
|
|
1365
|
+
'. The producer publishes an image by rewriting it to its /_assets/<digest>-<name> URL; ' +
|
|
1366
|
+
'a local src anywhere else names no file this build emits.');
|
|
1367
|
+
}
|
|
1176
1368
|
const src = source !== undefined && /^data:/i.test(source) && !SAFE_IMAGE_DATA_URI.test(source)
|
|
1177
1369
|
? undefined
|
|
1178
1370
|
: source;
|
|
1371
|
+
// **Delivery is not identity.** An authorized asset keeps its canonical
|
|
1372
|
+
// `/_assets/<digest>-<name>` path in the artifact, the staged file, the
|
|
1373
|
+
// output inventory, and the remote object key. Only when the build was
|
|
1374
|
+
// opted into a managed host does the emitted `src` become the absolute
|
|
1375
|
+
// custom-domain URL; the path after the origin is byte-identical, which
|
|
1376
|
+
// is what lets the upload plan and the HTML agree without a second
|
|
1377
|
+
// naming scheme. Remote author-supplied URLs and `data:` URIs are never
|
|
1378
|
+
// rewritten. The value was validated when it crossed into the process
|
|
1379
|
+
// (`media-delivery.ts`), so `https:` here is a checked fact rather than
|
|
1380
|
+
// an assumption about the environment.
|
|
1381
|
+
const delivered = src !== undefined && publishedAssets.has(src) && deliveryOrigin !== undefined
|
|
1382
|
+
? deliveredMediaUrl(src, deliveryOrigin)
|
|
1383
|
+
: src;
|
|
1384
|
+
// The lead is the first image whose source the sanitizer will actually
|
|
1385
|
+
// keep. A transform runs before the scheme allowlist, so testing only
|
|
1386
|
+
// `src !== undefined` spent it on a URL that was about to be stripped.
|
|
1387
|
+
const survives = imageSourceSurvivesPolicy(delivered);
|
|
1388
|
+
let dimensions;
|
|
1389
|
+
let loading;
|
|
1390
|
+
if (survives && src !== undefined) {
|
|
1391
|
+
// The intrinsic size the producer read from these exact bytes, if its
|
|
1392
|
+
// header yielded one. Serialized as strings because that is what an
|
|
1393
|
+
// HTML attribute is; `CSS height: auto` plus `max-width: 100%` keeps
|
|
1394
|
+
// the ratio responsive after the browser reserves the space.
|
|
1395
|
+
dimensions = assetDimensions.get(src);
|
|
1396
|
+
// Lead-versus-deferred is decided by rendered document order, not by a
|
|
1397
|
+
// guess about position: the first image that keeps a source is the one
|
|
1398
|
+
// a reader meets first, so it stays eager. Everything after it defers.
|
|
1399
|
+
// `decoding="async"` is assigned to both; it is a hint about the
|
|
1400
|
+
// decode thread, not a delay, and it cannot slow the lead's fetch.
|
|
1401
|
+
loading = leadImagePending ? 'eager' : 'lazy';
|
|
1402
|
+
leadImagePending = false;
|
|
1403
|
+
}
|
|
1404
|
+
// A stripped source carries no layout or loading claim: author-supplied
|
|
1405
|
+
// `loading`, `decoding`, `width` and `height` would otherwise survive
|
|
1406
|
+
// the allowlist on an element with no picture.
|
|
1407
|
+
const { loading: _loading, decoding: _decoding, width: _width, height: _height, ...withoutImageClaims } = attribs;
|
|
1179
1408
|
return {
|
|
1180
1409
|
tagName,
|
|
1181
1410
|
attribs: {
|
|
1182
|
-
...attribs,
|
|
1183
|
-
...(
|
|
1411
|
+
...(survives ? attribs : withoutImageClaims),
|
|
1412
|
+
...(delivered === undefined ? { src: '' } : { src: delivered }),
|
|
1184
1413
|
alt: typeof attribs['alt'] === 'string' ? attribs['alt'] : '',
|
|
1414
|
+
...(dimensions === undefined
|
|
1415
|
+
? {}
|
|
1416
|
+
: { width: String(dimensions.width), height: String(dimensions.height) }),
|
|
1417
|
+
...(loading === undefined ? {} : { loading, decoding: 'async' }),
|
|
1185
1418
|
},
|
|
1186
1419
|
};
|
|
1187
1420
|
},
|
|
1421
|
+
// A `figcaption` is a valid caption only as a direct child of a `figure`.
|
|
1422
|
+
// This renderer emits exactly that shape (the image transform above and
|
|
1423
|
+
// the two diagram modes), so a raw-HTML caption elsewhere is markup this
|
|
1424
|
+
// pipeline does not produce and must not survive with caption semantics.
|
|
1425
|
+
// Renaming it to an inert `span` keeps the author's text instead of
|
|
1426
|
+
// silently deleting it, while ensuring the page has no orphan caption.
|
|
1427
|
+
figcaption: (tagName) => {
|
|
1428
|
+
const parent = openTags[openTags.length - 2];
|
|
1429
|
+
return parent === 'figure'
|
|
1430
|
+
? { tagName, attribs: {} }
|
|
1431
|
+
: { tagName: 'span', attribs: {} };
|
|
1432
|
+
},
|
|
1188
1433
|
// Task-list checkboxes are visual state, not controls. Forcing the shape
|
|
1189
1434
|
// here also neutralizes any other raw-HTML input (a text field, a hidden
|
|
1190
1435
|
// field) instead of relying on the tag allowlist alone. `checked` and
|
|
@@ -1423,11 +1668,12 @@ export async function renderMarkdown(markdown, options = {}) {
|
|
|
1423
1668
|
calloutPlugin(),
|
|
1424
1669
|
mathPlugin(collected, maths),
|
|
1425
1670
|
codePlugin(collected, diagrams, chrome.diagramCaption),
|
|
1671
|
+
imageFigurePlugin(),
|
|
1426
1672
|
taskListPlugin(),
|
|
1427
1673
|
tablePlugin(),
|
|
1428
1674
|
],
|
|
1429
1675
|
});
|
|
1430
|
-
const sanitized = sanitize(html, routeForSlug, new Set(collected.headings.map((heading) => heading.id)), collected.rawIds);
|
|
1676
|
+
const sanitized = sanitize(html, routeForSlug, new Set(collected.headings.map((heading) => heading.id)), collected.rawIds, options.publishedAssets ?? new Set(), options.assetDimensions ?? new Map());
|
|
1431
1677
|
// Rendered after sanitization, at markers the sanitizer preserved. Both
|
|
1432
1678
|
// renderers are the trust boundary for their own output; see the note on
|
|
1433
1679
|
// `DIAGRAM_TOKEN_PREFIX`. A render failure throws and stops the build, which
|