diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts
index bf6f862bdc..3cc525870f 100644
--- a/make-pdf/src/print-css.ts
+++ b/make-pdf/src/print-css.ts
@@ -329,6 +329,16 @@ function inlineRules(): string {
`}`,
`strong { font-weight: 700; }`,
`em { font-style: italic; }`,
+ // APA-style hanging indent (0.5in) for reference-list entries. Scoped to
+ // .references so ordinary body paragraphs are untouched. wrapped by
+ // render.ts's wrapReferencesSection() around the content following an
+ //
References
heading.
+ `.references p {`,
+ ` margin: 0 0 11pt;`,
+ ` padding-left: 0.5in;`,
+ ` text-indent: -0.5in;`,
+ `}`,
+ `.references p:first-child { margin-top: 0; }`,
].join("\n");
}
diff --git a/make-pdf/src/render.ts b/make-pdf/src/render.ts
index 514fbbc89e..9c35a972eb 100644
--- a/make-pdf/src/render.ts
+++ b/make-pdf/src/render.ts
@@ -87,8 +87,13 @@ export function render(opts: RenderOptions): RenderResult {
// 4. Smartypants (code-safe)
const typographicHtml = smartypants(decoded);
+ // 4.5. Custom cover extraction (see extractCustomCover doc comment). Must
+ // run before title derivation so extractFirstHeading scans the REST of
+ // the document, not any decorative markup inside the custom cover.
+ const { cover: customCoverHtml, rest: bodyAfterCover } = extractCustomCover(typographicHtml);
+
// 4. Derive metadata (title from first H1 if not provided)
- const derivedTitle = opts.title ?? extractFirstHeading(typographicHtml) ?? "Document";
+ const derivedTitle = opts.title ?? extractFirstHeading(bodyAfterCover) ?? "Document";
const derivedAuthor = opts.author ?? "";
const derivedDate = opts.date ?? formatToday();
@@ -113,22 +118,26 @@ export function render(opts: RenderOptions): RenderResult {
const css = printCss(cssOptions);
// 6. Assemble document
- const coverBlock = opts.cover
- ? buildCoverBlock({
- title: derivedTitle,
- subtitle: opts.subtitle,
- author: derivedAuthor,
- date: derivedDate,
- })
- : "";
+ // A custom cover (extracted above) takes priority over the built-in
+ // template — they're mutually exclusive, never combined.
+ const coverBlock = customCoverHtml
+ ? customCoverHtml
+ : opts.cover
+ ? buildCoverBlock({
+ title: derivedTitle,
+ subtitle: opts.subtitle,
+ author: derivedAuthor,
+ date: derivedDate,
+ })
+ : "";
// TOC anchors must resolve: assign id="toc-N" to each H1-H3 in the same
// order buildTocBlock scans them, or every TOC link is a dead href (masked
// in PDFs by Chromium outline bookmarks, glaring in --to html). Headings
// that already carry an id keep it — the ids array records the ACTUAL id
// per heading so TOC entries always link to something real.
- const anchored = opts.toc ? addHeadingIds(typographicHtml) : { html: typographicHtml, ids: [] };
- const anchoredHtml = anchored.html;
+ const anchored = opts.toc ? addHeadingIds(bodyAfterCover) : { html: bodyAfterCover, ids: [] };
+ const anchoredHtml = wrapReferencesSection(anchored.html);
const tocBlock = opts.toc
? buildTocBlock(anchoredHtml, anchored.ids)
@@ -368,6 +377,73 @@ function wrapChaptersByH1(html: string): string {
return chunks.join("\n");
}
+/**
+ * Wrap an APA-style "References" section in a `.references` container so
+ * print CSS can apply a hanging indent to each entry without touching
+ * ordinary body paragraphs. Detects an whose text is exactly
+ * "References" (case-insensitive) and wraps everything from there to the
+ * next (or end of document) in
...
.
+ * The heading itself stays outside the wrapper so its own styling is
+ * unaffected. No-op if no such heading exists.
+ */
+function wrapReferencesSection(html: string): string {
+ const h1Re = /]*>([\s\S]*?)<\/h1>/gi;
+ let m: RegExpExecArray | null;
+ let headingStart = -1;
+ let headingEnd = -1;
+ while ((m = h1Re.exec(html)) !== null) {
+ const text = decodeTextEntities(stripTags(m[1])).trim().toLowerCase();
+ if (text === "references") {
+ headingStart = m.index;
+ headingEnd = m.index + m[0].length;
+ break;
+ }
+ }
+ if (headingStart === -1) return html;
+
+ // Find the next H1 after the References heading (end of section), or EOF.
+ const nextH1Re = /]*>/gi;
+ nextH1Re.lastIndex = headingEnd;
+ const next = nextH1Re.exec(html);
+ const sectionEnd = next ? next.index : html.length;
+
+ const before = html.slice(0, headingEnd);
+ const body = html.slice(headingEnd, sectionEnd);
+ const after = html.slice(sectionEnd);
+
+ return `${before}
${body}
${after}`;
+}
+
+/**
+ * Extract a custom, fully-styled cover section from the very start of the
+ * document, so it can be slotted into the same position the built-in
+ * --cover template occupies (before the TOC), instead of being left as
+ * ordinary body content (which the assembly order in render() places
+ * AFTER the TOC — see the `coverBlock, tocBlock, chapterHtml` order below).
+ *
+ * Detects a top-level ``
+ * as the first non-whitespace content in the document. This is a distinct
+ * class from the tool's own generated `.cover` (buildCoverBlock) so the two
+ * mechanisms never collide — a document uses one or the other, never both.
+ * Callers should NOT also pass --cover when using a custom cover section.
+ *
+ * No nested-tag balancing: the custom cover section is expected to be a
+ * single flat block (divs/spans/hr, no inner after the opening tag is assumed to close it.
+ */
+function extractCustomCover(html: string): { cover: string; rest: string } {
+ const trimmed = html.replace(/^\s+/, "");
+ const openMatch = trimmed.match(/^]*>/i);
+ if (!openMatch) return { cover: "", rest: html };
+ const closeIdx = trimmed.indexOf("", openMatch[0].length);
+ if (closeIdx === -1) return { cover: "", rest: html };
+ const closeEnd = closeIdx + "".length;
+ return {
+ cover: trimmed.slice(0, closeEnd),
+ rest: trimmed.slice(closeEnd),
+ };
+}
+
function extractFirstHeading(html: string): string | null {
const m = html.match(/]*>([\s\S]*?)<\/h1>/i);
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
diff --git a/make-pdf/src/smartypants.ts b/make-pdf/src/smartypants.ts
index 2dfe097e09..103d937837 100644
--- a/make-pdf/src/smartypants.ts
+++ b/make-pdf/src/smartypants.ts
@@ -20,7 +20,22 @@
const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
const TAG_RE = /<[^>]+>/g;
-const URL_RE = /\bhttps?:\/\/\S+/g;
+// BUG FIX (2026-07-20): a bare autolinked URL like X has
+// the URL text sitting with zero whitespace before its own closing
+// tag. TAG_RE carves that into a "\u0000SMARTPANTS_PRESERVED_N\u0000"
+// placeholder BEFORE this pattern runs. The old pattern (\S+, non-
+// whitespace-greedy) swallowed that adjacent placeholder into the URL
+// match, since \u0000 and the placeholder text are all non-whitespace.
+// The outer carve() then stored "https://...\u0000SMARTPANTS_PRESERVED_N\u0000"
+// as ONE preserved zone. String.replace() with a global regex does not
+// rescan replacement text for further matches, so that inner placeholder
+// was never restored -- it leaked through as literal "SMARTPANTS_PRESERVED_N"
+// text glued onto the end of the URL, corrupting the link (found in DOI
+// reference lists across the ebook PDFs). Excluding \u0000 from the URL
+// char class stops the match from ever crossing into an adjacent,
+// already-carved zone.
+const URL_RE = /\bhttps?:\/\/[^\s\u0000]+/g;
+
/**
* Apply smartypants to an HTML string. Zones that should not be touched:
diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts
index b54085ecb2..8758b251f1 100644
--- a/make-pdf/test/render.test.ts
+++ b/make-pdf/test/render.test.ts
@@ -61,6 +61,30 @@ describe("smartypants", () => {
expect(out).toContain(`href="it's-a-test.html"`);
});
+ test("does NOT leak SMARTPANTS_PRESERVED placeholders into autolinked URLs", () => {
+ // Regression test: found 2026-07-20 in AI4LD ebook PDF reference
+ // lists. A bare autolinked URL (anchor text == href, zero whitespace
+ // before the closing tag) previously had its placeholder
+ // swallowed by the URL regex's greedy \S+, leaking raw
+ // "SMARTPANTS_PRESERVED_N" text into the rendered link.
+ const input = `
See https://doi.org/10.1016/j.caeai.2026.100637 for details.
`;
+ const out = smartypants(input);
+ expect(out).not.toContain("SMARTPANTS_PRESERVED");
+ expect(out).toContain(
+ `https://doi.org/10.1016/j.caeai.2026.100637`
+ );
+ });
+
+ test("does NOT leak placeholders when a linked URL is immediately followed by another tag", () => {
+ // Same bug, different adjacency: URL directly abutting a second tag
+ // (e.g. two consecutive auto-linked references with no separating
+ // whitespace) must not bleed a placeholder into either link.
+ const input = `https://a.example/xhttps://b.example/y
`;
+ const out = smartypants(input);
+ expect(out).not.toContain("SMARTPANTS_PRESERVED");
+ expect(out).toBe(input);
+ });
+
test("does NOT convert -- in CLI flags", () => {
// Prose like "try --verbose mode" should not turn -- into em dash
const out = smartypants(`Try --verbose mode.
`);
@@ -157,6 +181,72 @@ describe("render (end-to-end)", () => {
expect(result.html).toContain("\u2014");
});
+ test("wraps a References section for APA hanging-indent styling", () => {
+ const result = render({
+ markdown: `# My Ebook\n\nBody text.\n\n# References\n\nSmith, J. (2020). A paper.\n\nJones, K. (2021). Another paper.\n`,
+ });
+ expect(result.html).toMatch(
+ /[\s\S]*Smith, J\. \(2020\)[\s\S]*Jones, K\. \(2021\)[\s\S]*<\/div>/
+ );
+ // The heading itself stays outside the wrapper.
+ expect(result.html).not.toMatch(/