Skip to content

Commit 371a3de

Browse files
icecrasher321claude
andcommitted
fix(search): match a Note body as it renders, instead of rewriting the file
Replaces the serializer change with one that writes nothing. The editor backslash-escapes every markdown-significant character in prose, so a Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}` and search — which matches the stored value — could not find it. The previous approach undid that escape in `postProcessSerializedMarkdown`, which meant re-deriving markdown structure from the serialized string with regexes so it knew what was code. That is a losing game: three review rounds, each finding another construct it did not model (longer fences, then delimiter-prefixed lines, then quoted fences), and each miss REWROTE somebody's code. `markdown-fidelity.ts` is back to staging, byte for byte. The escape is now undone on the matching side only. A field declares `searchTextFormat: 'markdown'` (the Note body is the only one), and the indexer matches it against `projectEscapedMarkdownForSearch(value)` — a total, structure-free function that returns the rendered text plus an index back into the source. Ranges stay in source coordinates, so replace still rewrites the whole `\_` and never strands a backslash. The asymmetry is the whole point: a matcher that de-escapes something a fence would have kept literal changes only which text highlights, and no caller writes it back. A rewriter making the identical mistake corrupts the file. So there is nothing here that needs to know about fences at all. Two consequences worth having: existing notes are searchable immediately rather than after their next edit, and no stored byte changes, so no document the editor has ever written can be affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0ccad3f commit 371a3de

11 files changed

Lines changed: 265 additions & 197 deletions

File tree

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

Lines changed: 4 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -18,63 +18,6 @@ const CODE_OR_PLAIN_LINK_REGEX =
1818
/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)|\[([^\]]+)]\(([^)\s<>]+)\)/g
1919
const HTTP_URL_REGEX = /^https?:\/\/\S+$/i
2020

21-
/**
22-
* Alternates an inline code span with a single underscore that has a letter or digit on both sides.
23-
*
24-
* CommonMark's intraword rule means such an underscore can neither open nor close emphasis, so the
25-
* serializer's backslash before it carries no meaning \u2014 it just writes `SB\_ACTION\_ROUTER\_SECRET`
26-
* into the document. That is ugly in the file, and it silently breaks workflow search, which matches
27-
* against the stored markdown rather than the rendered text: searching `SB_ACTION` finds nothing in
28-
* a note whose stored form has a backslash the reader never sees.
29-
*
30-
* Code is excluded because the serializer emits it verbatim: a `\_` inside a span is the author's own
31-
* backslash, not an escape this may drop. The span branch matches a backtick RUN and requires a run of
32-
* the same length to close it, per CommonMark \u2014 a fixed single-backtick pattern would read ``` ``a`b`` ```
33-
* as `` `a` `` plus loose text and rewrite the interior. Fenced blocks are handled a line at a time by
34-
* {@link unescapeIntrawordUnderscores}, which is the only way to honour a fence of any length.
35-
*
36-
* The flanking character is a capture group rather than a lookbehind: lookbehind only landed in
37-
* Safari 16.4, and an unsupported one throws when the pattern is constructed \u2014 taking the whole editor
38-
* module with it. The group is consumed and put back, and the lookahead is not, so runs like `A\_B\_C`
39-
* still match on every pair.
40-
*/
41-
const CODE_SPAN_OR_INTRAWORD_ESCAPED_UNDERSCORE =
42-
/(`+)((?:[^`]|(?!\1)`)*?)\1(?!`)|([\p{L}\p{N}])\\_(?=[\p{L}\p{N}])/gu
43-
44-
/**
45-
* Drops the meaningless backslash before an intraword underscore, outside code.
46-
*
47-
* Fenced blocks are skipped a line at a time, tracking the opening delimiter the same way
48-
* {@link stripEmptyListItemLines} does: a fence is three OR MORE backticks or tildes and is closed
49-
* only by a run of the same character at least as long, so a `````` ```` `````-fenced block wrapping
50-
* ``` ``` ``` stays code throughout. Matching a fixed ``` pair instead would end the region early and
51-
* hand the rest of the author's code to the rewrite.
52-
*/
53-
function unescapeIntrawordUnderscores(markdown: string): string {
54-
const lines = markdown.split('\n')
55-
let fence: string | null = null
56-
57-
for (let i = 0; i < lines.length; i++) {
58-
if (fence) {
59-
if (closesFence(lines[i], fence)) fence = null
60-
continue
61-
}
62-
63-
const delimiter = opensFence(lines[i])
64-
if (delimiter) {
65-
fence = delimiter
66-
continue
67-
}
68-
69-
lines[i] = lines[i].replace(
70-
CODE_SPAN_OR_INTRAWORD_ESCAPED_UNDERSCORE,
71-
(match, ticks, _span, flank) => (ticks === undefined ? `${flank}_` : match)
72-
)
73-
}
74-
75-
return lines.join('\n')
76-
}
77-
7821
/**
7922
* Collapses an autolinked destination back to its bare form: our normalizing serializer rewrites a bare
8023
* URL or `<url>` autolink to `[url](url)` and a bare email to `[a@b.com](mailto:a@b.com)`, which churns
@@ -167,38 +110,6 @@ export function normalizeLinkHref(href: string): string {
167110
const EMPTY_LIST_ITEM_LINE = /^([ \t]*)(?:[-*+]|\d+[.)])[ \t]*$/
168111
/** A fenced code-block delimiter (``` or ~~~), used to leave code interiors untouched. */
169112
const FENCE_DELIMITER = /^[ \t]*(`{3,}|~{3,})/
170-
/**
171-
* A line carrying nothing but a delimiter run — the only thing that CLOSES a fence.
172-
*
173-
* An OPENING fence may be followed by an info string (` ```python `), so opening is matched with
174-
* {@link FENCE_DELIMITER}; a closing one may be followed only by whitespace. Treating any
175-
* delimiter-prefixed line as a close ends the block at an interior line like ` ```example ` inside
176-
* a `````` ```` ``````-fence, and every cleanup below then processes the rest of the author's code
177-
* as prose.
178-
*/
179-
const CLOSING_FENCE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/
180-
/**
181-
* Leading blockquote markers, which every line of a fence inside a quote or a `[!NOTE]` callout
182-
* carries. The serializer writes those (` > ```js `), so a walk that only recognises a bare fence
183-
* never enters code state there and rewrites the block's interior as prose.
184-
*/
185-
const BLOCKQUOTE_PREFIX = /^[ \t]*(?:>[ \t]?)*/
186-
187-
/** The line with any blockquote markers removed, so a quoted fence reads like a bare one. */
188-
function unquote(line: string): string {
189-
return line.replace(BLOCKQUOTE_PREFIX, '')
190-
}
191-
192-
/** The delimiter run that OPENS a fence on this line, quoted or not, or undefined. */
193-
function opensFence(line: string): string | undefined {
194-
return unquote(line).match(FENCE_DELIMITER)?.[1]
195-
}
196-
197-
/** Whether `line` closes a fence opened with `fence`: same character, and at least as long. */
198-
function closesFence(line: string, fence: string): boolean {
199-
const delimiter = unquote(line).match(CLOSING_FENCE)?.[1]
200-
return Boolean(delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length)
201-
}
202113
/** Leading indentation of a line, used to detect whether an empty list item has indented children. */
203114
const LEADING_INDENT = /^[ \t]*/
204115

@@ -224,12 +135,12 @@ function stripEmptyListItemLines(markdown: string): string {
224135
let fence: string | null = null
225136
for (let i = 0; i < lines.length; i++) {
226137
const line = lines[i]
138+
const delimiter = line.match(FENCE_DELIMITER)?.[1]
227139
if (fence) {
228140
kept.push(line)
229-
if (closesFence(line, fence)) fence = null
141+
if (delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null
230142
continue
231143
}
232-
const delimiter = opensFence(line)
233144
if (delimiter) {
234145
fence = delimiter
235146
kept.push(line)
@@ -260,8 +171,7 @@ function stripEmptyListItemLines(markdown: string): string {
260171
/**
261172
* Cleans up serializer output: drops empty list-item marker lines that would otherwise corrupt on
262173
* round-trip ({@link stripEmptyListItemLines}), restores callout markers the serializer
263-
* backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), drops the equally unnecessary escape on an
264-
* intraword underscore ({@link unescapeIntrawordUnderscores}), and collapses trailing blank lines to a single
174+
* backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), and collapses trailing blank lines to a single
265175
* newline. Interior blank runs are NOT collapsed here — blank lines inside a fenced code block (or a
266176
* verbatim raw-markdown-snippet) are significant, and a global collapse would corrupt them. An interior
267177
* run between top-level blocks is significant too: it is how an empty paragraph is written, and
@@ -274,8 +184,6 @@ function stripEmptyListItemLines(markdown: string): string {
274184
*/
275185
export function postProcessSerializedMarkdown(markdown: string): string {
276186
return collapseAutolinkedUrls(
277-
unescapeIntrawordUnderscores(
278-
stripEmptyListItemLines(markdown).replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')
279-
)
187+
stripEmptyListItemLines(markdown).replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')
280188
).replace(/\n+$/, '\n')
281189
}

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

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -98,32 +98,6 @@ describe('markdown-fidelity utils', () => {
9898
expect(postProcessSerializedMarkdown('> \\[!NOTE\\]\n> hi')).toBe('> [!NOTE]\n> hi')
9999
})
100100

101-
/*
102-
* A closing fence carries nothing but its delimiter run; a line that merely STARTS with one is
103-
* content. Matching the prefix alone ends the block at an interior line like ` ````example `,
104-
* after which the rest of the author's code is cleaned up as prose and its backslashes vanish.
105-
*
106-
* Exercised directly rather than through `roundTrip` on purpose: the serializer always opens a
107-
* block with one more delimiter than the longest run inside it, so its own output cannot reach
108-
* this shape and a round-trip test of it would pass either way. Relying on that choice staying
109-
* true is an unstated coupling — this keeps the guard honest on any input.
110-
*/
111-
it('does not close a fence on a delimiter-prefixed content line', () => {
112-
const input = '````\nx = a\\_b\n````example\ny = c\\_d\n````\n'
113-
expect(postProcessSerializedMarkdown(input)).toBe(input)
114-
})
115-
116-
it('does not close a tilde fence on a delimiter-prefixed content line', () => {
117-
const input = '~~~~\n~~~~note\ny = c\\_d\n~~~~\n'
118-
expect(postProcessSerializedMarkdown(input)).toBe(input)
119-
})
120-
121-
it('still closes a fence on a bare delimiter run with trailing spaces', () => {
122-
expect(postProcessSerializedMarkdown('````\nx = a\\_b\n```` \ny = c\\_d\n')).toBe(
123-
'````\nx = a\\_b\n```` \ny = c_d\n'
124-
)
125-
})
126-
127101
it('restores escaped callout markers in nested blockquotes', () => {
128102
expect(postProcessSerializedMarkdown('> > \\[!WARNING\\]\n> > hi')).toBe(
129103
'> > [!WARNING]\n> > hi'
@@ -201,13 +175,6 @@ describe('editor markdown round-trip', () => {
201175
'highlight nested in bold': '**bold ==mark== here**',
202176
'highlight in list': '- ==a== item',
203177
'highlight with interior equals': 'x ==a=b== y',
204-
'intraword underscores': 'SB_ACTION_ROUTER_SECRET',
205-
'env token with underscores': '{{TE_SERET}} and {{OPENAI_API_KEY}}',
206-
'underscore emphasis': 'an _italic_ word',
207-
'underscore bold': 'a __bold__ word',
208-
'mixed underscores': '_em_ then SNAKE_CASE_NAME then _em again_',
209-
'escaped underscore in code': '```py\nx = a\\_b\n```',
210-
'escaped underscore in inline code': 'call `a\\_b` here',
211178
}
212179

213180
for (const [name, input] of Object.entries(cases)) {
@@ -237,63 +204,6 @@ describe('editor markdown round-trip', () => {
237204
expect(roundTrip('> [!NOTE]\n> Heads up')).toContain('[!NOTE]')
238205
})
239206

240-
/*
241-
* The serializer escapes every underscore, but CommonMark's intraword rule means one flanked by
242-
* letters or digits can neither open nor close emphasis. The escape is therefore invisible to a
243-
* reader and load-bearing for nobody — while workflow search matches the STORED markdown, so a
244-
* stray backslash made `SB_ACTION` unfindable in a note that plainly showed it.
245-
*/
246-
it('writes an intraword underscore without a backslash', () => {
247-
expect(roundTrip('SB_ACTION_ROUTER_SECRET')).toBe('SB_ACTION_ROUTER_SECRET')
248-
expect(roundTrip('{{TE_SERET}}')).toBe('{{TE_SERET}}')
249-
})
250-
251-
/* Emphasis itself normalises to asterisks, which is pre-existing and fine. What must survive
252-
is the distinction: a literal underscore pair keeps its escape, so re-parsing cannot turn
253-
the user's text into emphasis. */
254-
it('still escapes an underscore that would open or close emphasis', () => {
255-
expect(roundTrip('an _italic_ word')).toBe('an *italic* word')
256-
expect(roundTrip('literal \\_not emphasis\\_ here')).toContain('\\_')
257-
})
258-
259-
/* Code is emitted verbatim, so a backslash inside it is the author's own character and not an
260-
escape to drop. Unescaping blind would silently rewrite people's code. */
261-
it('leaves a backslash-underscore inside code alone', () => {
262-
expect(roundTrip('```py\nx = a\\_b\n```')).toContain('a\\_b')
263-
expect(roundTrip('call `a\\_b` here')).toContain('a\\_b')
264-
})
265-
266-
/* A fence is three OR MORE delimiters, closed only by a run at least as long, and an inline span
267-
opens and closes on backtick runs of equal length. Recognising just the shortest form ends the
268-
code region early and hands the rest of the author's code to the rewrite. */
269-
it('leaves code alone in a longer fence', () => {
270-
expect(roundTrip('````\nx = a\\_b\n```\nstill code y = c\\_d\n````')).toContain('a\\_b')
271-
expect(roundTrip('````\nx = a\\_b\n```\nstill code y = c\\_d\n````')).toContain('c\\_d')
272-
})
273-
274-
it('leaves code alone in a tilde fence', () => {
275-
expect(roundTrip('~~~~\nx = a\\_b\n~~~~')).toContain('a\\_b')
276-
})
277-
278-
it('leaves code alone in a multi-backtick inline span', () => {
279-
expect(roundTrip('call ``a\\_b`c`` here')).toContain('a\\_b')
280-
})
281-
282-
/* Unlike the fence-length cases, this one is reachable: the serializer really does write a
283-
quoted fence as ` > ```js `, so a walk that only recognises a bare fence never enters code
284-
state and rewrites the block's interior as prose. */
285-
it('leaves code alone inside a blockquoted fence', () => {
286-
expect(roundTrip('> ```js\n> x = a\\_b\n> ```')).toContain('a\\_b')
287-
})
288-
289-
it('leaves code alone inside a callout fence', () => {
290-
expect(roundTrip('> [!NOTE]\n> ```js\n> x = a\\_b\n> ```')).toContain('a\\_b')
291-
})
292-
293-
it('still unescapes prose inside a blockquote', () => {
294-
expect(roundTrip('> SB_ACTION_ROUTER_SECRET')).toContain('SB_ACTION_ROUTER_SECRET')
295-
})
296-
297207
it('preserves an image url (does not drop the src)', () => {
298208
const out = roundTrip('![alt](https://example.com/i.png)')
299209
expect(out).toContain('![alt](https://example.com/i.png)')

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
countNoteSearchOccurrencesBefore,
55
DEFAULT_NOTE_COLOR,
66
estimateNoteBlockHeight,
7+
forEachNoteSourceOccurrence,
78
getNoteStringValue,
89
isNoteColor,
910
NoteBlockView,
@@ -171,9 +172,15 @@ export const NoteBlock = memo(function NoteBlock({
171172
}
172173
}
173174

174-
return content.toLowerCase().includes(query.toLowerCase())
175-
? { query, occurrenceIndex: 0 }
176-
: null
175+
/* Asked through the same scan that will do the marking, rather than a local
176+
`includes`: a bare comparison skips the whitespace fold, so a query the
177+
indexer matched across a newline or a non-breaking space would read as
178+
absent here and the card would paint nothing while the panel counted it. */
179+
let occurs = false
180+
forEachNoteSourceOccurrence(content, query, () => {
181+
occurs = true
182+
})
183+
return occurs ? { query, occurrenceIndex: 0 } : null
177184
}, [content, isSearchTargetBlock, searchTarget])
178185

179186
/**
@@ -240,8 +247,16 @@ export const NoteBlock = memo(function NoteBlock({
240247
const searchExpandedRef = useRef(false)
241248
const hasSearchMatch = searchHighlight !== null || nameSearchRange !== null
242249
useEffect(() => {
250+
/* Losing edit rights already force-collapses the card during render. Drop the latch with it,
251+
or regaining them would hit the early return below and leave a deep match clipped in the
252+
compact body until the active match moved. */
253+
if (!canEditNote) {
254+
searchExpandedRef.current = false
255+
return
256+
}
257+
243258
if (hasSearchMatch) {
244-
if (searchExpandedRef.current || isExpandedRef.current || !canEditNote) return
259+
if (searchExpandedRef.current || isExpandedRef.current) return
245260
searchExpandedRef.current = true
246261
setIsExpanded(true)
247262
return

apps/sim/blocks/blocks/note.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const NoteBlock: BlockConfig = {
1515
{
1616
id: 'content',
1717
type: 'long-input',
18+
searchTextFormat: 'markdown',
1819
rows: 8,
1920
placeholder: 'Add context or instructions for collaborators...',
2021
description: 'Write your note using Markdown. YouTube links will display as embedded videos.',

apps/sim/blocks/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,19 @@ export interface SubBlockConfig {
269269
type: SubBlockType
270270
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle
271271
canonicalParamId?: string
272+
/**
273+
* Declares that the stored value is markdown, so workflow search matches it
274+
* against the text it RENDERS as rather than its source.
275+
*
276+
* The rich-text editor backslash-escapes every markdown-significant character
277+
* in prose, so a Note body the reader sees as `SB_ACTION` is stored as
278+
* `SB\_ACTION` and would otherwise be unfindable by what is on screen. Ranges
279+
* stay in source coordinates, so replace still rewrites the escaped span.
280+
*
281+
* Omit for every ordinary field: a code or plain-text value is searched as
282+
* stored, where a backslash is the author's own character.
283+
*/
284+
searchTextFormat?: 'markdown'
272285
/** Controls parameter visibility in agent/tool-input context */
273286
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
274287
/**

0 commit comments

Comments
 (0)