import type { StepTechStepView } from "@batch-cooking/shared"; /** * One run of a step's `description` — either plain text, or the exact * words that triggered a technique match (`techStep` set). What * `StepDescription.tsx` renders: plain segments as-is, technique segments * wrapped in a highlighted, tooltip-bearing ``. */ export interface DescriptionSegment { text: string; techStep: StepTechStepView["techStep"] | null; } /** * Splits `description` into an ordered sequence of plain/technique * {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end` * (see `StepTechStepView`, resolved server-side by * `tech-step-matcher.ts`'s `matchTechStepSpans`). * * `techSteps` is expected already sorted by `start` (the API returns it in * `StepTechStep.order`, which *is* reading order — see that model's schema * doc comment) but this re-sorts defensively rather than assuming it, and * silently drops any entry whose bounds don't make sense against * `description` (`start < 0`, `end > description.length`, `start >= end`, * or overlapping a previously-accepted entry) — a malformed/out-of-date * span degrades to "just don't highlight that one" rather than a garbled * slice or a crash. */ export function splitDescriptionByTechSteps( description: string, techSteps: StepTechStepView[], ): DescriptionSegment[] { const sorted = [...techSteps].sort((a, b) => a.start - b.start); const segments: DescriptionSegment[] = []; let cursor = 0; for (const { techStep, start, end } of sorted) { if (start < 0 || end > description.length || start >= end || start < cursor) continue; if (start > cursor) segments.push({ text: description.slice(cursor, start), techStep: null }); segments.push({ text: description.slice(start, end), techStep }); cursor = end; } if (cursor < description.length) { segments.push({ text: description.slice(cursor), techStep: null }); } return segments; }