@agulbra/uts58

中文文档
0.2.6发布于 2026/10/30BSD-2-ClauseTypeScriptES Module近 30 天下载 9640

UTS #58 web-link extraction for JavaScript.

原始使用文档
文档目录8 节
  1. Install
  2. Usage
  3. Email addresses
  4. Choosing the public-suffix check
  5. Suggested test cases and notable behaviour
  6. What's not here
  7. Regenerating the generated tables
  8. License

uts58

A JavaScript implementation of UTS58, the Unicode spec for finding links in running text. Given a chunk of text, it returns the URLs in it along with their UTF-16 offsets. This is a port of the Ruby uts58 gem; the test suite is the same, with very minor differences.

Tested extensively on relevant OSes: CI

Install

npm install @agulbra/uts58

ESM-only; Node 18+ (uses Unicode property escapes and lookbehinds). Starting with 0.2.4 this ought to work on browsers too.

Usage

import { extractUrls, extractUrlsWithIndices } from '@agulbra/uts58';

extractUrlsWithIndices('see https://example.com/ for details');
// => [{ url: 'https://example.com/', indices: [4, 24] }]

extractUrls('see https://example.com/ for details');
// => ['https://example.com/']

The API mirrors twitter-text's Extractor#extractUrlsWithIndices closely; it was written to provide what Mastodon-style consumers need. The two top-level functions above also strip partly overlapping matches via Extractor#removeOverlappingEntities: candidates are sorted by start offset and anything that begins inside an earlier survivor's span is dropped. Length doesn't enter into it; the earlier-starting candidate wins even if a later one is longer.

Import Extractor directly if you'd rather merge with other extractors (mentions, hashtags, …) and resolve overlap across all of them yourself.

Input without a scheme is recognised, and https:// is prepended in the returned url:

extractUrlsWithIndices('blogspot.com is still a thing');
// => [{ url: 'https://blogspot.com', indices: [0, 12] }]

As in twitter-text, you can pass { extractUrlsWithoutProtocol: false } to find only links that start with http:// or https://. The default is true, matching UTS58.

IDNs are decoded to use UTF-8 in the output, for better readability:

extractUrls('xn-----ctdbabcfhu9c2b9l1acccr4c.xn--mgbah1a3hjkrd')[0];
// => 'https://تجربة-القبول-الشامل.موريتانيا'

(Admittedly that output isn't very readable if you can't read Arabic. But the input wasn't readable to anyone, no matter what languages they can read.)

Trailing punctuation, balanced brackets, ports, paths, queries and fragments are handled per the spec. Indices in the output are UTF-16 code unit offsets (the units used by String.prototype.slice, String#length, the DOM, and text editors) so text.slice(start, end) returns the matched substring directly, even across characters outside the BMP. (This is different from the Ruby version. Ruby tends to use codepoint offsets instead, and the Ruby gem follows that convention. example.com/🐪/#camel is a good test of the difference, since the emoji is one codepoint but two UTF-16 units.)

Email addresses

import {
  extractEmailAddresses,
  extractEmailAddressesWithIndices,
  extractEntities,
  extractEntitiesWithIndices,
} from '@agulbra/uts58';

extractEmailAddressesWithIndices('contact info@grå.org today');
// => [{ email: 'info@grå.org', url: 'mailto:info@grå.org', indices: [8, 20] }]

extractEmailAddresses('contact info@grå.org today');
// => ['info@grå.org']

Each result carries both the bare email and a mailto: url, so it drops straight into anything that already renders a url entity. The domain is IDN-decoded the same way as in extractUrls, and a leading mailto: in the input is absorbed into indices per UTS58 5.2.

A plain address overlaps the bare domain that extractUrls would find after the @. extractEntitiesWithIndices runs both extractors, sorts by start offset, and removes overlaps. The email address starts earlier and wins over the domain inside it:

extractEntities('mail arnt@grå.org or see blogspot.com');
// => ['mailto:arnt@grå.org', 'https://blogspot.com']

Choosing the public-suffix check

To decide whether something.example is a plausible link, the extractor checks the host against a public-suffix table. Which table is a bundle-time choice:

importtablegzipped
@agulbra/uts58 (default)Public Suffix List, ICANN section~5 KB
@agulbra/uts58/ianaIANA root-zone TLDs~5 KB
@agulbra/uts58/corenone — you supply the check0 KB

All three expose the same API. The two tables are about the same size and agree on nearly every host. The difference is how strict the check is for the handful of TLDs that only register at the second level. @agulbra/uts58/iana asks only "is the rightmost label a real TLD": enough to tell blogspot.jp from blogspot.exe and to reject typos like example.cmo, but it treats a bare foo.za as plausible. The default @agulbra/uts58 carries the PSL, which knows South Africa registers under co.za / org.za and so rejects a bare foo.za. If that distinction doesn't matter to you, the tables are interchangeable.

Neither reproduces the PSL's wildcard/exception rules: the question here is "could this be a link", not "where is the exact registrable boundary", so a flat membership test is all it does. The PSL table is folded accordingly, that's why the gzip table is only a few bytes bigger than the IANA list.

@agulbra/uts58/core bundles no table at all, and you insert your own check like this::

import { Extractor } from '@agulbra/uts58/core';
import { parse } from 'tldts';

const ex = new Extractor({
  isPlausibleHost: (host) => {
    const p = parse(host);
    return !!p.domain && p.isIcann && p.publicSuffix !== 'invalid';
  },
});
ex.extractUrls('see example.com here');

That route is also how you get exact PSL semantics back, at the cost of a dependency you choose rather than one this package forces on you. (@agulbra/uts58 itself depends only on punycode.)

Suggested test cases and notable behaviour

There are a few sharp edges that you probably should cover in your own tests.

The href and the visible text are not the same string. For see example.com here, the indices span example.com, but url is the longer https://example.com. Use url for the href attribute, slice the original text by indices for the visible content. A test that compares text.slice(start, end) === url will fail on every scheme-less input, and on every IDN where A-labels were decoded.

No autoLink. This package extracts; it doesn't render. There's no equivalent of twitter-text's autoLink, autoLinkUrlsCustom, htmlEscape, etc. — building HTML is the caller's job. This code does not decide on escaping.

No mentions, hashtags, cashtags, replies. UTS58 doesn't define them, so this package doesn't either. If you need them, run twitter-text (or another extractor) for those alongside this one and merge with Extractor#removeOverlappingEntities.

Overlap resolution is start-wins, not longest-wins. This needs a test when you merge entities from multiple extractors. ask [email protected]/02074960909 for details shows why. The raw extractors find both email [email protected] at [4, 21] and url https://example.com/02074960909 at [10, 33]. Start-wins keeps [email protected], which is what a human reader probably considers right, at least one who reads 02074960909 as a phone number. Longest-wins would keep the longer https://example.com/02074960909.

maxLength measures the matched input span. Not the returned URL. IF you pass 12-codepoint cap, the code keeps blogspot.com (12) and drops https://example.com, even though the input span and url happen to be identical there. The asymmetry shows up the other way for example.com (11 input cp, 19 in url), where a cap of 12 lets the code detect a link.

Even though most of the API counts in terms of UTF-16, like the String class, maxLength uses codepoints. The reason is that most of the API is for software developers, but maxLength is for end-users, and codepoints are closer to what end-users see. "☺" and "😀" are both one codepoint long. (This isn't quite perfect: "é" may be either one or two codepoints long and "🇳🇴" is two. Life is hard.)

mailto: is absorbed into indices. Per UTS58 5.2, the input mailto:[email protected] returns an entity whose span covers the whole 23-codepoint run, not just the address. The email field still holds the bare address. If your link-rendering code assumes the span starts at the local-part, mailto inputs will surprise it.

What's not here

  • Link validation. Recognised URLs are not fetched, normalised beyond IDN decoding, or their hostnames checked in the DNS. There is no attempt at checking for possible attacks (Мышκин.рф and Мышкин.рф are both detected, note the Greek kappa in the middle of the prince's name).

Regenerating the generated tables

src/constants.js (the link-termination and bracket tables) is packed from the Ruby reference's constants.rb:

npm run maketables

That reads ../ruby/lib/uts58/constants.rb by default; pass a path to point it elsewhere. constants.rb is itself generated on the Ruby side, where tools/maketables.rb downloads the UTS58 data files (LinkTerm.txt and LinkBracket.txt) straight from unicode.org, so the source of truth is Unicode's published data.

src/tlds-iana.js and src/suffixes-psl.js are the public-suffix tables. With no arguments they're fetched from their canonical sources (IANA, PSL). Pass local paths to regenerate offline:

npm run maketlds
npm run maketlds -- /path/to/tlds-alpha-by-domain.txt /path/to/public_suffix_list.dat

License

BSD-2-Clause. See LICENSE.

FWIW, I wrote this as part of my work at ICANN and will maintain it as part of the same work. (I resolve problems relating to Unicode in domains, email addresses and similar, so more people, more communities, can use the internet in the way they prefer.)

版本与发布记录

@agulbra/uts58 共发布 4 个版本,首次发布于 2026/06/25;过去 12 个月发布了 4 个版本。

版本发布日期距今
0.2.6latest2026/10/309 天前
0.2.52026/10/2414 天前
0.2.42026/10/2018 天前
0.2.32026/06/254 个月前

关于 @agulbra/uts58 的常见问题

@agulbra/uts58 是什么?

UTS #58 web-link extraction for JavaScript.

如何安装 @agulbra/uts58?

在项目目录运行 npm install @agulbra/uts58,或使用 pnpm add / yarn add / bun add。

@agulbra/uts58 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@agulbra/uts58 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@agulbra/uts58 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@agulbra/uts58 可以商用吗?

@agulbra/uts58 使用 BSD-2-Clause 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@agulbra/uts58 的下载量有多少?

根据 npm 官方下载统计,@agulbra/uts58 在 2026/10/05 – 2026/11/03 这 30 天内被下载 9,640 次,日均约 321 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@agulbra/uts58 还在维护吗?

最新版本 0.2.6 发布于 2026/10/30(9 天前);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/06/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。