app/lib/publications/compile.mjs
131 lines and 6 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| derivedProblems | 31 to 87 | 34 | 77% | CriticalCRAP 47.3 | No data: no mutants in this code |
| <anonymous:43> | 43 to 43 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:47> | 47 to 47 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:51> | 51 to 51 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:68> | 68 to 68 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| compilePublication | 104 to 141 | 11 | 100% | WatchCRAP 11 | No data: no mutants in this code |
Imports
It imports app/lib/content/hashes.mjs, app/lib/publications/citation-tags.mjs, app/lib/publications/cited-by.mjs, app/lib/publications/entities.mjs, app/lib/publications/exports.mjs, app/lib/publications/parse.mjs, app/lib/publications/paths.mjs, app/lib/publications/record.mjs, app/lib/publications/search-inputs.mjs, app/lib/publications/twin.mjs, app/lib/publications/validate.mjs, app/lib/search/ask-keys.mjs. Imported by app/lib/publications/save.server.ts.
Source
// One door from a publication file to everything derived from it: parse, validate, the record every
// surface reads, the markdown twin and the search input. check:machine-readable, sync:content, the
// build and the Carrel adapter's save all call compilePublication, so a file CI passes is the file the
// save accepts and the page draws, and the twin a machine reads is the one a person's page agrees with.
import { gitBlobSha } from "../content/hashes.mjs";
import { keyForUrl, urlForKey } from "../search/ask-keys.mjs";
import { buildCitationTags, REQUIRED_CITATION_TAGS } from "./citation-tags.mjs";
import { citedByFetchedAt, citedByFor } from "./cited-by.mjs";
import { decodeEntities } from "./entities.mjs";
import { organismsIn, toBibtex, toRis } from "./exports.mjs";
import { parsePublication, publicationPath } from "./parse.mjs";
import { paperAskUrl, paperMarkdownPath, paperPath, paperPdfPath } from "./paths.mjs";
import { recordFromData } from "./record.mjs";
import { paperSearchInputs } from "./search-inputs.mjs";
import { paperTwin } from "./twin.mjs";
import { ASK_URL_MAX, validatePublicationFile } from "./validate.mjs";
const ORIGIN = "https://example.invalid";
const REFERENCE = /&(?:amp|lt|gt|quot|apos|#\d+);/;
/**
* What a record promises other surfaces, checked once it is built: the Scholar tag set, the exports,
* the twin, the search record and the Ask key. Each is a function of the record alone, so a file that
* passes here cannot fail the page at render time.
*
* @param {import("./types.ts").Publication} record
* @param {{ twin: string, searchBody: string, fullText: string | null }} built
* @returns {string[]}
*/
function derivedProblems(record, built) {
/** @type {string[]} */
const errors = [];
const { slug } = record;
const hosted = record.access === "self-hosted" && record.pdfPath !== null;
if (record.doi !== null) {
try {
const tags = buildCitationTags(record, {
abstractUrl: `${ORIGIN}${paperPath(slug)}`,
pdfUrl: hosted ? `${ORIGIN}${paperPdfPath(slug)}` : null,
});
const names = tags.map((t) => t.name);
for (const required of REQUIRED_CITATION_TAGS) {
if (!names.includes(required)) errors.push(`citation tags: no ${required}`);
}
const authorTags = tags.filter((t) => t.name === "citation_author").length;
if (authorTags !== record.authors.length) {
errors.push(`citation tags: ${authorTags} citation_author tags for ${record.authors.length} authors; one per author, or Scholar reads a single author whose name is the whole list`);
}
const pdf = tags.find((t) => t.name === "citation_pdf_url");
if (hosted && !pdf) errors.push("citation tags: hosted but no citation_pdf_url");
if (!hosted && pdf) errors.push("citation tags: not hosted but has citation_pdf_url");
if (pdf && !pdf.content.startsWith(`${ORIGIN}${paperPath(slug)}`)) {
errors.push(`citation tags: citation_pdf_url is not under ${paperPath(slug)}`);
}
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
const bib = toBibtex(record);
const ris = toRis(record);
if (!bib.includes(record.doi) || !ris.includes(record.doi)) errors.push("exports: the DOI is missing from the BibTeX or RIS entry");
if (record.doi !== record.doi.toLowerCase() && !bib.includes(`doi = {${record.doi}}`)) {
errors.push("exports: a mixed-case DOI must be exported as deposited");
}
if (REFERENCE.test(bib) || REFERENCE.test(ris)) errors.push("exports: a character reference survives into BibTeX or RIS");
const unprotected = organismsIn(record.title).filter((o) => !bib.includes(`{${o}}`));
if (unprotected.length > 0) errors.push(`exports: organism name(s) not brace-protected in BibTeX: ${unprotected.join(", ")}`);
}
if (REFERENCE.test(built.twin)) errors.push("twin: carries an undecoded character reference");
if (hosted && built.fullText !== null && !built.twin.includes("## Full text")) errors.push("twin: a hosted paper's twin lacks its Full text section");
// Classic search shows the line it matched, so two-column text would snippet a mangled one.
if (hosted && built.fullText && built.searchBody.length > built.fullText.length / 2) {
errors.push("search record: the body carries the PDF text; only the abstract, summary and citation belong in it");
}
const key = keyForUrl(paperPath(slug));
if (key !== `research/publications/${slug}.md` || urlForKey(key) !== paperPath(slug)) {
errors.push(`Ask key: ${key} does not map back to ${paperPath(slug)}`);
}
if (`/${key}` !== paperMarkdownPath(slug)) errors.push(`Ask key: ${key} is not the twin's path ${paperMarkdownPath(slug)}`);
const askUrl = paperAskUrl(decodeEntities(record.title));
if (!askUrl.startsWith("/search?q=") || askUrl.length > ASK_URL_MAX) errors.push("Ask link: the quoted title does not build a /search URL");
return errors;
}
/**
* @param {object} input
* @param {string} input.slug
* @param {string} input.raw the whole file
* @param {import("./validate.mjs").PublicationHost} input.host
* @param {import("./validate.mjs").OtherPublication[]} [input.others]
* @param {unknown} input.citedByArtifact the parsed data/publications.cited-by.json, which the twin's
* citedBy line is a pure function of, so a build and a save write the same twin
* @returns {Promise<
* | { ok: false, errors: string[] }
* | { ok: true, errors: [], record: import("./types.ts").Publication, draft: boolean, csl: Record<string, unknown> | null,
* fullText: string | null, pdfSha256: string | null, twin: string, searchInput: { uid: string, url: string, title: string, body: string },
* sourcePath: string, sourceBlobSha: string }
* >}
*/
export async function compilePublication({ slug, raw, host, others, citedByArtifact }) {
const file = publicationPath(slug);
const parsed = parsePublication({ file, raw });
const errors = await validatePublicationFile({ parsed, slug, host, others });
if (errors.length > 0) return { ok: false, errors };
const record = recordFromData(/** @type {any} */ (parsed.data), slug);
const hosted = record.access === "self-hosted" && record.pdfPath !== null;
const twin = paperTwin(record, {
pages: hosted ? [parsed.fullText ?? ""] : null,
citedBy: record.doi ? citedByFor(citedByArtifact, record.doi) : null,
citedByFetchedAt: citedByFetchedAt(citedByArtifact),
pagePath: paperPath(slug),
pdfPath: hosted ? paperPdfPath(slug) : null,
});
const searchInput = /** @type {NonNullable<ReturnType<typeof paperSearchInputs>[number]>} */ (paperSearchInputs([record])[0]);
const derived = derivedProblems(record, {
twin,
searchBody: searchInput.body,
fullText: parsed.fullText,
});
if (derived.length > 0) return { ok: false, errors: derived };
const csl = parsed.data.csl && typeof parsed.data.csl === "object" ? /** @type {Record<string, unknown>} */ (parsed.data.csl) : null;
return {
ok: true,
errors: [],
record,
draft: parsed.data.draft === true,
csl,
fullText: parsed.fullText,
pdfSha256: typeof parsed.data.pdfSha256 === "string" ? parsed.data.pdfSha256 : null,
twin,
searchInput,
sourcePath: file,
sourceBlobSha: await gitBlobSha(raw),
};
}