chrysopedia/frontend/src/utils/citations.tsx
jlightner 48bcf26bee feat: Added format-aware v2 body_sections rendering with nested TOC, ci…
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/components/TableOfContents.tsx"
- "frontend/src/utils/citations.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T01
2026-04-03 01:42:56 +00:00

76 lines
2.1 KiB
TypeScript

/**
* Parse [N] and [N,M] citation markers in text and replace with React anchor links.
*
* Citations are 1-based indices into the key_moments array.
* Each marker becomes a superscript link to #km-{momentId}.
*/
import React from "react";
import type { KeyMomentSummary } from "../api/public-client";
// Matches [1], [2,3], [1,2,3], etc.
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
/**
* Convert a text string containing [N] or [N,M] markers into an array of
* React nodes — plain strings interleaved with citation anchor elements.
*/
export function parseCitations(
text: string,
keyMoments: KeyMomentSummary[],
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
// Push text before this match
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
// Parse the indices from the match group
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
// Citation indices are 1-based; key_moments array is 0-based
const moment = keyMoments[idx - 1];
if (moment) {
if (i > 0) links.push(", ");
links.push(
<a
key={`${matchStart}-${idx}`}
href={`#km-${moment.id}`}
className="citation-link"
title={moment.title}
>
{idx}
</a>,
);
} else {
// Invalid index — render as plain text
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className="citation-group">
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
// Push remaining text after the last match
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}