pangu.js

GitHub

Opinionated paranoid text spacing in JavaScript

RAW Doc

Agents/Domain

Domain Docs

How the engineering skills should consume this repo's domain documentation when exploring the codebase.

Before exploring, read these

- CONTEXT.md at the repo root, or
- CONTEXT-MAP.md at the repo root if it exists — it points at one CONTEXT.md per context. Read each one relevant to the topic.
- docs/adr/ — read ADRs that touch the area you're about to work in. In multi-context repos, also check src/<context>/docs/adr/ for context-scoped decisions.

If any of these files don't exist, proceed silently. Don't flag their absence; don't suggest creating them upfront. The /domain-modeling skill (reached via /grill-with-docs and /improve-codebase-architecture) creates them lazily when terms or decisions actually get resolved.

File structure

Single-context repo (most repos):

text
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/

Multi-context repo (presence of CONTEXT-MAP.md at the root):

text
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/

Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in CONTEXT.md. Don't drift to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for /domain-modeling).

Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:

_Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

---

Agents/Issue Tracker

Issue tracker: GitHub

Issues and PRDs for this repo live as GitHub issues. Use the gh CLI for all operations.

Conventions

- Create an issue: gh issue create --title "..." --body "...". Use a heredoc for multi-line bodies.
- Read an issue: gh issue view <number> --comments, filtering comments by jq and also fetching labels.
- List issues: gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]' with appropriate --label and --state filters.
- Comment on an issue: gh issue comment <number> --body "..."
- Apply / remove labels: gh issue edit <number> --add-label "..." / --remove-label "..."
- Close: gh issue close <number> --comment "..."

Infer the repo from git remote -vgh does this automatically when run inside a clone.

Pull requests as a triage surface

PRs as a request surface: no. _(Set to yes if this repo treats external PRs as feature requests; /triage reads this flag.)_

When set to yes, PRs run through the same labels and states as issues, using the gh pr equivalents:

- Read a PR: gh pr view <number> --comments and gh pr diff <number> for the diff.
- List external PRs for triage: gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments then keep only authorAssociation of CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, or NONE (drop OWNER/MEMBER/COLLABORATOR).
- Comment / label / close: gh pr comment, gh pr edit --add-label/--remove-label, gh pr close.

GitHub shares one number space across issues and PRs, so a bare #42 may be either — resolve with gh pr view 42 and fall back to gh issue view 42.

When a skill says "publish to the issue tracker"

Create a GitHub issue.

When a skill says "fetch the relevant ticket"

Run gh issue view <number> --comments.

Wayfinding operations

Used by /wayfinder. The map is a single issue with child issues as tickets.

- Map: a single issue labelled wayfinder:map, holding the Notes / Decisions-so-far / Fog body. gh issue create --label wayfinder:map.
- Child ticket: an issue linked to the map as a GitHub sub-issue (gh api on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put Part of #<map> at the top of the child body. Labels: wayfinder:<type> (research/prototype/grilling/task). Once claimed, the ticket is assigned to the driving dev.
- Blocking: GitHub's native issue dependencies — the canonical, UI-visible representation. Add an edge with gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>, where <blocker-db-id> is the blocker's numeric database id (gh api repos/<owner>/<repo>/issues/<n> --jq .id, _not_ the #number or node_id). GitHub reports issue_dependencies_summary.blocked_by (open blockers only — the live gate). Where dependencies aren't available, fall back to a Blocked by: #<n>, #<n> line at the top of the child body. A ticket is unblocked when every blocker is closed.
- Frontier query: list the map's open children (gh issue list --state open, scoped to the map's sub-issues / task list), drop any with an open blocker (issue_dependencies_summary.blocked_by > 0, or an open issue in the Blocked by line) or an assignee; first in map order wins.
- Claim: gh issue edit <n> --add-assignee @me — the session's first write.
- Resolve: gh issue comment <n> --body "<answer>", then gh issue close <n>, then append a context pointer (gist + link) to the map's Decisions-so-far.

---

Agents/Triage Labels

Triage Labels

The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.

| Canonical role | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| needs-triage | needs-triage | Maintainer needs to evaluate this issue |
| needs-info | needs-info | Waiting on reporter for more information |
| ready-for-agent | ready-for-agent | Fully specified, ready for an AFK agent |
| ready-for-human | ready-for-human | Requires human implementation |
| wontfix | wontfix | Will not be actioned |

When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.

---

Adr/0001 Vitest Imports Dist

Vitest suites import built artifacts from dist/, not src/

Unit tests exist to verify what users actually install, so every vitest file for a shipped surface (shared, node) imports from dist/, and the browser bundle is exercised through Playwright loading dist/browser/pangu.umd.js. This also makes every test run an implicit build verification, which has caught real packaging regressions (see 3d67d8a, 8dee05b). The cost is accepted: test runs are gated behind a full build, and watch-mode on source is not possible.

Consequences

- Do not "fix" test imports to point at src/ for speed. The slow loop is a deliberate trade.
- Bundle-internal modules that users cannot import from dist/ (internal seams, e.g. the boundary spacing module) are the exception: their vitest files import from src/ directly rather than exporting internals just to make them reachable from the bundle.

---

Adr/0002 Hyphen Between Half Width Is Word Connector

Hyphens between half-width characters are word connectors, never operators

Superseded by ADR 0003, which generalizes this rule to every symbol.

A - between two half-width characters is ambiguous: minus (operator) or hyphen/dash (connector). The algorithm previously treated letter-letter and digit-letter hyphens as operators whenever the text contained CJK anywhere (得到一個A-B的結果 became 得到一個 A - B 的結果), which mangled hyphenated passport names (WANG,HSIAO-MING became HSIAO - MING), acronym pairs (USB-C, X-RAY), and codes (5-A). Since the hyphen/dash reading dominates real-world text, the connector reading wins: ANS_HYPHEN_ANS_NOT_COMPOUND was deleted, and a hyphen acts as an operator only in direct contact with CJK (前面-後面, 陳上進-Vinta). This makes - contact-gated like & (S&P stays intact while 陳上進&Vinta gets spaces), whereas = + * < > remain text-gated because they have no competing word-joining reading.

Alternatives rejected:

- Keep single-letter pairs (A-B) as operators via boundary guards: preserves the minus reading but still splits X-RAY and USB-C, and the boundary conditions are hard to explain.
- Contact-gate all operators uniformly: A+B between CJK is usually genuine arithmetic, and the churn to published output would be far larger.

Consequences

- A-B meaning "A minus B" in CJK prose stays unspaced. Accepted cost; authors who mean subtraction can write A - B themselves, and already-spaced text is preserved.
- Digit-letter codes (5-A) stay intact. The old 5 - A operator output had no test coverage.
- The glossary terms for this model were Symbol handling and Hyphen reading in CONTEXT.md (contact-gated vs text-gated). Hyphen reading has since been folded into Symbol handling by ADR 0003.

---

Adr/0003 Symbols Between Half Width Are Tokens

Symbols between half-width characters are tokens, never operators

ADR 0002 made - contact-gated while = + < > stayed text-gated, meaning they got spaces between half-width characters whenever the text contained CJK anywhere. A usage inventory of real-world symbol readings showed that split was wrong twice over. First, the dominant readings between half-width characters are token-like across the board: query strings (foo=bar&baz=1), inline math typed tight (5+5, A<B), comparisons, and codes. Second, text-gated spacing never actually worked. Compound-word and joiner-token placeholders shield neighboring operators from the between-half-width rules, so mixed formulas came out as arbitrary mixes like X 5+Y/10 = 123 depending on which substring happened to be protected first.

The decision generalizes ADR 0002's hyphen rule to every symbol:

1. A symbol with half-width characters on both sides binds them into one token, never split, spaced from adjacent CJK as a unit (A+B, 5+5, A*B, a=1, A<B, A-B, A/B, S&P).
2. A symbol in direct contact with CJK reads as an operator and gets spaces (你 + 我, 前面 - 後面, 溫度 > 30, 公里 / 小時).
3. Affix readings override the operator reading at a CJK boundary: + and - attach to following digits as signs (打 +886, 氣溫是 -5 度), - attaches to a following lowercase flag (參數要加 -m 的旗標), + attaches to a preceding half-width run as a suffix (Disney+ 上架, 有 100+ 的選擇), and single-letter grades keep attaching (成績是 A+ 的等級). A capitalized word after a hyphen keeps the operator reading (陳上進 - Vinta).
4. Pattern preservation and slash reading are unchanged (C++, *.log, =>, ->, file paths, dates).

Alternatives rejected:

- Keep text-gating and fix the placeholder shielding so spaced math works reliably: more code for an output nobody types by hand, and formulas containing - or / would stay half-tight forever.
- Exempt only = to fix query strings: leaves a four-symbol formula with three typographic treatments.
- Attach to a preceding digit as a rating (這是 5 的飯店): too rare to earn a rule, the operator reading is fine.

Consequences

- Replaces ADR 0002. The hyphen outcome survives as a special case of rule 1.
- Inline math in CJK prose stays tight. Authors who want 5 + 5 write the spaces themselves, and already-spaced text is never collapsed.
- Sign attachment trades away year ranges and CJK-adjacent arithmetic: 2016年-2018年 renders with -2018 attached, and 庫存-2件 reads as a signed delta (庫存 -2 件). Accepted cost, though the year range reading stays open as a FIXME.
- The plus suffix trades away the word-plus-CJK operator reading: Vinta+陳上進 renders as Vinta+ 陳上進. Accepted cost, brand names (Disney+, Apple TV+) and quantity markers (18+, 100+) are far more common in real text.
- "No CJK contact, no change" becomes symbol-level for every symbol: CJK elsewhere in the text never licenses spacing between half-width characters.
- The per-symbol test files pin the model (the + * = < > files follow the ampersand operator/token template). Three readings stay open as FIXMEs: CJK brand suffixes (公視+上架) versus the CJK operator reading (前面+後面, structurally identical so a lexicon would be needed), tags mentioned in prose (寫一個<div>的標籤) versus real markup (文字<br>換行), and year ranges (2016年-2018年 as 2016 年 - 2018 年).

---

Adr/0004 Pipe Reads Per Line On Cjk Contact

A pipe in CJK contact turns every pipe on its line into a spaced separator

Pipes never got spaces under the old separator model, regardless of context. That left concatenated page titles unreadable (支援的 Apple TV 型號|Disney+ 幫助中心|TW), the exact shape the Chrome extension meets in every <title> it processes, and credit lines like 作詞|林夕 stayed cramped. A usage inventory showed the tight readings worth protecting are code and data shapes typed between half-width characters (ps aux|grep node, string|number, (cat|dog), a||b), which never put a pipe in direct contact with CJK.

The decision extends ADR 0003's model to | with a per-line reading, following the slash precedent:

1. A pipe in direct contact with CJK reads as a separator, and it flips every pipe on its line: a mixed list like Mollie|Vinta|貓咪 spaces all its pipes (Mollie | Vinta | 貓咪), not just the one touching CJK.
2. A line whose pipes touch no CJK keeps them tight as joiner tokens (條件是 x|y 的情況, 得到一個 A||B 的結果), even when CJK appears elsewhere on the line.
3. Decided per line, never across lines.

Alternatives rejected:

- Per-pipe contact instead of per-line: leaves mixed separator lists half-spaced (Mollie|Vinta | 貓咪), but the pipes on such a line are one list and should read uniformly.
- Gating on whether the line already contains a space: fails the same mixed lists, which are typed without spaces, and reintroduces the action-at-a-distance gating that ADR 0003 removed.

Consequences

- The "separators never get spaces" rule in CONTEXT.md now covers only _. | gets its own pipe reading entry.
- Markdown table rows and wiki links that put CJK against a pipe get spaced ([[頁面|顯示文字]] breaks). Raw markup piped through the text engine was already a known casualty class, see the tag-in-prose FIXME in ADR 0003. Accepted cost.
- Only a pipe with content on both sides is spaced, so line-edge pipes such as the outer delimiters of a markdown table row stay untouched.

---

Adr/0005 Bare Unpaired Tags Read As Mentions

A bare unpaired non-void tag reads as a tag mention, not markup

ADR 0003 left tags in prose open as a FIXME: 寫一個<div>的標籤 wants spacing while 文字<br>換行 must stay untouched. The engine protected every tag-shaped match behind a placeholder invisible to all spacing rules, so a tag mentioned in prose stayed cramped against CJK (在這裡插入一個<div>標籤), and generic type parameters that happen to be tag-shaped (型別是List<String>的容器) were cramped the same way. A usage inventory showed the two populations differ in shape, not just intent: real markup passed through the text engine comes paired (<p>文字</p>), carries attributes (<a href="#">), or is a void element that renders on its own (<br>, <hr />). A tag mentioned in prose is a bare name in one pair of angle brackets with no counterpart.

The decision splits tag-shaped matches into two readings:

1. A match is a tag mention when all three hold: it is a bare tag with no attributes (a trailing self-closing slash still counts as bare), its name is not an HTML void element, and no closing tag with the same name appears anywhere in the text (case-insensitive). A mention reads as one unit, never split internally, spaced at direct CJK contact on either side (在這裡插入一個 <div> 標籤, 型別是 List<String> 的容器, 這裡放 <Spinner /> 元件).
2. Every other tag-shaped match is markup and keeps the existing protection: paired tags (<p>用<code>標記程式碼</p> spaces only the unpaired <code>), void elements (文字<br>換行, 文字<br />換行), and tags with attributes (<button disabled>送出表單</button>). Attribute values are still spaced.

Alternatives rejected:

- Treating every CJK-flanked tag as a mention: breaks inline markup like <div>測試<span>內容</span>結束</div>, where <span> sits between CJK but is paired markup.
- A tag-name lexicon (div/span/code read as mentions, br/hr as markup): generic type parameters (<String>, <u8>, <iostream>) are not HTML names at all, and any fixed list misreads custom elements in both directions. The void-element list is the only lexicon worth keeping because it is closed by spec.
- Pairing decided per line like slash and pipe reading: markup routinely opens and closes across lines, and the browser layer feeds the engine per text node anyway, so whole-text pairing is both safer and effectively local.

Consequences

- Closes the tag-in-prose FIXME from ADR 0003. Its other two readings (CJK brand suffixes, year ranges) stay open.
- Between half-width characters a mention degenerates to joiner-token behavior (條件是 a<b>c 的情況), consistent with ADR 0003, because a mention only gets spaces at direct CJK contact.
- A markup fragment whose closing tag lives in a different chunk reads as a mention and gets spaced (<div>中文 alone becomes <div> 中文). Raw markup piped through the text engine in pieces was already a known casualty class, see ADR 0004. Accepted cost.
- A lone closing tag in prose (記得加上</div>結尾) keeps the markup reading and stays cramped. Too rare to earn a rule, and closing-tag syntax is a strong markup signal.
- Self-closing syntax by itself is not a markup signal: a bare JSX-style tag reads as a mention (這裡放 <Spinner /> 元件), and only the void-element list keeps <br /> and <hr /> on the markup reading.

---

Adr/0006 Protected Words And Per Line Plus Reading

A protected word list pins CJK brand suffixes, and a plus in contact flips its line's unsettled pluses

Partially superseded by ADR 0013, which removes the protected word list and its machinery. Plus reading survives, minus its protected-word contact edge: a plus now flips its line's unsettled pluses on direct CJK contact only, so read every "or a protected word" below as struck.

ADR 0003 left CJK brand suffixes open as a FIXME: 公視+上架 wants the suffix reading (公視+ 上架) while 前面+後面 wants the operator reading, and the two are structurally identical, so no shape rule can split them — it predicted a lexicon would be needed. Real-world bundle plans sharpened the problem: HiNet光世代+MOD影劇館+(300M/300M) chains products with pluses where most read as separators but one belongs to the brand 影劇館+, and the affix shape rule misread MOD+影劇館+ as a MOD+ suffix.

The decision adds two mechanisms on top of the affix shape rule:

1. A protected word list of literal strings, exactly 公視+ and 影劇館+. A protected word is never modified inside, is spaced from its neighbors as one unit, and a symbol in direct contact with it reads as an operator, never as an affix (MOD+影劇館+ reads MOD + 影劇館+, not a MOD+ suffix). Protection runs before the affix readings. Open-class shapes stay out of the list: Disney+, Apple TV+, and 100+ keep the affix shape rule, C++ keeps pattern preservation.
2. Plus reading, a sibling of pipe reading: a plus in direct contact with CJK or a protected word makes every unsettled plus on the line a separator with spaces on both sides. A plus is settled when it is already space-adjacent, attached by an affix reading, or inside a preserved pattern. A line with no such contact keeps its pluses tight as joiner tokens (A+B, 5+5). Decided per line, never across lines.

Alternatives rejected:

- Segment-content gating (flip a line's pluses when any plus-delimited segment contains CJK): breaks the A+B/5+5 joiner tokens on mixed lines and reintroduces the action-at-a-distance text gating that ADR 0003 removed.
- A whitelist-only world (drop the affix shape rule and enumerate every brand): Disney+-style suffixes and quantity markers are an open class no list can enumerate. The list earns its keep only where shape cannot decide.

Consequences

- Closes the CJK-brand-suffix FIXME from ADR 0003. Year ranges stay open, the last of its three.
- On a flipped line a half-width joiner flips too: x+y gets spaces when another plus on its line touches CJK or a protected word, the same per-line tradeoff pipe reading accepted.
- A protected word wins everywhere, including bundle contexts where its plus semantically reads as a separator: MOD 影劇館+ (300M/300M) keeps the plus attached to the brand.
- Slash reading is untouched: repeated slashes stay tight and a protected word sits flush against a following slash (影劇館+/全選/自選 20).
- The list is maintained by hand and starts at exactly two entries. Growing it is a per-entry judgement, not a policy change.
- The machinery only supports entries made of CJK and +: only + is masked, entries are re-exposed to the general rules after restore, and overlapping entries are not ordered. A word needing any other preserved symbol or shape (同學-, AC/DC, ANTHROP\C, mixed-script internals like MOD影劇館+ as one entry) is unsupported today. Generalize the masking and ordering when a real entry earns it, and the new entry's red test is the signal.

---

Adr/0007 Punctuation Before Cjk Needs No Left Anchor

A !;,? run directly before CJK is spaced without a left anchor

Trailing-space rules for ! ; , ? were left-anchored: a mark only got its space when a CJK character or a half-width letter/digit sat immediately before it. Real-world telecom copy broke the model: in 精采5G購機方案(30個月),月繳599元購機優惠(30個月) the comma follows a right bracket, which matched neither anchor, so ,月 stayed glued while everything around it spaced. Adding a right-bracket anchor fixed that report but committed the engine to growing an anchor allowlist one character class at a time, with %, $, quotes, and full-width neighbors still falling through.

The decision drops the left anchor entirely:

1. A ! ; , or ? run directly touching CJK on its right gets one trailing space, whatever sits on its left ((30 個月), 月繳, 50%, 以上, "你好", 她說).
2. The right side still requires direct CJK contact, so joiner shapes stay tight: 1,000, f(a,b), foo(),bar(), and URLs never space (nothing CJK follows the mark).
3. CJK_PUNCTUATION remains for what the blanket rule does not cover: colon handling and punctuation before letters/digits (前面,ABC, 前面! abc), both of which stay CJK-anchored.

The left anchor's only observable job was preserving the already-spaced shape 前面 ,後面. That shape is a typo, not a formatting choice worth a contract, so its "DO NOT change if already spacing" tests are retired for the glued-right case across the four symbol test files. 前面 , 後面 and 前面, 後面 remain untouched because the mark no longer touches CJK directly.

Alternatives rejected:

- A third anchored rule per newly reported left neighbor (right brackets shipped briefly as c609ce1): each unvetted neighbor needs its own report and rule, and the rule set grows without converging.
- A (?<=\S) guard instead of no guard: passes the same suite and preserves the typo shape, but keeps a special case whose only beneficiary is input judged a typo.

Consequences

- 前面 ,後面 becomes 前面 , 後面: the stray space stays, the missing one is added. Retyping the typo correctly is the user's move, not pangu's.
- Left neighbors nobody vetted now space: ……,然後, 。,你 and similar degenerate shapes get a trailing space after the mark. Accepted, the mark touches CJK so spacing is licensed.
- Colon and period keep their anchored rules and full-width conversion (FIX_CJK_COLON_ANS) is unaffected.
- AN_PUNCTUATION_CJK and RIGHT_BRACKET_PUNCTUATION_CJK are deleted, replaced by the single PUNCTUATION_CJK rule.

---

Adr/0008 Text Autospace Default On Ignores Filters

Native autospacing ships default-on and ignores site filters

The Chrome extension registers a second, CSS-only content script that turns on native autospacing (text-autospace, Chrome 140+) at the root of every http(s) page and frame. The is_enable_text_autospace setting defaults to true, and the layer deliberately ignores spacing_mode, filter_mode, blacklist, and whitelist. The rendering is visual-only, inserting no character and leaving copied text unchanged, so the destructive-edit concerns behind those filters do not apply. Blanket coverage is the point: it fills the holes the DOM engine leaves (the cramped flash before processing, iframes, excluded sites, click-to-space pages before the click). Injection is author-level with no !important, so a site that declares text-autospace itself wins through the cascade with zero detection code.

Consequences

- Sites excluded by blacklist or whitelist still show native spacing. This is intentional and the options copy says so. Do not "fix" it by gating the registration on filters.
- The layer registers in its own chrome.scripting.registerContentScripts() call. Registration is all-or-nothing across the array, and isValidMatchPattern passes some patterns Chrome rejects (URLPattern allows ports, match patterns do not), so batching it with the DOM script would let a bad whitelist entry take down native spacing as well.
- The gap is fixed by the platform at 1/8 ic and only covers CJK against letters and digits. Real U+0020 insertion stays the extension's core value, and real spaces (including those rendered by Pangu elements) suppress the native gap, so the two layers never double up.
- Toggle changes take effect on subsequent page loads only, matching how spacing_mode changes behave. Already-open tabs keep whatever was injected.
- If Chrome ever enables autospacing by default, this layer becomes redundant but harmless, and the default-on decision can be revisited.

---

Adr/0009 Nbsp Suppresses Spacing Never Rewritten

0009. NBSP suppresses spacing and is never rewritten

Date: 2026-07-26

Status

Accepted. Reverses the normalization decision recorded in #287 and commit 88ab44c.

Context

SOLITARY_NBSP rewrote an author's U+00A0 into a plain U+0020 before any spacing rule ran:

js
const SOLITARY_NBSP = /(?<=\S)[ ] [ ](?=\S)/g;
newText = newText.replace(SOLITARY_NBSP, ' ');

It was added in #287 on the reasoning that the engine treats NBSP as inert (ANS_CJK_AFTER and ANS_BEFORE_CJK use ¡-ÿ, which starts one character past U+00A0), so text containing one was getting different treatment from text containing a plain space.

That reasoning conflated two things. Inertness means no rule matches across an NBSP, which is the correct outcome: an NBSP already separates the runs it sits between, so nothing needs inserting. It does not require rewriting the character. Measured against the engine, 第 5 章 comes back completely unchanged without the rule, and 我們說We invited gains a space only at the genuinely missing 說|We junction. The normalization was doing almost no spacing work; it was changing bytes.

Rewriting is also outside what pangu claims to do. The library exists to insert whitespace between CJK and half-width characters. Silently replacing a character the author did not ask it to touch is a different operation, and it is unobservable to the user until it changes their layout.

The concrete case that forced the decision is a Google Calendar event description (fixtures/calendar-event-description.html), where pangu rewrote two of the four &nbsp; in the author's text.

Decision

pangu never rewrites or deletes an author's NBSP. It only ever inserts U+0020 where a separator is genuinely missing.

SOLITARY_NBSP is deleted. The inertness of U+00A0 is now load-bearing rather than incidental, and is documented at the ANS_CJK_AFTER / ANS_BEFORE_CJK definitions so nobody re-derives it or re-adds a normalization pass.

One guard needed widening. CJK_HASH and HASH_CJK used [^ ] to mean "something is glued to this #, so it is a hashtag". An NBSP passed that guard, so 台北 #中文 became 台北 # 中文, splitting a legitimate hashtag. Both are now [^  ]. They are deliberately not \S: that would also exclude zero-width characters such as U+FEFF, and treating those as a gap would suppress a space that genuinely belongs, leaving the runs flush.

Consequences

NBSP keeps its line-break behavior. This is the real cost and it was weighed explicitly. U+00A0 is break glue, not merely a same-width space, so preserved NBSPs remove wrap opportunities and WYSIWYG-pasted text wraps in more lines than before. Measured on the calendar fixture in a 180px box: 2 lines before, 3 lines after. This is accepted. Editors such as Google Calendar, Docs and Word emit &nbsp; mechanically, so the character often is not authorial intent, but pangu cannot tell the difference and guessing would be a worse contract than not touching it.

NBSP followed by a plain space stays doubled. 或  " paints wider than one space, because CSS collapses a run of plain spaces but never collapses NBSP plus space. The old rule removed that gap. Preserving it is the deliberate choice: the doubled gap is in the author's input, and removing either character would be the rewrite this ADR forbids.

Author NBSP padding now survives where rules used to absorb it. MIDDLE_DOT, fixBracketSpacing and FIX_QUOTE_ANY_QUOTE strip only literal spaces, so padding inside brackets and quotes and around a middle dot is kept, and asymmetric input renders asymmetrically (安室 · 奈美惠 keeps its gaps rather than closing them). This follows from the decision and is not separately fixable without reintroducing rewrites.

Known gap, not fixed here. PIPE_SEPARATOR refuses to fire when an NBSP touches exactly one side of a pipe, so 作詞 |林夕 stays glued on the right. That is a failure to insert rather than a rewrite, so it does not violate this ADR, but it is a real missing space. Fixing it needs a replacer that re-emits the captured NBSP.

Verified. Idempotency converges in one pass (f(f(x)) === f(x) over 8,414 inputs). The shared and browser layers agree: every browser-layer whitespace check is \s-based, which includes NBSP, and each can only suppress insertion. Text containing no NBSP is unaffected.

Notes

The engine still rewrites other characters (MIDDLE_DOT maps · to , FIX_CJK_COLON_ANS maps : to , and several rules delete literal U+0020). This ADR is specifically about U+00A0 and does not claim the engine is rewrite-free.

---

Adr/0010 Dot Entry Is The Node Build

0010. The . entry is the Node.js build, and nothing swaps it for the browser

Date: 2026-07-27

Status

Accepted. Removes the top-level browser field introduced long before exports existed. Shipped in v9.0.0.

Context

package.json carried a legacy browser field that told bundlers to substitute the browser build whenever they resolved the . entry:

json
"browser": {
"./dist/node/index.js": "./dist/browser/pangu.js",
"./dist/node/index.cjs": "./dist/browser/pangu.umd.js"
}

Every bundler that reads that field honored it. TypeScript does not. tsc deliberately excludes browser from its condition set under every moduleResolution mode, and customConditions is the only way to add it, which is a setting only the consumer can write. There is no package-side switch that makes types follow the swap.

So . meant two different classes depending on who was asking, and the two classes have genuinely different surfaces: NodePangu has spacingFile() and spacingFileSync(), BrowserPangu has spacingNode() and autoSpacingPage().

Both directions were wrong, and one of them was dangerous. Measured against the published v8.2.0 package in a Vite app with moduleResolution: bundler:

ts
import pangu from 'pangu';
pangu.spacingNode(document.body); // TS2339, but works at runtime
pangu.spacingFile('./some.txt'); // typechecks clean, throws at runtime

The second line is the one that forced the decision. typeof pangu.spacingFile is undefined in that bundle. TypeScript was blessing a call that crashes, and attw cannot detect it because attw does not model the browser condition either.

The obvious modern fix looked like upgrading the legacy field to a browser condition inside exports, which is what publint suggests and what current packaging guides recommend. That was tested and rejected: it moves the swap onto a standardized mechanism but changes nothing about the type side, so the trap survives. It also widens the swap to exports-only resolvers such as esm.sh and Deno, which the legacy field never reached.

Decision

. is the Node.js build. It resolves to NodePangu for types and for runtime, in every resolver, with no substitution anywhere.

The top-level browser field is deleted, and no browser condition is added in its place. Browser code uses the ./browser subpath, which has always been the documented path and whose types have always matched its runtime.

The condition is not merely unused, it is refused. A future reader following publint or a packaging guide will be tempted to add it back. Adding it restores the mismatch this ADR exists to remove.

Consequences

This is a breaking change, hence v9.0.0. Browser code doing bare import pangu from 'pangu' and calling spacingNode() or autoSpacingPage() worked before and throws now. That code has always had a type error, so the break is visible to anyone who typechecks, but it is a real runtime break for anyone who did not.

spacingText() in the browser is unaffected. The Node build works in a browser bundle. node:fs is imported at module scope but only touched inside spacingFile() and spacingFileSync(), and bundlers stub it, so the text engine runs normally. Verified: a Vite browser build of import pangu from 'pangu'; pangu.spacingText('當你凝視著bug') returns 當你凝視著 bug. Bundlers do emit an externalization warning for node:fs, which is a useful nudge toward pangu/browser.

Consumers who want the old behavior have a supported route. Setting customConditions: ["browser"] in tsconfig is the TypeScript-sanctioned mechanism, but it only helps if a browser condition exists, and this ADR is the decision not to publish one. The supported route is pangu/browser.

publint now passes clean. Its standing suggestion was specifically about the object-valued browser field, so removing the field resolves it rather than ignoring it.

main stays, and module was subsequently dropped. main points at the Node build, which is now unambiguously what . means, and TypeScript's node10 resolution still resolves through it, which is why attw keeps checking that mode. module was the same webpack 4 era generation as the browser field and followed it out: tools that read exports never consult it, and exports-blind tools fall back to main.

Verified. attw is green on all four resolution modes for . and on the esm-only profile for ./browser. publint reports no problems.

---

Adr/0011 Two Tsc Passes Police The Shared Layer

0011. The two declaration passes overlap on purpose

Date: 2026-07-27

Status

Accepted. Documents existing behavior rather than changing it.

Context

build:types runs tsc --emitDeclarationOnly twice, once per platform tsconfig:

text
tsc -p src/browser/tsconfig.json --emitDeclarationOnly --declarationDir dist
tsc -p src/node/tsconfig.json --emitDeclarationOnly --declarationDir dist

Both tsconfigs include ../shared//*.ts, so both emit dist/shared/index.d.ts into the same location. The node pass runs second and wins.

Read quickly this looks like waste: duplicated work, a last-writer-wins collision, and an ordering dependency nobody wrote down. The obvious tidy-up is to give shared/ a single owner, either by excluding it from one pass or by promoting it to a composite project with references.

That tidy-up would silently delete a guard.

The overlap is not redundant, because the two passes do not compile shared/ under the same assumptions:

- src/browser/tsconfig.json sets "types": [] and lib: ["ES2022", "DOM", "DOM.Iterable"]. Under it, shared/ sees no Node typings at all.
- src/node/tsconfig.json sets "types": ["node"] and lib: ["ES2022"]. Under it, shared/ sees no DOM.

So the passes mutually police the invariant that the shared layer is platform-free. If anything in src/shared/ reached for a Node global, the browser pass would fail to compile. If it reached for document, the node pass would fail. Neither failure needs a test to exist, and neither can be reached by only running one pass.

That also explains why the collision is harmless in practice. The two passes can only emit a differing dist/shared/index.d.ts if shared/ uses something that exists in both configurations but types differently, which is close to unreachable. Verified by emitting each pass to a separate directory and diffing: the two shared/index.d.ts outputs are byte-identical.

Note that exclude would not achieve the tidy-up anyway. TypeScript pulls imported files into the program regardless of exclude, so the only real way to give shared/ a single owner is project references, which is materially more machinery than the problem deserves.

Decision

Keep both passes, keep the overlap, and treat the redundant emit as the cost of the cross-check rather than as a defect.

Neither pass may be narrowed to stop covering src/shared/, and "types": [] on the browser side is load-bearing rather than tidiness.

Consequences

dist/shared/index.d.ts is written twice per build. The node pass wins. This is accepted and is the reason the two passes must not be reordered into something that changes which one lands last without re-verifying they still agree.

The guard is invisible in the code. Nothing named guard or check exists. If someone deletes a pass or adds "types": ["node"] to the browser config, everything keeps building and the invariant quietly stops being enforced. This ADR is the only thing standing between that and a future cleanup.

No dedicated test covers this. The enforcement is the compile itself, which is why typecheck runs all three tsconfigs rather than just the root one.

---

Adr/0012 Cjs Half Is Self Contained

0012. pangu still ships CommonJS, and the CJS pass is self-contained

Date: 2026-07-27

Status

Accepted.

Context

Two questions get asked of this build, and they have the same answer often enough that they belong in one record.

Why ship CommonJS at all in 2026? Node 22 and later can require() an ESM module, so the usual argument for a dual package is gone. The blocker is not resolution, it is shape. require(esm) returns the module namespace object, so const pangu = require('pangu') would hand back { default, pangu, NodePangu, __esModule } instead of the instance. The README has documented const pangu = require('pangu') returning the instance for years.

Node does provide an escape hatch. An ESM module can export { pangu as "module.exports" }, and require() then returns exactly that value. This was tested and works, including destructuring. It was rejected for two reasons. First, that interop ships unflagged only in Node 20.19+ and 22.12+, so with engines at >=20.0.0 an ESM-only package would break require('pangu') on every Node 20 before 20.19. Second, and independent of Node versions, Jest's CJS test environment implements its own require on top of the vm module and never inherits Node's require(esm), so every Jest consumer that loads pangu without ESM transform configuration depends on a real CJS file existing. pangu is widely enough installed that the CJS half is cheap insurance against both. If that calculus changes, this is the route: it deletes the .cts source and two thirds of the build config. Re-evaluated against the Node docs and kept on 2026-07-27.

Why does the CJS half need its own source file? export = pangu is TypeScript's only syntax for module.exports = <value>, and it is legal only in a CJS-emitting file. Under verbatimModuleSyntax that file must also use require rather than import. So src/node/index.cts exists because of a language constraint, not a preference.

What it should not do is re-implement NodePangu. It used to, alongside a hand-wired cross-output dependency: it did require('../shared/index.cjs'), and the config marked that literal string external with makeAbsoluteExternalsRelative: false so Rolldown would not rewrite it. That made one bundler pass depend on a file another pass of the same build emits, and it required a dedicated sharedCjs environment whose only output was a 14.5 KB file no consumer could reach, since exports exposes only . and ./browser.

That externalization avoided no duplication. dist/shared/index.cjs was already a byte-for-byte CJS twin of dist/shared/index.js.

Decision

The CJS pass is self-contained. src/node/index.cts requires the ESM entry and lets the bundler inline everything it needs.

This deletes the sharedCjs environment, the '../shared/index.cjs' external, makeAbsoluteExternalsRelative: false, the duplicated NodePangu class, and the two comments that existed to explain the hack. Externals are for other people's packages, not for another output of your own build.

The require binding is aliased (const { NodePangu: NodePanguClass } = ...). Without the alias it collides with the inlined class NodePangu, and Rolldown renames the class to NodePangu$1, which is observable through constructor.name and in stack traces.

Consequences

A TypeScript 5.3+ floor on index.d.cts, accepted deliberately. Because the .cts now references types from the ESM entry, tsc emits typeof import("./index.js", { with: { "resolution-mode": "import" } }). That with syntax needs TypeScript 5.3 or later on the consumer side. The old duplicated class was quietly buying portability to older TypeScript. This is the one real cost of the change, attw does not flag it, and it is the thing to revisit if someone reports it.

Package grows by about 3 KB. dist/node/index.cjs goes from 0.6 KB to 18.3 KB, while dist/shared/index.cjs (14.5 KB) stops being emitted. Net change on a roughly 504 KB package.

Build order stopped mattering for the CJS pass. It no longer reads another environment's output, so the only remaining ordering constraint in builder.buildApp is that esm runs first because it is the one that empties dist/.

Verified. The require() surface is unchanged: constructor.name is still NodePangu, spacingText() works, const { NodePangu } = require('pangu') destructures, and .default and .pangu are present. attw is green on all four resolution modes, publint reports no problems, and export = pangu still appears in the emitted .d.cts.

Notes

Addendum (2026-07-28): the CJS entry was rewritten the day after this ADR was accepted (85ad7e5, f1e17a8), and two statements above went stale. The aliased destructure in the Decision section is gone: src/node/index.cts:7 is now import index = require('./index.js') with class PanguModule extends index.NodePangu, so the NodePangu$1 collision is avoided by namespace access rather than by an alias. The TypeScript 5.3+ floor in the Consequences section no longer describes the shipped types either: the emitted dist/node/index.d.cts carries no import attribute at all, and src/node/index.cts:1 records the current constraint instead, TypeScript 5.8+ under NodeNext to model Node's require(esm). The decision itself is unchanged: the CJS pass is still self-contained, with no sharedCjs environment, no '../shared/index.cjs' external, and no makeAbsoluteExternalsRelative: false.

Vite library mode was kept over tsdown, which is otherwise the better-fitting tool for this job and is slated to become Rolldown Vite's library mode. The reason is local: Vite is not removable from this repo because the Chrome extension build needs it, so adopting tsdown would mean running two bundlers to save a config file. Worth revisiting if the extension build ever moves or if Vite's own library mode absorbs tsdown.

---

Adr/0013 Protected Word List Removed

The protected word list is removed, CJK brand suffixes read as operators

ADR 0006 added a protected word list of exactly two entries, 公視+ and 影劇館+, to pin the suffix reading (公視+ 上架) that no shape rule can distinguish from the operator reading (前面+後面). The machinery cost was out of proportion to those two words: a dedicated placeholder pass with its own private-use characters (U+E020/U+E021 atom edges, U+E022 masked plus), a special edge in PLUS_CJK_CONTACT, two mask-restoration rules ordered against the general spacing rules, and a documented list of shapes the masking cannot support.

The decision deletes the list and its machinery entirely. A CJK brand suffix now reads as an operator like any other plus in CJK contact: 公視+上架 becomes 公視 + 上架, MOD影劇館+上架 becomes MOD 影劇館 + 上架. The affix shape rules are untouched, so Disney+, Apple TV+, 100+, and 18+ keep the suffix reading, and C++ stays pattern-preserved. Plus reading — the other half of ADR 0006 — survives unchanged, minus its protected-word contact edge.

Alternatives rejected:

- Keeping the list at two entries: the per-entry judgement ADR 0006 prescribed was applied to the entries it shipped with, and neither earns the machinery. Nobody reported the operator reading of 公視+ as a bug; the entries came from one telecom bundle-plan FIXME that remains open either way.
- Generalizing the machinery (ordering, arbitrary symbols) to attract more entries: that inverts the cost argument — the problem is that a lexicon this small should not own a placeholder pass at all.

Consequences

- ADR 0003's CJK-brand-suffix FIXME reopens, now deliberately: the suffix reading for CJK brands is unsupported until a lexicon earns its keep with real reports. The bundle-plan FIXME tests in symbol-plus-sign.test.ts keep the aspirational 影劇館+ outputs as a record of what that would need to produce.
- MOD+影劇館+ loses its protection-driven reading (MOD + 影劇館+): the first plus now reads as an AN_PLUS_CJK affix again, the misread ADR 0006 set out to fix. Accepted — it was only ever observed inside the same unresolved bundle-plan FIXME.
- The U+E020–U+E022 private-use characters, PROTECTED_WORDS, PROTECTED_WORDS_PATTERN, and both mask rules are deleted; PLUS_CJK_CONTACT returns to plain CJK contact.
- The "Protected word" glossary entry leaves CONTEXT.md.

---

Adr/0014 Single Job Publish

Publish runs as a single job, version guard retained

The publish workflow briefly split building and publishing: a credential-free pack job (checkout, npm ci, build, tarball, artifact upload) and a publish job that held id-token: write, downloaded the tarball, and published — its in-file comment documented the contract ("this is the only job holding id-token, so it installs nothing, checks out nothing, and runs no scripts"). That shape keeps build-time code out of the OIDC token's scope, and it is what security reviews recommend.

The decision collapses publish back into one job that checks out, installs with --ignore-scripts, builds, and publishes with provenance, with id-token: write in scope throughout. Three reasons. First, the split never defends the front door: anyone able to push a v* tag ships a legitimate-looking release through either shape, and for a solo-maintainer repo the tag push is the realistic compromise path. Second, the threat the split does close — a compromised devDependency exfiltrating the short-lived, package-scoped OIDC token during the build — is exotic next to the upkeep it costs: two jobs, an artifact handoff, and upload/download action pins to maintain. Third, the protections that pay rent all survive the collapse: the fail-fast tag↔package.json version check runs before anything installs (mis-tagging is the failure that actually happens, and a wrong version published to npm is unrecoverable), plus persist-credentials: false, npm ci --ignore-scripts, and npm publish --provenance. pangu.py made the same decision the same day (its ADR 0002), so both repos publish through the same shape.

Alternatives rejected:

- Keeping the split: it worked, but it maintains ceremony against a threat the front door dwarfs.
- A plain single job without the version check: saves a few lines and reinstates the one publish failure with a track record.

Consequences

- Build-time code (devDependencies executed by npm run build) runs while the job can mint the npm OIDC token. Accepted for a solo maintainer; revisit if the repo gains a second committer or the dependency tree grows.
- Security reviews will flag this shape again; this ADR is the standing answer, to be re-litigated only with new facts.
- The version check reads package.json only. The hardcoded this.version in src/shared/index.ts stays in sync through npm run bump-version, which rewrites both.

---

CHANGELOG

Changelog

v9.1.1 / 2026-xx-xx

- 修正 dist/browser/pangu.js 會 import 其他檔案的問題,現在是獨立的單一檔案了,可以直接用 <script type="module"> 載入
- v8.1.0 到 v9.1.0 都有這個問題,在只複製 dist/browser/ 目錄的 CDN 上(例如 cdnjs)會因為找不到檔案而載入失敗
- Chrome extension 支援的最低版本改成 Chrome 99

v9.1.0 / 2026-08-01

- CLI 的 -v 會在版本號前面加上 pangu.js,本來只印版本號
- CLI 的 -t-f-c 不能再混用,同時給多個會出現錯誤訊息
- CLI 支援從 stdin 讀取文字,echo "他們在release的前一天爆炸" | pangu 會直接印出加好空格的結果,-c 也可以這樣用
- 修正 CLI 的輸出被導向 pipe 時,超過 64KB 的部分會被截斷的問題
- 修正 CLI 的 -f 現在會原封不動輸出加完空格的檔案內容,不會多加一個換行,檔案結尾本來有幾個換行就是幾個
- 修正引號的加空格規則,內容以 +| 結尾時,結尾引號前面不會再多一個空格,同樣的文字加第二次空格也不會變,排版完馬上用 -c 檢查也會通過
- 新增 pangu-js 這個 CLI command,不管 PATH 順序如何都會執行到 pangu.js

v9.0.0 / 2026-07-28

- 修正 <wbr> 這類元素把 / 兩邊拆成不同文字節點時,斜線前面會漏加空格的問題
- 拿掉了 package.jsonmodule 欄位
- 拿掉了 package.jsonbrowser 欄位
- 瀏覽器端請改用 import pangu from 'pangu/browser',本來就是文件建議的用法,型別也一直是對的
- 之前用 bundler 直接 import pangu from 'pangu',執行時會拿到瀏覽器版本,但是 TypeScript 給的型別是 Node.js 版本
- 用 <script> 載入 UMD 檔案、或是已經在用 pangu/browser 的話都不受影響
- 支援的 Node.js 版本改成 v20 以上

v8.2.0 / 2026-07-26

- 修正引號的加空格規則,當引號的內容跨越換行時,不會再把引號外面本來就有的空格吃掉

v8.1.0 / 2026-07-26

- 修正 - * = < > _ + 這些符號的加空格規則,夾在半形字元中間時會跟兩邊黏成同一個詞,不會再被拆開
- 修正 + 在字尾的加空格規則
- 修正 | 的加空格規則,同一行只要有 | 直接貼著中文,整行的 | 都會當成分隔符來加空格,例如 標題|網站名稱 會變成 標題 | 網站名稱
- 修正 + 直接貼著中文時會當成分隔符來加空格,跟 | 一樣以行為單位來判斷
- 修正 ! ; , ? 後面直接貼著中文時的加空格規則,現在不管這些符號前面是什麼字元,都會在符號後面加空格
- 修正純文字中的 <tag> 現在會被當成一個詞來加空格,但是一般網頁中的 HTML 標籤不受影響
- 修正文字節點的開頭或結尾是 &nbsp;、旁邊又緊接著連結之類的元素時,會多加一個半形空格的問題
- 不再把單獨出現的 &nbsp; 改寫成半形空格,這是 v8.0.0 的行為,現在會原封不動保留你打的 &nbsp;,只在真的缺空格的地方補上空格
- Chrome extension 的工具列圖示新增了 OFF 狀態,切到手動模式、或是目前網址被黑白名單排除時,圖示會換成頭戴紙袋的圖示
- Chrome extension 會在所有網頁啟用瀏覽器原生的 text-autospace: normal; 排版
- 預設啟用,可以在設定裡關掉
- 需要 Chrome v140 以上版本
- pangu/browser 改成只提供 ESM,拿掉了 require 條件,require('pangu/browser') 會出現 ERR_PACKAGE_PATH_NOT_EXPORTED
- 用 bundler 的話 import 照舊,不受影響
- 在瀏覽器裡直接用 <script> 載入 UMD 檔案的方式也不受影響

v8.0.0 / 2026-07-19

- 各位觀眾!Paranoid Text Spacing 演算法 v8!
- 修正有些網頁加空格會抖動的問題
- 修正 /& 的加空格規則
- 修正單獨出現的 &nbsp; 會造成空格判斷錯誤的問題,現在會先把它當成一般的半形空格
- 修正引號緊鄰日文時,空格規則跟中文不一致的問題
- 修正同一批動態新增的相鄰節點之間會正確加空格了
- 修正兩個變動的節點中間如果隔著沒變動的內容,不會再把它們誤判成相鄰節點而加錯空格
- 修正連結被其他元素包住、後面又接著其他文字時,連結前後漏加空格的問題
- 修正兩段文字中間本來就有空白時,不會再多加一個空格,之前隔著 <a> 這類元素、或空白被包在 <span> 這類元素裡時會看不到那個空白
- 又一次史詩級性能提升!

v7.2.1 / 2026-03-02

- 修正在 CSS Grid 和 Flexbox 容器裡插入 <pangu> 元素會破壞排版的問題
- 移除 Chrome extension 的 file:// host permission

v7.2.0 / 2025-07-05

- 趁末日前更新一下,不然以後就沒機會了
- 修正在 Google Calendar 會不小心在 CSS 隱藏元素與中文之間加空格的問題

v7.0.0 / 2025-07-02

- 各位觀眾!Paranoid Text Spacing 演算法 v7 橫空出世!
- 會自動判斷某些元素是不是被 CSS 隱藏來決定要不要加空格
- 不會把半形的標點符號轉成全形了
- 史詩級性能提升!
- 把 XPath 換成 TreeWalker,快他媽 5 倍!
- 比較慢的操作都丟到 requestIdleCallback(),內容太多的網站終於不卡了!
- Chrome/Firefox 都有支援 requestIdleCallback(),但是 Safari 不支援

v6.1.3 / 2025-07-01

- 修正 Asana 的 comments 會被重複加空格的問題

v6.1.2 / 2025-06-30

- 修正 Node.js imports

v6.1.0 / 2025-06-30

- 各位觀眾!Paranoid Text Spacing 演算法 v6.1
- 好啦好啦,我要去玩死亡擱淺 2 了

v6.0.0 / 2025-06-28

- 各位觀眾!Paranoid Text Spacing 演算法 v6
- 特別處理了各種括號 () [] {} <>/ 的問題,仁至義盡了

v5.3.2 / 2025-06-27

- 在 popup 加了一個方便把目前的網址加到黑名單的按鈕

v5.2.0 / 2025-06-26

- 各位觀眾!Paranoid Text Spacing 演算法 v5

v5.1.1 / 2025-06-24

- 偉哉 Claude Code,精雕細琢的 v5.1.0 終於推出啦!
- 雖然看起來好像只是換了一個新 UI,但是爽啦!

v5.0.0 / 2025-06-17

- 請 Claude Code 把 Chrome extension 升級成 Manifest v3 了
- 請 Claude Code 幫我把 codebase 改寫成 TypeScript 了

v4.0.7 / 2019-02-15

- 修正 “ ” 的問題

v4.0.6 / 2019-02-04

- 修正 , 的問題
- 新增 pangu 指令

v4.0.5 / 2019-01-30

- 修正 <pre> 的問題

v4.0.4 / 2019-01-29

- 修正 Bilibili 影片會消失的問題

v4.0.3 / 2019-01-29

- 修正 node.isContentEditable 的問題

v4.0.2 / 2019-01-29

- 修正 <code> 的問題
- 修正 <i> 的問題
- 修正 的問題

v4.0.0 / 2019-01-27

- 各位觀眾!Paranoid Text Spacing 演算法 v4
- 大幅地改進 Chrome extension 的效能,使用 MutationObserverdebounce
- 忍痛拿掉「空格之神顯靈了」
- 修正 Pangu.spacingText() 的 error callback
- 新增 BrowserPangu.autoSpacingPage()

v3.3.0 / 2016-12-28

- 修個 bug 好過年
- 修正在 Twitter 上跟 Buffer 一起使用時會隨機出現的錯誤問題

v3.2.1 / 2016-06-26

- 又他媽改善效能問題

v3.2.0 / 2016-06-26

- 修正效能問題

v3.1.1 / 2016-06-26

- 剛吃完烤肉來改進一下 Paranoid Text Spacing 演算法

v3.1.0 / 2016-06-25

- NodePangu 新增 spacingFile(),支援 callback 與 promise
- NodePangu 新增 spacingFileSync()

v3.0.0 / 2016-01-10

- Isomorphic!

v2.5.6 / 2015-05-13

- 大家好,很抱歉這麼快又跟大家見面了

v2.5.5 / 2015-05-13

- 持續改進 Paranoid Text Spacing 演算法

v2.5.1 / 2015-05-11

- 再次改進 Paranoid Text Spacing 演算法

v2.5.0 / 2015-05-11

- 改進 Paranoid Text Spacing 演算法

v2.4.2 / 2014-12-10

- 修正 Facebook 留言框的空格錯位

v2.4.1 / 2014-12-10

- 修正 <title> 網頁標題的加空格
- 修正 ' 單引號的加空格

v2.4.0 / 2014-12-08

- 改進效能
- 完善對雙引號的處理
- 修正 Popup Page 的 CSS 問題

v2.3.4 / 2014-03-01

- 再度完善 Paranoid Text Spacing 演算法
- 修正 Options Page 的小錯誤

v2.3.3 / 2014-02-16

- 完善 Paranoid Text Spacing 演算法

v2.3.2 / 2014-02-12

- 心血來潮,加個版本號!

v2.3.1 / 2014-02-10

- 真的不會對 <code><pre> 裡的文字加空格了

v2.3.0 / 2014-02-08

- 威力加強版!
- 解決特定情況下在同一個地方會一直加空格的問題

v2.2.3 / 2014-02-07

- 不會在 Google+ 的輸入框裡加空格
- 記事本不懂 Vim 的黑

v2.2.2 / 2014-02-07

- 銀河大客車指南!

v2.2.1 / 2014-02-05

- 改進 Paranoid Text Spacing 演算法

v2.1.2 / 2014-02-01

- 不會對 <textarea> 裡的文字加空格!

v2.1.1 / 2014-01-31

- 不對 _ 加空格
- 對 | 加空格
- 新增 Popup Page
- 空格之神 姍姍來遲

v2.1.0 / 2014-01-29

- 解決在 Facebook、Twitter、QQ 空间、百度贴吧等網站輸入文字時游標會亂衝的問題
- 支援 file:/// 開頭的網頁

v2.0.2 / 2014-01-26

- 遇到 <br> 就不加空格

v2.0.1 / 2014-01-25

- 拿掉 console.log()
- 修正 Option Page footer 裡的超連結

v2.0.0 / 2014-01-24

- 新年新氣象,翻新 Option Page 的 UI 和 Icon
- 網址黑白名單可以使用 Chrome 的同步功能(Chrome Storage API)
- 修正在 Gmail 中加空格的問題
- 改善效率問題

v1.8 / 2013-09-17

- 修正在 Google Docs 中游標錯位的問題
- 網址黑、白名單支援 // 前綴

v1.7 / 2012-04-08

- 減少 Chrome extension 的大小

v1.6 / 2012-04-04

- 感謝 @jiefoxi,現在英文超連結和中文之間也會加上空格了

v1.5 / 2012-04-04

- 新增 Firefox 版本
- 目前支援「中文(繁體、簡體)」、「日文(漢字、平假名、片假名)」加空格
- 修正網址黑白名單沒有作用的問題

v1.3 / 2012-03-20

- 根據 @Fenng 的回報,修正了「新浪微博的 @ 符號之後不要加空格」的問題
- 新增「簡體中文」語系

v1.0 / 第一份工作的某一個下班日

- Paranoid Text Spacing 演算法橫空出世

---

README

為什麼你們就是不能加個空格呢?

[](https://www.npmjs.com/package/pangu)
[](https://chromewebstore.google.com/detail/paphcfdffjnbcgkokihcdjliihicmbpd)<br>
[](https://chromewebstore.google.com/detail/paphcfdffjnbcgkokihcdjliihicmbpd/reviews)
[](https://www.jsdelivr.com/package/npm/pangu)
[](https://www.npmjs.com/package/pangu)

如果你跟我一樣,每次看到網頁上的中文字和英文、數字、符號擠在一塊,就會坐立難安,忍不住想在它們之間加個空格。這個 Google Chrome 外掛正是你在網路世界走跳所需要的東西,它會自動替你在網頁中所有的中文字和半形的英文、數字、符號之間插入空白。

漢學家稱這個空白字元為「盤古之白」,因為它劈開了全形字和半形字之間的混沌。另有研究顯示,打字的時候不喜歡在中文和英文之間加空格的人,感情路都走得很辛苦,有七成的比例會在 34 歲的時候跟自己不愛的人結婚,而其餘三成的人最後只能把遺產留給自己的貓。畢竟愛情跟書寫都需要適時地留白。

與大家共勉之。

[](https://chromewebstore.google.com/detail/paphcfdffjnbcgkokihcdjliihicmbpd)

Installation

For Users

- Official support:
- Google Chrome Extension
- Community support:
- Paranoid Text Spacing
- 盤古之白 - 文案排版轉換

For Developers

- Official support:
- pangu.js
- pangu.py
- pangu.go
- pangu.java
- pangu.space
- Community support:
- pangu.clj
- pangu.dart
- pangu.ex
- pangu.objective-c
- pangu.php
- pangu.rb
- pangu.rs
- pangu.swift
- pangu.skill

Usage

bash
npm install pangu --save-exact

Learn more in the changelog or on npm.

Browser

Make sure to import from pangu/browser in ESM, which is the DOM-aware build (spacingNode(), autoSpacingPage()) with matching TypeScript types and resolves correctly across all bundlers.

js
import pangu from 'pangu/browser';
// or
// <script src="https://cdn.jsdelivr.net/npm/pangu@latest/dist/browser/pangu.umd.js"></script>

const text = pangu.spacingText('當你凝視著bug,bug也凝視著你');
// text = '當你凝視著 bug,bug 也凝視著你'

pangu.spacingNode(document.getElementById('main'));
document.querySelectorAll('.comment').forEach((el) => pangu.spacingNode(el));
document.querySelectorAll('p').forEach((el) => pangu.spacingNode(el));

// Listen to any DOM change and automatically perform spacing via MutationObserver()
document.addEventListener('DOMContentLoaded', () => pangu.autoSpacingPage());

Also on:

- jsDelivr
- https://cdn.jsdelivr.net/npm/[email protected]/dist/browser/pangu.umd.js
- unpkg
- https://unpkg.com/[email protected]/dist/browser/pangu.umd.js
- cdnjs
- https://cdnjs.cloudflare.com/ajax/libs/pangu/x.y.z/browser/pangu.umd.min.js

Replace x.y.z with the version you want to use.

Node.js

js
import pangu from 'pangu';
// or
// const pangu = require('pangu');

const text = pangu.spacingText('與PM戰鬥的人,應當小心自己不要成為PM');
// text = '與 PM 戰鬥的人,應當小心自己不要成為 PM'

const content = await pangu.spacingFile('/path/to/text.txt');

You SHOULD NOT use pangu.js to spacing Markdown documents, this library is specially designed for HTML webpages and plain texts without any markup language. See issue #127.

CLI

bash
$ pangu "不能信任那些Terminal或Editor用白底的人"
不能信任那些 Terminal 或 Editor 用白底的人

$ pangu -t "你在每個commit裡修改的程式碼越多,你在code review時被發現的錯誤就會越少"
你在每個 commit 裡修改的程式碼越多,你在 code review 時被發現的錯誤就會越少

$ pangu -f path/to/file.txt
新來的 Designer 趁特價的時候幫自己買了一本 GoF Design Patterns

$ pangu -c "盤古新聞網:工程師會議中默不作聲,PM恐成最大贏家"; echo $?
Corrected: 盤古新聞網:工程師會議中默不作聲,PM 恐成最大贏家
1

$ echo "他們在release的前一天爆炸" | pangu
他們在 release 的前一天爆炸

License

Released under the MIT License.

Author

- GitHub: @vinta
- Twitter: @vinta
- Website: vinta.ws

---