issue-map 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ # issue-map
2
+
3
+ [English](README.md) · **繁體中文** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
4
+
5
+ > 原稿是[英文版](README.md)。翻譯落後時以它為準。
6
+
7
+ 把 GitHub Issues 的阻擋關係畫成一頁開發地圖:**哪幾張票現在可以動、哪幾張在等誰、關鍵路徑是哪一條。**
8
+
9
+ 狀態的權威永遠是 GitHub Issues。這一頁只是快照,頁面上不能改狀態——所以不會長出第二個事實來源。
10
+
11
+ ## 為什麼有這個專案
12
+
13
+ 起點是 [mattpocock/skills](https://github.com/mattpocock/skills)。團隊照它那套「把工作流寫成
14
+ skill、讓 agent 照著跑」開始做事之後,開票變得很便宜:想到一件事就開一張票,交給 skill 去接。
15
+ 票因此長得很快——那是流程在運作的證據,不是問題。
16
+
17
+ 問題在下一步。Agent 一輪吃一張票,所以每一輪真正要決定的是**派哪一張**,而這個答案不在任何
18
+ 單一張票裡,它在票與票之間:誰擋著誰、哪一組子票還差幾張、最長的那條鏈有多長。GitHub Issues
19
+ 一次只讓你讀一張票,要湊出那張圖就得一張一張點開來,而且每天都要重湊一次。
20
+
21
+ 這一頁就是那張圖。
22
+
23
+ ## 用法
24
+
25
+ 在**要看的那個 repo** 裡跑:
26
+
27
+ ```bash
28
+ bunx issue-map@latest
29
+ ```
30
+
31
+ `@latest` 取 npm 上最新的一版;要釘住特定版本就寫 `bunx issue-map@0.2.0`。
32
+
33
+ 起在 `http://localhost:4747` **並直接開瀏覽器**。每次重新整理都重抓 GitHub,看到的一定是現在的狀態。repo 是 `gh` 從 cwd 的 git 推斷的,不必填。
34
+
35
+ 不要自動開分頁就設 `ISSUE_MAP_OPEN=0`。
36
+
37
+ 只要一份靜態 HTML 的話(`-p` 是用來選另一個 bin 的,少了它會變成起 server):
38
+
39
+ ```bash
40
+ bunx -p issue-map@latest issue-map-build # 寫到 dist/issue-map.html
41
+ bunx -p issue-map@latest issue-map-build out.html
42
+ ```
43
+
44
+ 快照就是快照——狀態會過期,要看現在的狀態就用上面的 server。
45
+
46
+ ## 語言
47
+
48
+ 頁面右上角切換,**預設英文**,另外支援繁體中文、簡體中文、日文。選了哪一種記在瀏覽器
49
+ (localStorage 的 `issue-map:locale`),跟 repo 無關——語言是看的人的偏好,不是某個專案的設定。
50
+ 刻意不看 `navigator.language`:預設就是英文,猜錯了反而每次進來都要改回去。
51
+
52
+ 文案全部在 `scripts/issue-map-i18n.ts`,那是頁面上每一句話的唯一來源:
53
+
54
+ - `EN` 是原稿,也是鍵的定義處。三份翻譯的型別由它推導,少翻一個鍵 `bun run typecheck` 就會紅。
55
+ - 句子裡的代入名(`{n}`、`{issues}`)也是型別的一部分,少傳一個編不過——不然缺的那個會以
56
+ `{n}` 的樣子印在畫面上,而那要真的跑到那一格才看得到。
57
+ - 英文要分單複數的鍵寫成 `{ one, other }`,中日文寫一句字串就好(`Intl.PluralRules` 對這幾種
58
+ 語言只有 `other`)。
59
+
60
+ 模型與抓取那一側**不再算好句子**:`nextStep` 是 `{ kind: 'waitChildren', count: 2 }` 這種結構化
61
+ 的值,分組名字也一樣,話在 i18n 那一層才組出來。快照裡存中文句子的話,換一次語言就得重抓一次
62
+ GitHub。
63
+
64
+ CLI 那一側(產檔訊息、錯誤)刻意留中文:那是給開發者看的,不是頁面的一部分。
65
+
66
+ ## 前置條件
67
+
68
+ - **Bun**。這幾支用了 `Bun.build`、`Bun.serve`、`Bun.file` 與 bun 的 `spawnSync`,Node 跑不起來。
69
+ - **`gh` CLI 已登入**,而且對目標 repo 有讀取權。
70
+ - 目標 repo 有 git remote 指向 GitHub。
71
+ - 沒有 runtime 依賴;devDependencies 只有型別與 lint/format 工具。
72
+
73
+ ## 設定
74
+
75
+ 全部有預設值,一個都不設也跑得起來。預設值長在 `scripts/issue-map.ts` 的 `CONFIG`。
76
+
77
+ | 環境變數 | 預設 | 意思 |
78
+ | -------------------------- | --------------------------------- | ------------------------------------------------------------------ |
79
+ | `GH_REPO` | 從 cwd 的 git 推斷 | 要畫別的 repo 時設它(`gh` 自己的變數,fork 與多 remote 也交給它) |
80
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在內文指向母票的段落標題。GitHub 原生 sub-issue 有值時優先 |
81
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 掛了就是還沒評估完,不能交給誰做 |
82
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 掛了才算評估完、可以動工 |
83
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 掛了代表有人在做,不必有 assignee |
84
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 這些要人做,下一步不寫實作指令 |
85
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以動工時圖上叫人跑的指令 |
86
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 還要評估時圖上叫人跑的指令 |
87
+ | `ISSUE_MAP_PORT` | `4747` | server 的 port |
88
+ | `ISSUE_MAP_OPEN` | 開 | 設 `0` 就不自動開瀏覽器(`bun --watch` 的開發模式預設關掉) |
89
+
90
+ 兩個要特別想過的:
91
+
92
+ - **標籤字彙**:目標 repo 沒在用這套標籤就要換成它自己的名字。程式會偵測——快照裡完全沒出現 ready/unready 任何一個標籤時,就不拿 triage 當閘門,否則每張票都會變成「待評估」。
93
+ - **指令名**:`/implement`、`/triage` 是 Claude Code 的 skill。目標 repo 沒有的話一定要換掉,不然圖上會叫人跑不存在的東西。
94
+
95
+ ## 常見失敗
96
+
97
+ - `gh api graphql 失敗:…` — `gh` 沒登入,或 cwd 不在目標 repo 的 git 樹裡。
98
+ - `open issue 超過 100 張,這支要改成分頁抓` — GraphQL 的 `first` 上限就是 100。要支援更多票得在 `issue-map.ts` 的 `query()` 加分頁;這是要改程式,不是設定。
99
+
100
+ ## 檔案
101
+
102
+ | 檔案 | 責任 |
103
+ | ---------------------------- | ---------------------------------------------------------------------- |
104
+ | `scripts/issue-map.ts` | 抓快照、算每張票的狀態與下一步、產出 HTML。移植設定在裡面的 `CONFIG` |
105
+ | `scripts/issue-map-model.ts` | 純資料模型:分組、關鍵路徑。前後端共用 |
106
+ | `scripts/issue-map-i18n.ts` | 四種語言的文案與查表。頁面上每一句話的唯一來源 |
107
+ | `scripts/issue-map-page.ts` | 瀏覽器端程式碼,建置時被打包進 HTML |
108
+ | `scripts/issue-map.html` | 樣板。兩個佔位區塊(`issue-map-data`、`issue-map-code`)會被填入 |
109
+ | `scripts/issue-map-serve.ts` | 本機 server,每個請求重抓一次 |
110
+ | `scripts/mutate.ts` | 突變測試:改壞一行看測試會不會紅。守門測試的反向驗證用它,不要手改檔案 |
111
+
112
+ ## 在這個 repo 裡開發
113
+
114
+ ```bash
115
+ bun install
116
+ bun run issue-map:serve # --watch,改程式碼會自動重啟;刻意不自動開瀏覽器(每存一次檔就會多一個分頁)
117
+ bun run issue-map # 只產檔到 dist/issue-map.html
118
+ bun run check # lint + format:check + typecheck
119
+ bun test # 純模型那一層(分組、關鍵路徑、排版)
120
+ ```
121
+
122
+ 這個 repo 自己還沒有 issue,`GH_REPO=<owner>/<repo>` 指到有票的 repo 才畫得出東西。
123
+
124
+ `tests/` 只守會讓地圖說謊或不能看的事,外觀(顏色、形狀、間距)刻意不驗。新增守門測試要走反向驗證——把它宣稱要擋的缺陷放回產品碼,確認它會紅:
125
+
126
+ ```bash
127
+ bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
128
+ ```
129
+
130
+ ## 兩個設計上的決定,改之前先知道
131
+
132
+ - **這一頁不能改狀態。** 沒有按鈕會回寫 GitHub。刻意的:狀態只有一個事實來源,多一個入口就會不一致。
133
+ - **票名不進地圖。** 節點只掛票號,名字在下方清單。試過在內文加短名段落,那是票名的第二個事實來源,改標題不會改它;機械縮短標題讀不通。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "issue-map",
3
- "version": "0.2.0",
4
- "description": " GitHub Issues 的阻擋關係畫成一頁開發地圖:哪幾張票現在可以動、哪幾張在等誰、關鍵路徑是哪一條。",
3
+ "version": "0.3.0",
4
+ "description": "Draws the blocking relationships between your GitHub Issues as a one-page dev map: which issues can be picked up now, which are waiting on what, and where the critical path runs.",
5
5
  "keywords": [
6
6
  "bun",
7
7
  "cli",
@@ -19,7 +19,11 @@
19
19
  "issue-map-build": "./scripts/issue-map.ts"
20
20
  },
21
21
  "files": [
22
+ "README.zh-TW.md",
23
+ "README.zh-CN.md",
24
+ "README.ja.md",
22
25
  "scripts/issue-map.ts",
26
+ "scripts/issue-map-i18n.ts",
23
27
  "scripts/issue-map-model.ts",
24
28
  "scripts/issue-map-page.ts",
25
29
  "scripts/issue-map-serve.ts",
@@ -0,0 +1,486 @@
1
+ /**
2
+ * 頁面文案。**沒有 Bun、沒有 DOM**——它只是字典與一個查表函式,所以抓資料那一側與畫面那一側
3
+ * 都能 import。
4
+ *
5
+ * 這裡是頁面上所有給人看的字的唯一來源。畫面那一側不寫死任何一句話,模型那一側也不再算好
6
+ * 中文句子送過來——`nextStep` 與分組名字都是結構化的值,句子在這裡才組出來。少了這一層,同一
7
+ * 句話會同時長在模型、樣板與畫面三處,換語言只會換到其中一處。
8
+ *
9
+ * `en` 是原稿,也是鍵的定義處:`Messages` 由它推導,少翻一個鍵就編不過。翻譯缺鍵不會 fallback
10
+ * 成英文——那會安靜地留下半英半中的畫面。
11
+ *
12
+ * 字典的值會進 `innerHTML`(`lede.*` 刻意帶 `<strong>`),所以**翻譯裡不要放意料外的標記**;
13
+ * 代入的值由呼叫端負責逃脫,跟原本用模板字串時一樣。
14
+ */
15
+
16
+ /** 支援的語言。第一個是預設。 */
17
+ export const LOCALES = ['en', 'zh-TW', 'zh-CN', 'ja'] as const
18
+
19
+ export type Locale = (typeof LOCALES)[number]
20
+
21
+ export const DEFAULT_LOCALE: Locale = 'en'
22
+
23
+ /** 語言選單上的字。一律寫該語言自己的說法,不翻譯。 */
24
+ export const LOCALE_NAME: Readonly<Record<Locale, string>> = {
25
+ en: 'English',
26
+ 'zh-TW': '繁體中文',
27
+ 'zh-CN': '简体中文',
28
+ ja: '日本語',
29
+ }
30
+
31
+ /** 要分單複數的句子。英文才用得到,中日文寫成單一句字串就好。 */
32
+ type Plural = { readonly one: string; readonly other: string }
33
+
34
+ type Message = string | Plural
35
+
36
+ // ---- 原稿 ----
37
+
38
+ const EN = {
39
+ 'lang.label': 'Language',
40
+
41
+ 'title.withRepo': '{repo} dev map',
42
+ 'title.plain': 'Dev map',
43
+
44
+ 'stat.ready': 'ready now',
45
+ 'stat.active': 'picked up',
46
+ 'stat.blocked': 'blocked',
47
+ 'stat.triage': 'not settled',
48
+ 'stat.critical': 'critical path',
49
+ 'stat.critical.unit': { one: 'issue', other: 'issues' },
50
+
51
+ 'lede.allDone': 'Nothing open in this repo.',
52
+ 'lede.open': { one: '{n} issue open.', other: '{n} issues open.' },
53
+ 'lede.ready': {
54
+ one: '<strong>{n} ready now — {issues}</strong>.',
55
+ other: '<strong>{n} ready now, starting with {issues}</strong>.',
56
+ },
57
+ 'lede.none':
58
+ '<strong>Nothing is ready right now</strong> — every issue waits on a prerequisite or on triage.',
59
+ 'lede.blocked': {
60
+ one: '{n} issue blocked by a prerequisite.',
61
+ other: '{n} issues blocked by prerequisites.',
62
+ },
63
+ 'lede.triage': {
64
+ one: '{n} issue still needs triage.',
65
+ other: '{n} issues still need triage.',
66
+ },
67
+ 'lede.critical': 'Critical path {n} — that is the fewest rounds this can close in.',
68
+
69
+ 'foot.truth':
70
+ 'GitHub Issues is the source of truth. This page is a snapshot; you cannot change status here.',
71
+ 'foot.refresh': 'Re-run {build} for a file, or {serve} so a refresh is always current.',
72
+ 'foot.vocab':
73
+ "This run's gate: {ready} means ready to work on; {unready} or no role label means not settled.",
74
+ 'foot.noVocab':
75
+ 'This repo does not use triage labels, so status only reflects prerequisites and hand-offs.',
76
+ /** 兩種串接:`or` 是「A 或 B」,`slash` 串一票標籤名,`items` 串一串票號。 */
77
+ 'join.or': ' or ',
78
+ 'join.slash': ', ',
79
+ 'join.items': ', ',
80
+
81
+ 'fold.collapse': 'Collapse sub-issues of #{n}',
82
+ 'fold.expand': 'Expand sub-issues of #{n}',
83
+
84
+ 'map.stations': { one: '{n} stop', other: '{n} stops' },
85
+ 'map.islandName': 'no prerequisites',
86
+ 'map.islandSub': 'can start in parallel',
87
+ 'legend.ready': 'ready now',
88
+ 'legend.active': 'picked up',
89
+ 'legend.blocked': 'waiting on prerequisite',
90
+ 'legend.triage': 'not settled',
91
+ 'legend.done': 'closed',
92
+ 'legend.solid': 'solid = prerequisite still open',
93
+ 'legend.dashed': 'dashed = prerequisite closed',
94
+ 'node.aria': '#{n} {title}, {status}',
95
+
96
+ 'group.linked': 'other dependency chains',
97
+ 'group.island': 'independent issues',
98
+ 'group.orphan': 'sub-issues of #{n}',
99
+ 'group.islandSub': 'nothing blocks anything, all can start in parallel',
100
+ 'group.linkedSub': 'linked by prerequisites, but under no parent issue',
101
+ 'group.spec': '#{n} parent spec',
102
+ 'group.progress': 'sub-issues {done} / {total} done · parent closes only when all do',
103
+
104
+ 'status.ready': 'ready',
105
+ 'status.active': 'in progress',
106
+ 'status.blocked': 'blocked',
107
+ 'status.triage': 'needs triage',
108
+ 'status.done': 'done',
109
+
110
+ 'step.active': 'in progress',
111
+ 'step.manual': 'human work',
112
+ 'step.waitIssues': 'waiting on {issues}',
113
+ 'step.waitChildren': {
114
+ one: 'waiting on {n} sub-issue',
115
+ other: 'waiting on {n} sub-issues',
116
+ },
117
+ 'step.parentReady': 'all sub-issues closed, safe to close',
118
+
119
+ 'detail.status': 'status',
120
+ 'detail.next': 'next step',
121
+ 'detail.author': 'opened by',
122
+ 'detail.parent': 'parent',
123
+ 'detail.waiting': 'waiting on',
124
+ 'detail.settled': 'cleared prerequisites',
125
+ 'detail.unlocks': 'closing unlocks',
126
+ 'detail.labels': 'labels',
127
+ 'detail.assignees': 'assigned',
128
+ 'detail.closedAt': ' closed {date}',
129
+ 'detail.open': 'Open on GitHub ↗',
130
+ 'detail.empty': 'No issue',
131
+
132
+ 'row.waits': 'waiting on {list}',
133
+
134
+ 'list.title': 'List',
135
+ 'list.count': { one: '{n} issue', other: '{n} issues' },
136
+ 'tabs.aria': 'Filter by status',
137
+ 'tabs.all': 'all',
138
+
139
+ 'copy.title': 'Copy {command}',
140
+ 'copy.done': 'copied',
141
+ 'copy.fail': 'copy failed',
142
+ } as const satisfies Readonly<Record<string, Message>>
143
+
144
+ export type MessageKey = keyof typeof EN
145
+
146
+ /** 翻譯要把鍵補齊;單複數那幾句可以只寫一句。 */
147
+ type Messages = Readonly<Record<MessageKey, Message>>
148
+
149
+ // ---- 翻譯 ----
150
+
151
+ const ZH_TW: Messages = {
152
+ 'lang.label': '語言',
153
+
154
+ 'title.withRepo': '{repo} 開發地圖',
155
+ 'title.plain': '開發地圖',
156
+
157
+ 'stat.ready': '現在可動',
158
+ 'stat.active': '有人接手',
159
+ 'stat.blocked': '被前置擋住',
160
+ 'stat.triage': '規格未定案',
161
+ 'stat.critical': '關鍵路徑',
162
+ 'stat.critical.unit': '張',
163
+
164
+ 'lede.allDone': '這個 repo 沒有未完成的票。',
165
+ 'lede.open': '{n} 張未完成。',
166
+ 'lede.ready': '<strong>現在可動 {n} 張,最前面是 {issues}</strong>。',
167
+ 'lede.none': '<strong>現在沒有可動的票</strong>——每一張都在等前置或等 triage。',
168
+ 'lede.blocked': '{n} 張被前置擋住。',
169
+ 'lede.triage': '{n} 張規格還沒定案,要先 triage。',
170
+ 'lede.critical': '關鍵路徑 {n} 張,那是最少要幾輪才收得完。',
171
+
172
+ 'foot.truth': '狀態的權威是 GitHub Issues。這一頁是快照,頁面上不能改狀態。',
173
+ 'foot.refresh': '重跑 {build} 產檔,或 {serve} 讓重新整理就是最新。',
174
+ 'foot.vocab': '這一次的判準:掛 {ready} 才算可動;掛 {unready} 或沒掛角色標籤算未定案。',
175
+ 'foot.noVocab': '這個 repo 沒有在用 triage 標籤,狀態只看阻擋與接手。',
176
+ 'join.or': ' 或 ',
177
+ 'join.slash': '/',
178
+ 'join.items': '、',
179
+
180
+ 'fold.collapse': '收起 #{n} 的子票',
181
+ 'fold.expand': '展開 #{n} 的子票',
182
+
183
+ 'map.stations': '{n} 站',
184
+ 'map.islandName': '無前置',
185
+ 'map.islandSub': '可各自開工',
186
+ 'legend.ready': '現在可動',
187
+ 'legend.active': '有人接手',
188
+ 'legend.blocked': '等前置',
189
+ 'legend.triage': '規格未定案',
190
+ 'legend.done': '已關閉',
191
+ 'legend.solid': '實線=還沒解開的前置',
192
+ 'legend.dashed': '虛線=前置已關',
193
+ 'node.aria': '#{n} {title},{status}',
194
+
195
+ 'group.linked': '其他依賴鏈',
196
+ 'group.island': '獨立票',
197
+ 'group.orphan': '#{n} 的子票',
198
+ 'group.islandSub': '互不阻擋,可各自開工',
199
+ 'group.linkedSub': '有前置關係,但不屬於任何母票',
200
+ 'group.spec': '#{n} 母票規格',
201
+ 'group.progress': '子票 {done} / {total} 已完成 · 全關後才關 parent',
202
+
203
+ 'status.ready': '可接手',
204
+ 'status.active': '進行中',
205
+ 'status.blocked': '阻擋中',
206
+ 'status.triage': '待 triage',
207
+ 'status.done': '已完成',
208
+
209
+ 'step.active': '進行中',
210
+ 'step.manual': '人工實作',
211
+ 'step.waitIssues': '等 {issues}',
212
+ 'step.waitChildren': '等 {n} 張子票關完',
213
+ 'step.parentReady': '子票全關,可以關掉了',
214
+
215
+ 'detail.status': '狀態',
216
+ 'detail.next': '下一步',
217
+ 'detail.author': '開票',
218
+ 'detail.parent': '母票',
219
+ 'detail.waiting': '等誰',
220
+ 'detail.settled': '已解鎖的前置',
221
+ 'detail.unlocks': '關掉後解鎖',
222
+ 'detail.labels': '標籤',
223
+ 'detail.assignees': '接手',
224
+ 'detail.closedAt': ' {date} 關閉',
225
+ 'detail.open': '在 GitHub 開啟 ↗',
226
+ 'detail.empty': '沒有 issue',
227
+
228
+ 'row.waits': '等 {list}',
229
+
230
+ 'list.title': '清單',
231
+ 'list.count': '{n} 張',
232
+ 'tabs.aria': '狀態篩選',
233
+ 'tabs.all': '全部',
234
+
235
+ 'copy.title': '複製 {command}',
236
+ 'copy.done': '已複製',
237
+ 'copy.fail': '複製不了',
238
+ }
239
+
240
+ const ZH_CN: Messages = {
241
+ 'lang.label': '语言',
242
+
243
+ 'title.withRepo': '{repo} 开发地图',
244
+ 'title.plain': '开发地图',
245
+
246
+ 'stat.ready': '现在可动',
247
+ 'stat.active': '有人接手',
248
+ 'stat.blocked': '被前置挡住',
249
+ 'stat.triage': '规格未定案',
250
+ 'stat.critical': '关键路径',
251
+ 'stat.critical.unit': '张',
252
+
253
+ 'lede.allDone': '这个 repo 没有未完成的票。',
254
+ 'lede.open': '{n} 张未完成。',
255
+ 'lede.ready': '<strong>现在可动 {n} 张,最前面是 {issues}</strong>。',
256
+ 'lede.none': '<strong>现在没有可动的票</strong>——每一张都在等前置或等 triage。',
257
+ 'lede.blocked': '{n} 张被前置挡住。',
258
+ 'lede.triage': '{n} 张规格还没定案,要先 triage。',
259
+ 'lede.critical': '关键路径 {n} 张,那是最少要几轮才收得完。',
260
+
261
+ 'foot.truth': '状态的权威是 GitHub Issues。这一页是快照,页面上不能改状态。',
262
+ 'foot.refresh': '重跑 {build} 产文件,或 {serve} 让刷新就是最新。',
263
+ 'foot.vocab': '这一次的判准:挂 {ready} 才算可动;挂 {unready} 或没挂角色标签算未定案。',
264
+ 'foot.noVocab': '这个 repo 没有在用 triage 标签,状态只看阻挡与接手。',
265
+ 'join.or': ' 或 ',
266
+ 'join.slash': '/',
267
+ 'join.items': '、',
268
+
269
+ 'fold.collapse': '收起 #{n} 的子票',
270
+ 'fold.expand': '展开 #{n} 的子票',
271
+
272
+ 'map.stations': '{n} 站',
273
+ 'map.islandName': '无前置',
274
+ 'map.islandSub': '可各自开工',
275
+ 'legend.ready': '现在可动',
276
+ 'legend.active': '有人接手',
277
+ 'legend.blocked': '等前置',
278
+ 'legend.triage': '规格未定案',
279
+ 'legend.done': '已关闭',
280
+ 'legend.solid': '实线=还没解开的前置',
281
+ 'legend.dashed': '虚线=前置已关',
282
+ 'node.aria': '#{n} {title},{status}',
283
+
284
+ 'group.linked': '其他依赖链',
285
+ 'group.island': '独立票',
286
+ 'group.orphan': '#{n} 的子票',
287
+ 'group.islandSub': '互不阻挡,可各自开工',
288
+ 'group.linkedSub': '有前置关系,但不属于任何母票',
289
+ 'group.spec': '#{n} 母票规格',
290
+ 'group.progress': '子票 {done} / {total} 已完成 · 全关后才关 parent',
291
+
292
+ 'status.ready': '可接手',
293
+ 'status.active': '进行中',
294
+ 'status.blocked': '阻挡中',
295
+ 'status.triage': '待 triage',
296
+ 'status.done': '已完成',
297
+
298
+ 'step.active': '进行中',
299
+ 'step.manual': '人工实作',
300
+ 'step.waitIssues': '等 {issues}',
301
+ 'step.waitChildren': '等 {n} 张子票关完',
302
+ 'step.parentReady': '子票全关,可以关掉了',
303
+
304
+ 'detail.status': '状态',
305
+ 'detail.next': '下一步',
306
+ 'detail.author': '开票',
307
+ 'detail.parent': '母票',
308
+ 'detail.waiting': '等谁',
309
+ 'detail.settled': '已解锁的前置',
310
+ 'detail.unlocks': '关掉后解锁',
311
+ 'detail.labels': '标签',
312
+ 'detail.assignees': '接手',
313
+ 'detail.closedAt': ' {date} 关闭',
314
+ 'detail.open': '在 GitHub 打开 ↗',
315
+ 'detail.empty': '没有 issue',
316
+
317
+ 'row.waits': '等 {list}',
318
+
319
+ 'list.title': '清单',
320
+ 'list.count': '{n} 张',
321
+ 'tabs.aria': '状态筛选',
322
+ 'tabs.all': '全部',
323
+
324
+ 'copy.title': '复制 {command}',
325
+ 'copy.done': '已复制',
326
+ 'copy.fail': '复制不了',
327
+ }
328
+
329
+ const JA: Messages = {
330
+ 'lang.label': '言語',
331
+
332
+ 'title.withRepo': '{repo} 開発マップ',
333
+ 'title.plain': '開発マップ',
334
+
335
+ 'stat.ready': '着手できる',
336
+ 'stat.active': '対応中',
337
+ 'stat.blocked': '前提待ち',
338
+ 'stat.triage': '仕様未確定',
339
+ 'stat.critical': 'クリティカルパス',
340
+ 'stat.critical.unit': '件',
341
+
342
+ 'lede.allDone': 'この repo に未完了のチケットはありません。',
343
+ 'lede.open': '未完了 {n} 件。',
344
+ 'lede.ready': '<strong>着手できるのは {n} 件、先頭は {issues}</strong>。',
345
+ 'lede.none':
346
+ '<strong>いま着手できるチケットはありません</strong>——すべて前提か triage 待ちです。',
347
+ 'lede.blocked': '{n} 件が前提で止まっています。',
348
+ 'lede.triage': '{n} 件は仕様が未確定で、まず triage が必要です。',
349
+ 'lede.critical': 'クリティカルパス {n} 件——最短でも何周かかるかを示します。',
350
+
351
+ 'foot.truth':
352
+ '状態の正は GitHub Issues です。このページはスナップショットで、ここでは状態を変えられません。',
353
+ 'foot.refresh': 'ファイルを作り直すなら {build}、更新が常に最新になるのは {serve}。',
354
+ 'foot.vocab':
355
+ '今回の判定基準:{ready} が付いていれば着手可。{unready} または役割ラベルなしは未確定。',
356
+ 'foot.noVocab':
357
+ 'この repo は triage ラベルを使っていないため、状態は前提と担当だけで決まります。',
358
+ 'join.or': ' または ',
359
+ 'join.slash': '/',
360
+ 'join.items': '、',
361
+
362
+ 'fold.collapse': '#{n} のサブチケットを折りたたむ',
363
+ 'fold.expand': '#{n} のサブチケットを開く',
364
+
365
+ 'map.stations': '{n} 駅',
366
+ 'map.islandName': '前提なし',
367
+ 'map.islandSub': 'それぞれ着手可',
368
+ 'legend.ready': '着手できる',
369
+ 'legend.active': '対応中',
370
+ 'legend.blocked': '前提待ち',
371
+ 'legend.triage': '仕様未確定',
372
+ 'legend.done': 'クローズ済み',
373
+ 'legend.solid': '実線=未解決の前提',
374
+ 'legend.dashed': '破線=前提はクローズ済み',
375
+ 'node.aria': '#{n} {title}、{status}',
376
+
377
+ 'group.linked': 'その他の依存チェーン',
378
+ 'group.island': '独立チケット',
379
+ 'group.orphan': '#{n} のサブチケット',
380
+ 'group.islandSub': '互いにブロックせず、それぞれ着手できる',
381
+ 'group.linkedSub': '依存関係はあるが、親チケットには属さない',
382
+ 'group.spec': '#{n} 親チケットの仕様',
383
+ 'group.progress': 'サブチケット {done} / {total} 完了 · すべて閉じてから親を閉じる',
384
+
385
+ 'status.ready': '着手可',
386
+ 'status.active': '対応中',
387
+ 'status.blocked': 'ブロック中',
388
+ 'status.triage': 'triage 待ち',
389
+ 'status.done': '完了',
390
+
391
+ 'step.active': '対応中',
392
+ 'step.manual': '人が実装',
393
+ 'step.waitIssues': '{issues} を待つ',
394
+ 'step.waitChildren': 'サブチケット {n} 件の完了を待つ',
395
+ 'step.parentReady': 'サブチケットは全て完了、閉じられます',
396
+
397
+ 'detail.status': '状態',
398
+ 'detail.next': '次の一手',
399
+ 'detail.author': '起票',
400
+ 'detail.parent': '親チケット',
401
+ 'detail.waiting': '待っているもの',
402
+ 'detail.settled': '解決済みの前提',
403
+ 'detail.unlocks': '閉じると動くもの',
404
+ 'detail.labels': 'ラベル',
405
+ 'detail.assignees': '担当',
406
+ 'detail.closedAt': ' {date} クローズ',
407
+ 'detail.open': 'GitHub で開く ↗',
408
+ 'detail.empty': 'issue がありません',
409
+
410
+ 'row.waits': '{list} を待つ',
411
+
412
+ 'list.title': '一覧',
413
+ 'list.count': '{n} 件',
414
+ 'tabs.aria': '状態で絞り込む',
415
+ 'tabs.all': 'すべて',
416
+
417
+ 'copy.title': '{command} をコピー',
418
+ 'copy.done': 'コピーしました',
419
+ 'copy.fail': 'コピーできません',
420
+ }
421
+
422
+ const DICTS: Readonly<Record<Locale, Messages>> = {
423
+ en: EN,
424
+ 'zh-TW': ZH_TW,
425
+ 'zh-CN': ZH_CN,
426
+ ja: JA,
427
+ }
428
+
429
+ // ---- 查表 ----
430
+
431
+ /**
432
+ * 句子裡的代入名,從原稿的字面型別抽出來。少傳一個編不過——不然缺的那個會以 `{name}` 的樣子
433
+ * 印在畫面上,而那要真的跑到那一格才看得到。
434
+ */
435
+ type Names<S extends string> = S extends `${string}{${infer K}}${infer Rest}`
436
+ ? K | Names<Rest>
437
+ : never
438
+
439
+ type VarsOf<M> = M extends string
440
+ ? Names<M>
441
+ : M extends Plural
442
+ ? 'n' | Names<M['one']> | Names<M['other']>
443
+ : never
444
+
445
+ type Args<K extends MessageKey> = [VarsOf<(typeof EN)[K]>] extends [never]
446
+ ? []
447
+ : [vars: Readonly<Record<VarsOf<(typeof EN)[K]>, string | number>>]
448
+
449
+ /** 現在的語言只有這一份。畫面那一側先 `setLocale` 再整頁重畫。 */
450
+ let current: Locale = DEFAULT_LOCALE
451
+
452
+ export function locale(): Locale {
453
+ return current
454
+ }
455
+
456
+ export function isLocale(value: string | null): value is Locale {
457
+ return LOCALES.includes(value as Locale)
458
+ }
459
+
460
+ export function setLocale(next: Locale): void {
461
+ current = next
462
+ }
463
+
464
+ const RULES = new Map<Locale, Intl.PluralRules>()
465
+
466
+ function pluralRules(of: Locale): Intl.PluralRules {
467
+ const cached = RULES.get(of)
468
+ if (cached) return cached
469
+ const made = new Intl.PluralRules(of)
470
+ RULES.set(of, made)
471
+ return made
472
+ }
473
+
474
+ export function t<K extends MessageKey>(key: K, ...args: Args<K>): string {
475
+ const vars = (args[0] ?? {}) as Readonly<Record<string, string | number>>
476
+ const message: Message = DICTS[current][key]
477
+ const text =
478
+ typeof message === 'string'
479
+ ? message
480
+ : pluralRules(current).select(Number(vars.n)) === 'one'
481
+ ? message.one
482
+ : message.other
483
+ return text.replace(/\{(\w+)\}/g, (whole, name: string) =>
484
+ name in vars ? String(vars[name]) : whole,
485
+ )
486
+ }