@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.
@@ -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
+ * `![alt](path "Caption")` 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 ![alt](x) prose` keeps its inline image, and
954
+ * a link-wrapped image (`[![badge](x)](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 (`- ![alt](x "Cap")`) 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` and `span` from the code-block transform.
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. `diagram-canvas` and `diagram-source` are the two shapes a
1055
- // diagram takes, one per mode.
1056
- figure: ['code-block', 'diagram'],
1057
- figcaption: [],
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
- ...(src === undefined ? { src: '' } : { src }),
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