prreviewbuddy 0.55.6 → 0.56.0

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.
@@ -6235,7 +6235,7 @@ function sanitiseThread(value, seen) {
6235
6235
  const comments = rawComments.slice(0, 100).map(sanitiseComment).filter((c) => c !== null);
6236
6236
  if (comments.length === 0) return null;
6237
6237
  const dropped = Math.max(0, rawComments.length - comments.length);
6238
- const declared = count(value.truncatedCommentCount);
6238
+ const declared = count$1(value.truncatedCommentCount);
6239
6239
  return {
6240
6240
  id,
6241
6241
  type: value.type,
@@ -6260,7 +6260,7 @@ function sanitiseComment(value) {
6260
6260
  body,
6261
6261
  timestamp: timestamp(value.timestamp),
6262
6262
  replyToId: label(value.replyToId) || null,
6263
- url: href(value.url)
6263
+ url: href$1(value.url)
6264
6264
  };
6265
6265
  }
6266
6266
  function sanitiseLocation(value) {
@@ -6269,7 +6269,7 @@ function sanitiseLocation(value) {
6269
6269
  if (!file) return null;
6270
6270
  return {
6271
6271
  file,
6272
- line: count(value.line) || null,
6272
+ line: count$1(value.line) || null,
6273
6273
  side: value.side === "LEFT" ? "LEFT" : "RIGHT",
6274
6274
  lineIsOriginal: value.lineIsOriginal === true,
6275
6275
  commitSha: label(value.commitSha) || null
@@ -6289,8 +6289,8 @@ function sanitiseDecision(value) {
6289
6289
  }
6290
6290
  function sanitisePullRequest(value) {
6291
6291
  if (!isRecord(value)) return null;
6292
- const number = count(value.number);
6293
- const url = href(value.url);
6292
+ const number = count$1(value.number);
6293
+ const url = href$1(value.url);
6294
6294
  if (!number || !url) return null;
6295
6295
  const pr = {
6296
6296
  provider: label(value.provider) || "unknown",
@@ -6306,7 +6306,7 @@ function sanitisePullRequest(value) {
6306
6306
  if (headRef) pr.headRef = headRef;
6307
6307
  const baseRef = label(value.baseRef);
6308
6308
  if (baseRef) pr.baseRef = baseRef;
6309
- const filesUrl = href(value.filesUrl);
6309
+ const filesUrl = href$1(value.filesUrl);
6310
6310
  if (filesUrl) pr.filesUrl = filesUrl;
6311
6311
  const template = fileUrlTemplate$1(value.fileUrlTemplate);
6312
6312
  if (template) pr.fileUrlTemplate = template;
@@ -6327,7 +6327,7 @@ function pullRequestAuthor(value) {
6327
6327
  if (name) author.name = name;
6328
6328
  const handle = label(value.handle).replace(/^@+/, "");
6329
6329
  if (handle) author.handle = handle;
6330
- const url = href(value.url);
6330
+ const url = href$1(value.url);
6331
6331
  if (url) author.url = url;
6332
6332
  return author.name || author.handle ? author : null;
6333
6333
  }
@@ -6339,7 +6339,7 @@ function label(value) {
6339
6339
  return typeof value === "string" ? value.trim().slice(0, 200) : "";
6340
6340
  }
6341
6341
  /** A non-negative integer, or 0. Strings are not numbers here, however numeric they look. */
6342
- function count(value) {
6342
+ function count$1(value) {
6343
6343
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
6344
6344
  }
6345
6345
  /** Kept only if it parses. An unparseable date sorts and renders as nothing rather than as a lie. */
@@ -6364,7 +6364,7 @@ function fileUrlTemplate$1(value) {
6364
6364
  if (typeof value !== "string") return "";
6365
6365
  const trimmed = value.trim();
6366
6366
  if (!trimmed.includes("{")) return "";
6367
- return href(fillFileUrlTemplate(trimmed, "a")) ? trimmed : "";
6367
+ return href$1(fillFileUrlTemplate(trimmed, "a")) ? trimmed : "";
6368
6368
  }
6369
6369
  /**
6370
6370
  * Substitute a path into a template.
@@ -6376,7 +6376,7 @@ function fileUrlTemplate$1(value) {
6376
6376
  function fillFileUrlTemplate(template, path) {
6377
6377
  return template.replace(/\{path\}/g, path).replace(/\{pathEncoded\}/g, encodeURIComponent(path)).replace(/\{pathSha1\}/g, () => createHash("sha1").update(path).digest("hex")).replace(/\{pathSha256\}/g, () => createHash("sha256").update(path).digest("hex"));
6378
6378
  }
6379
- function href(value) {
6379
+ function href$1(value) {
6380
6380
  if (typeof value !== "string") return "";
6381
6381
  try {
6382
6382
  const url = new URL(value.trim());
@@ -9406,6 +9406,436 @@ function renderInlineText(value) {
9406
9406
  //#region ../../packages/review-harness/src/domain/utils/code_block.ts
9407
9407
  var CODE_BLOCK_CLASS = "pr-helper-code-block";
9408
9408
  var CODE_COPY_CLASS = "pr-helper-code-copy";
9409
+ var LINE_START_TOKEN = /^\u0002(\d+)\u0002/;
9410
+ /** Anything outside this set (javascript:, data:, vbscript:, a relative path) is not a link. */
9411
+ var SAFE_HREF = /^(?:https?:\/\/|#)/i;
9412
+ /** Media is linked only over https: a link the reader opens in their own session. */
9413
+ var MEDIA_HREF = /^https:\/\//i;
9414
+ var GENERIC_ALT = /^(?:image|img|screenshot|screen ?shot|picture|photo|pic)$/i;
9415
+ var FILE_NAME = /\.(?:png|jpe?g|gif|webp|svg|bmp|heic|avif|tiff?)$/i;
9416
+ /** An image's link text, from its alt text when that says what it shows. Takes and gives plain text. */
9417
+ function imageLabel(alt) {
9418
+ const text = (alt ?? "").trim();
9419
+ return !text || GENERIC_ALT.test(text) || FILE_NAME.test(text) ? "View attached image" : `View image: ${text}`;
9420
+ }
9421
+ /**
9422
+ * A link to an image, audio or video, or null when its address is not https. Both arguments are
9423
+ * already escaped: the Markdown rules hand over escaped text, and liftHtml escapes before calling.
9424
+ */
9425
+ function mediaAnchor(href, label) {
9426
+ return MEDIA_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : null;
9427
+ }
9428
+ function mediaFromHtml(src, label) {
9429
+ return mediaAnchor(escapeHtml((src ?? "").trim()), escapeHtml(label));
9430
+ }
9431
+ var align = (value) => /^(?:left|right|center|justify)$/i.test(value) ? value.toLowerCase() : null;
9432
+ var span = (value) => /^[1-9]\d{0,2}$/.test(value) ? value : null;
9433
+ var count = (value) => /^\d{1,9}$/.test(value) ? value : null;
9434
+ var title = (value) => value.slice(0, 300);
9435
+ var href = (value) => {
9436
+ const url = value.trim();
9437
+ return SAFE_HREF.test(url) ? url : null;
9438
+ };
9439
+ /** Maps, not objects, so a name every object inherits (`constructor`, `__proto__`) is in neither. */
9440
+ var ALLOWED = new Map(Object.entries({
9441
+ a: {
9442
+ href,
9443
+ title
9444
+ },
9445
+ abbr: { title },
9446
+ b: {},
9447
+ blockquote: {},
9448
+ br: {},
9449
+ caption: { align },
9450
+ cite: {},
9451
+ code: {},
9452
+ dd: {},
9453
+ del: {},
9454
+ details: {},
9455
+ div: { align },
9456
+ dl: {},
9457
+ dt: {},
9458
+ em: {},
9459
+ hr: {},
9460
+ i: {},
9461
+ ins: {},
9462
+ kbd: {},
9463
+ li: {},
9464
+ mark: {},
9465
+ ol: { start: count },
9466
+ p: { align },
9467
+ q: {},
9468
+ s: {},
9469
+ samp: {},
9470
+ small: {},
9471
+ span: {},
9472
+ strike: {},
9473
+ strong: {},
9474
+ sub: {},
9475
+ summary: {},
9476
+ sup: {},
9477
+ table: { align },
9478
+ tbody: {},
9479
+ td: {
9480
+ align,
9481
+ colspan: span,
9482
+ rowspan: span
9483
+ },
9484
+ tfoot: {},
9485
+ th: {
9486
+ align,
9487
+ colspan: span,
9488
+ rowspan: span
9489
+ },
9490
+ thead: {},
9491
+ tr: { align },
9492
+ u: {},
9493
+ ul: {},
9494
+ var: {},
9495
+ h1: { align },
9496
+ h2: { align },
9497
+ h3: { align },
9498
+ h4: { align },
9499
+ h5: { align },
9500
+ h6: { align }
9501
+ }).map(([tag, checks]) => [tag, new Map(Object.entries({
9502
+ title,
9503
+ ...checks
9504
+ }))]));
9505
+ var VOID = /* @__PURE__ */ new Set([
9506
+ "br",
9507
+ "hr",
9508
+ "img",
9509
+ "input",
9510
+ "source",
9511
+ "meta",
9512
+ "link",
9513
+ "base",
9514
+ "wbr",
9515
+ "col",
9516
+ "area",
9517
+ "track",
9518
+ "param",
9519
+ "embed"
9520
+ ]);
9521
+ /** Gone, with everything inside them: what they hold is code, styling or someone else's page. */
9522
+ var DROP_WITH_CONTENT = /* @__PURE__ */ new Set([
9523
+ "script",
9524
+ "style",
9525
+ "iframe",
9526
+ "object",
9527
+ "embed",
9528
+ "svg",
9529
+ "math",
9530
+ "template",
9531
+ "noscript",
9532
+ "textarea",
9533
+ "frame",
9534
+ "frameset",
9535
+ "applet",
9536
+ "head",
9537
+ "title",
9538
+ "select"
9539
+ ]);
9540
+ /** Gone, keeping what they hold: the words in a form or a figure are still the author's. */
9541
+ var DROP_KEEP_CONTENT = /* @__PURE__ */ new Set([
9542
+ "picture",
9543
+ "pre",
9544
+ "source",
9545
+ "track",
9546
+ "input",
9547
+ "form",
9548
+ "label",
9549
+ "button",
9550
+ "option",
9551
+ "optgroup",
9552
+ "font",
9553
+ "center",
9554
+ "section",
9555
+ "article",
9556
+ "header",
9557
+ "footer",
9558
+ "nav",
9559
+ "figure",
9560
+ "figcaption",
9561
+ "main",
9562
+ "aside",
9563
+ "html",
9564
+ "body",
9565
+ "meta",
9566
+ "link",
9567
+ "base",
9568
+ "wbr",
9569
+ "col",
9570
+ "colgroup",
9571
+ "area",
9572
+ "map",
9573
+ "param",
9574
+ "fieldset",
9575
+ "legend",
9576
+ "dialog"
9577
+ ]);
9578
+ /** Tags that open an HTML block when a line begins with one (CommonMark's sixth kind). */
9579
+ var BLOCK = /* @__PURE__ */ new Set([
9580
+ "address",
9581
+ "article",
9582
+ "aside",
9583
+ "blockquote",
9584
+ "caption",
9585
+ "center",
9586
+ "dd",
9587
+ "details",
9588
+ "dialog",
9589
+ "div",
9590
+ "dl",
9591
+ "dt",
9592
+ "fieldset",
9593
+ "figcaption",
9594
+ "figure",
9595
+ "footer",
9596
+ "form",
9597
+ "h1",
9598
+ "h2",
9599
+ "h3",
9600
+ "h4",
9601
+ "h5",
9602
+ "h6",
9603
+ "header",
9604
+ "hr",
9605
+ "li",
9606
+ "main",
9607
+ "nav",
9608
+ "ol",
9609
+ "p",
9610
+ "pre",
9611
+ "section",
9612
+ "summary",
9613
+ "table",
9614
+ "tbody",
9615
+ "td",
9616
+ "tfoot",
9617
+ "th",
9618
+ "thead",
9619
+ "tr",
9620
+ "ul"
9621
+ ]);
9622
+ /**
9623
+ * The tag a raw line opens an HTML block with, as GitHub reads one: the line begins, after at most
9624
+ * three spaces, with a block tag. Until the next blank line it is HTML, with no Markdown inside.
9625
+ * The preview splits a description with this, so it agrees with the renderer on where one ends.
9626
+ */
9627
+ function opensHtmlBlock(line) {
9628
+ const match = line.match(/^ {0,3}<\/?([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/);
9629
+ return Boolean(match && BLOCK.has(match[1].toLowerCase()));
9630
+ }
9631
+ var SCAN = new RegExp([
9632
+ "`[^`\\n]+`",
9633
+ "<(\\/?)([A-Za-z][A-Za-z0-9-]*)((?:\\s+[^\\s\"'<>\\/=]+(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s\"'=<>`]+))?)*)\\s*(\\/?)>",
9634
+ "&(?:#\\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});"
9635
+ ].join("|"), "g");
9636
+ var ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
9637
+ /** An attribute's value as the author meant it: entities in it are read, so `&amp;` is one `&`. */
9638
+ function decodeEntities(value) {
9639
+ return value.replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|amp|lt|gt|quot|apos|nbsp);/g, (_match, entity) => {
9640
+ if (entity[0] === "#") {
9641
+ const code = entity[1] === "x" || entity[1] === "X" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
9642
+ return code > 0 && code <= 1114111 ? String.fromCodePoint(code) : "";
9643
+ }
9644
+ return {
9645
+ amp: "&",
9646
+ lt: "<",
9647
+ gt: ">",
9648
+ quot: "\"",
9649
+ apos: "'",
9650
+ nbsp: "\xA0"
9651
+ }[entity] ?? "";
9652
+ });
9653
+ }
9654
+ function attributesOf(source) {
9655
+ const result = /* @__PURE__ */ new Map();
9656
+ for (const match of source.matchAll(ATTRIBUTE)) {
9657
+ const name = match[1].toLowerCase();
9658
+ if (!result.has(name)) result.set(name, decodeEntities(match[2] ?? match[3] ?? match[4] ?? ""));
9659
+ }
9660
+ return result;
9661
+ }
9662
+ /** A heading is drawn three levels down, as Markdown headings are here: these sit inside a sidebar. */
9663
+ function emittedTag(name) {
9664
+ const heading = name.match(/^h([1-6])$/);
9665
+ return heading ? `h${Math.min(Number(heading[1]), 3) + 3}` : name;
9666
+ }
9667
+ function openingMarkup(name, attributeSource) {
9668
+ const tag = emittedTag(name);
9669
+ const allowed = ALLOWED.get(name);
9670
+ const kept = [];
9671
+ for (const [attribute, value] of attributesOf(attributeSource)) {
9672
+ const check = allowed.get(attribute);
9673
+ const safe = check ? check(value) : null;
9674
+ if (safe !== null) kept.push(` ${attribute}="${escapeHtml(safe)}"`);
9675
+ }
9676
+ if (name === "a" && kept.some((attribute) => attribute.startsWith(" href="))) kept.push(" target=\"_blank\" rel=\"noopener noreferrer\"");
9677
+ return `<${tag}${kept.join("")}>`;
9678
+ }
9679
+ /**
9680
+ * Every tag and entity the policy handles, replaced by a token; the rest left as text to be escaped.
9681
+ * Runs on Markdown with its code blocks already lifted out, so nothing in a code block is read here.
9682
+ */
9683
+ function liftHtml(text) {
9684
+ const tokens = [];
9685
+ const token = (value) => `${tokens.push(value) - 1}`;
9686
+ let out = "";
9687
+ let last = 0;
9688
+ SCAN.lastIndex = 0;
9689
+ for (let match; match = SCAN.exec(text);) {
9690
+ const [whole, slash, rawName, attributeSource, selfClosing] = match;
9691
+ let replacement = null;
9692
+ if (whole[0] === "&") replacement = token({
9693
+ kind: "entity",
9694
+ name: "",
9695
+ tag: "",
9696
+ markup: whole
9697
+ });
9698
+ else if (rawName) {
9699
+ const name = rawName.toLowerCase();
9700
+ if (name === "img") {
9701
+ const attributes = attributesOf(attributeSource);
9702
+ const alt = attributes.get("alt");
9703
+ replacement = token({
9704
+ kind: "text",
9705
+ name,
9706
+ tag: "",
9707
+ markup: slash ? "" : mediaFromHtml(attributes.get("src"), imageLabel(alt)) ?? escapeHtml(alt ?? "")
9708
+ });
9709
+ } else if (name === "video" || name === "audio") {
9710
+ let src = slash ? void 0 : attributesOf(attributeSource).get("src");
9711
+ if (!slash && !selfClosing) {
9712
+ const rest = text.slice(SCAN.lastIndex);
9713
+ const close = rest.match(new RegExp(`</${name}\\s*>`, "i"));
9714
+ const inner = close ? rest.slice(0, close.index) : "";
9715
+ src ??= attributesOf(inner.match(/<source\b([^<>]*)>/i)?.[1] ?? "").get("src");
9716
+ if (close) SCAN.lastIndex += close.index + close[0].length;
9717
+ }
9718
+ replacement = token({
9719
+ kind: "text",
9720
+ name,
9721
+ tag: "",
9722
+ markup: (slash ? null : mediaFromHtml(src, name === "video" ? "Open attached video" : "Open attached audio")) ?? ""
9723
+ });
9724
+ } else if (DROP_WITH_CONTENT.has(name)) {
9725
+ if (!slash && !selfClosing && !VOID.has(name)) {
9726
+ const close = new RegExp(`</${name}\\s*>`, "i");
9727
+ const rest = text.slice(SCAN.lastIndex);
9728
+ const end = rest.search(close);
9729
+ if (end !== -1) SCAN.lastIndex += end + rest.slice(end).match(close)[0].length;
9730
+ }
9731
+ replacement = "";
9732
+ } else if (DROP_KEEP_CONTENT.has(name)) replacement = token({
9733
+ kind: "text",
9734
+ name,
9735
+ tag: "",
9736
+ markup: ""
9737
+ });
9738
+ else if (ALLOWED.has(name)) {
9739
+ const tag = emittedTag(name);
9740
+ if (slash) replacement = token({
9741
+ kind: "close",
9742
+ name,
9743
+ tag,
9744
+ markup: ""
9745
+ });
9746
+ else if (VOID.has(name)) replacement = token({
9747
+ kind: "void",
9748
+ name,
9749
+ tag,
9750
+ markup: openingMarkup(name, attributeSource)
9751
+ });
9752
+ else replacement = token({
9753
+ kind: "open",
9754
+ name,
9755
+ tag,
9756
+ markup: openingMarkup(name, attributeSource)
9757
+ });
9758
+ }
9759
+ }
9760
+ if (replacement === null) continue;
9761
+ out += text.slice(last, match.index) + replacement;
9762
+ last = SCAN.lastIndex;
9763
+ }
9764
+ return {
9765
+ text: out + text.slice(last),
9766
+ tokens
9767
+ };
9768
+ }
9769
+ /** Whether lifted text holds a token that is markup, which only text positions may take. */
9770
+ function holdsMarkup(text, lifted) {
9771
+ for (const match of text.matchAll(/\u0002(\d+)\u0002/g)) if (lifted.tokens[Number(match[1])].kind !== "entity") return true;
9772
+ return false;
9773
+ }
9774
+ /** Whether a lifted line begins with a block tag, so opens an HTML block. */
9775
+ function startsHtmlBlock(line, lifted) {
9776
+ const match = line.match(LINE_START_TOKEN);
9777
+ return Boolean(match && BLOCK.has(lifted.tokens[Number(match[1])].name));
9778
+ }
9779
+ /**
9780
+ * A link inside another unwrapped to its text, keeping the outer one: the destination the author
9781
+ * gave the whole thing. Media inside a link (a badge) is the usual case, written as Markdown or HTML
9782
+ * in any mix, and a browser handed nested links closes the outer one first, leaving the label linked
9783
+ * to the image. Run on finished output, where every `<a` and `</a>` is ours: the author's are
9784
+ * escaped or were rebuilt, so one rule covers every way the two were combined.
9785
+ */
9786
+ function unnestLinks(html) {
9787
+ const unwrapped = [];
9788
+ return html.replace(/<a\b[^>]*>|<\/a>/g, (tag) => {
9789
+ if (tag === "</a>") return unwrapped.pop() ? "" : tag;
9790
+ const inner = unwrapped.length > 0;
9791
+ unwrapped.push(inner);
9792
+ return inner ? "" : tag;
9793
+ });
9794
+ }
9795
+ /**
9796
+ * The tokens put back, in document order, balanced, and only where text goes.
9797
+ *
9798
+ * Only where text goes: a token that a Markdown rule carried into one of our own tags (a link's
9799
+ * destination is an attribute) is dropped, since markup there would end the attribute and start
9800
+ * another. An entity is the exception, being safe in both. The test is cheap and exact because every raw `<` and `>` in this string is ours: the
9801
+ * author's are escaped, or are the tokens themselves.
9802
+ *
9803
+ * Balanced: a closer for a tag that is not open is dropped; one for a tag further out closes what
9804
+ * is inside it first; whatever is still open at the end is closed there, inside the description.
9805
+ */
9806
+ function restoreHtml(html, lifted) {
9807
+ const open = [];
9808
+ const put = (index) => {
9809
+ const item = lifted.tokens[index];
9810
+ if (item.kind === "open") {
9811
+ open.push(item.tag);
9812
+ return item.markup;
9813
+ }
9814
+ if (item.kind === "close") {
9815
+ const at = open.lastIndexOf(item.tag);
9816
+ if (at === -1) return "";
9817
+ return open.splice(at).reverse().map((tag) => `</${tag}>`).join("");
9818
+ }
9819
+ return item.markup;
9820
+ };
9821
+ let out = "";
9822
+ let last = 0;
9823
+ let inTag = false;
9824
+ for (let i = 0; i < html.length; i++) {
9825
+ const char = html[i];
9826
+ if (char === "<") inTag = true;
9827
+ else if (char === ">") inTag = false;
9828
+ else if (char === "") {
9829
+ const end = html.indexOf("", i + 1);
9830
+ if (end === -1) break;
9831
+ const index = Number(html.slice(i + 1, end));
9832
+ out += html.slice(last, i) + (inTag && lifted.tokens[index].kind !== "entity" ? "" : put(index));
9833
+ i = end;
9834
+ last = end + 1;
9835
+ }
9836
+ }
9837
+ return out + html.slice(last) + open.reverse().map((tag) => `</${tag}>`).join("");
9838
+ }
9409
9839
  //#endregion
9410
9840
  //#region ../../packages/review-harness/src/domain/utils/markdown.ts
9411
9841
  var BLOCK_MARKER = "\0";
@@ -9413,10 +9843,20 @@ var INLINE_MARKER = "";
9413
9843
  var BLOCK_TOKEN = /\u0000(\d+)\u0000/g;
9414
9844
  var BLOCK_TOKEN_LINE = /^\u0000\d+\u0000$/;
9415
9845
  var INLINE_TOKEN = /\u0001(\d+)\u0001/g;
9416
- var SAFE_HREF = /^(?:https?:\/\/|#)/i;
9846
+ /**
9847
+ * Markdown a person wrote on GitHub: a PR description or comment. One policy for every place that
9848
+ * shows one, so the Author's context preview, its full view and a PR comment never disagree.
9849
+ */
9850
+ var GITHUB_MARKDOWN = {
9851
+ hideHtmlComments: true,
9852
+ safeHtml: true,
9853
+ mermaid: true
9854
+ };
9417
9855
  function renderMarkdown(value, options = {}) {
9418
- const { text, blocks } = extractFencedBlocks(scanMarkdownLines(value.replace(/[\u0000\u0001]/g, ""), options));
9419
- return restoreFencedBlocks(renderBlocks(text), blocks);
9856
+ const { text, blocks } = extractFencedBlocks(scanMarkdownLines(value.replace(/[\u0000\u0001\u0002]/g, ""), options), options);
9857
+ if (!options.safeHtml) return restoreFencedBlocks(renderBlocks(text), blocks);
9858
+ const lifted = liftHtml(text);
9859
+ return unnestLinks(restoreHtml(restoreFencedBlocks(renderBlocks(lifted.text, lifted), blocks), lifted));
9420
9860
  }
9421
9861
  /**
9422
9862
  * The fence character a line opens a code block with, or null when it opens none. Shared with the
@@ -9519,51 +9959,118 @@ function scanMarkdownLines(value, options = {}) {
9519
9959
  }
9520
9960
  return result;
9521
9961
  }
9522
- function extractFencedBlocks(lines) {
9962
+ function extractFencedBlocks(lines, options = {}) {
9523
9963
  const blocks = [];
9524
9964
  const out = [];
9525
9965
  let body = null;
9966
+ let diagramTitle;
9967
+ const close = () => {
9968
+ out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
9969
+ const code = body.join("\n");
9970
+ blocks.push(diagramTitle === void 0 ? { code } : {
9971
+ code,
9972
+ diagram: { label: `Mermaid diagram: ${diagramTitle ?? diagramType(code)}` }
9973
+ });
9974
+ body = null;
9975
+ };
9526
9976
  for (const line of lines) {
9527
9977
  if (line.absent) continue;
9528
- if (line.code === "open") body = [];
9529
- else if (line.code === "body") body.push(line.raw);
9530
- else if (line.code === "close") {
9531
- out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
9532
- blocks.push(body.join("\n"));
9533
- body = null;
9534
- } else out.push(line.visible);
9535
- }
9536
- if (body) {
9537
- out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
9538
- blocks.push(body.join("\n"));
9539
- }
9978
+ if (line.code === "open") {
9979
+ body = [];
9980
+ diagramTitle = options.mermaid && fenceInfo(line.raw).toLowerCase() === "mermaid" ? titleAbove(out) : void 0;
9981
+ } else if (line.code === "body") body.push(line.raw);
9982
+ else if (line.code === "close") close();
9983
+ else out.push(line.visible);
9984
+ }
9985
+ if (body) close();
9540
9986
  return {
9541
9987
  text: out.join("\n"),
9542
9988
  blocks
9543
9989
  };
9544
9990
  }
9991
+ /** The language a fence names, as written: `mermaid` in ```` ```mermaid ````. */
9992
+ function fenceInfo(line) {
9993
+ return line.match(/^\s*(?:`{3,}|~{3,})\s*([\w+#.-]*)/)?.[1] ?? "";
9994
+ }
9995
+ /**
9996
+ * What the author called a diagram: the heading, or the line that is all bold, just above it, as
9997
+ * in `**Diagram Walkthrough**`. Null when the nearest line above says something else.
9998
+ */
9999
+ function titleAbove(out) {
10000
+ for (let i = out.length - 1; i >= 0; i--) {
10001
+ const line = out[i].trim();
10002
+ if (!line) continue;
10003
+ const title = line.match(/^#{1,6}\s+(.+?)\s*#*$/)?.[1] ?? line.match(/^(\*\*|__)(.+)\1$/)?.[2];
10004
+ return title ? title.replace(/[`*_]/g, "").trim() || null : null;
10005
+ }
10006
+ return null;
10007
+ }
10008
+ /**
10009
+ * A diagram's kind, from its first line that is not a directive or front matter: `flowchart`,
10010
+ * `sequenceDiagram`. Front matter is the `---` block a diagram may open with.
10011
+ */
10012
+ function diagramType(code) {
10013
+ const lines = code.split("\n").map((line) => line.trim());
10014
+ let start = 0;
10015
+ if (lines.find((line) => line) === "---") {
10016
+ const open = lines.indexOf("---");
10017
+ const close = lines.indexOf("---", open + 1);
10018
+ start = close === -1 ? lines.length : close + 1;
10019
+ }
10020
+ return lines.slice(start).find((line) => line && !line.startsWith("%%"))?.split(/\s+/)[0] || "diagram";
10021
+ }
9545
10022
  function restoreFencedBlocks(html, blocks) {
9546
- return html.replace(BLOCK_TOKEN, (_match, index) => `<div class="${CODE_BLOCK_CLASS}"><pre><code>${escapeHtml(blocks[Number(index)])}</code></pre><button type="button" class="${CODE_COPY_CLASS}" aria-label="Copy code">Copy</button></div>`);
10023
+ return html.replace(BLOCK_TOKEN, (_match, index) => {
10024
+ const block = blocks[Number(index)];
10025
+ return block.diagram ? diagramFigure(block.code, block.diagram.label) : codeBlock(block.code, "Copy", "Copy code");
10026
+ });
10027
+ }
10028
+ function codeBlock(code, label, accessibleName, attributes = "") {
10029
+ return `<div class="${CODE_BLOCK_CLASS}"${attributes}><pre><code>${escapeHtml(code)}</code></pre><button type="button" class="${CODE_COPY_CLASS}" aria-label="${accessibleName}">${label}</button></div>`;
10030
+ }
10031
+ var diagramCount = 0;
10032
+ /**
10033
+ * A Mermaid block as the workspace's client finds it: the source, visible and copyable, and the
10034
+ * empty places the drawing, its toggle and its failure note go. The client draws it when it first
10035
+ * comes into view; until then, without JavaScript, or when drawing fails, the source is what shows.
10036
+ */
10037
+ function diagramFigure(code, label) {
10038
+ const id = `prb-md-mermaid-source-${++diagramCount}`;
10039
+ return `<figure class="prb-md-mermaid" data-md-mermaid data-state="source"><div class="prb-md-mermaid-canvas" data-md-mermaid-canvas role="img" aria-label="${escapeHtml(label)}" hidden></div><p class="prb-md-mermaid-error" data-md-mermaid-error hidden>This diagram couldn\u2019t be displayed.</p><button type="button" class="prb-md-mermaid-toggle" data-md-mermaid-toggle aria-expanded="true" aria-controls="${id}" hidden>Hide source</button>` + codeBlock(code, "Copy source", "Copy source", ` id="${id}" data-md-mermaid-source`) + `</figure>`;
9547
10040
  }
9548
- function renderBlocks(text) {
10041
+ function renderBlocks(text, lifted) {
9549
10042
  const out = [];
9550
10043
  let paragraph = [];
9551
10044
  let quote = [];
9552
10045
  let list = null;
10046
+ let html = null;
9553
10047
  const flush = () => {
9554
- if (paragraph.length) out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
9555
- if (quote.length) out.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
9556
- if (list) out.push(`<${list.tag}>${list.items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${list.tag}>`);
10048
+ if (paragraph.length) out.push(`<p>${renderInline(paragraph.join(" "), lifted)}</p>`);
10049
+ if (quote.length) out.push(`<blockquote>${renderInline(quote.join(" "), lifted)}</blockquote>`);
10050
+ if (list) out.push(`<${list.tag}>${list.items.map((item) => `<li>${renderInline(item, lifted)}</li>`).join("")}</${list.tag}>`);
9557
10051
  paragraph = [];
9558
10052
  quote = [];
9559
10053
  list = null;
9560
10054
  };
9561
10055
  for (const raw of text.split("\n")) {
9562
10056
  const line = raw.trim();
10057
+ if (html) {
10058
+ if (line) {
10059
+ html.push(escapeHtml(line));
10060
+ continue;
10061
+ }
10062
+ out.push(html.join("\n"));
10063
+ html = null;
10064
+ }
9563
10065
  if (!line) {
9564
10066
  flush();
9565
10067
  continue;
9566
10068
  }
10069
+ if (lifted && startsHtmlBlock(line, lifted)) {
10070
+ flush();
10071
+ html = [escapeHtml(line)];
10072
+ continue;
10073
+ }
9567
10074
  if (BLOCK_TOKEN_LINE.test(line)) {
9568
10075
  flush();
9569
10076
  out.push(line);
@@ -9578,7 +10085,7 @@ function renderBlocks(text) {
9578
10085
  if (heading) {
9579
10086
  flush();
9580
10087
  const level = Math.min(heading[1].length, 3) + 3;
9581
- out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
10088
+ out.push(`<h${level}>${renderInline(heading[2], lifted)}</h${level}>`);
9582
10089
  continue;
9583
10090
  }
9584
10091
  const bullet = line.match(/^[-*+]\s+(.+)$/);
@@ -9622,14 +10129,15 @@ function renderBlocks(text) {
9622
10129
  paragraph.push(line);
9623
10130
  }
9624
10131
  flush();
10132
+ if (html) out.push(html.join("\n"));
9625
10133
  return out.join("");
9626
10134
  }
9627
- function renderInline(value) {
10135
+ function renderInline(value, lifted) {
9628
10136
  const codeSpans = [];
9629
10137
  return escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, code) => {
9630
10138
  codeSpans.push(code);
9631
10139
  return `${INLINE_MARKER}${codeSpans.length - 1}${INLINE_MARKER}`;
9632
- }).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
10140
+ }).replace(/!\[([^\]\n]*)\]\(([^)\s]+)\)/g, (match, alt, href) => lifted && !holdsMarkup(href, lifted) ? mediaAnchor(href, imageLabel(alt)) ?? match : match).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) && !(lifted && holdsMarkup(href, lifted)) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
9633
10141
  }
9634
10142
  //#endregion
9635
10143
  //#region ../../packages/review-harness/src/domain/ai_review/author_description.ts
@@ -9713,7 +10221,7 @@ var ATX_HEADING = /^#{1,6}(?:\s|$)/;
9713
10221
  * on what is code and what is a hidden comment.
9714
10222
  */
9715
10223
  function visibleLines(description) {
9716
- return scanMarkdownLines(description, { hideHtmlComments: true }).filter((line) => !line.absent);
10224
+ return scanMarkdownLines(description, GITHUB_MARKDOWN).filter((line) => !line.absent);
9717
10225
  }
9718
10226
  /**
9719
10227
  * The description's Markdown blocks, with a code block kept whole and always a block of its own.
@@ -9721,16 +10229,24 @@ function visibleLines(description) {
9721
10229
  * Split where Markdown itself would end one: a blank line, a heading (always a block of its own)
9722
10230
  * and a list that starts straight after a paragraph. Descriptions are often written with no blank
9723
10231
  * lines at all, and on blank lines alone those would be one block. A line that is only a comment
9724
- * ends a block like a blank one.
10232
+ * ends a block like a blank one. An HTML block runs from the line that opens it to the next blank
10233
+ * line whatever its lines look like, as the renderer reads it (`opensHtmlBlock`).
9725
10234
  */
9726
10235
  function blocks(description) {
9727
10236
  const result = [];
9728
10237
  let current = [];
10238
+ let html = false;
9729
10239
  const end = () => {
9730
10240
  if (current.length) result.push(current);
9731
10241
  current = [];
10242
+ html = false;
9732
10243
  };
9733
- for (const line of visibleLines(description)) if (line.code === "open") {
10244
+ for (const line of visibleLines(description)) if (html && line.visible.trim() !== "" && !line.code) current.push(line);
10245
+ else if (!line.code && opensHtmlBlock(line.visible)) {
10246
+ end();
10247
+ html = true;
10248
+ current.push(line);
10249
+ } else if (line.code === "open") {
9734
10250
  end();
9735
10251
  current.push(line);
9736
10252
  } else if (line.code === "body") current.push(line);
@@ -9798,6 +10314,7 @@ function boundList(lines) {
9798
10314
  * The first block the author wrote decides what to show; the bounds decide how much of it. A
9799
10315
  * template's labels, comments and unticked items ahead of it are passed over, so "## Summary" opens
9800
10316
  * on the summary itself. A heading of the author's own is kept, with the block it introduces.
10317
+ * Formatted HTML is passed over too, so the preview opens on words.
9801
10318
  * A paragraph is cut after its first sentences or near a character limit, a list after its first
9802
10319
  * items, and a long code block is left out; nothing is cut mid-word or left as open Markdown.
9803
10320
  *
@@ -9814,6 +10331,7 @@ function authorPreview(description) {
9814
10331
  const lines = all[i];
9815
10332
  const first = lines.findIndex((line) => substance(line.visible) !== "");
9816
10333
  if (first === -1) continue;
10334
+ if (opensHtmlBlock(lines[0].visible)) continue;
9817
10335
  const raw = lines.slice(first).map((line) => line.visible);
9818
10336
  if (raw.length === 1 && ATX_HEADING.test(lines[first].visible.trim())) {
9819
10337
  shown.push(raw[0]);
@@ -13818,4 +14336,4 @@ function lineageIds(id) {
13818
14336
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
13819
14337
  }
13820
14338
  //#endregion
13821
- export { extractJsonText as $, resolveModel as $n, isClaimed as $t, createPrState as A, parseLifecycle as An, writePortPreference as At, toneOf as B, issuesOutstanding as Bn, allJobs as Bt, readEvents as C, LIFECYCLES as Cn, feedbackUrl as Ct, announceJob as D, lifecycleChip as Dn, parseWorkspacePort as Dt, telemetryUploadConsent as E, lifecycleAction as En, MIN_WORKSPACE_PORT as Et, markAllRead as F, queueStamp as Fn, fillFileUrlTemplate as Ft, authorPreview as G, describeAuthorship as Gn, saveJob as Gt, withUsageRecorded as H, reviewerDispositions as Hn, fail as Ht, markRead as I, readQueue as In, checkCodeFreshness as It, CODE_BLOCK_CLASS as J, resolveTarget as Jn, phasesFor as Jt, hasAuthorContent as K, WITHHELD_REASON as Kn, PHASES as Kt, pruneNotifications as L, doneVerb as Ln, checkReview as Lt, eventName as M, queueAdd as Mn, forgeResolver as Mt, headline as N, queueAddMany as Nn, forgeFor as Nt, branchOf as O, lifecyclePill as On, resolveWorkspacePort as Ot, listNotifications as P, queueRemove as Pn, remoteHost as Pt, asString as Q, resolveAgent as Qn, driverGone as Qt, readNotification as R, isIssueOutstanding as Rn, followedBranch as Rt, startJob as S, workspaceStamps as Sn, PACKAGE_NAME as St, recordTelemetryUploadConsent as T, isLifecycle as Tn, MAX_WORKSPACE_PORT as Tt, EXPLAIN_SIMPLY_PROMPT as U, processDiscussion as Un, isTerminal as Ut, unreadCount as V, questionsOutstanding as Vn, endedAt as Vt, UNESTABLISHED_MOTIVATION as W, recommendationsOf as Wn, loadJob as Wt, escapeHtml as X, isCommitReview as Xn, purposeOf as Xt, CODE_COPY_CLASS as Y, displayRef as Yn, progressSteps as Yt, renderInlineText as Z, git as Zn, clearClaim as Zt, agentEnvOf as _, reviewedCommit as _n, spawnServer as _t, wasBlocked as a, kissDecisionRefusal as an, AGENT_IDS as ar, relativeTime as at, reanalyseReview as b, touchWorkspace as bn, writeServerRecord as bt, RefreshUnavailableError as c, lineageKeyFor as cn, agentFor as cr, queueForReview as ct, refreshPrContext as d, matchingWorkspaceIds as dn, AgentUnavailableError as dr, bootstrapUrl as dt, decideKissRecommendation as en, clearAgentPreference as er, ReviewBeingDeletedError as et, isUnchanged as f, mutateWorkspace as fn, clearServerRecord as ft, runningJobs as g, recentWorkspaces as gn, serverEntryExists as gt, liveUpdateFor as h, previousKissResult as hn, reviewsUrl as ht, liveJobsFor as i, indexReviewGroups as in, STORE_ROOT as ir, modelHelpLines as it, createPrUpdate as j, sectionOf as jn, claimRequestUrl as jt, subjectOf as k, offersLifecycle as kn, statedWorkspacePort as kt, mergeUpdateInto as l, lineagePosition as ln, detectAgents as lr, settleQueue as lt, UpdateAlreadyRunningError as m, presentedKissRunOf as mn, readServerRecord as mt, deleteReview as n, groupByLineage as nn, writeAgentPreference as nr, readMarker as nt, discardJob as o, kissDecisionsOf as on, DEFAULT_AGENT_ID as or, activateQueued as ot, askCheckout as p, positionInLineage as pn, ensureServer as pt, renderMarkdown as q, kissOutcome as qn, isLiveJob as qt, lineageIds as r, indexKeptIds as rn, CONFIG_PATH as rr, removeWorktree as rt, FETCH_TIMEOUT_MS as s, latestKissRun as sn, agentById as sr, compactQueue as st, deleteLineage as t, followedRefName as tn, readAgentPreference as tr, MANAGED_ROOT as tt, updateReview as u, loadWorkspace as un, AgentCancelledError as ur, readIndexToken as ut, recordKissRun as v, reviewedRepositories as vn, stopServer as vt, record as w, activityLabel as wn, DEFAULT_WORKSPACE_PORT as wt, runJob as x, workspaceRevision as xn, BUILD_VERSION as xt, startKissJob as y, summarise as yn, workspaceUrl as yt, removeNotification as z, isQuestionOutstanding as zn, reviewedShaOf as zt };
14339
+ export { asString as $, resolveAgent as $n, driverGone as $t, createPrState as A, offersLifecycle as An, statedWorkspacePort as At, toneOf as B, isQuestionOutstanding as Bn, reviewedShaOf as Bt, readEvents as C, workspaceStamps as Cn, PACKAGE_NAME as Ct, announceJob as D, lifecycleAction as Dn, MIN_WORKSPACE_PORT as Dt, telemetryUploadConsent as E, isLifecycle as En, MAX_WORKSPACE_PORT as Et, markAllRead as F, queueRemove as Fn, remoteHost as Ft, authorPreview as G, recommendationsOf as Gn, loadJob as Gt, withUsageRecorded as H, questionsOutstanding as Hn, endedAt as Ht, markRead as I, queueStamp as In, fillFileUrlTemplate as It, renderMarkdown as J, kissOutcome as Jn, isLiveJob as Jt, hasAuthorContent as K, describeAuthorship as Kn, saveJob as Kt, pruneNotifications as L, readQueue as Ln, checkCodeFreshness as Lt, eventName as M, sectionOf as Mn, claimRequestUrl as Mt, headline as N, queueAdd as Nn, forgeResolver as Nt, branchOf as O, lifecycleChip as On, parseWorkspacePort as Ot, listNotifications as P, queueAddMany as Pn, forgeFor as Pt, renderInlineText as Q, git as Qn, clearClaim as Qt, readNotification as R, doneVerb as Rn, checkReview as Rt, startJob as S, workspaceRevision as Sn, BUILD_VERSION as St, recordTelemetryUploadConsent as T, activityLabel as Tn, DEFAULT_WORKSPACE_PORT as Tt, EXPLAIN_SIMPLY_PROMPT as U, reviewerDispositions as Un, fail as Ut, unreadCount as V, issuesOutstanding as Vn, allJobs as Vt, UNESTABLISHED_MOTIVATION as W, processDiscussion as Wn, isTerminal as Wt, CODE_COPY_CLASS as X, displayRef as Xn, progressSteps as Xt, CODE_BLOCK_CLASS as Y, resolveTarget as Yn, phasesFor as Yt, escapeHtml as Z, isCommitReview as Zn, purposeOf as Zt, agentEnvOf as _, recentWorkspaces as _n, serverEntryExists as _t, wasBlocked as a, indexReviewGroups as an, STORE_ROOT as ar, modelHelpLines as at, reanalyseReview as b, summarise as bn, workspaceUrl as bt, RefreshUnavailableError as c, latestKissRun as cn, agentById as cr, compactQueue as ct, refreshPrContext as d, loadWorkspace as dn, AgentCancelledError as dr, readIndexToken as dt, isClaimed as en, resolveModel as er, extractJsonText as et, isUnchanged as f, matchingWorkspaceIds as fn, AgentUnavailableError as fr, bootstrapUrl as ft, runningJobs as g, previousKissResult as gn, reviewsUrl as gt, liveUpdateFor as h, presentedKissRunOf as hn, readServerRecord as ht, liveJobsFor as i, indexKeptIds as in, CONFIG_PATH as ir, removeWorktree as it, createPrUpdate as j, parseLifecycle as jn, writePortPreference as jt, subjectOf as k, lifecyclePill as kn, resolveWorkspacePort as kt, mergeUpdateInto as l, lineageKeyFor as ln, agentFor as lr, queueForReview as lt, UpdateAlreadyRunningError as m, positionInLineage as mn, ensureServer as mt, deleteReview as n, followedRefName as nn, readAgentPreference as nr, MANAGED_ROOT as nt, discardJob as o, kissDecisionRefusal as on, AGENT_IDS as or, relativeTime as ot, askCheckout as p, mutateWorkspace as pn, clearServerRecord as pt, GITHUB_MARKDOWN as q, WITHHELD_REASON as qn, PHASES as qt, lineageIds as r, groupByLineage as rn, writeAgentPreference as rr, readMarker as rt, FETCH_TIMEOUT_MS as s, kissDecisionsOf as sn, DEFAULT_AGENT_ID as sr, activateQueued as st, deleteLineage as t, decideKissRecommendation as tn, clearAgentPreference as tr, ReviewBeingDeletedError as tt, updateReview as u, lineagePosition as un, detectAgents as ur, settleQueue as ut, recordKissRun as v, reviewedCommit as vn, spawnServer as vt, record as w, LIFECYCLES as wn, feedbackUrl as wt, runJob as x, touchWorkspace as xn, writeServerRecord as xt, startKissJob as y, reviewedRepositories as yn, stopServer as yt, removeNotification as z, isIssueOutstanding as zn, followedBranch as zt };