### 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//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): ``` / ├── 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): ``` / ├── 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 --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 --body "..."` - **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` - **Close**: `gh issue close --comment "..."` Infer the repo from `git remote -v` — `gh` 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 --comments` and `gh pr diff ` 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 --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 #` at the top of the child body. Labels: `wayfinder:` (`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///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --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: #, #` 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 --add-assignee @me` — the session's first write. - **Resolve**: `gh issue comment --body ""`, then `gh issue close `, 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](0003-symbols-between-half-width-are-tokens.md), 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 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 (`寫一個
的標籤`) versus real markup (`文字
換行`), 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 `` 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](0013-protected-word-list-removed.md), 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 ` ` 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 ` ` 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: ``` 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.json` 的 `module` 欄位 - 拿掉了 `package.json` 的 `browser` 欄位 - 瀏覽器端請改用 `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 標籤不受影響 - 修正文字節點的開頭或結尾是 ` `、旁邊又緊接著連結之類的元素時,會多加一個半形空格的問題 - 不再把單獨出現的 ` ` 改寫成半形空格,這是 v8.0.0 的行為,現在會原封不動保留你打的 ` `,只在真的缺空格的地方補上空格 - Chrome extension 的工具列圖示新增了 OFF 狀態,切到手動模式、或是目前網址被黑白名單排除時,圖示會換成頭戴紙袋的圖示 - Chrome extension 會在所有網頁啟用瀏覽器原生的 [`text-autospace: normal;`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-autospace) 排版 - 預設啟用,可以在設定裡關掉 - 需要 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! - 修正有些網頁加空格會抖動的問題 - 修正 `/` 和 `&` 的加空格規則 - 修正單獨出現的 ` ` 會造成空格判斷錯誤的問題,現在會先把它當成一般的半形空格 - 修正引號緊鄰日文時,空格規則跟中文不一致的問題 - 修正同一批動態新增的相鄰節點之間會正確加空格了 - 修正兩個變動的節點中間如果隔著沒變動的內容,不會再把它們誤判成相鄰節點而加錯空格 - 修正連結被其他元素包住、後面又接著其他文字時,連結前後漏加空格的問題 - 修正兩段文字中間本來就有空白時,不會再多加一個空格,之前隔著 `<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](https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker),快他媽 5 倍! - 比較慢的操作都丟到 [requestIdleCallback()](https://developer.mozilla.org/en-US/docs/Web/API/Window/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 的效能,使用 `MutationObserver` 和 `debounce` - 忍痛拿掉「空格之神顯靈了」 - 修正 `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](https://github.com/jiefoxi),現在英文超連結和中文之間也會加上空格了 ## v1.5 / 2012-04-04 - 新增 Firefox 版本 - 目前支援「中文(繁體、簡體)」、「日文(漢字、平假名、片假名)」加空格 - 修正網址黑白名單沒有作用的問題 ## v1.3 / 2012-03-20 - 根據 [@Fenng](https://github.com/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](https://chromewebstore.google.com/detail/paphcfdffjnbcgkokihcdjliihicmbpd) - Community support: - [Paranoid Text Spacing](https://tools.1chooo.com/paranoid-text-spacing) - [盤古之白 - 文案排版轉換](https://pangu.serko.dev/) ### For Developers - Official support: - [pangu.js](https://github.com/vinta/pangu.js) - [pangu.py](https://github.com/vinta/pangu.py) - [pangu.go](https://github.com/vinta/pangu) - [pangu.java](https://github.com/vinta/pangu.java) - [pangu.space](https://github.com/vinta/pangu.space) - Community support: - [pangu.clj](https://github.com/coldnew/pangu.clj) - [pangu.dart](https://github.com/SemonCat/pangu.dart) - [pangu.ex](https://github.com/cataska/pangu.ex) - [pangu.objective-c](https://github.com/Cee/pangu.objective-c) - [pangu.php](https://github.com/Kunr/pangu.php) - [pangu.rb](https://github.com/dlackty/pangu.rb) - [pangu.rs](https://github.com/airt/pangu.rs) - [pangu.swift](https://github.com/X140Yu/pangu.Swift) - [pangu.skill](https://github.com/shihyuho/pangu.skill) ## Usage ```bash npm install pangu --save-exact ``` Learn more in the [changelog](CHANGELOG.md) or on [npm](https://www.npmjs.com/package/pangu). ### 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://www.jsdelivr.com/package/npm/pangu) - `https://cdn.jsdelivr.net/npm/pangu@x.y.z/dist/browser/pangu.umd.js` - [unpkg](https://app.unpkg.com/pangu) - `https://unpkg.com/pangu@x.y.z/dist/browser/pangu.umd.js` - [cdnjs](https://cdnjs.com/libraries/pangu) - `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](https://github.com/vinta/pangu.js/issues/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](https://opensource.org/licenses/MIT). ## Author - GitHub: [@vinta](https://github.com/vinta) - Twitter: [@vinta](https://twitter.com/vinta) - Website: [vinta.ws](https://vinta.ws/code/) ---