Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/typeahead-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@tinacms/search": minor
"@tinacms/schema-tools": minor
"tinacms": minor
"@tinacms/cli": minor
---

Added fuzzy search options + support. This is now set as the new default. Going forward this package will also be used on the TinaCloud side so we don't duplicate code. Search now uses the closest index instead of being exact.
12 changes: 11 additions & 1 deletion packages/@tinacms/cli/src/next/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,18 @@ export class Codegen {
JSON.stringify(this.graphqlSchemaDoc)
);

// Include search config in lock file, but exclude sensitive indexerToken
// Only add search if search.tina exists - plain search without tina is not included
const { search, ...rest } = this.tinaSchema.schema.config;
this.tinaSchema.schema.config = rest;
if (search?.tina) {
const { indexerToken, ...safeSearchConfig } = search.tina;
this.tinaSchema.schema.config = {
...rest,
search: { tina: safeSearchConfig },
};
} else {
this.tinaSchema.schema.config = rest;
}

// update _schema.json
await this.writeConfigFile(
Expand Down
13 changes: 12 additions & 1 deletion packages/@tinacms/cli/src/next/commands/dev-command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,21 @@ export class DevCommand extends BaseCommand {
);
}

// Pass both searchIndex and fuzzySearchWrapper
const searchIndexWithFuzzy = searchIndexClient.searchIndex as
| (typeof searchIndexClient.searchIndex & {
fuzzySearchWrapper?: typeof searchIndexClient.fuzzySearchWrapper;
})
| undefined;
if (searchIndexWithFuzzy && searchIndexClient.fuzzySearchWrapper) {
searchIndexWithFuzzy.fuzzySearchWrapper =
searchIndexClient.fuzzySearchWrapper;
}

const server = await createDevServer(
configManager,
database,
searchIndexClient.searchIndex,
searchIndexWithFuzzy,
apiURL,
this.noWatch,
dbLock
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,148 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { SearchQueryResponse, SearchResult } from '@tinacms/search';

export interface PathConfig {
apiURL: string;
searchPath: string;
}

interface SearchIndexOptions {
DOCUMENTS?: boolean;
PAGE?: { NUMBER: number; SIZE: number };
}

interface SearchIndexResult {
RESULT: SearchResult[];
RESULT_LENGTH: number;
}

interface FuzzySearchWrapper {
query: (
query: string,
options: {
limit?: number;
cursor?: string;
fuzzyOptions?: Record<string, unknown>;
}
) => Promise<SearchQueryResponse>;
}

interface SearchIndex {
PUT: (docs: Record<string, unknown>[]) => Promise<unknown>;
DELETE: (id: string) => Promise<unknown>;
QUERY: (
query: { AND?: string[]; OR?: string[] },
options: SearchIndexOptions
) => Promise<SearchIndexResult>;
fuzzySearchWrapper?: FuzzySearchWrapper;
}

interface RequestWithBody extends IncomingMessage {
body?: { docs?: Record<string, unknown>[] };
}

export const createSearchIndexRouter = ({
config,
searchIndex,
}: {
config: PathConfig;
searchIndex: any;
searchIndex: SearchIndex;
}) => {
const put = async (req, res) => {
const { docs } = req.body as { docs: Record<string, any>[] };
const put = async (req: RequestWithBody, res: ServerResponse) => {
const docs = req.body?.docs ?? [];
const result = await searchIndex.PUT(docs);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ result }));
};

const get = async (req, res) => {
const requestURL = new URL(req.url, config.apiURL);
const get = async (req: IncomingMessage, res: ServerResponse) => {
const requestURL = new URL(req.url ?? '', config.apiURL);
const isV2 = requestURL.pathname.startsWith('/v2/searchIndex');

res.writeHead(200, { 'Content-Type': 'application/json' });

if (isV2) {
const queryParam = requestURL.searchParams.get('query');
const collectionParam = requestURL.searchParams.get('collection');
const limitParam = requestURL.searchParams.get('limit');
const cursorParam = requestURL.searchParams.get('cursor');

if (!queryParam) {
res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
return;
}

if (!searchIndex.fuzzySearchWrapper) {
res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
return;
}

try {
const paginationOptions: { limit?: number; cursor?: string } = {};
if (limitParam) {
paginationOptions.limit = parseInt(limitParam, 10);
}
if (cursorParam) {
paginationOptions.cursor = cursorParam;
}

const searchQuery = collectionParam
? `${queryParam} _collection:${collectionParam}`
: queryParam;

const result = await searchIndex.fuzzySearchWrapper.query(searchQuery, {
...paginationOptions,
});

if (collectionParam) {
result.results = result.results.filter(
(r) => r._id && r._id.startsWith(`${collectionParam}:`)
);
}

res.end(
JSON.stringify({
RESULT: result.results,
RESULT_LENGTH: result.total,
NEXT_CURSOR: result.nextCursor,
PREV_CURSOR: result.prevCursor,
FUZZY_MATCHES: result.fuzzyMatches || {},
})
);
return;
} catch (error) {
console.warn(
'[search] v2 fuzzy search failed:',
error instanceof Error ? error.message : error
);
res.end(JSON.stringify({ RESULT: [], RESULT_LENGTH: 0 }));
return;
}
}

const query = requestURL.searchParams.get('q');
const optionsParam = requestURL.searchParams.get('options');
let options = {
DOCUMENTS: false,
};

if (!query) {
res.end(JSON.stringify({ RESULT: [] }));
return;
}

let searchIndexOptions: SearchIndexOptions = { DOCUMENTS: false };
if (optionsParam) {
options = {
...options,
searchIndexOptions = {
...searchIndexOptions,
...JSON.parse(optionsParam),
};
}
res.writeHead(200, { 'Content-Type': 'application/json' });
if (query) {
const result = await searchIndex.QUERY(JSON.parse(query), options);
res.end(JSON.stringify(result));
} else {
res.end(JSON.stringify({ RESULT: [] }));
}

const queryObj = JSON.parse(query);
const result = await searchIndex.QUERY(queryObj, searchIndexOptions);
res.end(JSON.stringify(result));
};

const del = async (req, res) => {
const requestURL = new URL(req.url, config.apiURL);
const del = async (req: IncomingMessage, res: ServerResponse) => {
const requestURL = new URL(req.url ?? '', config.apiURL);
const docId = requestURL.pathname
.split('/')
.filter(Boolean)
Expand Down
5 changes: 4 additions & 1 deletion packages/@tinacms/cli/src/next/vite/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ export const devServerEndPointsPlugin = ({
return;
}

if (req.url.startsWith('/searchIndex')) {
if (
req.url.startsWith('/searchIndex') ||
req.url.startsWith('/v2/searchIndex')
) {
if (req.method === 'POST') {
await searchIndexRouter.put(req, res);
} else if (req.method === 'GET') {
Expand Down
24 changes: 20 additions & 4 deletions packages/@tinacms/schema-tools/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,10 +815,9 @@ export interface Config<
* url: `https://github.com/tinacms/tinacms/commits/${branch}/examples/next-2024/${relativePath}`
* })
* */
historyUrl?: (context: {
relativePath: string;
branch: string;
}) => { url: string };
historyUrl?: (context: { relativePath: string; branch: string }) => {
url: string;
};
};
search?: (
| {
Expand Down Expand Up @@ -846,6 +845,23 @@ export interface Config<
* regex used for splitting tokens (default: /[\p{L}\d_]+/)
*/
tokenSplitRegex?: string;
/**
* Enable fuzzy search (default: true)
*/
fuzzyEnabled?: boolean;
/**
* Fuzzy search options
*/
fuzzyOptions?: {
/** Maximum edit distance for fuzzy matching (default: 2) */
maxDistance?: number;
/** Minimum similarity score 0-1 for matches (default: 0.6) */
minSimilarity?: number;
/** Maximum number of fuzzy term expansions to return per search term (default: 10) */
maxTermExpansions?: number;
/** Use Damerau-Levenshtein (allows transpositions) (default: true) */
useTranspositions?: boolean;
};
};
}
) & {
Expand Down
Loading
Loading