Skip to content

punycoder

1.3.0Package record

Unicode and Punycode Domain Name Processing

2packages depend
2.3Kdownloads / year
97.9%test coverage
13/13flavors OK

Overview

About

High-performance Unicode and Punycode processing for internationalized domain names. The 'puny_encode()' / 'puny_decode()' helpers are a low-level, RFC 3492 compliant Punycode codec for domain labels (the 'xn--' ASCII-Compatible Encoding of RFC 5890/5891); they perform the raw transform plus letter-digit-hyphen checks and do not apply Unicode IDNA normalization. 'host_normalize()' is the Unicode Technical Standard #46 host-normalization entry point, mapping a host name to a canonical lowercase ASCII comparison form (non-transitional profile, pinned default Unicode version, selectable per call from the set the build ships). Aimed at host normalization and data analysis workflows. Used as the Punycode and IDNA engine by the 'pslr' and 'rurl' packages.

Changes in 1.3.0

Breaking changes

  • **The pinned Unicode version moved from 16.0.0 to 17.0.0, and the profile token is now uts46-nontransitional-std3-v2.** A call that names no unicode_version now normalizes against Unicode 17.0.0 data. The change is accept-only in practice: across both vendored IdnaTestV2 corpora and all 8 flag combinations, the new default matches an explicit "17.0.0" in all 102,448 comparisons and differs from "16.0.0" on 3 rows (16.0.0 corpus) and 5 rows (17.0.0 corpus) --- every one of them NA becoming a value, with zero changed values. Nothing that normalized before stops normalizing. The -v1 → -v2 bump is deliberate and is the part to act on. The bare token means "whatever the pin is", so without the bump one string would have denoted 16.0.0 before this release and 17.0.0 after. Anything using the token as a cache or reproducibility key must re-key: a stale key now misses loudly instead of colliding silently. Callers who want the old data can pass unicode_version = "16.0.0", which is still shipped and returns identical results to 1.2.1 --- their token becomes uts46-nontransitional-std3-v2+unicode-16.0.0. pslr detects the move on its own (it compares both normalization_profile and unicode_version, and rebuilds its index on either mismatch), so the effect there is a one-time rebuild on load until it reships, not a stale answer. This release also fixes the shipped-versions policy: punycoder ships the **current and the previous** Unicode version, and a version is deprecated for one release cycle --- announced here, still shipped and selectable --- before it is dropped (ADR-017).
Show all changes in 1.3.0Hide the rest of the changes in 1.3.0
  • The deprecated URL surface --- url_encode(), url_decode(), and parse_url() --- has been **removed**, one release after the .Deprecated() warning cycle introduced in 1.2.0. These were always best-effort host extraction/rewriting, never an RFC 3986 / WHATWG URL parser or canonicalizer. Use the rurl package for URL parsing and canonicalization, or pass the host alone to host_normalize() / puny_encode() / puny_decode() for host-only needs. puny_encode() / puny_decode() continue to reject URL-shaped input with an actionable error pointing at rurl::get_host(). The internal URL parser (punycoder_url.cpp) and the punycoder_parsed_url print method are gone with the surface.
  • is_punycode() and is_idn() now agree on input that is not well-formed UTF-8, and both report FALSE for it. Previously is_punycode() reported TRUE **silently** for such input --- steering callers into puny_decode() for a string every other function in the package refuses to process --- while is_idn() warned and reported FALSE. The split was not deliberate: is_punycode() matches with R's default TRE engine, which matches bytewise, and is_idn() used perl = TRUE, where PCRE validates UTF-8 first. Both now share one gate, so the spurious is_idn() warning is gone too. The predicates remain total and strictly logical --- never NA, never an error --- so if (is_punycode(x)) is always safe; call validUTF8(x) to distinguish "not punycode" from "not well-formed text". Answers for well-formed input, including strings marked latin1, are unchanged.

New features

  • The Unicode table set is now selectable per call. host_normalize(x, unicode_version = ) and normalization_profile_info(unicode_version = ) accept any version this build ships, and a new unicode_versions() reports what that is (currently "16.0.0" and "17.0.0"). NULL, the default, means the pinned version --- which this release moves to 17.0.0, see Breaking changes above; NULL never means "newest", so a later release compiling in another table set will not change results underneath a caller. Selecting another version appends +unicode-<version> to that token, on the same rule as a relaxed flag, so two normalizations that genuinely differ can never mint identical() tokens. Naming a version the build does not ship is an error listing what is available, never a silent fall back to the pin --- a fallback would let a caller record a profile identity describing a normalization that never ran. There is deliberately **no** global option for this: unlike punycoder.strict, which is an error-policy preference, the Unicode version is part of profile identity, and making identity ambient would let the same code mint different reproducibility keys in different sessions (ADR-016).
  • validate_domain() results are now readable at scale. print() opens with a count header (12 domains: 5 valid, 7 invalid (strict = TRUE)), stops after 10 per-domain blocks with a ... and N more footer instead of dumping one block per element, and shows each error's machine-readable code alongside its message. A new summary() method condenses the whole vector into a data frame of error_code / n sorted by count descending, carrying n, n_valid, n_invalid, and strict as attributes, so failures across a large batch can be tallied programmatically.
  • host_normalise() and normalisation_profile_info() are now exported as British-spelling aliases of host_normalize() and normalization_profile_info(). Each alias is the same function as its US-spelled primary and is documented on the primary's help page; the US spellings stay the canonical names (SEOR-qwomlgjd).

Bug fixes

  • The tracker links a reader clicks --- in codemeta.json, SECURITY.md, .bestpractices.json and the intro vignette --- now point at the GitLab tracker's work_items path. The path they used before returns 404 across gitlab.com since GitLab moved issues platform-wide, so the address shipped in those files no longer resolved for an anonymous reader. DESCRIPTION's BugReports: is deliberately **not** repointed and keeps naming https://gitlab.com/bart-turczynski/punycoder/-/issues (see the next entry): CRAN's incoming check accepts a gitlab.com BugReports: only when the path ends in /-/issues, and the NOTE it emits otherwise archived a sibling package at the pretest. That check reads DESCRIPTION alone, so the two audiences are served independently; the URL check's 404 on the /-/issues address is expected and is explained in cran-comments.md (PUNY-uixamcbp, PUNY-fxwavtvu).
  • BugReports: points at the GitLab tracker's /-/issues path, the form CRAN's incoming check requires (a browser is redirected to /-/work_items) (PUNY-yomemdzz).
  • puny_decode() now rejects malformed A-label input consistently across backends. The in-tree fallback decoder previously accepted non letter-digit-hyphen (LDH) characters in a label's literal section (e.g. xn--(o)-...) and echoed an empty Bootstring payload (xn---) back unchanged, where libidn2 rejected both. It now applies the documented LDH check to decode input and reports these as an error under strict = TRUE / NA under strict = FALSE, so the two backends agree on every input.
  • puny_encode(), puny_decode(), and validate_domain() now transcode character input to UTF-8 before dispatching to native code, and Unicode output is explicitly marked as UTF-8. Previously only host_normalize() transcoded, so a Latin-1-marked (or non-UTF-8 native) host reached the UTF-8 decoder as ill-formed bytes and the same string produced different answers depending on how R happened to mark it (#67).

Performance

  • host_normalize() no longer normalizes input that is already normalized --- **1.22x** on all-ASCII hosts, 1.21x at 20% non-ASCII, 1.22x at 50%, and 1.19x when every host is non-ASCII. Unicode NFC ran unconditionally, so a host that was already in NFC --- which nearly all real host text is --- paid for a full decompose, canonical-reorder and recompose pass to produce a byte-identical copy of itself. An all-ASCII host was making 295,792 pointless composition attempts per 20,000 hosts. nfc() now applies the UAX #15 quick check (NFC_Quick_Check plus a combining-class ordering test) and returns its input untouched when it passes; below U+0300 that costs one comparison per character and no table read at all. This is the largest single normalizer win in this release, and unlike the table work below it helps ASCII input most, because that input was paying the most for nothing. Input that genuinely needs normalizing does not regress: on a corpus transformed to NFD, so the check always fails, throughput is still 1.13x/1.07x/1.01x, because the check abandons at the first character that fails rather than scanning to the end. NFC_Quick_Check=Maybe is treated as a real third value and falls through to the full pipeline. Output is unchanged on every input in the UTS #46 conformance corpus under all supported flag combinations, and nfc() was verified against the pipeline it skips over all 19,965 rows of the official UAX #15 normalization corpus, every single code point, and 280 million code-point pairs.
  • host_normalize() is faster again on non-ASCII hosts --- **1.05x** when every host is non-ASCII, 1.04x at 50%, 1.03x at 20%, unchanged on all-ASCII input --- with the last Unicode table still on a binary search converted. Canonical composition is keyed on a *pair* of code points, so it could not become a trie the way the four accessors below did; it is now a trie on the **first** element with a short scan over that starter's partners behind it. Keying on the first element is measured rather than assumed: the 961 pairs hold 391 distinct first elements but only 72 distinct second ones, so the runs to scan are a median of 1 long instead of 3, and it is the first element that answers the common *reject* --- a starter that never composes, such as any CJK ideograph, used to walk the whole 961-pair search only to conclude 0. The accessor is 6.1x faster on that reject path and 2.5x on a successful composition, and its share of self time falls from 5.3% to 1.9%. The end-to-end figure is smaller than either of those because most attempted compositions never reach the table at all: 87% of them are answered by the bound on the second element, since that element is just the next character after a starter and ordinary text is not combining marks. That bound is now exposed inline from the generated header and applied *before* the call rather than inside it, which is what makes the ASCII-heavy end of the range faster rather than merely unchanged. Output is unchanged on every input in the UTS #46 conformance corpus under all supported flag combinations.
  • host_normalize() is faster on non-ASCII hosts --- **1.25x** when every host is non-ASCII, 1.16x at 50%, 1.08x at 20%, and unchanged on all-ASCII input --- and the installed shared object is **64 KB smaller**. The four Unicode accessors that profiled hot (combining class, UTS #46 mapping, canonical decomposition, Bidi_Class) no longer binary-search: each is a two-stage trie, two loads and no branches, O(1) for every code point rather than only for ASCII. The previous release made ASCII free but left every non-ASCII code point paying 11-14 branch-mispredicting probes --- and the worst case was not an exotic character but a common one the table does not list, since every CJK ideograph walked the whole combining-class table only to conclude 0. Table lookups fell from 33% of self time to 17% on an all-non-ASCII corpus. Storing identical blocks once, and keying the UTS #46 trie on distinct (status, mapping) values rather than on ranges, is what makes the structure smaller than the range tables it replaced. Every array, element type and block size is derived in data-raw/generate_unicode_tables.R, which now also verifies each trie against its source ranges for all 1,114,112 code points before emitting it. Output is unchanged on every input in the UTS #46 conformance corpus under all supported flag combinations.
  • host_normalize() is substantially faster again --- **1.75x** on all-ASCII hosts, 1.36x on a mixed corpus at 20% non-ASCII, and 1.12x even when every host is non-ASCII. Lookups into the vendored Unicode tables were 56% of process time: seven near-identical binary searches, ~14 branch-mispredicting probes per code point for the 9,185-range UTS #46 table alone. They now share one search, and each answers ASCII --- the dominant input --- without searching at all, via a 128-entry direct index for the two tables that cover ASCII and a bounds test for the rest. Every boundary is derived in data-raw/generate_unicode_tables.R from the UCD data itself, so it cannot drift from the table it guards and a Unicode version bump moves it automatically. Output is unchanged on every input in the UTS #46 conformance corpus under all supported flag combinations.
  • host_normalize() is roughly 25-30% faster. Label validation no longer re-runs Unicode NFC on labels taken straight from the label split: NFC is applied to the whole host before splitting, and U+002E is a safe break point (it appears in no canonical decomposition and no composition pair), so those labels are already in NFC by construction. Punycode-decoded A-label payloads, which never go through that pass, are still checked. Output is unchanged on every input in the UTS #46 conformance corpus, under all supported flag combinations.

Internal

  • The documentation site is published at <https://bart-turczynski.gitlab.io/punycoder/>, the GitLab Pages namespace path every package in the family uses. DESCRIPTION's URL:, _pkgdown.yml and .bestpractices.json name it; the project's unique-domain Pages address no longer resolves (PUNY-rsxtbbln, SEOR-hcmtspmv).
  • The agent instructions no longer import fp's generated FP_CLAUDE.md, and point at the house agent-workflow and fp skills for the git workflow. The tracker snapshot is refreshed before a copy leaves the machine, not on every issue closure (SEOR-ipwcbcov, PUNY-lezxwoov).
  • **CI folds the formerly-separate lint, readme, and news-version jobs into one gates job.** Each was cheap on its own -- well under a minute of real checking combined -- but every GitLab job pays a roughly constant ~2 minute tax for runner pickup and setup regardless of how little script it runs. scripts/gates.R runs all three checks unconditionally, accumulates a PASS/FAIL for each, and prints one summary before exiting nonzero if any failed, so folding them costs no diagnostic signal. citation-version was a candidate but stayed its own job: it needs python3, which the lint/readme/news-version jobs' shared rocker/r-ver image does not have (SEOR-pgammbgo).
  • **full-check and sanitizers can now run on a weekly schedule instead of only on a release tag or by hand.** osv-audit and security-audit, which already ran on any pipeline schedule, now additionally require a SCHEDULE_KIND selector so a second schedule (for the release-shaped checks) cannot silently also fire the dependency audits. Adding the actual pipeline schedules is a separate, manual step (SEOR-ihmntqzm).
  • The readme check, now inside the gates job, ignores blank-line-only differences in README.md. pandoc versions disagree about the blank line after the badges marker, so a README rendered with a newer local pandoc passed the pre-push gate and then failed CI, as it did in seor (SEOR-kaqtnovh).
  • The pkgdown site no longer publishes the repository's agent instruction files. pkgdown renders every top-level .md, so AGENTS.html and CLAUDE.html were being served next to the function reference; the pages job now strips them with a glob before build_site (SEOR-pibdjanz).
  • **CI now creates exactly one pipeline per merge, on main, instead of three.** The workflow: block used to allow both merge-request and branch pipelines (suppressing the branch one only when an MR was already open). CI runs on a self-hosted Docker runner on one Mac, not on GitLab.com's shared runners, and its few job slots are shared by six of the fleet's repositories. The branch and MR pipelines test the same tree as the eventual main pipeline an

The NEWS section is longer than shown here.

From NEWS.md in the release

Install

Health

CRAN checks
13OK
Slowest check: 4.1 min · r-devel-windows-x86_64
Status changes
  • OK13 OK2026-08-05
  • NOTE12 OK, 1 NOTE2026-08-01
  • OK7 OK2026-06-12
Advisories

none known

Documentation

In release 1.3.0
READMEYes · 1,824 wordsVignettesYes · 1 · dynamicNEWSYes · 100% structured · mentions 1.3.0Citation fileYes · computed when R runs itWebsiteYes
Help pages
12 (1 internal)includes a package overview page
Examples that run
100%12 run in a plain check
Exported functions with examples
100%
Documented parameters
75%
Return-value docs
100%
Help pages citing references
0%

Downloads

2.3K
CRAN downloads in the past year
Rank #4,931 · ~6/day · ~196/mo
Daily download trend is not available in this view yet.
1K30 days
1.7K90 days
2.3K1 year
Compare downloads with other packages →
Also on105 r2u3 autocran

Repository

0Stars
0Forks
0Open issues
0Open PRs
1Releases
381Commits
4Commit identities
domain-namesdomainshostnameidnidnainternationalized-domain-namespunycodepunycode-decode
License file reads as MIT · 381 commits · Last activity 2026-10-19 · Default branch at 1.3.0.9000, ahead of CRAN 1.3.0 (checked 2026-10-21)
Responsiveness
How these are sampled
PRs merged (all time)
100%
Median days to close, 50 most recently updated closed issues
GitHub only
Median days to close, 50 most recently updated closed PRs
GitHub only
Earlier repositories
PracticesBeta

18 development-tooling and community-health practices detected across 9 families in the upstream repository

Checks run against gitlab.com/bart-turczynski/punycoder on 2026-10-21.

Continuous integration (1)
GitLab CI
Governance and community (5)
Code of conductContributing guideIssue templatesPR templateSecurity policy
Docs source (3)
README.Rmdpkgdown

GitHub Pages site

Coverage (1)
.covrignore
Show all practicesHide 5 practices
Reproducibility and dev environment (1)
data-raw/
CRAN release process (2)
cran-comments.mdCRAN-SUBMISSION
Research and citation (3)
CITATION.cffcodemeta.jsonZenodo
Automation and maintenance (1)
pre-commit
Lint, format, editor (1)
lintr
How this is detected·Detection ruleset v4 (2026-10-20)
AI coding toolsBeta

This repository shows signs of AI coding tools.

  • Claude Codefirst seen 2026-03-13
    Seen in the repository: CLAUDE.md
    Named in .Rbuildignore: CLAUDE.md
    Named in .gitignore: .claude
    Commits: at least 247 crediting it in the message
Shared conventions any agent may read, so they name no particular tool.
Seen in the repository: AGENTS.md
Models named in its commits
  • Claude Opus 5.597 commits · Sep to Oct 2026
  • Claude Opus 4.864 commits · Jun to Jul 2026
  • Claude Opus 561 commits · Jul to Sep 2026
  • Claude Opus 4.713 commits · May 2026
  • Claude Sonnet 4.67 commits · Jun 2026

2 more models not listed.

Counts are commits, not lines of code. Commits by a tool's accounts count once across all of its accounts. Commits crediting it are the largest count among its credit lines, a minimum.How this is detected·Detection ruleset v4 (updated 2026-09-27)

Dependencies

Declared dependencies
7 external dependencies (excludes base and recommended)

Installing punycoder adds 1 package (1 with compiled code, 1.8 MB of source tarballs). Heaviest: Rcpp 1.8 MB.

Depends (1)
R >= 3.5.0
Imports (1)
LinkingTo (1)
Enhances (0)
none
Reverse dependencies
2direct
1indirect
Imports 2
Depends, Imports or LinkingTo, most downloaded first

People & History

People (1)
Author, Maintainer since 1.0.0 (2026)
Releases (4)

Pick two releases to compare their code metrics.

  • 1.3.0Latest
    record
    2026-09-30 · current release · diff ↗ ·
    removed 3 exports
    parse_url, url_decode, url_encode
    added 3 exports
    host_normalise, normalisation_profile_info, unicode_versions
  • 1.2.1
    record
    2026-07-19 · diff ↗
  • 1.1.0
    record
    2026-06-16 · diff ↗ ·
    added 2 exports
    host_normalize, normalization_profile_info
  • 1.0.0
    record
    2026-06-11
  • R
    R 4.6.0 released · 2026-04-24

Package metadata

First published
2026-06-11
Total releases
4 / 1 yrs
License
MIT + file LICENSE OSI
Minimum R
≥ 3.5.0
Download size
517 KB

Cite

Cite this package

Run in R for the authors' preferred citation:

citation("punycoder")

The package provides a citation file that builds its entry when R runs it. Run citation() to see it.

The repository also keeps a CITATION.cff.

Cite the R Observatory

For a number measured here: a download total, a coverage figure, an archival date.

APA

Balamuta, J. J. (2026). R Observatory: Metrics for punycoder version 1.3.0 [Data set]. HJJB, LLC. Data release v2026-10-23. https://doi.org/10.5281/zenodo.21843040

From data release v2026-10-23, which the citation names so these numbers can be found later. More on citing and the projects behind them.

Report a problem with this page →

Privacy choices

These apply to this browser and are stored on this device only. Nothing about your choice is sent to us.

Read the privacy policy