Skip to content

fix(search): quote the field the query matched in snippets - #2481

Open
DavidPivert wants to merge 2 commits into
emdash-cms:mainfrom
DavidPivert:fix/fts-snippet-column
Open

fix(search): quote the field the query matched in snippets#2481
DavidPivert wants to merge 2 commits into
emdash-cms:mainfrom
DavidPivert:fix/fts-snippet-column

Conversation

@DavidPivert

Copy link
Copy Markdown
Contributor

What does this PR do?

An FTS5 index is laid out as id UNINDEXED, locale UNINDEXED, ...searchable fields, so column 2 is whichever field happens to be searchable first — usually the title. snippet() was pinned to that column:

snippet("<table>", 2, '<mark>', '</mark>', '...', 32) as snippet

A match anywhere else therefore came back as the first field's text, unhighlighted. On my site, searching mustang matched an album through its tracklist and the snippet read Endless Night — the title, no highlight, no indication of why the entry matched. The same happened for hits on an artist name, a label, or the body of a post.

Passing -1 lets FTS5 pick the column that actually matched. Reproduced standalone:

column  2 (before) : Endless Night
column -1 (after)  : … 6. <mark>Mustang</mark> Sally 7. Up All Night
column -1, title match : <mark>Endless</mark> Night

A title match still quotes the title, so nothing regresses for the common case.

This also explains the NULL snippets the surrounding comment described — snippet() returns NULL when the targeted column is empty for that row, "even if the row matched via a different searchable column". Selecting the matched column removes that case instead of working around it; I've adjusted the comment accordingly and kept the null guard, since an empty matched column is still possible.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (oxlint --type-aware --deny-warnings)
  • pnpm test passes (targeted: tests/integration/search/ — 10 files, 43 tests)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings in the admin UI are wrapped for translation (n/a)
  • I have added a changeset
  • New features link to an approved Discussion (n/a — bug fix)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Test output

tests/integration/search/snippet-column.test.ts pins both directions. It fails on main and passes with the fix:

FAIL  tests/integration/search/snippet-column.test.ts > quotes the body when the match is in the body, not the title
 → expect(items[0].snippet).toContain("<mark>Mustang</mark>")

Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

With the fix applied:

Test Files  1 passed (1)
      Tests  2 passed (2)

🤖 Generated with Claude Code

An FTS5 index is laid out as `id UNINDEXED, locale UNINDEXED, ...searchable
fields`, so column 2 is whichever field happens to be searchable first —
usually the title. snippet() was pinned to that column, so a match anywhere
else returned the first field's text with no highlight: a hit in the body,
an artist name or a tracklist all rendered as the bare title, telling the
reader nothing about why the entry matched.

Passing -1 lets FTS5 pick the column that actually matched, and a title
match still quotes the title.

This also explains the NULL snippets the surrounding comment described:
snippet() returns NULL when the *targeted* column is empty for that row,
even though the row matched elsewhere. Selecting the matched column removes
that case rather than working around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 886a499

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Aug 15, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2481

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2481

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2481

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2481

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2481

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2481

emdash

npm i https://pkg.pr.new/emdash@2481

create-emdash

npm i https://pkg.pr.new/create-emdash@2481

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2481

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2481

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2481

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2481

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2481

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2481

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2481

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2481

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2481

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2481

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2481

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2481

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2481

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2481

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2481

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2481

commit: 886a499

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR fixes a real search bug: snippet() was hard-coded to FTS5 column 2 (the first searchable field), so matches in any other field returned that first field's text without highlighting. Passing -1 asks FTS5 to select the column that actually matched, which is the correct FTS5 semantics. The regression test covers both directions (body match and title match), and the SQL change is otherwise identical — no new query count, injection surface, or locale-filter change.

I found no logic bugs, security issues, or performance regressions. However, there are a handful of AGENTS.md / comment hygiene issues: the changeset is copied straight from the PR description, two existing search tests now have stale comments because the old fixed-column behavior no longer exists, and a few new comments read like PR narrative rather than evergreen code comments.


Findings

  • [needs fixing] .changeset/fts-snippet-matched-column.md:5

    The changeset is the PR description pasted into release notes. AGENTS.md says a changeset is "release notes a user reads while upgrading — not a commit message, PR description, or summary of your diff" and should leave out internal mechanics (FTS5 column layout, column numbers, etc.).

    Fixes search snippets so they quote the field the query matched instead of the first searchable field.
    
  • [needs fixing] packages/core/tests/integration/search/snippet-sanitization.test.ts:94

    This test name and the surrounding comments describe the old fixed-column behavior. With snippet(..., -1, ...), a row with a NULL title that matches via the content column now gets its snippet from the matched content column, not NULL. The test still passes as a no-crash guard, but its name and rationale are now misleading and no longer exercise the null-guard path they claim to.

    Rename and update the comment, e.g.:

    	it("does not crash when content matches while the title is NULL", async () => {
    		// Enable a second searchable column so a row with a NULL title can
    		// still match via content. A regression that drops the null-guard
    		// throws "Cannot read properties of null (reading 'replace')" before
    		// these assertions can run.
    
  • [needs fixing] packages/core/tests/integration/search/snippet-sanitization.test.ts:111

    This inline comment is also stale: the row now matches and snips from the content column, so the premise "matched via the content column so this row still surfaces in results" is the only relevant part.

    					// NULL title; the row must match via the searchable content field.
    
  • [needs fixing] packages/core/tests/integration/search/portable-text-indexing.test.ts:40

    This comment documents the old implementation (snippet() pinned to FTS5 column 2). After this PR, snippet(..., -1, ...) selects the matched column, so the field creation order no longer determines which column is quoted. The comment is now stale and confusing.

    		// These tests assert snippets contain prose from indexed Portable Text fields.
    
  • [suggestion] packages/core/src/search/query.ts:310

    The inline SQL comment narrates the rejected fixed-column alternative (Hard-coding 2...). AGENTS.md asks for non-obvious "why" comments without recounting rejected approaches. The first line is sufficient.

    			-- -1 selects the FTS5 column that matched the query.
    			snippet("${sql.raw(ftsTable)}", -1, '<mark>', '</mark>', '...', 32) as snippet,
    
  • [suggestion] packages/core/tests/integration/search/snippet-column.test.ts:12

    This block reads like a PR description: it recounts the old behavior and the fix at length. AGENTS.md says comments should not be summaries of the change or narrative about rejected alternatives. A one-sentence invariant is enough.

    /**
     * FTS5's snippet() defaults to the first searchable column unless passed -1
     * to select the matched column. These tests pin both cases.
     */
    
  • [suggestion] packages/core/tests/integration/search/snippet-column.test.ts:65

    Inline "before the fix" comments are historical narrative. The test name already captures the expectation.

    		expect(items).toHaveLength(1);
    		expect(items[0].snippet).toContain("<mark>Mustang</mark>");
    		expect(items[0].snippet).toContain("Sally");
    

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant