dorfteich/packages/shared/src/search.ts
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

58 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from 'zod';
import type { PageClassification } from './pages';
/**
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
* matching ('Baume' finds 'Bäume') is achieved by normalizing both the indexed
* text and the query in application code — so no Postgres `unaccent` extension
* is required (which keeps the schema-pushed test databases working).
*/
// Combining diacritical marks (U+0300U+036F), removed after NFKD decomposition.
const COMBINING_MARKS = /[̀-ͯ]/g;
/**
* Fold a string for search: strip diacritics (NFKD + drop combining marks) and
* lowercase, leaving tokenization to Postgres `to_tsvector('simple', …)`. Used
* for BOTH the stored search vector and the query, so they always agree.
*/
export function normalizeForSearch(text: string): string {
return text.normalize('NFKD').replace(COMBINING_MARKS, '').toLowerCase();
}
/** Sentinels wrapping a matched span in a result snippet (`ts_headline`), split
* by the UI to render highlights without trusting HTML from the content.
* Private-use codepoints that never occur in page text. */
export const SEARCH_HIGHLIGHT_START = String.fromCodePoint(0xe000);
export const SEARCH_HIGHLIGHT_END = String.fromCodePoint(0xe001);
/** How many results one search returns (v1, ADR 0010 target scale). */
export const SEARCH_RESULT_LIMIT = 30;
export const searchQuerySchema = z.object({
q: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'),
/** Restrict to one pond; omitted = all ponds the user may read. */
pondId: z.string().min(1).optional(),
/** Restrict to pages carrying any of these label ids. */
labels: z.array(z.string().min(1)).optional(),
});
export type SearchQuery = z.infer<typeof searchQuerySchema>;
/** One hit: the page, its pond, its labels, and a highlighted snippet. */
export interface SearchResultView {
pageId: string;
title: string;
slug: string;
pondId: string;
pondSlug: string;
pondName: string;
labelIds: string[];
/** Snippet with matches wrapped in the highlight sentinels above. */
snippet: string;
/** VS-NfD level (issue #211, ADR 0022): a snippet of a classified page is
* never shown unmarked — the UI renders the marking with every hit. */
classification: PageClassification;
}