@fractallambda/prose-markup
v0.0.1
Published
A tiny compiler for spoken prose: markdown → emphasis IR → per-TTS-target markup, with capability-aware lowering.
Maintainers
Readme
@fractallambda/prose-markup
A tiny compiler for spoken prose: markdown in, TTS-ready markup out.
Dialogue text (e.g. an LLM's reply) carries semantic intent in its markdown —
*this* word is stressed, that break is a beat. Different TTS engines express
that intent in different syntaxes (SSML, espeak-ng markup, bracket tags), and
most express only part of it. This package extracts the intent once and
renders it per target, with capability-aware lowering so no engine is ever
sent syntax it can't interpret.
markdown ──parse──▶ emphasis IR ──render──▶ per-target markup
(frontend) (typed spans) (MarkupTarget impls)import { render, PlainTextTarget } from "@fractallambda/prose-markup";
render("The *gate* is **shut**.", new PlainTextTarget());
// → "The gate is shut."The IR
A deliberately minimal list of typed spans — text, emphasis (italic/bold, nested), and pauses:
type Span =
| { kind: "text"; text: string }
| { kind: "emphasis"; level: 1 | 2; children: Span[] }
| { kind: "pause"; seconds: number };parse(markdown) produces it; render(input, target) compiles it. Frontend
mapping: *em*/**strong** → emphasis; code blocks are dropped, code
spans speak their text; links speak their text, images their alt; paragraph
breaks, headings and list items become pauses + words; GFM strikethrough keeps
its words.
Targets and lowering
A MarkupTarget declares its capabilities and renders the IR:
interface MarkupTarget {
readonly name: string;
supports(feature: "emphasis" | "pause"): boolean;
render(spans: readonly Span[]): string;
}The invariant: render() never emits syntax the target didn't declare —
unsupported features are lowered (emphasis flattens to its words, pauses to
separators). This is what prevents a voice literally reading
"open-bracket emphasis".
Included targets:
| Target | Emits | Notes |
|---|---|---|
| PlainTextTarget | plain text | The lowering floor. Scrubs stray unpaired markers by default (scrubMarkers: false for faithful extraction). |
Planned (one impl each, no pipeline changes): espeak-ng markup, SSML.
Non-diegetic detection
Spoken-dialogue convention: a wholly italic sentence is usually a stage direction ("A pause — the kind that would once have been silence.") meant to be read, not heard. The package provides the mechanism; whether to drop is your application's policy:
import { parse, isWhollyEmphasized } from "@fractallambda/prose-markup";
isWhollyEmphasized(parse("*A pause, then.*")); // true — stage direction
isWhollyEmphasized(parse("*Hmm,* she said.")); // false — inline emphasis
isWhollyEmphasized(parse("**Bold sentence.**")); // false — bold ≠ directionStreaming note
The compiler is pure and synchronous. For token streams, buffer to sentence
boundaries upstream and call render() per sentence — a complete sentence is
valid markdown. Emphasis spans that straddle your chunking (an unpaired *)
degrade safely: the stray marker is scrubbed, the words survive.
Development
just ci # install + typecheck + build + testReleases publish to npm on v* tags via GitHub Actions.
License
MIT
