@calebduren/typograph
v1.1.0
Published
Conservative English typography for AI-generated text: streamed chat, finished Markdown, and plain strings
Maintainers
Readme
@calebduren/typograph
Conservative English typography for AI-generated text: smart quotes and apostrophes, optional nonbreaking spaces, and optional hanging opening quotes. One engine powers every entry point:
- Streaming chat: a Remark plugin (the default export of
@calebduren/typograph) that refines rendered prose as tokens arrive, while the app keeps the original model text. - Finished Markdown:
typeset()from@calebduren/typograph/markdownreturns web HTML, email HTML, or typeset Markdown for artifacts such as a daily brief. - HTML:
typeset()from@calebduren/typograph/html, orrehypeTypographyfrom the root entry, for prose that is already HTML, such as an email template. - Plain strings:
typesetText()from the root entry for titles, notifications, and other text that is not Markdown. - Opening-quote hanging:
hangingPunctuationfrom@calebduren/typograph/hanging, an optional Rehype helper for the web. - Scripts and CLIs:
typeset()from@calebduren/typograph/statictakes Markdown or HTML in one call. See the/staticentry before using it under a bundler.
It adds no React, chat SDK, network service, or hosting requirement.
English only, by design. Multilingual typography is outside this product's scope. English locale variants share one house style; they do not select regional quotation conventions. Use the plugin only on responses known to be English, or in apostrophes-only mode when the language is unknown.
ESM, Node 22+ for server use. Browser rendering and the local Cloudflare chat transport are checked by the integration fixture. No install scripts or telemetry.
Install
Each entry needs only the packages in its row. The parser packages are peer dependencies, so your project controls their versions.
| Entry | For | Install | Notes |
| -------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| @calebduren/typograph | Remark plugin, rehypeTypography, and typesetText | npm install @calebduren/typograph | No peers. |
| @calebduren/typograph/hanging | Hanging opening quotes in your own Rehype pipeline | npm install @calebduren/typograph | Needs @calebduren/typograph/hanging.css. |
| @calebduren/typograph/markdown | Finished Markdown to web HTML, email HTML, or Markdown | npm install @calebduren/typograph unified remark-parse remark-gfm remark-rehype rehype-stringify | web output with hanging markup needs @calebduren/typograph/hanging.css. Add remark-math to parse math. |
| @calebduren/typograph/html | Trusted HTML input | npm install @calebduren/typograph unified rehype-parse | No Markdown packages. |
| @calebduren/typograph/static | Markdown or HTML input in one call, for Node scripts and CLIs | npm install @calebduren/typograph unified remark-parse remark-gfm remark-math remark-rehype rehype-stringify rehype-parse | Under a bundler, needs all seven peers even when a call never uses them. |
Start with one stable preset
import typography from '@calebduren/typograph';
import { Streamdown, defaultRemarkPlugins } from 'streamdown';
const remarkPlugins = [
...Object.values(defaultRemarkPlugins),
[typography, { locale: 'en-US' }],
] satisfies NonNullable<React.ComponentProps<typeof Streamdown>['remarkPlugins']>;
// Inside the app's assistant text component:
<Streamdown remarkPlugins={remarkPlugins} isAnimating={streaming}>
{part.text}
</Streamdown>;Streamdown's remarkPlugins prop replaces its defaults. Spread defaultRemarkPlugins first, as above, or GFM tables, strikethrough, and autolinks stop rendering.
Supply the known language of the response, not just the interface language. en, en-US, en-GB, and other valid English BCP 47 tags use the same English house style. Regional punctuation conventions are not implemented. Unknown, invalid, missing, or unsupported locales pass through. The plugin does not detect mixed-language passages.
The default changes quote marks and apostrophes; it leaves whitespace unchanged. To enable conservative nonbreaking pairs such as 30 min, Dr. Smith, and Fig. 2, set spacing: true. This uses the credited Typehug engine. Disabling spacing skips its analysis but does not remove it from the bundle. There is no spacing-free entry point in 1.x; the plugin, Typehug included, is about 4 KB gzip.
Only pass assistant text parts through the prose renderer. Render tool results, JSON, citations, and other structured UI using their appropriate components. A generic Remark pipeline can use .use(typography, options) after parsing; pass the original Markdown as the VFile value so escaped punctuation and unfinished syntax can be protected.
When the response language is unknown
Many products have no per-response language signal. In that case, convert apostrophes only:
[typography, { locale: 'en', punctuation: { quotes: false, apostrophes: true } }];L'homme dit "bonjour" et c'est l'été.→L’homme dit "bonjour" et c’est l’été.Er sagt "nein", das ist's.→Er sagt "nein", das ist’s.Bob's brief says "we're done" and '90s pricing.→Bob’s brief says "we’re done" and ’90s pricing.
The typographic apostrophe is the correct character across Latin-script languages, so this preset is safe on prose of unknown language. Quotation marks stay as written, so regional conventions such as « » and „ “ are left alone. This is not language detection. The package remains English only.
To enable quotes, take the locale from a signal about the response: a tenant or user language setting, the language the system prompt tells the model to answer in, or a structured output field the model fills per response.
AI Elements, shadcn, and changing settings
The runnable integration fixture uses the official AI Elements MessageResponse, with a small change to its copied source. It exercises both Vercel AI SDK's HTTP transport and Cloudflare Agents' WebSocket/persistence path. The typography configuration is identical for both.
With the tested AI Elements component and Streamdown 2.6.0, changing only remarkPlugins can be ignored by memo comparisons. In the owned MessageResponse:
- Remove its custom comparator so React compares all props.
- Preserve the existing
streamdownPluginsand create this identity inside the component:
const plugins = useMemo(
() => ({ ...(props.plugins ?? streamdownPlugins) }),
[props.plugins, props.remarkPlugins, props.rehypePlugins],
);
return <Streamdown {...props} plugins={plugins} />;Keep the component's existing styling. Memoize the caller's remarkPlugins array on the locale and rule options, as in TypographyResponse. This updates settings without remounting the message; the browser test checks that the existing paragraph DOM survives. These are version-specific integration details, not a new streaming protocol. Recheck them when upgrading Streamdown or regenerating the component.
A fixed preset requires no finish callbacks, per-message completion tracking, or changing React keys. The default remains conservative even after the reply ends.
Contract
The default export is the Remark plugin, also available as the named export remarkTypography. Its options type is TypographyOptions. The former name, ChatTypographyOptions, remains exported as a deprecated alias for all of 1.x and is removed in 2.0.0. /markdown exports its options type as MarkdownOptions, whose math takes only a MathPlugin, and /html exports HtmlOptions. In /static, MarkdownTypesetOptions.math accepts boolean | MathPlugin. MathPlugin is exported from /static and /markdown.
| Option | Default | Behavior |
| ------------- | ----------- | -------------------------------------------------------------------------------------------------------------- |
| locale | absent | A valid English language tag enables transformations. |
| phase | streaming | complete permits decisions at the final edge. |
| punctuation | true | false disables it; { quotes, apostrophes } controls each independently. |
| spacing | false | true enables units, initials, and abbreviations. An object controls these plus shortWords and lastWords. |
| skip | absent | A predicate preserves a selected Markdown node and its descendants. |
Short-word and paragraph-ending joins are off unless requested. lastWords also requires phase: 'complete'. If opting into completion refinements, track a successful finish for each message, exclude abort/disconnect/error, and decide which finish reasons qualify. A chat status of ready alone is insufficient. The default avoids this lifecycle bookkeeping.
Punctuation context crosses emphasis, link labels, and inline elements such as em, b, and span within a block. Any other raw HTML element, including a custom element such as <citation>, is preserved as a unit, and its content receives no typography. No-break spacing can cross emphasis but does not cross a link, code, math, or skipped subtree boundary. Code, math, raw HTML, link destinations, and syntax are preserved. The one exception is typeset with web or email output for Markdown input, which removes a link or image URL with an unsafe protocol; see Finished Markdown. Escaped quotes are protected when the original source is available. Bare URLs, email addresses, and unfinished backtick spans receive conservative protection. Unfinished inline code protection ends at the next blank line, so later paragraphs still receive typography.
Quote state resets at each block. A quotation that spans paragraphs can leave its final straight closing mark unchanged. An ambiguous inch mark inside an open quotation, such as "Buy a 24" monitor," he said., can be interpreted as the closing quote. Write explicit prime characters (24″) or opt out when literal intent must be preserved. A matching closer can resolve an elision-like opening ('Round the corner.') as a quotation; incomplete streams can revise that decision as the closer arrives. This is a conservative heuristic, not grammatical analysis.
To bound known expensive Typehug recognition, spacing passes through an entire prose run if any token exceeds 256 UTF-16 code units. Punctuation still runs. A run is a contiguous span of prose that may include emphasis; links and protected content delimit it. This fallback deliberately forgoes some optional joins.
The core plugin's edits are length-preserving character substitutions in existing text nodes. The plugin is stateless between transformations; it does not mutate the SDK message, manage transport, throttle chunks, sanitize HTML, or replace the Markdown parser. The /markdown, /html, and /static entries use your installed unified, remark, and rehype packages rather than bundling a parser. Incomplete Markdown can still change interpretation as more text arrives. Dashes, ellipses, primes, hyphenation, automatic language detection, and arbitrary-language typography are outside this package's scope. Running the plugin twice on the same parsed tree leaves it unchanged, and so does running it on text that already contains curly quotes and nonbreaking spaces. Serializing the tree back to Markdown can drop escapes, so a reparsed result is not covered by this guarantee.
Stability
From 1.0.0 the package follows semantic versioning. The public API is the exports, the option names and their defaults, and the guarantees documented here: edits are length-preserving character substitutions in text nodes; running again on the output leaves it unchanged; code, math, URLs, link destinations, raw HTML, and escaped punctuation are protected; and typography applies only when the locale is English.
- A patch release fixes behavior that contradicts this documentation. It does not change output on input that was already handled as documented.
- A minor release adds an opt-in capability or refines a heuristic on input this documentation calls ambiguous. The changelog lists every change to rendered output.
- A major release changes a default, removes or renames an export, or changes an established rule.
Which pairs spacing joins is decided by Typehug and is outside this promise. Spacing is opt-in, @typehug/en is pinned to an exact version, and a Typehug upgrade that changes existing joins ships as a minor release that lists those changes, so a product that needs fixed spacing output pins its own version. Punctuation rules are covered by the promise in full. The limits described above, such as quotations across paragraphs and inch marks inside an open quotation, are documented behavior rather than bugs, so a patch release does not change them.
Plain strings
For AI-generated text that is not Markdown (titles, notifications, email subject lines, summaries stored in JSON), use the synchronous typesetText export. It needs no parser and adds no dependencies.
import { typesetText } from '@calebduren/typograph';
typesetText(`Bob's "weekly" brief takes 30 min`, { locale: 'en', spacing: true });
// → Bob’s “weekly” brief takes 30 min (with a nonbreaking space before “min”)It accepts locale, punctuation, spacing, and phase, with the same meanings as above. phase defaults to complete; pass streaming for a string that is still arriving. Markdown syntax has no meaning here: *, _, #, and list markers are ordinary characters. Blank lines separate blocks, so quote state resets at each one. URLs, email addresses, backtick spans, and tag-like <…> runs keep their straight marks. Output length always equals input length in UTF-16 code units, so offsets computed on the input remain valid.
Finished Markdown: web, email, and Markdown output
For text that is complete before anyone reads it, such as a daily brief written overnight by an agent, use typeset from @calebduren/typograph/markdown. It parses, typesets, and serializes in one call.
npm install @calebduren/typograph unified remark-parse remark-gfm remark-rehype rehype-stringifyimport { typeset } from '@calebduren/typograph/markdown';
const html = await typeset(brief, { target: 'web', locale: 'en', spacing: true });| target | Returns | Hanging markup | Peers needed |
| ---------- | --------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| web | HTML fragment | on by default (hanging: false off) | the five above |
| email | HTML fragment | never; email clients do not render it | the five above |
| markdown | typeset Markdown, same formatting | not applicable | the five above; the entry imports all five, although this target runs only the first three |
target is required. Options otherwise match the plugin (locale, punctuation, spacing, skip), except that phase is always complete. The entry imports its five peers statically, so a bundler resolves those five and no others. If one is missing, the build or the import fails with the bundler's or Node's own error. There is no HTML input here: input: 'html' throws and names @calebduren/typograph/html.
- Syntax: CommonMark plus GFM (tables, strikethrough, autolink literals, footnotes, task lists). Math is off by default because
remark-mathreads single dollars, which are usually currency, as inline math. To parse$…$and$$…$$, installremark-mathand pass the plugin itself asmath, as in the example below.math: truethrows, because this entry does not importremark-math. Math is rendered asremark-rehype's default<code class="language-math">markup, so bring your own TeX renderer. - Raw HTML is dropped from
webandemailoutput, followingremark-rehype's defaults; sanitize and add it yourself if you need it.markdownoutput keeps it as written. - Unsafe URLs are removed from
webandemailoutput, as in the CommonMark reference renderer. Links keephttp,https,irc,ircs,mailto,xmpp, and relative URLs; images keephttp,https, and relative URLs. Any otherhreforsrcattribute, such as ajavascript:ordata:URL, is removed, and the element and its text stay:[x](javascript:alert(1))becomes<p><a>x</a></p>.markdownoutput keeps the URL as written. - Not a sanitizer. With raw HTML dropped, the remaining attributes come from
remark-rehype. A product that needs attribute-level control should still runrehype-sanitizeor its own sanitizer on the output. - Hanging CSS:
weboutput with hanging markup needs@calebduren/typograph/hanging.css.typesetreturns markup only. markdownoutput writes each change back into your original text, so emphasis markers, bullets, line wrapping, and escapes stay as they were. Backslash-escaped quotes stay straight. Quotes written as entities (") stay as entities, althoughweboutput curls them. Text containing a named entity other than& < > " ' keeps its original characters.- Idempotent: typesetting the
markdownoutput again returns it unchanged.
npm install remark-mathimport remarkMath from 'remark-math';
import { typeset } from '@calebduren/typograph/markdown';
const html = await typeset(notes, { target: 'web', locale: 'en', math: remarkMath });If your pipeline already has a Markdown renderer, the simplest option is to typeset the Markdown before it enters your HTML or email template. That covers text the model wrote, but not prose the template adds itself.
If outbound mail is rendered outside JavaScript, such as by a Python template, typeset cannot sit in that pipeline. Typeset a separate copy with target: 'markdown' when the text is generated, and pass that copy to the template. Keep the stored original unchanged, because the same string may also feed SMS or copy actions.
The /static entry
@calebduren/typograph/static is the one entry that can do everything. Its typeset takes Markdown with the options above, or HTML with input: 'html' and the options in HTML input, and its math option accepts either true or the remark-math plugin. It loads its seven peers with dynamic imports, so in Node it needs only the packages a call uses, and it rejects with an error naming any that are missing. That suits Node scripts and CLIs. A bundler that resolves imports at build time, such as Turbopack (the default in Next.js 16) or esbuild, fails on the ones that are not installed, so under such a bundler /static needs all seven peers installed even when a call never uses them: a project that typesets only Markdown still needs rehype-parse and remark-math. In applications, use /markdown or /html.
HTML input
For prose that is already HTML, such as an email template with a brief interpolated into it, use one of two entry points:
typeset(html, { target: 'web' | 'email', locale: 'en' })from@calebduren/typograph/htmlreturns your HTML with only typographic characters changed. Markup, attributes, comments, the doctype, and line endings stay byte for byte. Installunifiedandrehype-parse; the entry imports no Markdown packages. Passhtml: 'document'for a full document; the default parses a fragment. Both targets return the same string. Thetargetoption is kept for symmetry with Markdown input, and both values are stable.rehypeTypographyfrom@calebduren/typographtypesets hast text nodes inside your own rehype pipeline. Pair it withrehypeHangingPunctuation({ locale: 'en', source: 'html' })for hanging quotes on the web.
npm install @calebduren/typograph unified rehype-parseimport { typeset } from '@calebduren/typograph/html';
const email = await typeset(template, {
html: 'document',
target: 'email',
locale: 'en',
});- Trusted HTML only. The input is returned unsanitized, and link and image URLs are not checked.
- What is prose. Text inside
code,kbd,samp,var,pre,script,style,textarea,template,svg,math,head,title,noscript, and ruby annotations is never changed. Neither is anything underdata-typograph="off"or yourskip(node). Text under a non-Englishlang, an emptylang, ortranslate="no"is left alone, but a nestedlang="en"ortranslate="yes"turns it back on. Inline elements such asem,b,span, andfontjoin the surrounding sentence, so quotes pair across them. Links end a no-break spacing run. Every other element, including custom elements, starts a new block, and quote state resets there. Only inlinestyleattributes are read: an inline element styleddisplay: blockcounts as a block. - Escaped quotes. Template engines escape interpolated text, so quotes often arrive as
"or'. Those references are replaced with curly quotes. Other references, including&quot;, are left as written. - Conservative edits. A text node is edited only when every character lines up with the source. Text the parser moves (such as stray text inside a
<table>), or text next to a reference it cannot account for, keeps its original characters. - UTF-8. Edits are written as characters, not entities, so serve or send the result as UTF-8.
- Line wrapping. Words split across source lines (
30\n min) are not joined. - No hanging here.
typesetwith HTML input cannot add hanging punctuation, which needs new markup. It rejectshanging: trueandmath.
Optional opening-quote hanging
Import hangingPunctuation from @calebduren/typograph/hanging and load @calebduren/typograph/hanging.css in your app's stylesheet/bundler entry. Add [hangingPunctuation, { locale: 'en' }] to your Rehype plugins after existing sanitization, retaining the renderer's defaults. See the full integration example.
This independent layout helper hangs an opening straight or curly quote at the start of English, left-aligned paragraphs and headings outside lists, including quotes inside emphasis and links. It uses real text in two small spans, standard inline layout, and a transform based on the quote's own width. No native hanging-punctuation support, DOM measurements, React dependency, or resize observer is required. It is opt-in; importing the core plugin alone does not load the helper or stylesheet.
Ambiguous leading straight apostrophes, including 'em, 'Tis, and '90s, remain inside the reading edge when punctuation conversion is off. Curly opening quotes produced by the punctuation plugin can hang normally.
The isolated quote does not retain the font's pair kerning with the following letter. This can be noticeable with pairs such as “A, especially in large headings; the helper does not measure or compensate for individual font pairs.
Bulleted and numbered lists are skipped entirely, including paragraphs and headings nested inside list items, so quotes cannot crowd their markers. It does not hang punctuation at every wrapped line ending or handle full optical margin alignment. Provide a left gutter and preserve the generated classes/attributes in custom renderers. Opt out of RTL, vertical, centered, and right-aligned text. Code, math, tables, and unknown custom elements pass through. Raw HTML and escaped opening quotes are protected when original source positions are available. The helper supports skip(node) and the same explicit English-locale requirement as the core, but operates on HTML (HAST) nodes. Its span additions are separate from the core's text-only substitutions.
Copy and persistence
For paragraph-ending orphan control, prefer progressive-enhancement CSS text-wrap: pretty on completed assistant prose paragraphs. Keep normal wrapping while streaming; browser support and the exact line breaks vary. This avoids adding characters to copied text and lets layout respond to the current width and font. See the integration guide for a scoped CSS example and completion handling, and Chrome's explanation for browser behavior.
spacing.lastWords remains opt-in and complete-phase only. Use it when a product explicitly wants to bind the final pair in eligible prose runs, accepting that long pairs can wrap awkwardly on narrow screens and nonbreaking spaces are included in displayed-text copying. Neither this rule nor CSS guarantees a specific final line across arbitrary Markdown boundaries and layouts.
Persist and copy the original part.text for a Copy original action. Browser selection of rendered prose includes curly punctuation and any nonbreaking spaces. Code copy should use the renderer's original code content. The fixture verifies raw text, clipboard output, and protected rendered content; full screen-reader and selection-copy evaluation remains separate work.
Typeset only what is displayed, never the stored string that feeds SMS. A single curly quote or nonbreaking space moves a message from the GSM-7 alphabet to UCS-2, which cuts a segment from 160 to 70 characters. As a safeguard, an SMS path can also fold curly marks back to straight before sending.
The runtime dependency is @typehug/en (which uses @typehug/core). @types/mdast and @types/hast supply the public TypeScript definitions and add no runtime code. License attribution is in THIRD_PARTY_NOTICES.md.
Verify
From the repository root:
npm ci
npm run check
npm run test:chat-integration
npm run bench:chatBrowser prerequisites and local startup are in the fixture README. The clean-package check installs the packed artifact into a temporary generic Remark consumer and checks public types without skipLibCheck. It also bundles each entry with esbuild in a clean consumer that has only that entry's peers, and confirms that /static with only the Markdown peers fails to bundle. The verification record lists what ran for each release, the 1.0.0 model-reply review, and what was not verified. The earlier hardening report is kept as history.
