Skip to content

Commit f994143

Browse files
committed
fix(rich-md-editor): fix raw-HTML-block fragmentation and styling
- 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 <details> 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".
1 parent 0eb68c2 commit f994143

3 files changed

Lines changed: 218 additions & 43 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
* and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`).
77
*/
88
import { describe, expect, it } from 'vitest'
9-
import { serializeMarkdownDocument } from './markdown-parse'
9+
import { parseMarkdownToDoc, serializeMarkdownDocument } from './markdown-parse'
1010

1111
function roundTrip(input: string): string {
1212
return serializeMarkdownDocument(input).trim()
1313
}
1414

15+
/** Top-level node type names of the parsed doc, for structural (not just string) assertions. */
16+
function topLevelTypes(input: string): (string | undefined)[] {
17+
return (parseMarkdownToDoc(input).content ?? []).map((n) => n.type)
18+
}
19+
1520
describe('raw markdown snippet nodes', () => {
1621
it('preserves a standalone HTML comment', () => {
1722
const input = '<!-- a note -->\n\ntext'
@@ -96,3 +101,57 @@ describe('raw markdown snippet nodes', () => {
96101
expect(roundTrip(input)).toBe(input)
97102
})
98103
})
104+
105+
describe('raw HTML block: does not fragment across blank lines', () => {
106+
it('a <details><summary> block with a blank-line-separated body is ONE node, not three', () => {
107+
const input =
108+
'<details>\n<summary>Click to expand</summary>\n\nThis is inside a details/summary block.\n\n</details>'
109+
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
110+
expect(roundTrip(input)).toBe(input)
111+
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
112+
})
113+
114+
it('a <div> with multiple blank-line-separated paragraphs inside is ONE node', () => {
115+
const input = '<div>\n\nfirst paragraph\n\nsecond paragraph\n\n</div>'
116+
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
117+
expect(roundTrip(input)).toBe(input)
118+
})
119+
120+
it('nested same-tag block HTML balances depth across blank lines', () => {
121+
const input = '<div>\nouter\n\n<div>\n\ninner\n\n</div>\n\nstill outer\n</div>'
122+
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
123+
expect(roundTrip(input)).toBe(input)
124+
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
125+
})
126+
127+
it('a paragraph starting with a non-block-list inline tag is NOT captured as a raw block', () => {
128+
// `em`/`a` aren't in the CommonMark block-HTML tag whitelist — they can legitimately start an
129+
// ordinary paragraph, and must keep parsing as real marks, not freeze as raw source.
130+
expect(topLevelTypes('<em>hi</em> there, this is a normal paragraph')).toEqual(['paragraph'])
131+
expect(roundTrip('<em>hi</em> there')).toBe('*hi* there')
132+
})
133+
134+
it('a stray inline-only tag alone on its own line is left to the stock (non-whitelisted) path', () => {
135+
// `<span>` isn't in the block whitelist, so the new block tokenizer must not claim it — it falls
136+
// through to marked's own (stricter) block-HTML detection, unaffected by this change.
137+
const input = '<span>\n\nnot a block-html tag\n\n</span>'
138+
expect(() => roundTrip(input)).not.toThrow()
139+
})
140+
141+
it('an unterminated block tag falls back gracefully (no crash, no infinite loop)', () => {
142+
const input = '<details>\n<summary>never closed</summary>\n\nbody'
143+
expect(() => roundTrip(input)).not.toThrow()
144+
})
145+
146+
it('a block comment spanning blank lines still round-trips via the new shared tokenizer path', () => {
147+
const input = '<!--\n\nmulti-line comment\n\n-->\n\ntext after'
148+
expect(topLevelTypes(input)[0]).toBe('rawHtmlBlock')
149+
expect(roundTrip(input)).toBe(input)
150+
})
151+
152+
it('a table and code block adjacent to a fragmenting-prone details block still coexist correctly', () => {
153+
const input =
154+
'<details>\n<summary>s</summary>\n\nbody\n\n</details>\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
155+
expect(roundTrip(input)).toBe(input)
156+
})
157+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ function verbatimText(node: JSONContent): string {
5050
return (node.content ?? []).map((child) => child.text ?? '').join('')
5151
}
5252

53+
const RAW_HTML_COMMENT_RE = /^<!--[\s\S]*?-->/
54+
55+
const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i
56+
57+
/**
58+
* Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`,
59+
* tracking nesting depth from `fromIndex` onward so `<span>outer <span>inner</span></span>` consumes
60+
* both levels instead of stopping at the first (inner) `</span>`. Returns -1 if unterminated. A
61+
* nested self-closing same-name tag (`<span/>`) is skipped — it neither opens nor closes a level.
62+
* Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines).
63+
*/
64+
function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number {
65+
const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi')
66+
tagRe.lastIndex = fromIndex
67+
let depth = 1
68+
for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) {
69+
const isClose = match[1] === '/'
70+
const isSelfClosing = Boolean(match[2])
71+
if (isSelfClosing) continue
72+
if (isClose) {
73+
depth -= 1
74+
if (depth === 0) return match.index + match[0].length
75+
} else {
76+
depth += 1
77+
}
78+
}
79+
return -1
80+
}
81+
5382
/**
5483
* Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment
5584
* 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) {
112141
}
113142
}
114143

115-
/** Block-level raw HTML — `<div>…</div>`, `<details>…</details>`, standalone `<!-- comment -->`, etc.
116-
* Marked's own block tokenizer already classifies all of these as a single `'html'` token
117-
* (`token.block === true`); `@tiptap/markdown`'s parser registry is checked *before* its built-in
118-
* HTML handling for block tokens (unlike inline, see {@link RawInlineHtml}), so claiming the
119-
* `'html'` token name here needs no custom tokenizer. */
144+
/**
145+
* Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see
146+
* `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block
147+
* opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags
148+
* NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary
149+
* paragraph (`<em>hi</em> there`), so they're deliberately left to marked's own stricter, single-line
150+
* block-HTML detection below — claiming them here would risk swallowing a paragraph that merely
151+
* starts with inline HTML.
152+
*/
153+
const BLOCK_HTML_TAG_NAMES = new Set([
154+
'address',
155+
'article',
156+
'aside',
157+
'base',
158+
'basefont',
159+
'blockquote',
160+
'body',
161+
'caption',
162+
'center',
163+
'col',
164+
'colgroup',
165+
'dd',
166+
'details',
167+
'dialog',
168+
'dir',
169+
'div',
170+
'dl',
171+
'dt',
172+
'fieldset',
173+
'figcaption',
174+
'figure',
175+
'footer',
176+
'form',
177+
'frame',
178+
'frameset',
179+
'h1',
180+
'h2',
181+
'h3',
182+
'h4',
183+
'h5',
184+
'h6',
185+
'head',
186+
'header',
187+
'html',
188+
'iframe',
189+
'legend',
190+
'li',
191+
'link',
192+
'main',
193+
'menu',
194+
'menuitem',
195+
'meta',
196+
'nav',
197+
'noframes',
198+
'ol',
199+
'optgroup',
200+
'option',
201+
'p',
202+
'param',
203+
'search',
204+
'section',
205+
'summary',
206+
'table',
207+
'tbody',
208+
'td',
209+
'tfoot',
210+
'th',
211+
'thead',
212+
'title',
213+
'tr',
214+
'track',
215+
'ul',
216+
])
217+
218+
/**
219+
* Marked's built-in block-HTML rule ends a `<details>`/`<div>`/… block at the *first blank line*
220+
* (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim
221+
* preservation: any real-world `<details>` with a paragraph inside would fragment into a raw chip,
222+
* an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between.
223+
* This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank
224+
* lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and
225+
* falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block
226+
* tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment
227+
* rule already spans blank lines correctly, but routing through one path keeps the two tokenizers
228+
* symmetric and independently testable.
229+
*/
230+
function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined {
231+
const comment = RAW_HTML_COMMENT_RE.exec(src)
232+
if (comment) return { type: 'html', raw: comment[0], text: comment[0], block: true }
233+
234+
const open = OPEN_TAG_RE.exec(src)
235+
if (!open) return undefined
236+
const tag = open[1].toLowerCase()
237+
if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined
238+
if (open[2]) return { type: 'html', raw: open[0], text: open[0], block: true }
239+
240+
const end = findBalancedCloseEnd(src, tag, open[0].length)
241+
if (end < 0) return undefined
242+
const raw = src.slice(0, end)
243+
return { type: 'html', raw, text: raw, block: true }
244+
}
245+
120246
const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i
121247

122248
export const RawHtmlBlock = Node.create({
123249
...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }),
124250
markdownTokenName: 'html',
251+
markdownTokenizer: {
252+
name: 'rawHtmlBlockTag',
253+
level: 'block' as const,
254+
// Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown`
255+
// auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which
256+
// corrupts the in-progress lexer's shared state (verified directly — every other construct on the
257+
// page silently loses its content once a tokenizer without an explicit `start` is registered).
258+
// The other custom tokenizers below all reference this comment rather than repeating it.
259+
//
260+
// The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same
261+
// `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the
262+
// distinct `name` here only avoids colliding with marked's own built-in `html` extension.
263+
start: () => -1,
264+
tokenize: tokenizeRawHtmlBlockTag,
265+
},
125266
parseMarkdown(token: MarkdownToken) {
126267
if (!token.block) return []
127268
const raw = token.raw ?? token.text ?? ''
@@ -171,11 +312,8 @@ export const FootnoteDef = Node.create({
171312
markdownTokenizer: {
172313
name: 'footnoteDef',
173314
level: 'block' as const,
174-
// Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown`
175-
// auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which
176-
// corrupts the in-progress lexer's shared state (verified directly — every other construct on the
177-
// page silently loses its content once a tokenizer without an explicit `start` is registered).
178-
// The cost is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no
315+
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost
316+
// here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no
179317
// blank line between them) is picked up on the next block boundary instead of interrupting early.
180318
start: () => -1,
181319
tokenize: tokenizeFootnoteDef,
@@ -196,7 +334,7 @@ export const FootnoteRef = Node.create({
196334
markdownTokenizer: {
197335
name: 'footnoteRef',
198336
level: 'inline' as const,
199-
// See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer.
337+
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer.
200338
start: () => -1,
201339
tokenize(src: string) {
202340
const match = FOOTNOTE_REF_RE.exec(src)
@@ -211,34 +349,6 @@ export const FootnoteRef = Node.create({
211349
},
212350
})
213351

214-
const RAW_HTML_COMMENT_RE = /^<!--[\s\S]*?-->/
215-
216-
const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i
217-
218-
/**
219-
* Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`,
220-
* tracking nesting depth from `fromIndex` onward so `<span>outer <span>inner</span></span>` consumes
221-
* both levels instead of stopping at the first (inner) `</span>`. Returns -1 if unterminated. A
222-
* nested self-closing same-name tag (`<span/>`) is skipped — it neither opens nor closes a level.
223-
*/
224-
function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number {
225-
const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi')
226-
tagRe.lastIndex = fromIndex
227-
let depth = 1
228-
for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) {
229-
const isClose = match[1] === '/'
230-
const isSelfClosing = Boolean(match[2])
231-
if (isSelfClosing) continue
232-
if (isClose) {
233-
depth -= 1
234-
if (depth === 0) return match.index + match[0].length
235-
} else {
236-
depth += 1
237-
}
238-
}
239-
return -1
240-
}
241-
242352
/**
243353
* Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single
244354
* void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema
@@ -279,7 +389,7 @@ export const RawInlineHtml = Node.create({
279389
markdownTokenizer: {
280390
name: 'rawInlineHtml',
281391
level: 'inline' as const,
282-
// See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer.
392+
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer.
283393
start: () => -1,
284394
tokenize: tokenizeRawInlineHtml,
285395
},

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,17 +279,23 @@
279279

280280
/* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks,
281281
comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts).
282-
Styled distinctly (monospace, tinted) so it's clear this text isn't interpreted, unlike a code block. */
282+
Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state,
283+
which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */
283284
.rich-markdown-prose .raw-markdown-block,
284285
.rich-markdown-prose .raw-markdown-inline {
285286
font-family: var(--font-martian-mono, ui-monospace, monospace);
286287
font-size: 0.875em;
287288
color: var(--text-muted);
288-
background: color-mix(in srgb, var(--warning, orange) 8%, var(--surface-5));
289+
background: var(--surface-5);
289290
white-space: pre-wrap;
290291
overflow-wrap: anywhere;
291292
}
292293

294+
.dark .rich-markdown-prose .raw-markdown-block,
295+
.dark .rich-markdown-prose .raw-markdown-inline {
296+
background: var(--code-bg);
297+
}
298+
293299
.rich-markdown-prose .raw-markdown-block {
294300
border-radius: 8px;
295301
padding: 0.75rem 1rem;

0 commit comments

Comments
 (0)