From 7b970857e538cdd55da7dac5e00bd7aa16d63c11 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 13:06:17 -0700 Subject: [PATCH 1/5] fix(rich-md-editor): fix raw-HTML-block fragmentation and styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RawHtmlBlock previously delegated to marked's built-in block-HTML tokenizer, which (per CommonMark's HTML-block-type-6 rule) stops at the first blank line. A real
with a paragraph inside — the common case — fragmented into a raw chip, an ordinary rendered paragraph, and a second raw chip, stranding real content in between. - Fixed with a custom block-level tokenizer that scans to the tag's matching close (reusing the balanced open/close depth-tracking already built for nested inline HTML), spanning blank lines, restricted to CommonMark's own block-tag whitelist (details, div, table, section, etc.) so tags that can legitimately start a paragraph (em, a, span, code, kbd) are left untouched. - Dropped the warning-colored tint on raw HTML/footnote blocks (color-mix with --warning read as an error state) in favor of the same neutral surface as existing code blocks — the hover badge alone now signals "this is raw, unrendered text". --- .../raw-markdown-snippet.test.ts | 61 +++++- .../raw-markdown-snippet.tsx | 190 ++++++++++++++---- .../rich-markdown-editor.css | 10 +- 3 files changed, 218 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index e086d0dc27e..8c5620c6caf 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -6,12 +6,17 @@ * and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`). */ import { describe, expect, it } from 'vitest' -import { serializeMarkdownDocument } from './markdown-parse' +import { parseMarkdownToDoc, serializeMarkdownDocument } from './markdown-parse' function roundTrip(input: string): string { return serializeMarkdownDocument(input).trim() } +/** Top-level node type names of the parsed doc, for structural (not just string) assertions. */ +function topLevelTypes(input: string): (string | undefined)[] { + return (parseMarkdownToDoc(input).content ?? []).map((n) => n.type) +} + describe('raw markdown snippet nodes', () => { it('preserves a standalone HTML comment', () => { const input = '\n\ntext' @@ -96,3 +101,57 @@ describe('raw markdown snippet nodes', () => { expect(roundTrip(input)).toBe(input) }) }) + +describe('raw HTML block: does not fragment across blank lines', () => { + it('a
block with a blank-line-separated body is ONE node, not three', () => { + const input = + '
\nClick to expand\n\nThis is inside a details/summary block.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a
with multiple blank-line-separated paragraphs inside is ONE node', () => { + const input = '
\n\nfirst paragraph\n\nsecond paragraph\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('nested same-tag block HTML balances depth across blank lines', () => { + const input = '
\nouter\n\n
\n\ninner\n\n
\n\nstill outer\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a paragraph starting with a non-block-list inline tag is NOT captured as a raw block', () => { + // `em`/`a` aren't in the CommonMark block-HTML tag whitelist — they can legitimately start an + // ordinary paragraph, and must keep parsing as real marks, not freeze as raw source. + expect(topLevelTypes('hi there, this is a normal paragraph')).toEqual(['paragraph']) + expect(roundTrip('hi there')).toBe('*hi* there') + }) + + it('a stray inline-only tag alone on its own line is left to the stock (non-whitelisted) path', () => { + // `` isn't in the block whitelist, so the new block tokenizer must not claim it — it falls + // through to marked's own (stricter) block-HTML detection, unaffected by this change. + const input = '\n\nnot a block-html tag\n\n' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('an unterminated block tag falls back gracefully (no crash, no infinite loop)', () => { + const input = '
\nnever closed\n\nbody' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('a block comment spanning blank lines still round-trips via the new shared tokenizer path', () => { + const input = '\n\ntext after' + expect(topLevelTypes(input)[0]).toBe('rawHtmlBlock') + expect(roundTrip(input)).toBe(input) + }) + + it('a table and code block adjacent to a fragmenting-prone details block still coexist correctly', () => { + const input = + '
\ns\n\nbody\n\n
\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + expect(roundTrip(input)).toBe(input) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 9081fe3fa7c..7f1f85f4f19 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -50,6 +50,35 @@ function verbatimText(node: JSONContent): string { return (node.content ?? []).map((child) => child.text ?? '').join('') } +const RAW_HTML_COMMENT_RE = /^/ + +const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i + +/** + * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, + * tracking nesting depth from `fromIndex` onward so `outer inner` consumes + * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A + * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. + * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). + */ +function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { + const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi') + tagRe.lastIndex = fromIndex + let depth = 1 + for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) { + const isClose = match[1] === '/' + const isSelfClosing = Boolean(match[2]) + if (isSelfClosing) continue + if (isClose) { + depth -= 1 + if (depth === 0) return match.index + match[0].length + } else { + depth += 1 + } + } + return -1 +} + /** * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in @@ -112,16 +141,128 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { } } -/** Block-level raw HTML — `
`, `
`, standalone ``, etc. - * Marked's own block tokenizer already classifies all of these as a single `'html'` token - * (`token.block === true`); `@tiptap/markdown`'s parser registry is checked *before* its built-in - * HTML handling for block tokens (unlike inline, see {@link RawInlineHtml}), so claiming the - * `'html'` token name here needs no custom tokenizer. */ +/** + * Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see + * `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block + * opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags + * NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary + * paragraph (`hi there`), so they're deliberately left to marked's own stricter, single-line + * block-HTML detection below — claiming them here would risk swallowing a paragraph that merely + * starts with inline HTML. + */ +const BLOCK_HTML_TAG_NAMES = new Set([ + 'address', + 'article', + 'aside', + 'base', + 'basefont', + 'blockquote', + 'body', + 'caption', + 'center', + 'col', + 'colgroup', + 'dd', + 'details', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'html', + 'iframe', + 'legend', + 'li', + 'link', + 'main', + 'menu', + 'menuitem', + 'meta', + 'nav', + 'noframes', + 'ol', + 'optgroup', + 'option', + 'p', + 'param', + 'search', + 'section', + 'summary', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'title', + 'tr', + 'track', + 'ul', +]) + +/** + * Marked's built-in block-HTML rule ends a `
`/`
`/… block at the *first blank line* + * (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim + * preservation: any real-world `
` with a paragraph inside would fragment into a raw chip, + * an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between. + * This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank + * lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and + * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block + * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment + * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers + * symmetric and independently testable. + */ +function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { + const comment = RAW_HTML_COMMENT_RE.exec(src) + if (comment) return { type: 'html', raw: comment[0], text: comment[0], block: true } + + const open = OPEN_TAG_RE.exec(src) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined + if (open[2]) return { type: 'html', raw: open[0], text: open[0], block: true } + + const end = findBalancedCloseEnd(src, tag, open[0].length) + if (end < 0) return undefined + const raw = src.slice(0, end) + return { type: 'html', raw, text: raw, block: true } +} + const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i export const RawHtmlBlock = Node.create({ ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), markdownTokenName: 'html', + markdownTokenizer: { + name: 'rawHtmlBlockTag', + level: 'block' as const, + // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` + // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which + // corrupts the in-progress lexer's shared state (verified directly — every other construct on the + // page silently loses its content once a tokenizer without an explicit `start` is registered). + // The other custom tokenizers below all reference this comment rather than repeating it. + // + // The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same + // `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the + // distinct `name` here only avoids colliding with marked's own built-in `html` extension. + start: () => -1, + tokenize: tokenizeRawHtmlBlockTag, + }, parseMarkdown(token: MarkdownToken) { if (!token.block) return [] const raw = token.raw ?? token.text ?? '' @@ -171,11 +312,8 @@ export const FootnoteDef = Node.create({ markdownTokenizer: { name: 'footnoteDef', level: 'block' as const, - // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` - // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which - // corrupts the in-progress lexer's shared state (verified directly — every other construct on the - // page silently loses its content once a tokenizer without an explicit `start` is registered). - // The cost is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost + // here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no // blank line between them) is picked up on the next block boundary instead of interrupting early. start: () => -1, tokenize: tokenizeFootnoteDef, @@ -196,7 +334,7 @@ export const FootnoteRef = Node.create({ markdownTokenizer: { name: 'footnoteRef', level: 'inline' as const, - // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. start: () => -1, tokenize(src: string) { const match = FOOTNOTE_REF_RE.exec(src) @@ -211,34 +349,6 @@ export const FootnoteRef = Node.create({ }, }) -const RAW_HTML_COMMENT_RE = /^/ - -const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i - -/** - * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, - * tracking nesting depth from `fromIndex` onward so `outer inner` consumes - * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A - * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. - */ -function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { - const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi') - tagRe.lastIndex = fromIndex - let depth = 1 - for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) { - const isClose = match[1] === '/' - const isSelfClosing = Boolean(match[2]) - if (isSelfClosing) continue - if (isClose) { - depth -= 1 - if (depth === 0) return match.index + match[0].length - } else { - depth += 1 - } - } - return -1 -} - /** * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema @@ -279,7 +389,7 @@ export const RawInlineHtml = Node.create({ markdownTokenizer: { name: 'rawInlineHtml', level: 'inline' as const, - // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. start: () => -1, tokenize: tokenizeRawInlineHtml, }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index febdb83e3aa..66a80d470f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -279,17 +279,23 @@ /* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks, comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). - Styled distinctly (monospace, tinted) so it's clear this text isn't interpreted, unlike a code block. */ + Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state, + which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */ .rich-markdown-prose .raw-markdown-block, .rich-markdown-prose .raw-markdown-inline { font-family: var(--font-martian-mono, ui-monospace, monospace); font-size: 0.875em; color: var(--text-muted); - background: color-mix(in srgb, var(--warning, orange) 8%, var(--surface-5)); + background: var(--surface-5); white-space: pre-wrap; overflow-wrap: anywhere; } +.dark .rich-markdown-prose .raw-markdown-block, +.dark .rich-markdown-prose .raw-markdown-inline { + background: var(--code-bg); +} + .rich-markdown-prose .raw-markdown-block { border-radius: 8px; padding: 0.75rem 1rem; From d16d1beca66ff7479fa64aebc0270fdb4f04a9d0 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 13:33:23 -0700 Subject: [PATCH 2/5] fix(rich-md-editor): fix indented HTML, quoted attributes, and code-mention edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommonMark allows up to 3 leading spaces before a block-HTML opening line; the new block tokenizer required column 0, so an indented
/
still fragmented across blank lines. Fixed by splitting off the leading indent, matching against the rest, and stitching the indent back onto raw. - The open-tag and balanced-close regexes stopped at the first `>`, even inside a quoted attribute value (e.g. data-example="a > b"), producing wrong match lengths and miscounting a same-tag mention inside the quoted value as a real nested tag. Replaced with an attribute-aware pattern that treats a full quoted value (including any interior >) as one unit. - The balanced-tag scan couldn't distinguish real markup from a tag name mentioned inside inline code or a fenced code block. Now masks code regions (same-length filler, positions preserved) before scanning, so a properly-escaped mention (backticks or a fenced example) is never miscounted. A genuinely bare, unescaped mention outside code remains a known, inherent limitation of regex-based tag matching (shared by real HTML parsers given the same ambiguous input) — verified this can never lose data or hang, only reflow to a stable fixpoint on save. --- .../raw-markdown-snippet.test.ts | 57 ++++++++++++++++ .../raw-markdown-snippet.tsx | 66 ++++++++++++++++--- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index 8c5620c6caf..ebbe5746dc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -154,4 +154,61 @@ describe('raw HTML block: does not fragment across blank lines', () => { '
\ns\n\nbody\n\n
\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' expect(roundTrip(input)).toBe(input) }) + + it('preserves an indented (up to 3 spaces) block-HTML opening line', () => { + // `roundTrip`'s `.trim()` would strip the very leading indent this test verifies, so check the + // parsed node's own text (and the untrimmed serialization) instead of the trimmed helper. + for (const indent of [' ', ' ', ' ']) { + const input = `${indent}
\nx\n\nbody\n\n
` + const doc = parseMarkdownToDoc(input) + expect(doc.content?.map((n) => n.type)).toEqual(['rawHtmlBlock']) + expect(doc.content?.[0].content?.[0].text).toBe(input) + expect(serializeMarkdownDocument(input)).toBe(`${input}\n`) + } + }) + + it('preserves a quoted attribute value containing a literal >', () => { + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a quoted attribute containing a nested same-tag mention does not confuse the balance scan', () => { + // Without attribute-aware matching, `
` inside the quoted value below would be miscounted as + // a real nested open tag, throwing off the depth count entirely. + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an inline code span for a real closing tag', () => { + const input = '
\nx\n\nSee `
` in the docs.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside a fenced code block for a real closing tag', () => { + const input = '
\n\nExample:\n\n```html\n
example
\n```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('a bare (unescaped, un-fenced) tag-name mention never crashes and always converges to a stable save', () => { + // Known, inherent limitation of regex-based (non-DOM) tag matching, shared by any HTML-block + // scanner (and by real HTML parsers given the same ambiguous input) — a bare mention outside + // code can still be misread as the real closer. The bar this file holds itself to is: never + // crash, never lose text, and always settle to a fixpoint after one save (isRoundTripSafe's own + // documented tolerance for single-pass normalization) — not a perfect, DOM-aware parse. + const input = + '
\nx\n\nSee the literal text
in docs.\n\nmore body\n\n
' + expect(() => roundTrip(input)).not.toThrow() + const once = roundTrip(input) + const twice = roundTrip(once) + expect(once).toBe(twice) + // No word from the original is dropped, even though the structure/whitespace may be reflowed. + for (const word of ['See', 'the', 'literal', 'text', 'in', 'docs', 'more', 'body']) { + expect(once).toContain(word) + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 7f1f85f4f19..5c0b36239f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -52,7 +52,31 @@ function verbatimText(node: JSONContent): string { const RAW_HTML_COMMENT_RE = /^/ -const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i +/** + * One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value + * alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of + * the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending + * the tag match at the internal `>`. + */ +const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*` + +/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the + * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ +const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') + +/** + * Mask fenced code blocks and inline code spans with same-length filler (newlines kept, everything + * else replaced with a space) so a tag-like mention *inside code* — `` `
` ``, or a fenced + * example showing HTML syntax — is never mistaken for a real balancing tag while scanning. Reuses the + * same fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches, but preserves + * length/position (masks in place) instead of deleting, so match indices still map onto the + * original, unmodified `src` the caller slices from. + */ +function maskCodeRegions(src: string): string { + return src + .replace(/^([`~]{3,})[^\n]*\n[\s\S]*?^\1[`~]*[ \t]*$/gm, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) +} /** * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, @@ -60,12 +84,22 @@ const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i * both levels instead of stopping at the first (inner) `
`. Returns -1 if unterminated. A * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). + * + * Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't + * count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based + * (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in + * code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML + * parser given the same ambiguous input (there is no valid way to "escape" a literal `` inside + * real HTML content other than an entity or code region). Verified this can't lose data even in that + * case — the result still reaches a stable fixpoint on save, just restructured — matching this file's + * "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`). */ function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { - const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi') + const masked = maskCodeRegions(src) + const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi') tagRe.lastIndex = fromIndex let depth = 1 - for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) { + for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) { const isClose = match[1] === '/' const isSelfClosing = Boolean(match[2]) if (isSelfClosing) continue @@ -225,21 +259,33 @@ const BLOCK_HTML_TAG_NAMES = new Set([ * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers - * symmetric and independently testable. + * symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML + * opening line, so the leading indent is split off, matched against separately, and stitched back + * onto `raw` — everything after that first line (including the tag's own body) can be indented + * however the author wrote it, since the balanced scan doesn't care about column position there. */ function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { - const comment = RAW_HTML_COMMENT_RE.exec(src) - if (comment) return { type: 'html', raw: comment[0], text: comment[0], block: true } + const indent = /^ {0,3}/.exec(src)?.[0] ?? '' + const rest = src.slice(indent.length) - const open = OPEN_TAG_RE.exec(src) + const comment = RAW_HTML_COMMENT_RE.exec(rest) + if (comment) { + const raw = indent + comment[0] + return { type: 'html', raw, text: raw, block: true } + } + + const open = OPEN_TAG_RE.exec(rest) if (!open) return undefined const tag = open[1].toLowerCase() if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined - if (open[2]) return { type: 'html', raw: open[0], text: open[0], block: true } + if (open[2]) { + const raw = indent + open[0] + return { type: 'html', raw, text: raw, block: true } + } - const end = findBalancedCloseEnd(src, tag, open[0].length) + const end = findBalancedCloseEnd(rest, tag, open[0].length) if (end < 0) return undefined - const raw = src.slice(0, end) + const raw = indent + rest.slice(0, end) return { type: 'html', raw, text: raw, block: true } } From 0ea00f37416d17f2925160bc1a09887f2f90a20b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 13:43:21 -0700 Subject: [PATCH 3/5] fix(rich-md-editor): fix blockquoted-fence masking and void-tag balance scan - maskCodeRegions's fenced-code regex required fence markers at column 0, so a fence quoted inside a markdown blockquote (each line prefixed with `> `) wasn't recognized, leaving tag mentions inside it visible to the balance scanner. Extended the fence pattern to tolerate an optional blockquote prefix on both the opening and closing fence line. - BLOCK_HTML_TAG_NAMES includes several void elements (link, meta, base, hr, ...) that have no closing tag at all. The block tokenizer only treated an explicit self-closing `/>` as complete, so a void tag without one would scan the rest of the document for a `` that will never appear, risking a false match on a later same-name mention. Now reuses the existing VOID_TAGS set (already used by the inline tokenizer) to treat these as complete right after the open tag. Also restored `hr` to the whitelist, which was accidentally dropped when transcribing marked's tag list. --- .../raw-markdown-snippet.test.ts | 31 +++++++++++++++++++ .../raw-markdown-snippet.tsx | 28 +++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index ebbe5746dc5..de72195b342 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -211,4 +211,35 @@ describe('raw HTML block: does not fragment across blank lines', () => { expect(once).toContain(word) } }) + + it('does not mistake a tag name mentioned inside a blockquoted fenced code block for a real closing tag', () => { + const input = + '
\n\n> Example:\n>\n> ```html\n>
example
\n> ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('treats a void block tag (no closing tag exists) as complete right after the open tag', () => { + // `link`/`meta`/`base`/`hr` are in the CommonMark block-HTML whitelist but are void elements — + // scanning for a `` that will never legitimately appear would risk grabbing unrelated + // later content (or a stray same-name mention) into the block. + for (const input of [ + '\n\nafter', + '\n\nafter', + '
\n\nafter', + ]) { + const doc = parseMarkdownToDoc(input) + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(roundTrip(input)).toContain('after') + } + }) + + it('a void block tag does not swallow a later, unrelated mention of its own tag name', () => { + const input = '\n\nSee the `` tag in docs.\n\nmore body' + const doc = parseMarkdownToDoc(input) + // The is its own complete block; the later mention (in code) stays in a separate paragraph. + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(doc.content?.[0].content?.[0].text).toBe('') + expect(roundTrip(input)).toContain('more body') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 5c0b36239f0..89a704cf39a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -64,17 +64,27 @@ const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^'] * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') +/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with + * up to 3 spaces then one or more `>` markers, each optionally followed by a space) — matched on + * both the open and close fence line so a fence quoted like `> \`\`\`` still masks correctly. */ +const BLOCKQUOTE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*' + /** * Mask fenced code blocks and inline code spans with same-length filler (newlines kept, everything * else replaced with a space) so a tag-like mention *inside code* — `` `
` ``, or a fenced - * example showing HTML syntax — is never mistaken for a real balancing tag while scanning. Reuses the - * same fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches, but preserves - * length/position (masks in place) instead of deleting, so match indices still map onto the - * original, unmodified `src` the caller slices from. + * example showing HTML syntax — is never mistaken for a real balancing tag while scanning. Mirrors + * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also + * tolerate a blockquote prefix on the fence markers, since a raw HTML block can itself be quoted), + * but preserves length/position (masks in place) instead of deleting, so match indices still map + * onto the original, unmodified `src` the caller slices from. */ function maskCodeRegions(src: string): string { + const fenceRe = new RegExp( + `^${BLOCKQUOTE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${BLOCKQUOTE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, + 'gm' + ) return src - .replace(/^([`~]{3,})[^\n]*\n[\s\S]*?^\1[`~]*[ \t]*$/gm, (m) => m.replace(/[^\n]/g, ' ')) + .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) } @@ -218,6 +228,7 @@ const BLOCK_HTML_TAG_NAMES = new Set([ 'h6', 'head', 'header', + 'hr', 'html', 'iframe', 'legend', @@ -278,7 +289,12 @@ function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { if (!open) return undefined const tag = open[1].toLowerCase() if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined - if (open[2]) { + // A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no + // closing tag at all — treat them as complete right after the open tag (like an explicit `/>`), + // same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a + // ``/`` that will never legitimately appear risks grabbing unrelated later content + // (or a stray same-name mention) as if it belonged to this block. + if (open[2] || VOID_TAGS.has(tag)) { const raw = indent + open[0] return { type: 'html', raw, text: raw, block: true } } From 54afeb686c19b8c6e22b3031211afd9d007d9130 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 13:52:24 -0700 Subject: [PATCH 4/5] fix(rich-md-editor): tolerate an indented (non-blockquoted) fence in code masking maskCodeRegions's fence pattern handled a blockquoted fence (`> \`\`\``) but still required the fence marker at column 0 otherwise, missing CommonMark's independent up-to-3-space fence indent tolerance this codebase already relies on elsewhere (FENCE_OPEN/FENCE_CLOSE in markdown-parse.ts). An indented fence's tag-name mention could still end a whitelisted block early. Fixed by combining both allowances in one prefix pattern (zero-or-more blockquote levels, each independently followed by up to 3 more spaces of indent). --- .../raw-markdown-snippet.test.ts | 7 +++++++ .../rich-markdown-editor/raw-markdown-snippet.tsx | 15 +++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index de72195b342..d3e9e18fe9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -219,6 +219,13 @@ describe('raw HTML block: does not fragment across blank lines', () => { expect(roundTrip(input)).toBe(input) }) + it('does not mistake a tag name mentioned inside an indented (no blockquote) fenced code block for a real closing tag', () => { + const input = + '
\n\nExample:\n\n ```html\n
example
\n ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + it('treats a void block tag (no closing tag exists) as complete right after the open tag', () => { // `link`/`meta`/`base`/`hr` are in the CommonMark block-HTML whitelist but are void elements — // scanning for a `` that will never legitimately appear would risk grabbing unrelated diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 89a704cf39a..1d64facfa14 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -65,22 +65,25 @@ const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^'] const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') /** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with - * up to 3 spaces then one or more `>` markers, each optionally followed by a space) — matched on - * both the open and close fence line so a fence quoted like `> \`\`\`` still masks correctly. */ -const BLOCKQUOTE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*' + * up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be + * independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent + * tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the + * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ +const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' /** * Mask fenced code blocks and inline code spans with same-length filler (newlines kept, everything * else replaced with a space) so a tag-like mention *inside code* — `` `
` ``, or a fenced * example showing HTML syntax — is never mistaken for a real balancing tag while scanning. Mirrors * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also - * tolerate a blockquote prefix on the fence markers, since a raw HTML block can itself be quoted), - * but preserves length/position (masks in place) instead of deleting, so match indices still map + * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw + * HTML block can itself be indented or quoted), but preserves length/position (masks in place) + * instead of deleting, so match indices still map * onto the original, unmodified `src` the caller slices from. */ function maskCodeRegions(src: string): string { const fenceRe = new RegExp( - `^${BLOCKQUOTE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${BLOCKQUOTE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, + `^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, 'gm' ) return src From c043d9c052429002065d7c7013b332b43f750138 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 14:02:06 -0700 Subject: [PATCH 5/5] fix(rich-md-editor): mask HTML comments in balanced-tag scan A tag-name mention inside an HTML comment (e.g. ``) inside a whitelisted raw HTML block could still be matched by the balance scan and end the block early, fragmenting it. maskCodeRegions already masked fenced/inline code the same way; extend it to mask comments too. --- .../raw-markdown-snippet.test.ts | 6 ++++++ .../raw-markdown-snippet.tsx | 16 +++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index d3e9e18fe9e..9588b487b2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -194,6 +194,12 @@ describe('raw HTML block: does not fragment across blank lines', () => { expect(roundTrip(input)).toBe(input) }) + it('does not mistake a tag name mentioned inside an HTML comment for a real closing tag', () => { + const input = '
\n\n\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + it('a bare (unescaped, un-fenced) tag-name mention never crashes and always converges to a stable save', () => { // Known, inherent limitation of regex-based (non-DOM) tag matching, shared by any HTML-block // scanner (and by real HTML parsers given the same ambiguous input) — a bare mention outside diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 1d64facfa14..f2e27209ce7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -71,15 +71,20 @@ const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' +/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by + * {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */ +const HTML_COMMENT_ANYWHERE_RE = //g + /** - * Mask fenced code blocks and inline code spans with same-length filler (newlines kept, everything - * else replaced with a space) so a tag-like mention *inside code* — `` `
` ``, or a fenced - * example showing HTML syntax — is never mistaken for a real balancing tag while scanning. Mirrors + * Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines + * kept, everything else replaced with a space) so a tag-like mention *inside one of these* — + * `` `` ``, a fenced example showing HTML syntax, or a comment documenting the tag + * (``) — is never mistaken for a real balancing tag while scanning. Mirrors * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw * HTML block can itself be indented or quoted), but preserves length/position (masks in place) - * instead of deleting, so match indices still map - * onto the original, unmodified `src` the caller slices from. + * instead of deleting, so match indices still map onto the original, unmodified `src` the caller + * slices from. */ function maskCodeRegions(src: string): string { const fenceRe = new RegExp( @@ -89,6 +94,7 @@ function maskCodeRegions(src: string): string { return src .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) + .replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' ')) } /**