Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/searchable-blocks-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds support for marking a `blocks` field as `searchable`, so text inside a page's blocks is found by full-text search. Portable Text content inside blocks (including image alt and caption, and code blocks) is indexed; a page built only from blocks can now be found by searching for words in its body.

Set `searchable: true` on the field. Toggling it on for an existing collection rebuilds the index from current content:

```ts
{ 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`. `extractSearchableFields()` also takes an optional `fieldTypes` map so it can read `blocks` fields.

The field editor shows the Searchable switch when adding or editing a blocks field. When it is on, a note under the switch explains that Portable Text content inside blocks is indexed.
4 changes: 3 additions & 1 deletion docs/src/content/docs/guides/blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ Old versions remain available for revisions, drafts, media tracking, and stored

Block definitions support `string`, `text`, `url`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, and `repeater`.

References, JSON, slugs, nested blocks, custom widgets, physical indexes, uniqueness, and per-subfield localization are not supported inside a block definition. A blocks field itself cannot be required, unique, searchable, indexed, or assigned a custom field widget.
References, JSON, slugs, nested blocks, custom widgets, physical indexes, uniqueness, and per-subfield localization are not supported inside a block definition. A blocks field itself cannot be required, unique, indexed, or assigned a custom field widget.

A blocks field can be marked `searchable`. Full-text search then indexes Portable Text content inside blocks (including image alt and caption, and code blocks). 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`.

For exact field validation and stored value rules, see the [`blocks` field reference](/reference/field-types/#blocks). For seed conflict and export behavior, see [Seed files](/themes/seed-files/#block-types).
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ Blocks validation accepts these properties:
- `minItems` — Minimum number of blocks. Raising it above zero on a populated collection requires a content migration.
- `maxItems` — Maximum number of blocks, up to 100.

A blocks field is optional and defaults to an empty array. It cannot be required, unique, searchable, indexed, or rendered by a custom field widget. Block definitions can use scalar, text, selection, Portable Text, image, file, and repeater fields. They cannot contain references, JSON, slugs, or nested blocks.
A blocks field is optional and defaults to an empty array. It cannot be required, unique, indexed, or rendered by a custom field widget. It can be `searchable`: Portable Text content inside blocks (including image alt and caption, and code blocks) is added to full-text search. 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`. Block definitions can use scalar, text, selection, Portable Text, image, file, and repeater fields. They cannot contain references, JSON, slugs, or nested blocks.

Render the stored array with `<Blocks value components fallback>` from `emdash/ui`. See [Build pages with blocks](/guides/blocks/) for seed, component-map, missing-renderer, activation, and migration examples.

Expand Down Expand Up @@ -606,7 +606,7 @@ for sorting or filtering because every index adds storage and write overhead.

`searchable` adds the field's text to the collection's full-text search index. Set `translatable: false` for identifiers, prices, flags, and other values that must stay the same across every translation of an entry; when one locale changes a non-translatable field, EmDash synchronizes the value to its translated entries.

The `blocks` type uses only `slug`, `label`, `type`, `translatable`, `validation`, and `sortOrder` from this common set. Its array is never column-required and always defaults to `[]`.
The `blocks` type uses only `slug`, `label`, `type`, `searchable`, `translatable`, `validation`, and `sortOrder` from this common set. Its array is never column-required and always defaults to `[]`.

## Reserved field slugs

Expand Down
10 changes: 7 additions & 3 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const SEARCHABLE_FIELD_TYPES = new Set<FieldType>([
"string",
"text",
"portableText",
"blocks",
"slug",
"url",
]);
Expand Down Expand Up @@ -774,9 +775,7 @@ export function FieldEditor({
step: "config",
// Long text and slug fields send a pattern too, but have no input showing it.
...(type === "string" ? {} : { pattern: "" }),
...(type === "blocks"
? { required: false, unique: false, searchable: false, indexed: false }
: {}),
...(type === "blocks" ? { required: false, unique: false, indexed: false } : {}),
}));
};

Expand Down Expand Up @@ -1214,6 +1213,11 @@ export function FieldEditor({
/>
)}
</div>
{selectedType === "blocks" && searchable && (
<p className="text-xs text-kumo-subtle">
{t`Portable Text content inside blocks is indexed.`}
</p>
)}
</>
)}

Expand Down
24 changes: 24 additions & 0 deletions packages/admin/tests/components/FieldEditor.blocks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,28 @@ describe("FieldEditor blocks configuration", () => {
}),
);
});

it("offers a searchable switch for blocks and saves it", async () => {
const onSave = vi.fn<(input: CreateFieldInput) => void>();
const screen = await render(<FieldEditor open onOpenChange={vi.fn()} onSave={onSave} />);

screen
.getByRole("button", { name: /^Blocks/ })
.element()
.click();
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
expect(screen.getByText("Required").query()).toBeNull();

await screen.getByLabelText("Label").fill("Page layout");
screen.getByRole("checkbox", { name: "Hero" }).element().click();
screen.getByRole("switch", { name: "Searchable" }).element().click();
await expect
.element(screen.getByText("Portable Text content inside blocks is indexed."))
.toBeInTheDocument();
screen.getByRole("button", { name: "Add Field" }).element().click();

expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ type: "blocks", searchable: true, indexed: false }),
);
});
});
5 changes: 0 additions & 5 deletions packages/core/src/schema/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2187,11 +2187,6 @@ export class SchemaRegistry {
path: "indexed",
});
}
if (input.searchable) {
throw new SchemaError("Blocks fields cannot be searchable", "VALIDATION_ERROR", {
path: "searchable",
});
}
if (input.widget !== undefined) {
throw new SchemaError("Blocks fields cannot use custom widgets", "VALIDATION_ERROR", {
path: "widget",
Expand Down
20 changes: 13 additions & 7 deletions packages/core/src/search/fts-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ export class FTSManager {
const columns = ["id UNINDEXED", "locale UNINDEXED", ...searchableFields].join(", ");

// Create the FTS5 virtual table. The table stores its own copy of the
// indexed values (no `content=` option): Portable Text fields are
// indexed as extracted plain text — see searchValueExpr — which cannot
// indexed values (no `content=` option): Portable Text and blocks
// fields are indexed as extracted plain text — see searchValueExpr — which cannot
// mirror the raw JSON in the ec_* column, and external-content FTS5
// requires the index to exactly mirror the backing table's values
// (snippet() reads them, and the 'delete' command must be fed the
Expand All @@ -168,7 +168,7 @@ export class FTSManager {
/**
* SQL expression producing the indexed value for one searchable field.
*
* Portable Text fields are stored as JSON; indexing the raw JSON pollutes
* Portable Text and blocks fields are stored as JSON; indexing the raw JSON pollutes
* the index with structural tokens (`_type`, style values like `normal`,
* `_key` ULIDs) and makes snippets show JSON fragments. Extract the prose
* instead: every JSON string under a `text`, `alt`, `caption`, or `code`
Expand All @@ -181,10 +181,16 @@ export class FTSManager {
* JSON scalar (`Some title`, `2024`) are indexed as-is. Extraction must
* live in SQL because the sync triggers cannot call into JS.
*
* Blocks fields use the same extraction, so Portable Text content inside
* blocks (including image alt and caption, and code blocks) is indexed.
* The 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`.
*
* `ref` must be a validated column reference (`NEW.x`, `OLD.x`, `"x"`).
*/
private searchValueExpr(ref: string, fieldType: string | undefined): string {
if (fieldType !== "portableText") return ref;
if (fieldType !== "portableText" && fieldType !== "blocks") return ref;
return (
`CASE WHEN ${ref} IS NULL THEN NULL ` +
`WHEN json_valid(${ref}) AND json_type(${ref}) IN ('array', 'object') THEN (` +
Expand Down Expand Up @@ -233,9 +239,9 @@ export class FTSManager {
* on one index row per content row instead of failing on the rowid
* constraint.
*
* The trigger SQL emitted here MUST stay in lock-step with migration
* `064_fts_plain_text.ts`. If this changes again, add a new migration
* rather than editing shipped ones — migrations are forward-only.
* For fields that already have triggers, the trigger SQL must match
* migration `064_fts_plain_text.ts`. If the expression changes for such
* fields, add a forward migration rather than editing shipped ones.
*/
private async createTriggers(collectionSlug: string, searchableFields: string[]): Promise<void> {
this.validateInputs(collectionSlug, searchableFields);
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/search/text-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,86 @@ export function extractPlainText(blocks: PortableTextBlock[] | string | null | u
return allTexts.join("\n");
}

/**
* Collect Portable Text prose from anywhere inside a block value. Walks
* objects and arrays recursively, skips `_`-prefixed keys, and extracts only
* Portable Text arrays via `extractPlainText`. This is narrower than the SQL
* path (see FTSManager.searchValueExpr), which also indexes any string under
* a `text`, `alt`, `caption` or `code` key, so the two can see different text
* for the same document.
*/
function collectBlockText(value: unknown, parts: string[]): void {
if (Array.isArray(value)) {
if (value.length > 0 && isPortableTextArray(value) && value.some(isPortableTextNode)) {
const text = extractPlainText(value);
if (text) parts.push(text);
return;
}
for (const item of value) collectBlockText(item, parts);
return;
}
if (typeof value === "object" && value !== null) {
for (const [key, sub] of Object.entries(value)) {
if (key.startsWith("_")) continue;
collectBlockText(sub, parts);
}
}
}

/** Portable Text content nodes (as opposed to a block instance like `{ _type: "hero" }`). */
function isPortableTextNode(item: PortableTextBlock): boolean {
return (
item._type === "block" ||
item._type === "image" ||
item._type === "code" ||
item._type === "video"
);
}

/**
* Extract text from a blocks field value: an array of `{ _type, ...subFields }`
* objects. Only Portable Text sub-fields are extracted.
*/
function extractBlocksText(value: unknown): string {
let blocks: unknown = value;
if (typeof blocks === "string") {
try {
blocks = JSON.parse(blocks);
} catch {
return "";
}
}
if (!Array.isArray(blocks)) return "";
const parts: string[] = [];
for (const block of blocks) {
if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
for (const [key, sub] of Object.entries(block)) {
if (key.startsWith("_")) continue;
collectBlockText(sub, parts);
}
}
return parts.join("\n");
}

/**
* Extract searchable text from a content entry
*
* Extracts text from specified fields, handling both plain text and Portable Text.
*
* A blocks field value is an array of `{ _type }` objects, which is
* indistinguishable from Portable Text by shape alone. Pass `fieldTypes`
* (field slug to field type) so `blocks` fields are walked sub-field by
* sub-field; without it they are treated as Portable Text.
*
* @param entry - Content entry data
* @param fields - Field names to extract text from
* @param fieldTypes - Optional map of field slug to field type
* @returns Object mapping field names to extracted text
*/
export function extractSearchableFields(
entry: Record<string, unknown>,
fields: string[],
fieldTypes?: Readonly<Record<string, string>>,
): Record<string, string> {
const result: Record<string, string> = {};

Expand All @@ -139,6 +207,12 @@ export function extractSearchableFields(
continue;
}

const fieldType = fieldTypes?.[field];
if (fieldType === "blocks") {
result[field] = extractBlocksText(value);
continue;
}

if (typeof value === "string") {
// Could be plain text or JSON Portable Text
if (value.startsWith("[")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ describeEachDialect("blocks field schema", (dialect) => {
{ required: true },
{ unique: true },
{ indexed: true },
{ searchable: true },
{ widget: "custom" },
{ defaultValue: [{ _type: "hero" }] },
].entries()) {
Expand Down Expand Up @@ -120,6 +119,17 @@ describeEachDialect("blocks field schema", (dialect) => {
).rejects.toMatchObject({ code: "BLOCK_TYPE_NOT_FOUND" });
});

it("accepts searchable on a blocks field", async () => {
const field = await schema.createField("pages", {
slug: "layout",
label: "Layout",
type: "blocks",
searchable: true,
validation: { allowedTypes: ["hero"] },
});
expect(field).toMatchObject({ type: "blocks", searchable: true });
});

it("requires a migration before introducing a positive minimum on populated content", async () => {
const repo = new ContentRepository(ctx.db);
await repo.create({ type: "pages", slug: "existing", data: {} });
Expand Down
Loading
Loading