From 4574866ef7c1f15599777516a19400a5f3aa3657 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 13:21:48 -0700 Subject: [PATCH 1/4] fix(rich-markdown-editor): tighten read-only gate for uppercase entities and orphan ref-defs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent-corruption cases the idempotency probe can't see: - The HTML-entity safe-list used a case-insensitive regex, so `&`/`<`/`>` were treated as the round-trippable canonical entities and let through as editable, but the serializer only round-trips the lowercase forms and mangles the uppercase ones. Make the safe-list case-sensitive. - An unused link/image reference definition (`[x]: url` with no `[x]` reference) is dropped entirely on serialize, a deletion the idempotency probe misses. Detect orphan definitions and open read-only; used definitions still inline losslessly and stay editable. The use check tolerates bracket-internal padding (`[ x ]`), and GFM footnote definitions (`[^id]: …`) are excluded since they round-trip verbatim. --- .../round-trip-safety.test.ts | 12 +++++ .../rich-markdown-editor/round-trip-safety.ts | 50 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts index c476e2f4df9..d673091006b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts @@ -55,6 +55,18 @@ describe('isRoundTripSafe', () => { expect(isRoundTripSafe('a b')).toBe(false) expect(isRoundTripSafe('a & b < c > d')).toBe(true) expect(isRoundTripSafe('AT&T and R&D')).toBe(true) + expect(isRoundTripSafe('a & b')).toBe(false) + expect(isRoundTripSafe('a < b > c')).toBe(false) + }) + + it('rejects an orphan reference definition (serializer drops it) but allows used ones', () => { + expect(isRoundTripSafe('Some text.\n\n[unused]: https://example.com "title"')).toBe(false) + expect(isRoundTripSafe('[a]: u1\n[b]: u2\n\nuse only [a]')).toBe(false) + expect(isRoundTripSafe('See [x][1].\n\n[1]: https://example.com "T"')).toBe(true) + expect(isRoundTripSafe('A [shortcut] ref.\n\n[shortcut]: https://example.com')).toBe(true) + expect(isRoundTripSafe('Case [Foo] insensitive.\n\n[foo]: https://example.com')).toBe(true) + expect(isRoundTripSafe('A note.\n\n[^x]: the footnote body')).toBe(true) + expect(isRoundTripSafe('See [ foo ] here.\n\n[foo]: https://example.com')).toBe(true) }) it('does not flag HTML/comments/entities inside tilde or nested code fences', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index 423669de7c8..2e3dfe2ceb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -24,14 +24,16 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * flattens `one
two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `
`. * - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits * the heading, ejecting the second line into a separate paragraph. - * - **HTML entity** other than `&`/`<`/`>` (e.g. `©`, `'`, ` `) — the - * serializer escapes the `&`, turning the rendered character into literal entity source. A bare - * `&` with no `;` is left alone (it re-renders identically, so it's harmless churn). + * - **HTML entity** other than the lowercase canonical `&`/`<`/`>` (e.g. `©`, `'`, + * ` `, or the uppercase `&`) — the serializer escapes the `&`, turning the rendered character + * into literal entity source. The safe-list is deliberately case-*sensitive*: `@tiptap/markdown` only + * round-trips the lowercase forms, so `&`/`<`/`>` must fall through to read-only rather than + * be treated as safe. A bare `&` with no matching `;`-terminated name is left alone (harmless churn). */ const STABLE_LOSS_PATTERNS: ReadonlyArray = [ /^(?=(?:[^\n]*\|){2})[^\n]*/im, /^#{1,6}\s.*(?: {2,}|\\)$/m, - /&(?!(?:amp|lt|gt);)(?:#x?[0-9a-f]+|[a-z][a-z0-9]*);/i, + /&(?!(?:amp|lt|gt);)(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/, ] /** @@ -60,6 +62,45 @@ function linkedImageCount(content: string): number { return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0 } +/** + * A link/image reference definition line: `[label]: destination "optional title"` (up to 3 leading + * spaces). The `(?!\^)` excludes GFM footnote definitions (`[^id]: …`) — those are preserved verbatim + * by the footnote node and round-trip regardless of whether their reference is present, so they must + * not be treated as droppable orphan definitions. + */ +const REFERENCE_DEFINITION = /^ {0,3}\[(?!\^)([^\]]+)]:[ \t]+\S[^\n]*$/gm + +/** CommonMark reference labels match case-insensitively with internal whitespace collapsed. */ +function normalizeReferenceLabel(label: string): string { + return label.trim().replace(/\s+/g, ' ').toLowerCase() +} + +/** + * True when `content` defines a link/image reference that nothing uses. A *used* reference inlines + * losslessly on serialize (`[x][id]` + `[id]: url` → `[x](url)`), but an *unused* definition is dropped + * entirely — a silent deletion the idempotency probe can't see (the drop happens on the first pass, + * which is then stable). We open such a file read-only rather than lose the definition on first edit. + * Conservative: a label counts as used if it appears bracketed anywhere in the body, so the rare + * inline-text collision errs toward editable, never toward a false read-only. + */ +function hasOrphanReferenceDefinition(content: string): boolean { + const labels = new Set() + for (const match of content.matchAll(REFERENCE_DEFINITION)) { + labels.add(normalizeReferenceLabel(match[1])) + } + if (labels.size === 0) return false + const body = content + .replace(REFERENCE_DEFINITION, '') + .replace(/\s+/g, ' ') + .replace(/\[\s+/g, '[') + .replace(/\s+\]/g, ']') + .toLowerCase() + for (const label of labels) { + if (!body.includes(`[${label}]`)) return true + } + return false +} + /** * Whether `content` survives the editor's markdown round-trip without data loss or autosave * churn. The editor opens the content read-only when this is false, so the probe is deliberately @@ -75,6 +116,7 @@ export function isRoundTripSafe(content: string): boolean { if (content.length > PROBE_SIZE_LIMIT) return false const stripped = stripCode(content) if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false + if (hasOrphanReferenceDefinition(stripped)) return false try { const once = serializeMarkdownDocument(content) if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false From 02fa9300e3b3c8dd0174c1c1ca04fb0fb2cfffc7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 13:21:48 -0700 Subject: [PATCH 2/4] feat(rich-markdown-editor): VSCode code paste and strip
a
' + const cleaned = transformHtml(editor, gsheets) + expect(cleaned).not.toContain('