/** * Adapter: CrossRefWork → CslItem * * CrossRef's wire format uses camelCase + flat arrays; CSL-JSON uses * kebab-case + nested author objects. This adapter is the only place * that bridges the two. Adding more adapters (OpenAlex, Europe PMC) * means writing more adapters to the SAME CslItem shape — never * let CrossRefWork cross module boundaries outside this adapter. * * Field mapping reference: * * CrossRefWork → CslItem * ───────────────────────────────────────────────────── * doi → id (via doiToId), DOI * title[0] → title * author[].given/family → author[].given/family * author[].name → author[].literal (institutional) * published/publishedPrint/ * publishedOnline → issued["date-parts"][0] (first year wins) * containerTitle[0] → "container-title" * volume → volume * issue → issue * page → page (with `--` → en-dash) * abstract → abstract * * CrossRefWork `published.dateParts` is a flat array of numbers * (e.g. `[2024, 4, 30]`); CSL-JSON `issued["date-parts"]` is * an ARRAY OF TUPLES (e.g. `[[2024, 4, 30]]`) because one entry * can carry multiple dates. We always use a single tuple. */ import type { CrossRefWork } from "../../crossref.ts"; import { doiToId, type CslItem } from "../schema.ts"; /** * Convert a CrossRefWork into a CslItem suitable for Citestyle. * * @param work The normalized CrossRef API response. * @param doi The DOI string used to fetch the work. Required because * CrossRef sometimes returns the DOI in mixed case or * uppercase, and we want a stable lowercase id. */ export function crossrefToCsl(work: CrossRefWork, doi: string): CslItem { return { id: doiToId(doi), type: mapCrossRefType(work.type), title: work.title?.[0] ?? "(untitled)", author: (work.author ?? []).map(crossrefAuthorToCsl), issued: pickIssued(work), "container-title": work.containerTitle?.[0], volume: work.volume, issue: work.issue, // CrossRef uses `--` for page ranges (e.g. "123--145"); CSL prefers // the en-dash `–`. Normalize so bibliographies look typographically // correct, regardless of how the publisher encoded the range. page: work.page?.replace(/--/g, "\u2013"), DOI: doi, URL: work.url ?? `https://doi.org/${doi}`, abstract: stripJats(work.abstract), publisher: work.publisher, ISSN: work.issn?.[0], source: "crossref", }; } // ── helpers ──────────────────────────────────────────────────────────────── function crossrefAuthorToCsl(a: { family?: string; given?: string; name?: string; }): { family?: string; given?: string; literal?: string } { // Institutional / corporate authors (no family/given) come through // as `name` only — we map to CSL's `literal` field which Citestyle // renders without trying to split into first/last. if (!a.family && !a.given && a.name) { return { literal: a.name }; } return { family: a.family ?? a.name ?? "?", given: a.given, }; } function pickIssued(work: CrossRefWork): CslItem["issued"] { // CrossRef has three date slots; we prefer published > published-print > // published-online in that order. Each is `{ dateParts: [year] }`. const year = work.published?.dateParts?.[0] ?? work.publishedPrint?.dateParts?.[0] ?? work.publishedOnline?.dateParts?.[0]; if (year == null) return undefined; return { "date-parts": [[year]] }; } function mapCrossRefType(t: string | undefined): CslItem["type"] { // CrossRef types map roughly to CSL types. The most common case is // "journal-article" → "article-journal". Anything unknown falls back // to "article" which most styles can render as a generic citation. switch (t) { case "journal-article": return "article-journal"; case "book": return "book"; case "book-chapter": return "chapter"; case "proceedings-article": return "paper-conference"; case "dissertation": return "thesis"; case "report": return "report"; case "posted-content": case "preprint": return "article"; // CSL doesn't distinguish preprints cleanly default: return "article"; } } /** * Strip JATS XML tags from a CrossRef abstract. * * CrossRef returns abstracts as JATS XML fragments (e.g. * `Background.`). CSL-JSON's `abstract` field is * supposed to be plain text — passing JATS through makes Citestyle * and downstream consumers emit raw `` to users. * * The implementation is intentionally a single regex, not a full XML * parser: JATS-in-abstract is well-formed in practice and we don't need * to handle nested tags. If a tag has attributes we keep the inner * text only. */ export function stripJats(jats: unknown): string | undefined { if (typeof jats !== "string" || !jats) return undefined; // Drop name-anchored tags only. Repeatedly apply to handle nested // tags like foo bar baz. // Do NOT use /<[^>]+>/ — that eats "P < 0.001 and > 2" → "P 2". let out = jats; let prev: string; do { prev = out; out = out.replace(/<\/?[a-zA-Z][^>]*>/g, ""); } while (out !== prev); // Decode entities CrossRef uses most often. HIGH-4 fix: numeric // hex entities like — (em-dash) were previously DELETED, not // decoded, corrupting abstracts. We now decode both decimal // (`Ӓ`) and hex (`ꯍ`) forms. return out .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&#(\d+);/g, (_m, code) => { const n = parseInt(code, 10); return Number.isFinite(n) ? String.fromCharCode(n) : _m; }) .replace(/&#x([a-f0-9]+);/gi, (_m, code) => { const n = parseInt(code, 16); return Number.isFinite(n) ? String.fromCharCode(n) : _m; }) .trim(); }