polytypo
Locale-correct quotes, dashes, ellipses, apostrophes, symbols and no-break spaces —
one portable spec, designed for byte-identical output across runtimes.
Try it live, no install: polytypo.dev
This is the Ruby implementation. The full spec — all locales, all rules, worked examples in
each — lives in polytypo/polytypo. This runtime supports
the text, html and yaml modes fully, and markdown for the commonmark dialect only — mdx
returns POLYTYPO_INVALID_DIALECT (no MDX/JSX parser is available for Ruby; see
Supported dialects).
Install
gem install polytypoOr in a Gemfile:
gem "polytypo"Usage
require "polytypo"
Polytypo.transform(%q{She said, "it's fine" -- but I wasn't sure...}, locale: "en-US")
# => "She said, “it’s fine”—but I wasn’t sure…"Same input, one locale changed — quotes, dash spacing and all follow the target locale, not a single hardcoded style:
Polytypo.transform(%q{Sie sagte: "Alles gut" -- aber ich war mir nicht sicher...}, locale: "de-DE")
# => "Sie sagte: „Alles gut“ – aber ich war mir nicht sicher…"HTML and Markdown are first-class modes, not an afterthought — tags, attributes and fenced code are left alone; only text content is touched:
Polytypo.transform(%q{<a title="test... wait">Wait... she said "go on."</a>}, locale: "en-US", mode: "html")
# => "<a title=\"test... wait\">Wait… she said “go on.”</a>"locale: has no default anywhere and must always be passed explicitly — there is no silent
fallback to English. mode: defaults to "text" if omitted.
Polytypo.transform(input, locale: "fr", mode: "markdown", dialect: "commonmark")yaml mode is the one that asks something of you, and it asks for a reason. YAML is a data
format with prose in some of it, so you name the keys whose values are prose; there is no default
and no guess:
Polytypo.transform("summary: Rates -- all of them...\nrun: git diff -- a--b\n",
locale: "en-US", mode: "yaml", keys: ["summary"])
# => "summary: Rates—all of them…\nrun: git diff -- a--b\n"Nothing in YAML's syntax separates a sentence from a shell script: description holds one and
run holds the other, spelled identically. Quoting, indentation, anchors and a block scalar's
chomping indicator are never decoded and rewritten — the file is located, not re-emitted — so the
trailing newlines of a |+ block come back exactly as you wrote them. An empty keys: array is
legal and processes nothing, and yaml mode needs no parser at all.
In markdown mode frontmatter is skipped whole by default, delimiters included — it is a
machine-read block, and fr would put a narrow no-break space in front of the colon of every
field in it. On a site whose frontmatter carries the headline that leaves the most visible string
on the page untouched, so name the keys you want processed:
Polytypo.transform(
%(---\ntitle: He said "hello" once\nslug: "he-said-hello"\n---\n\nThe body was typeset all along.\n),
locale: "en-US", mode: "markdown", dialect: "commonmark", frontmatter_keys: ["title"]
)
# ---
# title: He said “hello” once
# slug: "he-said-hello"
# ---
#
# The body was typeset all along.Without frontmatter_keys: nothing in the block changes, and a key you do not name never does. The
block is read with the same scan yaml mode uses, so it refuses the same constructs, and
reads a doubled apostrophe in a single-quoted scalar as an opaque unit: in title: 'It''s a test'
the '' is left alone and the text on either side is processed. The block is also processed separately from
the body, which shows up in exactly one place: an unbalanced quotation mark in title cannot pair
with a mark in your first paragraph.
Protecting regions of a text
In text mode, protect: names ranges of the input that are never edited and that the rules read
as opaque content, which is what a template hole is: ${…} in a JavaScript template literal, an
ICU argument, a {{ … }} tag. Ranges are [start, end] pairs of code-point offsets (not bytes and
not UTF-16 units), half-open, ascending and non-overlapping; touching ranges are one run.
Polytypo.transform("${x} - Rules", locale: "en-US", protect: [[0, 4]])
# => "${x}—Rules"
Polytypo.transform("${x} - Rules", locale: "en-GB", protect: [[0, 4]])
# => "${x} – Rules"The protected code points come back exactly as written. Edits before a range shift it, so a second
call over the output needs the ranges at their new offsets; transform does not return them.
nil and [] mean no protection. A malformed range, or a non-empty list in html, markdown or
yaml mode, raises Polytypo::Error with POLYTYPO_INVALID_OPTION. Polytypo.analyze takes the
same option and reports offsets into the input as passed.
require "polytypo" never loads the native commonmarker extension unless you actually call
Polytypo.transform with mode: "markdown" — the require happens lazily, inside the markdown
pipeline only. A single Polytypo.transform module method (not separate
polytypo/text/html/markdown gems) is the idiomatic Ruby shape for this — Ruby's dynamic
require already gets you the same dependency isolation JS/Python's subpath split exists for,
without a separate namespace per mode.
Polytypo.analyze runs the same pipeline and reports what it would do instead of doing it — one
record per edit, each with the rule that made it and code-point offsets into the input you passed
(into the document, in html, markdown and yaml mode, not into a span):
Polytypo.analyze(%q{Wait... "really"?}, locale: "en-US")
# => [#<data Polytypo::Change rule_id="ellipsis", start=4, end=7, before="...", after="…">,
# #<data Polytypo::Change rule_id="quotes", start=8, end=9, before="\"", after="“">, ...]It is a report, not a patch. The list is empty exactly when Polytypo.transform would return the
input unchanged, and every rule_id is a rule that was enabled for that call — but two rules may
touch the same original range (French spaces deletes the space before : and nbsp puts a
no-break one back), so replaying the list is not guaranteed to reproduce the output. Call
Polytypo.transform for the text. Full contract: spec/rules/analyze.md.
Errors
Every error Polytypo.transform raises is a Polytypo::Error carrying one of eight stable
codes — check #code, not the message text, which is English and informative but not part of
the contract:
begin
Polytypo.transform("x", locale: "xx-ZZ")
rescue Polytypo::Error => e
e.code # => "POLYTYPO_UNKNOWN_LOCALE"
endSupported dialects
markdown mode requires a dialect:, exactly as the spec requires (no default, detection is
forbidden). This runtime supports dialect: "commonmark" (CommonMark plus GFM — tables,
strikethrough, task lists, autolink literals). dialect: "mdx" is a real dialect the spec names,
but this runtime has no MDX/JSX parser for it and raises Polytypo::Error with
POLYTYPO_INVALID_DIALECT immediately rather than silently mishandling it — a narrower, honest
conformance claim, not a port defect.
Thread safety
Polytypo.transform is a pure module method with no class-level mutable state: safe to call
concurrently from any number of Threads with no external synchronization.
Licence
MIT. See LICENSE.