Add browser upload and viewer HTML resolution for HTML/Markdown
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

ingestHtmlFromFile and ingestMarkdownFromFile store session bytes with
correct content types. resolveDomViewerHtml renders sanitized HTML or
markdown paragraphs for HtmlViewerAdapter (EANCH-WP-0004).
This commit is contained in:
tegwick 2026-07-09 09:58:41 +02:00
parent 4999ea31f2
commit d605bf64be
5 changed files with 112 additions and 4 deletions

View file

@ -25,7 +25,11 @@ export interface PdfByteRecord {
}
export interface PdfByteStore {
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
put(
documentId: DocumentId,
bytes: Uint8Array,
contentType?: string,
): PdfByteRecord;
get(documentId: DocumentId): PdfByteRecord | null;
has(documentId: DocumentId): boolean;
delete(documentId: DocumentId): boolean;
@ -70,7 +74,7 @@ export function createPdfByteStore(
const records = new Map<DocumentId, PdfByteRecord>();
return {
put(documentId, bytes) {
put(documentId, bytes, contentType = "application/pdf") {
// Replace previous record (revoking the prior URL) if any.
const prior = records.get(documentId);
if (prior) revokeUrl(prior.blobUrl);
@ -79,7 +83,7 @@ export function createPdfByteStore(
// refuses without help. The bytes here always come from a fresh
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
const blob = new Blob([bytes as unknown as ArrayBuffer], {
type: "application/pdf",
type: contentType,
});
const blobUrl = createUrl(blob);
const record: PdfByteRecord = { bytes, blobUrl };

View file

@ -15,6 +15,12 @@ export {
type PdfByteStore,
} from "./byte-store";
export {
ingestHtmlFromFile,
ingestMarkdownFromFile,
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./upload";
export {
markdownSourceToViewerHtml,
resolveDomViewerHtml,
} from "./view-html";

View file

@ -18,6 +18,8 @@
* place that revokes them.
*/
import { ingestHtml, type IngestHtmlResult } from "../html/ingest";
import { ingestMarkdown, type IngestMarkdownResult } from "../markdown/ingest";
import { ingestPdf, type IngestPdfResult } from "../pdf/ingest";
import type { PdfByteStore } from "./byte-store";
@ -37,7 +39,39 @@ export async function ingestPdfFromFile(
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes);
const record = store.put(ingested.document.id, bytes, "application/pdf");
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}
export async function ingestHtmlFromFile(
file: File | Blob,
store: PdfByteStore,
options: IngestPdfFromFileOptions = {},
): Promise<IngestHtmlResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
const ingested = await ingestHtml(bytes, {
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes, "text/html");
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}
export async function ingestMarkdownFromFile(
file: File | Blob,
store: PdfByteStore,
options: IngestPdfFromFileOptions = {},
): Promise<IngestMarkdownResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
const ingested = await ingestMarkdown(bytes, {
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes, "text/markdown");
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { createPdfByteStore } from "./byte-store";
import { markdownSourceToViewerHtml, resolveDomViewerHtml } from "./view-html";
import { ingestHtmlFromFile } from "./upload";
const HTML = `<!DOCTYPE html><html><body><p>Hello HTML viewer</p></body></html>`;
describe("resolveDomViewerHtml", () => {
it("returns sanitized HTML for html-dom representations", async () => {
const store = createPdfByteStore();
const file = new File([HTML], "sample.html", { type: "text/html" });
const { document, representation } = await ingestHtmlFromFile(file, store);
const html = resolveDomViewerHtml(document, representation, store);
expect(html).toContain("Hello HTML viewer");
expect(html).not.toMatch(/<script/i);
});
it("renders markdown as paragraph HTML", () => {
const html = markdownSourceToViewerHtml("# Title\n\nBody paragraph.");
expect(html).toContain("<p>");
expect(html).toContain("Body paragraph.");
});
});

41
src/browser/view-html.ts Normal file
View file

@ -0,0 +1,41 @@
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
import { extractHtml } from "../html/extract";
import { extractMarkdown } from "../markdown/extract";
import type { PdfByteStore } from "./byte-store";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Render markdown canonical text as selectable HTML paragraphs. */
export function markdownSourceToViewerHtml(source: string): string {
const { canonicalText } = extractMarkdown(source);
const paragraphs = canonicalText.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("");
return `<article class="ea-markdown-body">${body}</article>`;
}
/**
* Resolve viewer HTML for a non-paginated document from session byte storage.
* Returns `null` when bytes are missing or the representation is unsupported.
*/
export function resolveDomViewerHtml(
document: Document,
representation: DocumentRepresentation,
store: PdfByteStore,
): string | null {
const record = store.get(document.id);
if (!record) return null;
const source = new TextDecoder("utf-8", { fatal: false }).decode(record.bytes);
if (representation.representationType === "html-dom") {
return extractHtml(source).sanitizedHtml;
}
if (representation.representationType === "markdown-rendered") {
return markdownSourceToViewerHtml(source);
}
return null;
}