Skip to content

About

polytypo — normalize typography for Ruby (quotes, dashes, ellipses, no-break spaces)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

52 Commits

Folders and files

Repository files navigation

polytypo

polytypo

Gem version CI License: MIT

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 polytypo

Or 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"
end

Supported 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.

About

polytypo — normalize typography for Ruby (quotes, dashes, ellipses, no-break spaces)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages