Skip to content

feat: allow blocks fields to be searchable - #4739

Open
adamslowe wants to merge 3 commits into
emdash-cms:mainfrom
adamslowe:feat/searchable-blocks-fields
Open

adamslowe wants to merge 3 commits into
emdash-cms:mainfrom
adamslowe:feat/searchable-blocks-fields

Conversation

@adamslowe

@adamslowe adamslowe commented Nov 7, 2026 •

Copy link
Copy Markdown

What does this PR do?

Lets a blocks field be marked searchable: true, so page and post bodies built from blocks are covered by full-text search and the admin's entry search.

I'm building a site on EmDash where every page body is a layout blocks field so editors can mix prose, two- and three-column sections and CTAs. Site search of body text is a hard requirement, and until now a blocks-based body had no text in search(). I prototyped this as a pnpm patch against 1.0.1 and verified it on D1 (details in #3670); this PR ports it to current main. @ascorbic asked for the PR in #3670 (comment).

Core

  • SchemaRegistry no longer rejects searchable on blocks fields. required, unique, indexed and widget are still rejected.
  • FTSManager.searchValueExpr treats blocks like portableText: the sync triggers, rebuildIndex and the searchable toggle index every JSON string under a text, alt, caption or code key, so the prose of every Portable Text sub-field in every block is indexed instead of the raw JSON.
  • extractSearchableFields takes an optional fieldTypes map (field slug to type) and walks blocks fields sub-field by sub-field, including repeater rows, extracting Portable Text arrays via extractPlainText. Without the map a blocks array is indistinguishable from Portable Text by shape, so existing callers are unchanged.
  • Migration 064_fts_plain_text.ts is left as shipped. Its copy of searchValueExpr only runs during that migration, and no searchable blocks field could exist then, so there is nothing to backfill. The "lock-step" comment in createTriggers is updated to say so.

Admin

  • The field editor shows the Searchable switch for blocks fields and no longer forces it off when the blocks type is selected. A note under the switch says that Portable Text content inside blocks is indexed.

Docs and changesets

  • reference/field-types.mdx and guides/blocks.mdx no longer say blocks cannot be searchable and state the limitation below.
  • Minor changesets for emdash and @emdash-cms/admin.

Known limitation. The SQL key list is fixed, so plain string/text sub-fields such as headings or labels are indexed only when their sub-field slug happens to be text, alt, caption or code. A schema-aware key list from the block type definitions would close that and is a possible follow-up.

Follow-up. @emdash-cms/plugin-ai-search calls extractSearchableFields without fieldTypes (packages/plugins/ai-search/src/documents.ts, entryText), so a searchable blocks field yields empty text for vectorize / AI search until the collection's field types are passed through. Left out of this PR to keep it to the FTS path the issue covers.

Closes #3670

Type of change

  • Bug fix
  • Feature (requires an accepted feature plan or RFC)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Design PR: #4740 (feature plan recording the design approved on the issue; opened after this PR, per the retrospective path in proposals/README.md).

Your connection to this change

  • I use EmDash and ran into the problem this PR addresses
  • I maintain a tool, dependency, or service that EmDash uses, and this change concerns that integration
  • A Maintainer approved this specific change before I opened the PR

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to their merged design PR: design: searchable blocks fields #4740
  • I have included screenshots below if this PR changes the UI

AI assistance disclosure

  • This PR includes AI-generated code or text — model/tool: Claude Code with Claude Fable 5.1 (orchestration) and Claude Sonnet 5.5 (implementation and review). I reviewed the complete diff and authorized this PR.

Screenshots / test output

Field editor, adding a Blocks field to the Pages content type in demos/simple.

Before After
Configure Field dialog for a Blocks field before the change, with no Searchable switch Configure Field dialog for a Blocks field after the change, Searchable switch on with the note "Portable Text content inside blocks is indexed."

Tests added:

  • packages/core/tests/integration/search/blocks-indexing.test.ts: an entry whose only body text lives in a block's Portable Text sub-fields is found via search(); structural tokens (two_column, normal, span, markDefs) do not match; toggling searchable on reindexes existing content; a draft is not found and is found after publish; update swaps the indexed word; soft delete removes the entry; a string sub-field slugged caption is found while one slugged heading is not; Portable Text in a repeater row is found; a searchable portableText field still indexes and a non-searchable one does not.
  • packages/core/tests/unit/search/text-extraction.test.ts: blocks branch of extractSearchableFields.
  • packages/core/tests/integration/schema/blocks-field-schema.test.ts: searchable: true accepted on a blocks field.
  • packages/admin/tests/components/FieldEditor.blocks.test.tsx: the switch renders for blocks, Required does not, and save sends searchable: true.

Core search and schema suites: 381 passed, 4 skipped. Admin FieldEditor suites: 105 passed.

What Changed:
- Remove the registry guard that rejected `searchable: true` on blocks fields.
- Route blocks fields through the same json_tree text extraction as Portable
  Text in FTSManager.searchValueExpr, so triggers, rebuildIndex and the
  searchable toggle all index Portable Text prose inside blocks.
- Add an optional fieldTypes map to extractSearchableFields and a blocks
  branch that walks sub-fields and repeater rows for Portable Text arrays.
- Add unit and integration tests, a minor changeset, and update the
  field-types reference and blocks guide.

Why:
A blocks field is the only way to give editors rich text inside multi-column
or sectioned layouts, but a page whose body lives in blocks had no text in
full-text search. Migration 064 is left as shipped: no searchable blocks field
could exist when it ran, so there is nothing to backfill.

Refs: emdash-cms#3670
What Changed:
- Add `blocks` to the field editor's searchable types so the switch renders.
- Stop forcing `searchable: false` when the blocks type is selected; required,
  unique and indexed are still forced off.
- Show a note under the switch that Portable Text content inside blocks is
  indexed.
- Add a FieldEditor test and a minor changeset for @emdash-cms/admin.

Why:
Core now accepts searchable blocks fields, but the field editor still hid the
switch, so editors could only enable it through seeds, the API or MCP.

Refs: emdash-cms#3670
@changeset-bot

changeset-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7dd14ab

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

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@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 commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@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.

The approach is sound. It removes the registry guard so blocks fields use the same json_tree text/alt/caption/code extraction as portableText, which covers the triggers, populateFromContent, populateRange and the searchable-toggle rebuild. Both reviewers found no runtime regression in the FTS path. The fixed-key limitation and the deferred AI-search integration are documented and consistent with the code. The remaining findings concern repository conventions, a docstring that overstates parity between the JS and SQL extractors, roadmap wording in public text, and test fixture realism. Two of them are needs_fixing under repo conventions, so the verdict is request_changes. Reviewers differed on the overall verdict: one approved with only suggestions, the other commented. Neither ran tests, builds or lint.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.

Comment thread .changeset/admin-searchable-blocks.md Outdated
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": minor

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.

[needs fixing] This and searchable-blocks-fields.md split one feature across two package-specific changesets. .changeset/README.md requires multi-package changes to use one changeset listing all affected packages. Combine the entries with both emdash and @emdash-cms/admin in the frontmatter, retaining the usage example, indexing limitation, and field-editor behavior in the shared release description.

Comment thread packages/core/src/search/fts-manager.ts Outdated
Comment on lines 243 to 249
* Migration `064_fts_plain_text.ts` covers the portableText case as
* shipped. Blocks fields were added later without a new migration because
* no pre-existing trigger could include a searchable blocks field (they
* could not be searchable before). If the expression changes for fields
* that already have triggers, add a new migration rather than editing
* shipped ones — migrations are forward-only.
*/

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.

[needs fixing] The new paragraph narrates why this PR did not add a migration (“were added later without a new migration because…”). AGENTS.md explicitly excludes change history and justification from code comments. Keep the evergreen requirement to add a forward migration when changing expressions used by existing triggers, but remove the account of how searchable blocks were introduced.

Comment on lines +187 to +205
});

it("indexes Portable Text inside repeater rows", async () => {
await repo.create({
type: "pages",
slug: "faq",
status: "published",
data: {
title: "FAQ",
layout: [
{
_type: "faq",
_key: "f1",
items: [{ _key: "r1", answer: prose("Refunds arrive within fortnight") }],
},
],
},
});
expect((await search("refunds")).items).toHaveLength(1);

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.

[suggestion] This fixture does not represent a blocks value that normal content writes can store: only two_column is registered/allowed, faq is not defined, and REPEATER_SUB_FIELD_TYPES does not permit portableText. Direct repository writes bypass those validations, so this test demonstrates recursive JSON extraction rather than a supported repeater scenario. Consider replacing it with a registered/allowed block containing supported repeater sub-fields (for example, a string caption), and keep hypothetical Portable Text-in-repeater coverage clearly separate from production behavior. The preceding figure fixture should likewise register and allow its block type.

* objects and arrays recursively (so repeater rows are covered), skips
* `_`-prefixed keys, and extracts only Portable Text arrays via
* `extractPlainText`. Plain string sub-fields are not emitted, which matches
* the SQL path (see FTSManager.searchValueExpr).

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.

[suggestion] This docstring says that leaving out plain string sub-fields "matches the SQL path", but the two paths behave differently. searchValueExpr indexes any string under a text, alt, caption or code key at any depth. That includes a plain string sub-field slugged caption, and the new integration test (indexes a plain string sub-field slugged caption) relies on it. collectBlockText only emits Portable Text arrays, so the same block produces different text from extractSearchableFields than it does from FTS. Either match the SQL key rule here, or reword the comment to say the JS path is narrower than the SQL one, as the searchValueExpr docstring already says for extractPlainText.

Comment thread .changeset/searchable-blocks-fields.md Outdated
{ slug: "layout", type: "blocks", searchable: true, validation: { allowedTypes: ["prose"] } }
```

Plain string and text sub-fields such as headings or labels are not indexed unless their sub-field slug happens to be `text`, `alt`, `caption` or `code`; a schema-aware key list is a possible follow-up. `extractSearchableFields()` also takes an optional `fieldTypes` map so it can read `blocks` fields.

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.

[suggestion] "a schema-aware key list is a possible follow-up" is roadmap speculation. This file is copied verbatim into the CHANGELOG, and the same phrase also appears in docs/.../guides/blocks.mdx, docs/.../reference/field-types.mdx and the searchValueExpr docstring. Readers need the current behaviour (headings and labels are not indexed unless their slug is text, alt, caption or code), not a possible future change. I'd delete the clause in all four places and keep the follow-up in an issue.

@adamslowe

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Nov 7, 2026
What Changed:
- Merge the core and admin changesets into one multi-package changeset.
- Drop the change-history narrative from the createTriggers docstring and
  keep only the forward-migration rule.
- Remove roadmap wording from the changeset, both docs pages and the
  searchValueExpr docstring; state current behavior only.
- Correct the collectBlockText docstring: the JS path extracts only Portable
  Text arrays and is narrower than the SQL key rule.
- Register and allow the figure and gallery block types in the indexing test,
  and replace the Portable Text-in-repeater case with a supported string
  caption repeater row.

Why:
The repository review flagged convention violations (one changeset per
feature, no history in comments, no speculation in public text) and test
fixtures that bypassed schema validation.

Refs: emdash-cms#3670

@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.

The approach remains sound: searchable blocks reuse the existing full-text extraction path and address the reported blocks-based body-search gap.

All five earlier findings are addressed. The changesets are consolidated, speculative roadmap wording is removed, the extraction docstring accurately distinguishes JS from SQL behavior, the migration comment states an evergreen requirement, and the integration fixtures register allowed block types with supported repeater sub-fields.

I reviewed the follow-up diff and relevant implementation, validation, tests, and documentation conventions. No new issues found in these changes. Review was static; tests, builds, and lint were not run.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

@adamslowe adamslowe mentioned this pull request Nov 7, 2026
6 of 9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blocks fields cannot be searchable, so page bodies built from blocks are invisible to full-text search

1 participant