From 52318dda79927475e1addcc487e1f9f06b00c0f8 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 14 Aug 2026 12:10:19 +0200 Subject: [PATCH 1/3] Skip decorative-bullet normalization inside fenced code blocks markdownToHtml prefixes emoji/star-led lines with a list marker so marked renders them as lists, but it applied that rewrite to the whole message, including fenced code block content. Code patches are extracted from the rendered HTML, so a search/replace block containing a line that starts with an emoji was mutated before matching: the inserted marker made the search pattern never match the target file, and applied patches wrote the inserted marker into the file. The normalization now walks lines with fence tracking and leaves fenced content verbatim. Co-Authored-By: Claude Fable 5 --- packages/host/tests/unit/marked-sync-test.ts | 39 +++++++++++++++ packages/runtime-common/marked-sync.ts | 50 +++++++++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/host/tests/unit/marked-sync-test.ts b/packages/host/tests/unit/marked-sync-test.ts index d994cb1b83a..7c9c7904f7a 100644 --- a/packages/host/tests/unit/marked-sync-test.ts +++ b/packages/host/tests/unit/marked-sync-test.ts @@ -344,6 +344,45 @@ module('Unit | marked-sync', function () { ); }); + test('markdownToHtml prefixes decorative bullets with a list marker', function (assert) { + const markdown = '๐ŸŒŸ First point\n๐ŸŒŸ Second point'; + const result = markdownToHtml(markdown); + + assert.true( + result.includes('
  • ๐ŸŒŸ First point
  • '), + 'emoji-led lines become list items', + ); + assert.true( + result.includes('
  • ๐ŸŒŸ Second point
  • '), + 'each emoji-led line is its own list item', + ); + }); + + test('markdownToHtml leaves fenced code block content verbatim when lines start with decorative bullets', function (assert) { + const markdown = [ + '๐ŸŒŸ A real list item', + '```gts', + " ", + ' ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง', + ' ', + '```', + ].join('\n'); + const result = markdownToHtml(markdown, { sanitize: false }); + + assert.true( + result.includes('
  • ๐ŸŒŸ A real list item
  • '), + 'bullet normalization still applies outside the fence', + ); + assert.true( + result.includes(' ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง'), + 'emoji-led line inside the fence is unchanged', + ); + assert.false( + result.includes('* ๐Ÿšง'), + 'no list marker is inserted inside the fence', + ); + }); + test('markdownToHtml preserves heading IDs through sanitization', function (assert) { const markdown = '## Test Heading'; const result = markdownToHtml(markdown); diff --git a/packages/runtime-common/marked-sync.ts b/packages/runtime-common/marked-sync.ts index 494840f561a..d3eea283928 100644 --- a/packages/runtime-common/marked-sync.ts +++ b/packages/runtime-common/marked-sync.ts @@ -72,7 +72,49 @@ bfmMarked.use({ const DECORATIVE_BULLET_PATTERN = // eslint-disable-next-line no-misleading-character-class -- match pictographic symbols plus a few geometric glyphs not covered by the Unicode class - /(^|\n)(\s*)([\p{Extended_Pictographic}โ˜…โ€ขโ–ชโ—โ–โœฆโœงโ—‰โ—ฆโ—พโ—ฝโฌขโฌกโ˜‘โœ”โ˜‘๏ธโžคโž”โžœโžกโ†’])(\s+)/gu; + /^(\s*)([\p{Extended_Pictographic}โ˜…โ€ขโ–ชโ—โ–โœฆโœงโ—‰โ—ฆโ—พโ—ฝโฌขโฌกโ˜‘โœ”โ˜‘๏ธโžคโž”โžœโžกโ†’])(\s+)/u; + +const CODE_FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)$/; + +// Prefix decorative bullets with a standard list marker so marked treats them +// as list items โ€” but never inside fenced code blocks. Code-block content must +// survive rendering verbatim: search/replace patches are extracted back out of +// the rendered HTML, and an inserted marker makes the patch text no longer +// match the file it targets. +function normalizeDecorativeBullets(markdown: string): string { + let inFence = false; + let fenceChar = ''; + let fenceLength = 0; + return markdown + .split('\n') + .map((line) => { + let fenceMatch = line.match(CODE_FENCE_PATTERN); + if (fenceMatch) { + let marker = fenceMatch[2]; + if (!inFence) { + inFence = true; + fenceChar = marker[0]; + fenceLength = marker.length; + } else if ( + marker[0] === fenceChar && + marker.length >= fenceLength && + fenceMatch[3].trim() === '' + ) { + inFence = false; + } + return line; + } + if (inFence) { + return line; + } + return line.replace( + DECORATIVE_BULLET_PATTERN, + (_match, indentation, bullet, whitespace) => + `${indentation}* ${bullet}${whitespace}`, + ); + }) + .join('\n'); +} const DEFAULT_MARKED_SYNC_OPTIONS = { escapeHtmlInCodeBlocks: true, @@ -187,11 +229,7 @@ export function markdownToHtml( return ''; } // Marked only treats ASCII list markers, so prefix decorative bullets with a standard marker. - let normalizedMarkdown = markdown.replace( - DECORATIVE_BULLET_PATTERN, - (_match, boundary, indentation, bullet, whitespace) => - `${boundary}${indentation}* ${bullet}${whitespace}`, - ); + let normalizedMarkdown = normalizeDecorativeBullets(markdown); let html = markedSync(normalizedMarkdown, { escapeHtmlInCodeBlocks: opts.escapeHtmlInCodeBlocks, enableMonacoSyntaxHighlighting: opts.enableMonacoSyntaxHighlighting, From 8341f9962b03b33915a823603159dccef82c3a5a Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Sat, 15 Aug 2026 14:47:50 +0200 Subject: [PATCH 2/3] Track fences the way marked parses them: CRLF, list-nested, indented code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fence tracker missed three shapes marked itself treats as code, so the decorative-bullet rewrite could still corrupt patch content. `.` never matches a trailing carriage return, so on CRLF input no fence line matched at all; a fence opened on a list-marker line (`- ```gts`) went unrecognized and its closer inverted the tracker's state; and 4-space indented code blocks were rewritten even though extraction treats their
     exactly
    like a fenced one. Fence lines now tolerate a trailing CR, openers accept a
    leading list marker (closers cannot carry one), and indented lines are left
    verbatim.
    
    The line-based pattern also dropped the old whole-string behavior of
    normalizing a decorative bullet alone on its line โ€” restored by accepting
    end-of-line after the bullet.
    
    Tests now exercise the contract that actually broke: a search/replace
    block must round-trip byte-identical through render (bodyHTML) and
    extraction (parseHtmlContent), including from CRLF input.
    
    Co-Authored-By: Claude Fable 5 
    ---
     packages/host/tests/unit/marked-sync-test.ts | 103 +++++++++++++++++++
     packages/runtime-common/marked-sync.ts       |  62 +++++++----
     2 files changed, 146 insertions(+), 19 deletions(-)
    
    diff --git a/packages/host/tests/unit/marked-sync-test.ts b/packages/host/tests/unit/marked-sync-test.ts
    index 7c9c7904f7a..f96b29e0236 100644
    --- a/packages/host/tests/unit/marked-sync-test.ts
    +++ b/packages/host/tests/unit/marked-sync-test.ts
    @@ -1,10 +1,31 @@
     import { module, test } from 'qunit';
     
    +import {
    +  SEARCH_MARKER,
    +  SEPARATOR_MARKER,
    +  REPLACE_MARKER,
    +} from '@cardstack/runtime-common';
    +import { escapeHtmlOutsideCodeBlocks } from '@cardstack/runtime-common/helpers/html';
     import {
       markedSync,
       markdownToHtml,
     } from '@cardstack/runtime-common/marked-sync';
     
    +import { parseHtmlContent } from '@cardstack/host/lib/formatted-message/utils';
    +
    +// The render path a bot message's code patch travels before the host applies
    +// it: markdown body โ†’ bodyHTML (message.ts) โ†’ parseHtmlContent โ†’
    +// codeData.searchReplaceBlock. The patch must survive this byte-identical โ€”
    +// the applier matches it against the target file.
    +function roundTripSearchReplaceBlock(body: string) {
    +  let html = markdownToHtml(escapeHtmlOutsideCodeBlocks(body), {
    +    sanitize: false,
    +    escapeHtmlInCodeBlocks: true,
    +  });
    +  let parts = parseHtmlContent(html, 'room-1', 'event-1');
    +  return parts.find((p) => p.type === 'pre_tag')?.codeData ?? null;
    +}
    +
     module('Unit | marked-sync', function () {
       test('markedSync converts markdown to HTML', function (assert) {
         const markdown = '# Hello\n**Bold text**';
    @@ -383,6 +404,88 @@ module('Unit | marked-sync', function () {
         );
       });
     
    +  test('a code patch round-trips byte-identical through render and extraction', function (assert) {
    +    const search = [
    +      "        
    ", + ' ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง     BEST VIEWED IN', + '
    ', + ].join('\n'); + const block = `${SEARCH_MARKER}\n${search}\n${SEPARATOR_MARKER}\nreplaced\n${REPLACE_MARKER}`; + const body = `Fixing the file now.\n\n\`\`\`gts\nhttps://example.test/hello-world.gts\n${block}\n\`\`\``; + + const codeData = roundTripSearchReplaceBlock(body); + assert.ok(codeData, 'a code block was extracted'); + assert.strictEqual( + codeData!.searchReplaceBlock, + block, + 'the extracted patch is byte-identical to what the bot authored', + ); + }); + + test('a code patch round-trips intact from CRLF input', function (assert) { + const search = ' ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง'; + const block = `${SEARCH_MARKER}\n${search}\n${SEPARATOR_MARKER}\nreplaced\n${REPLACE_MARKER}`; + const body = + `Fixing the file now.\n\n\`\`\`gts\nhttps://example.test/hello-world.gts\n${block}\n\`\`\``.replace( + /\n/g, + '\r\n', + ); + + const codeData = roundTripSearchReplaceBlock(body); + assert.ok(codeData, 'a code block was extracted from CRLF input'); + assert.ok( + codeData!.searchReplaceBlock?.includes(search), + 'the emoji-led search line survives unmutated', + ); + assert.false( + (codeData!.searchReplaceBlock ?? '').includes('* ๐Ÿšง'), + 'no list marker was inserted into the CRLF fenced content', + ); + }); + + test('a fence opened on a list-item line is respected and does not invert fence tracking', function (assert) { + const markdown = [ + '- ```gts', + ' ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง', + ' ```', + '', + '๐ŸŒŸ after the list', + ].join('\n'); + const result = markdownToHtml(markdown, { sanitize: false }); + + assert.false( + result.includes('* ๐Ÿšง'), + 'content of the list-nested fence is unchanged', + ); + assert.true( + result.includes('
  • ๐ŸŒŸ after the list
  • '), + 'normalization still applies after the fence closes (state did not invert)', + ); + }); + + test('a decorative bullet alone on its line still becomes a list item', function (assert) { + const result = markdownToHtml('๐ŸŒŸ\n\ntext after', { sanitize: false }); + + assert.true( + result.includes('
  • ๐ŸŒŸ
  • '), + 'a bare decorative bullet line is normalized', + ); + }); + + test('indented code blocks are left verbatim', function (assert) { + const markdown = 'Example:\n\n ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง\n\n๐ŸŒŸ tail'; + const result = markdownToHtml(markdown, { sanitize: false }); + + assert.false( + result.includes('* ๐Ÿšง'), + 'no list marker is inserted into the indented code block', + ); + assert.true( + result.includes('
  • ๐ŸŒŸ tail
  • '), + 'normalization still applies outside the indented block', + ); + }); + test('markdownToHtml preserves heading IDs through sanitization', function (assert) { const markdown = '## Test Heading'; const result = markdownToHtml(markdown); diff --git a/packages/runtime-common/marked-sync.ts b/packages/runtime-common/marked-sync.ts index d3eea283928..6ca8cd1a081 100644 --- a/packages/runtime-common/marked-sync.ts +++ b/packages/runtime-common/marked-sync.ts @@ -70,17 +70,36 @@ bfmMarked.use({ }, }); +// The trailing `\s+|\r?$` alternation keeps a bullet that ends its line โ€” +// with or without trailing text โ€” normalizable; split('\n') has already +// consumed the newline the old whole-string pattern used to match. const DECORATIVE_BULLET_PATTERN = // eslint-disable-next-line no-misleading-character-class -- match pictographic symbols plus a few geometric glyphs not covered by the Unicode class - /^(\s*)([\p{Extended_Pictographic}โ˜…โ€ขโ–ชโ—โ–โœฆโœงโ—‰โ—ฆโ—พโ—ฝโฌขโฌกโ˜‘โœ”โ˜‘๏ธโžคโž”โžœโžกโ†’])(\s+)/u; - -const CODE_FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)$/; + /^(\s*)([\p{Extended_Pictographic}โ˜…โ€ขโ–ชโ—โ–โœฆโœงโ—‰โ—ฆโ—พโ—ฝโฌขโฌกโ˜‘โœ”โ˜‘๏ธโžคโž”โžœโžกโ†’])(\s+|\r?$)/u; + +// `.` never matches `\r`, so on CRLF input the greedy group stops before a +// trailing `\r`; the explicit `\r?` lets `$` still anchor. Without it no +// fence line would ever match CRLF content and the tracker would rewrite +// fenced code. +const CODE_FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)\r?$/; +// Markdown can open a fence on the same line as a list marker (`- ```gts`); +// marked treats that as fenced code, so the tracker must too. Only openers +// take this form โ€” a closing fence cannot carry an info string, let alone a +// list marker. +const LIST_PREFIXED_CODE_FENCE_PATTERN = + /^(\s*)(?:[-*+]|\d{1,9}[.)])\s+(`{3,}|~{3,})(.*)\r?$/; +// A 4-space (or tab) indented line outside a fence is an indented code block +// to marked, and the extraction side treats its
     exactly like a fenced
    +// one โ€” so rewriting it corrupts code the same way. The rewrite is cosmetic
    +// when skipped wrongly and destructive when applied wrongly, so indented
    +// lines are left alone.
    +const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/;
     
     // Prefix decorative bullets with a standard list marker so marked treats them
    -// as list items โ€” but never inside fenced code blocks. Code-block content must
    -// survive rendering verbatim: search/replace patches are extracted back out of
    -// the rendered HTML, and an inserted marker makes the patch text no longer
    -// match the file it targets.
    +// as list items โ€” but never inside code blocks (fenced or indented). Code
    +// content must survive rendering verbatim: search/replace patches are
    +// extracted back out of the rendered HTML, and an inserted marker makes the
    +// patch text no longer match the file it targets.
     function normalizeDecorativeBullets(markdown: string): string {
       let inFence = false;
       let fenceChar = '';
    @@ -88,23 +107,28 @@ function normalizeDecorativeBullets(markdown: string): string {
       return markdown
         .split('\n')
         .map((line) => {
    -      let fenceMatch = line.match(CODE_FENCE_PATTERN);
    -      if (fenceMatch) {
    -        let marker = fenceMatch[2];
    -        if (!inFence) {
    -          inFence = true;
    -          fenceChar = marker[0];
    -          fenceLength = marker.length;
    -        } else if (
    -          marker[0] === fenceChar &&
    -          marker.length >= fenceLength &&
    -          fenceMatch[3].trim() === ''
    +      if (inFence) {
    +        let closeMatch = line.match(CODE_FENCE_PATTERN);
    +        if (
    +          closeMatch &&
    +          closeMatch[2][0] === fenceChar &&
    +          closeMatch[2].length >= fenceLength &&
    +          closeMatch[3].trim() === ''
             ) {
               inFence = false;
             }
             return line;
           }
    -      if (inFence) {
    +      let openMatch =
    +        line.match(CODE_FENCE_PATTERN) ??
    +        line.match(LIST_PREFIXED_CODE_FENCE_PATTERN);
    +      if (openMatch) {
    +        inFence = true;
    +        fenceChar = openMatch[2][0];
    +        fenceLength = openMatch[2].length;
    +        return line;
    +      }
    +      if (INDENTED_CODE_PATTERN.test(line)) {
             return line;
           }
           return line.replace(
    
    From 5d743df3a8b4cfb275eb130ab85dd827caacde40 Mon Sep 17 00:00:00 2001
    From: Matic Jurglic 
    Date: Wed, 19 Aug 2026 10:18:33 +0200
    Subject: [PATCH 3/3] Drop indented-code skip: four spaces inside a list is
     content, not code
    
    Inside a list item the content column is already indented, so a
    decorative bullet nested four spaces (or a tab) under a real list item
    is a nested bullet, not an indented code block. The line-based pattern
    had no block context and swallowed both, collapsing nested emoji
    bullets into the parent item's text. Fenced blocks remain protected;
    the patch format is always fenced, so indented code blocks are left to
    the rewrite as before.
    
    Co-Authored-By: Claude Fable 5 
    ---
     packages/host/tests/unit/marked-sync-test.ts | 14 ++++++------
     packages/runtime-common/marked-sync.ts       | 24 ++++++++------------
     2 files changed, 17 insertions(+), 21 deletions(-)
    
    diff --git a/packages/host/tests/unit/marked-sync-test.ts b/packages/host/tests/unit/marked-sync-test.ts
    index f96b29e0236..3a06e75bb67 100644
    --- a/packages/host/tests/unit/marked-sync-test.ts
    +++ b/packages/host/tests/unit/marked-sync-test.ts
    @@ -472,17 +472,17 @@ module('Unit | marked-sync', function () {
         );
       });
     
    -  test('indented code blocks are left verbatim', function (assert) {
    -    const markdown = 'Example:\n\n    ๐Ÿšง SITE UNDER CONSTRUCTION ๐Ÿšง\n\n๐ŸŒŸ tail';
    +  test('a decorative bullet indented four spaces under a list item renders as a nested list', function (assert) {
    +    const markdown = '- item one\n    ๐ŸŒŸ nested point';
         const result = markdownToHtml(markdown, { sanitize: false });
     
    -    assert.false(
    -      result.includes('* ๐Ÿšง'),
    -      'no list marker is inserted into the indented code block',
    +    assert.true(
    +      result.includes('
  • ๐ŸŒŸ nested point
  • '), + 'the indented decorative bullet becomes its own list item', ); assert.true( - result.includes('
  • ๐ŸŒŸ tail
  • '), - 'normalization still applies outside the indented block', + /
  • item one[\s\S]*
      [\s\S]*๐ŸŒŸ nested point/.test(result), + 'the decorative bullet nests as a sub-list under the parent item', ); }); diff --git a/packages/runtime-common/marked-sync.ts b/packages/runtime-common/marked-sync.ts index 6ca8cd1a081..beeb62280e5 100644 --- a/packages/runtime-common/marked-sync.ts +++ b/packages/runtime-common/marked-sync.ts @@ -88,18 +88,17 @@ const CODE_FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)\r?$/; // list marker. const LIST_PREFIXED_CODE_FENCE_PATTERN = /^(\s*)(?:[-*+]|\d{1,9}[.)])\s+(`{3,}|~{3,})(.*)\r?$/; -// A 4-space (or tab) indented line outside a fence is an indented code block -// to marked, and the extraction side treats its
       exactly like a fenced
      -// one โ€” so rewriting it corrupts code the same way. The rewrite is cosmetic
      -// when skipped wrongly and destructive when applied wrongly, so indented
      -// lines are left alone.
      -const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/;
      -
       // Prefix decorative bullets with a standard list marker so marked treats them
      -// as list items โ€” but never inside code blocks (fenced or indented). Code
      -// content must survive rendering verbatim: search/replace patches are
      -// extracted back out of the rendered HTML, and an inserted marker makes the
      -// patch text no longer match the file it targets.
      +// as list items โ€” but never inside fenced code blocks. Fenced content must
      +// survive rendering verbatim: search/replace patches are extracted back out
      +// of the rendered HTML, and an inserted marker makes the patch text no longer
      +// match the file it targets.
      +//
      +// Only *fenced* blocks are protected. A 4-space indented line is an indented
      +// code block to marked in some contexts, but inside a list item the same
      +// indentation is ordinary list content (a nested bullet); telling the two
      +// apart needs block context that only the lexer has. Since the patch format
      +// is always fenced, indented code blocks are left to the rewrite.
       function normalizeDecorativeBullets(markdown: string): string {
         let inFence = false;
         let fenceChar = '';
      @@ -128,9 +127,6 @@ function normalizeDecorativeBullets(markdown: string): string {
               fenceLength = openMatch[2].length;
               return line;
             }
      -      if (INDENTED_CODE_PATTERN.test(line)) {
      -        return line;
      -      }
             return line.replace(
               DECORATIVE_BULLET_PATTERN,
               (_match, indentation, bullet, whitespace) =>