Skill Tracer
Skills › gstack › make-pdf › smartypants.ts

smartypants.ts

105 lines · make-pdf/src/smartypants.ts at 3f79d81

1/**
2 * Inline typographic transform (smartypants).
3 *
4 * Converts ASCII typography to real Unicode:
5 * "quoted" → "quoted" (U+201C/U+201D)
6 * 'quoted' → 'quoted' (U+2018/U+2019)
7 * don't → don't (apostrophe: U+2019)
8 * -- → — (em dash U+2014)
9 * ... → … (ellipsis U+2026)
10 *
11 * Critical: must NOT touch code, URLs, or HTML attributes. The Codex round
12 * 2 review flagged this specifically — smartypants run over a fenced code
13 * block corrupts the code and tokens inside tag attributes can break
14 * parsing.
15 *
16 * This operates on HTML (marked already produced it) and walks text nodes
17 * only via a lightweight regex that recognizes code/pre/URL zones and
18 * skips them entirely.
19 */
21const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
22const TAG_RE = /<[^>]+>/g;
23// \u0000 is the placeholder sentinel (see PLACEHOLDER below). It must be
24// excluded here: URLs are carved AFTER tags, so a URL sitting flush against
25// a tag (`<a href="...">https://ex.com</a>`) would otherwise let \S+ swallow
26// the placeholder standing in for `</a>` — that placeholder then never
27// restores (restore is a single pass) and the tag is lost.
28const URL_RE = /\bhttps?:\/\/[^\s\u0000]+/g;
30/**
31 * Apply smartypants to an HTML string. Zones that should not be touched:
32 * - <pre>, <code>, <script>, <style> blocks (content unchanged)
33 * - HTML tags themselves (attributes unchanged)
34 * - URLs (http:// and https:// spans unchanged)
35 */
36export function smartypants(html: string): string {
37 // Step 1: split into preserved + transformed zones.
38 // Preserved zones: code/pre/script/style, tags, URLs.
39 // We carve them out with placeholder tokens, transform the rest, and
40 // splice them back.
41 const preserved: string[] = [];
42 const PLACEHOLDER = (i: number) => `\u0000SMARTPANTS_PRESERVED_${i}\u0000`;
44 const carve = (source: string, pattern: RegExp): string => {
45 return source.replace(pattern, (match) => {
46 const idx = preserved.length;
47 preserved.push(match);
48 return PLACEHOLDER(idx);
49 });
50 };
52 let s = html.replace(/\u0000/g, ""); // drop stray input NUL (can't forge a placeholder)
53 s = carve(s, CODE_ZONE_RE);
54 s = carve(s, TAG_RE);
55 s = carve(s, URL_RE);
57 s = transformText(s);
59 // Step 2: restore preserved zones.
60 // Use a function to avoid $-substitution gotchas.
61 s = s.replace(/\u0000SMARTPANTS_PRESERVED_(\d+)\u0000/g, (_, idx) => {
62 return preserved[parseInt(idx, 10)] ?? "";
63 });
65 return s;
68/**
69 * Transform plain text (no HTML, no code, no URLs).
70 *
71 * Order matters:
72 * 1. Triple dots first (so they don't collide with later apostrophes)
73 * 2. Em dashes (two hyphens → em dash)
74 * 3. Apostrophes (contractions + possessives)
75 * 4. Double quotes (open/close pairing)
76 * 5. Single quotes (open/close pairing — after apostrophes)
77 */
78function transformText(text: string): string {
79 let s = text;
81 // Ellipsis: three literal dots (with optional spaces) → …
82 s = s.replace(/\.\s?\.\s?\./g, "\u2026");
84 // Em dash: -- → —. Require space or word-char boundary on both sides so
85 // we don't mangle ARGV-style flags in prose like `--verbose`.
86 s = s.replace(/(\w|\s)--(\w|\s)/g, "$1\u2014$2");
87 // Standalone -- at start/end
88 s = s.replace(/^--\s/gm, "\u2014 ");
89 s = s.replace(/\s--$/gm, " \u2014");
91 // Apostrophes in contractions and possessives.
92 // "don't", "it's", "they're", "Garry's"
93 s = s.replace(/(\w)'(\w)/g, "$1\u2019$2");
95 // Double quotes: open if preceded by whitespace/bol, close if preceded
96 // by word char or punctuation.
97 s = s.replace(/(^|[\s\(\[\{\-\uff1a\uff08\u3010\u300c\u300e\u3008\u300a])"/g, "$1\u201c"); // opening "
98 s = s.replace(/"/g, "\u201d"); // remaining " are closing
100 // Single quotes (after apostrophe pass):
101 s = s.replace(/(^|[\s\(\[\{\-\uff1a\uff08\u3010\u300c\u300e\u3008\u300a])'/g, "$1\u2018"); // opening '
102 s = s.replace(/'/g, "\u2019"); // remaining ' are closing
104 return s;