|
13 | 13 | */ |
14 | 14 |
|
15 | 15 | import { act } from 'react' |
16 | | -import type { Root } from 'hast' |
| 16 | +import type { Element, ElementContent, Root, RootContent } from 'hast' |
17 | 17 | import { createRoot, type Root as ReactRoot } from 'react-dom/client' |
18 | 18 | import { afterEach, beforeAll, describe, expect, it } from 'vitest' |
19 | 19 | import { |
@@ -204,6 +204,117 @@ describe('note search rehype plugin', () => { |
204 | 204 | }) |
205 | 205 | }) |
206 | 206 |
|
| 207 | +/* |
| 208 | + * The indexer folds every `\s` to a space before matching, so a phrase can match |
| 209 | + * across a soft line break — which `remark-breaks` renders as a `<br>` splitting |
| 210 | + * the phrase over two text nodes. A per-node scan saw neither half, leaving the |
| 211 | + * hit counted in the panel and highlighted nowhere on the card. |
| 212 | + */ |
| 213 | +describe('note search across inline boundaries', () => { |
| 214 | + function markedTextsOf(tree: Root): string[] { |
| 215 | + const texts: string[] = [] |
| 216 | + const walk = (node: Root | Element) => { |
| 217 | + /* `Root['children']` and `Element['children']` are different unions, so iterating the |
| 218 | + parameter directly widens each child to their intersection and drops narrowing. */ |
| 219 | + const children: Array<RootContent | ElementContent> = node.children |
| 220 | + for (const child of children) { |
| 221 | + if (child.type !== 'element') continue |
| 222 | + if (child.tagName === 'mark') { |
| 223 | + const [first] = child.children |
| 224 | + texts.push(first?.type === 'text' ? first.value : '') |
| 225 | + continue |
| 226 | + } |
| 227 | + walk(child) |
| 228 | + } |
| 229 | + } |
| 230 | + walk(tree) |
| 231 | + return texts |
| 232 | + } |
| 233 | + |
| 234 | + function paragraphWithBreak(before: string, after: string): Root { |
| 235 | + return { |
| 236 | + type: 'root', |
| 237 | + children: [ |
| 238 | + { |
| 239 | + type: 'element', |
| 240 | + tagName: 'p', |
| 241 | + properties: {}, |
| 242 | + children: [ |
| 243 | + { type: 'text', value: before }, |
| 244 | + { type: 'element', tagName: 'br', properties: {}, children: [] }, |
| 245 | + { type: 'text', value: after }, |
| 246 | + ], |
| 247 | + }, |
| 248 | + ], |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + it('marks a phrase spanning a soft line break', () => { |
| 253 | + const tree = paragraphWithBreak('the quick', 'brown fox') |
| 254 | + noteSearchHighlightPlugin({ query: 'quick brown' })(tree) |
| 255 | + expect(markedTextsOf(tree)).toEqual(['quick', 'brown']) |
| 256 | + }) |
| 257 | + |
| 258 | + it('gives both halves of one hit the same ordinal', () => { |
| 259 | + const tree = paragraphWithBreak('the quick', 'brown fox') |
| 260 | + noteSearchHighlightPlugin({ query: 'quick brown' })(tree) |
| 261 | + |
| 262 | + const ordinals: unknown[] = [] |
| 263 | + const walk = (node: Root | Element) => { |
| 264 | + /* `Root['children']` and `Element['children']` are different unions, so iterating the |
| 265 | + parameter directly widens each child to their intersection and drops narrowing. */ |
| 266 | + const children: Array<RootContent | ElementContent> = node.children |
| 267 | + for (const child of children) { |
| 268 | + if (child.type !== 'element') continue |
| 269 | + if (child.tagName === 'mark') ordinals.push(child.properties.dataNoteSearchIndex) |
| 270 | + else walk(child) |
| 271 | + } |
| 272 | + } |
| 273 | + walk(tree) |
| 274 | + expect(ordinals).toEqual(['0', '0']) |
| 275 | + }) |
| 276 | + |
| 277 | + it('marks a phrase spanning a bold word', () => { |
| 278 | + const tree: Root = { |
| 279 | + type: 'root', |
| 280 | + children: [ |
| 281 | + { |
| 282 | + type: 'element', |
| 283 | + tagName: 'p', |
| 284 | + properties: {}, |
| 285 | + children: [ |
| 286 | + { type: 'text', value: 'a ' }, |
| 287 | + { |
| 288 | + type: 'element', |
| 289 | + tagName: 'strong', |
| 290 | + properties: {}, |
| 291 | + children: [{ type: 'text', value: 'bold' }], |
| 292 | + }, |
| 293 | + { type: 'text', value: ' word' }, |
| 294 | + ], |
| 295 | + }, |
| 296 | + ], |
| 297 | + } |
| 298 | + noteSearchHighlightPlugin({ query: 'a bold word' })(tree) |
| 299 | + expect(markedTextsOf(tree)).toEqual(['a ', 'bold', ' word']) |
| 300 | + }) |
| 301 | + |
| 302 | + /* Two paragraphs are not one phrase on screen. Joining them would invent a hit |
| 303 | + the reader cannot see — and one the indexer never counted, since the source |
| 304 | + carries a blank line there, not a single space. */ |
| 305 | + it('does not join text across a block boundary', () => { |
| 306 | + const tree = paragraphTree('the quick', 'brown fox') |
| 307 | + noteSearchHighlightPlugin({ query: 'quick brown' })(tree) |
| 308 | + expect(markedTextsOf(tree)).toEqual([]) |
| 309 | + }) |
| 310 | + |
| 311 | + it('folds a non-breaking space the way the indexer does', () => { |
| 312 | + const tree = paragraphTree('a b') |
| 313 | + noteSearchHighlightPlugin({ query: 'a b' })(tree) |
| 314 | + expect(markedTextsOf(tree)).toEqual(['a b']) |
| 315 | + }) |
| 316 | +}) |
| 317 | + |
207 | 318 | describe('note search highlight rendering', () => { |
208 | 319 | it('marks every occurrence in the read view', () => { |
209 | 320 | const container = renderNote('first SB_SECRET line\n\nsecond SB_SECRET line', { |
|
0 commit comments