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.
@@ -13,13 +13,21 @@ export type Status = 'ready' | 'active' | 'blocked' | 'triage' | 'done'
13
13
  /** 五個狀態的顯示順序,也是清單的排序權重。 */
14
14
  export const STATUS_ORDER: readonly Status[] = ['ready', 'active', 'blocked', 'triage', 'done']
15
15
 
16
- export const STATUS_LABEL: Readonly<Record<Status, string>> = {
17
- ready: '可接手',
18
- active: '進行中',
19
- blocked: '阻擋中',
20
- triage: '待 triage',
21
- done: '已完成',
22
- }
16
+ /**
17
+ * 下一步。**這裡只放事實,不放句子**——「等 3 張子票關完」這種話由 `issue-map-i18n.ts` 依當下
18
+ * 語言組出來。快照裡存中文句子的話,換語言就得重抓一次 GitHub。
19
+ *
20
+ * `command` 的內容是設定(`/implement`、`/triage`),不是文案,所以照原字帶著走。
21
+ */
22
+ export type NextStep =
23
+ | { readonly kind: 'none' }
24
+ | { readonly kind: 'command'; readonly command: string }
25
+ | { readonly kind: 'manual' }
26
+ /** 有人接手。`who` 是 assignee,沒有 assignee 但掛了 active 標籤時是那個標籤名。 */
27
+ | { readonly kind: 'active'; readonly who: readonly string[] }
28
+ | { readonly kind: 'waitIssues'; readonly issues: readonly number[] }
29
+ | { readonly kind: 'waitChildren'; readonly count: number }
30
+ | { readonly kind: 'parentReady' }
23
31
 
24
32
  /** 頁面吃的形狀。這裡是它唯一的定義。 */
25
33
  export type MapIssue = {
@@ -37,17 +45,28 @@ export type MapIssue = {
37
45
  /** 還開著的阻擋者,就是實際的閘門。 */
38
46
  readonly waitingFor: readonly number[]
39
47
  readonly status: Status
40
- /** 白話的下一步,已完成的票是空字串。 */
41
- readonly nextStep: string
48
+ /** 下一步。句子在 i18n 那一層才組出來。 */
49
+ readonly nextStep: NextStep
42
50
  /** 有子票的票。它自己不做事,等子票全關。 */
43
51
  readonly isParent: boolean
44
52
  }
45
53
 
54
+ /**
55
+ * 一群票的名字。`spec` 帶的是主票標題——那是真資料,不翻;另外三種是頁面自己的分類,翻譯在
56
+ * i18n 那一層。
57
+ */
58
+ export type GroupName =
59
+ | { readonly kind: 'spec'; readonly title: string }
60
+ /** 主票沒被帶進快照,只好用票號稱呼這一群。 */
61
+ | { readonly kind: 'orphan'; readonly parent: number }
62
+ | { readonly kind: 'linked' }
63
+ | { readonly kind: 'island' }
64
+
46
65
  /** 畫在同一張圖上的一群票。 */
47
66
  export type Group = {
48
67
  /** 有 parent 的群就是那張主票;沒有的是「其他依賴鏈」與「獨立票」這兩種。 */
49
68
  readonly parent: number | null
50
- readonly title: string
69
+ readonly name: GroupName
51
70
  readonly members: readonly number[]
52
71
  }
53
72
 
@@ -81,7 +100,8 @@ export function groupsOf(issues: readonly MapIssue[]): Group[] {
81
100
  const groups: Group[] = []
82
101
  for (const [parent, members] of byParent) {
83
102
  const spec = issues.find((issue) => issue.number === parent)
84
- groups.push({ parent, title: spec?.title ?? `#${parent} 的子票`, members })
103
+ const name: GroupName = spec ? { kind: 'spec', title: spec.title } : { kind: 'orphan', parent }
104
+ groups.push({ parent, name, members })
85
105
  }
86
106
 
87
107
  const grouped = new Set([...byParent.values()].flat())
@@ -92,8 +112,8 @@ export function groupsOf(issues: readonly MapIssue[]): Group[] {
92
112
  const hasEdge = blocking.has(issue.number) || issue.blockedBy.some((n) => known.has(n))
93
113
  ;(hasEdge ? linked : alone).push(issue.number)
94
114
  }
95
- if (linked.length) groups.push({ parent: null, title: '其他依賴鏈', members: linked })
96
- if (alone.length) groups.push({ parent: null, title: '獨立票', members: alone })
115
+ if (linked.length) groups.push({ parent: null, name: { kind: 'linked' }, members: linked })
116
+ if (alone.length) groups.push({ parent: null, name: { kind: 'island' }, members: alone })
97
117
  return groups
98
118
  }
99
119
 
@@ -3,9 +3,20 @@
3
3
  * **只能碰瀏覽器的東西**,不能 import Bun 的 API。
4
4
  *
5
5
  * 形狀與純推導在 `issue-map-model.ts`:狀態的五個值、票的形狀、分群、關鍵路徑、線路圖的排版
6
- * 都在那邊,兩側共用同一份定義。這裡剩下的是 DOM、事件與收合狀態。
6
+ * 都在那邊,兩側共用同一份定義。文案全部在 `issue-map-i18n.ts`,這一支不寫死任何一句給人看的
7
+ * 話。剩下的是 DOM、事件、收合與語言狀態。
7
8
  */
8
9
 
10
+ import {
11
+ DEFAULT_LOCALE,
12
+ isLocale,
13
+ type Locale,
14
+ LOCALE_NAME,
15
+ LOCALES,
16
+ locale,
17
+ setLocale,
18
+ t,
19
+ } from './issue-map-i18n.ts'
9
20
  import {
10
21
  layoutOf,
11
22
  MAP,
@@ -13,9 +24,9 @@ import {
13
24
  type Group,
14
25
  type Layout,
15
26
  type MapIssue,
27
+ type NextStep,
16
28
  type Point,
17
29
  type Snapshot,
18
- STATUS_LABEL,
19
30
  STATUS_ORDER,
20
31
  type Status,
21
32
  } from './issue-map-model.ts'
@@ -82,6 +93,62 @@ function issueAt(number: number): MapIssue | undefined {
82
93
  return byNumber.get(number)
83
94
  }
84
95
 
96
+ // ---- 語言 ----
97
+
98
+ /**
99
+ * 語言記在瀏覽器、而且不分 repo——同一個人看好幾個 repo 的地圖,語言是他的偏好,不是某個專案
100
+ * 的設定。讀不到(無痕、封鎖)或存的是舊值就用預設的英文。
101
+ *
102
+ * 刻意不看 `navigator.language`:這一頁的預設語言是英文,猜錯了反而要每次進來都改回去。
103
+ */
104
+ const LOCALE_KEY = 'issue-map:locale'
105
+
106
+ function readLocale(): Locale {
107
+ try {
108
+ const saved = localStorage.getItem(LOCALE_KEY)
109
+ if (isLocale(saved)) return saved
110
+ } catch {
111
+ // 讀不到就用預設,畫面照樣是完整的。
112
+ }
113
+ return DEFAULT_LOCALE
114
+ }
115
+
116
+ function statusLabel(status: Status): string {
117
+ return t(`status.${status}`)
118
+ }
119
+
120
+ /** 日期一律照當下語言排。快照裡存的是 ISO 字串,格式化是畫面的事。 */
121
+ function dateTime(iso: string): string {
122
+ return new Date(iso).toLocaleString(locale(), { hour12: false })
123
+ }
124
+
125
+ function dateOnly(iso: string): string {
126
+ return new Date(iso).toLocaleDateString(locale())
127
+ }
128
+
129
+ /** 語言選單只做一次;換語言是整頁重畫,選單自己不重建,不然焦點會掉。 */
130
+ function mountLangPicker(): void {
131
+ const picker = pick('lang')
132
+ if (!(picker instanceof HTMLSelectElement)) throw new Error('#lang 不是 select')
133
+ for (const option of LOCALES) {
134
+ const node = document.createElement('option')
135
+ node.value = option
136
+ node.textContent = LOCALE_NAME[option]
137
+ picker.appendChild(node)
138
+ }
139
+ picker.value = locale()
140
+ picker.addEventListener('change', () => {
141
+ if (!isLocale(picker.value)) return
142
+ setLocale(picker.value)
143
+ try {
144
+ localStorage.setItem(LOCALE_KEY, picker.value)
145
+ } catch {
146
+ // 存不了就只在這一次有效。
147
+ }
148
+ render()
149
+ })
150
+ }
151
+
85
152
  // ---- 收合狀態 ----
86
153
 
87
154
  /**
@@ -89,6 +156,8 @@ function issueAt(number: number): MapIssue | undefined {
89
156
  * 就當成全部展開。
90
157
  */
91
158
  const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
159
+
160
+ /** 每顆收合鈕重畫自己的樣子。整頁重畫時要清掉,不然會留著指向已被移除的鈕的函式。 */
92
161
  const onFold: (() => void)[] = []
93
162
 
94
163
  function readFolded(): Set<string> {
@@ -114,6 +183,7 @@ function setFolded(parent: number, shut: boolean): void {
114
183
  // 存不了就只在這一次有效,畫面照樣能開合。
115
184
  }
116
185
  for (const repaint of onFold) repaint()
186
+ paintFolded()
117
187
  }
118
188
 
119
189
  /** 做一顆收合鈕。`parent` 是主票號碼,同一個號碼的鈕與清單列連動。 */
@@ -124,7 +194,10 @@ function foldButton(parent: number): HTMLButtonElement {
124
194
  const paint = () => {
125
195
  const open = !isFolded(parent)
126
196
  button.setAttribute('aria-expanded', String(open))
127
- button.setAttribute('aria-label', `${open ? '收起' : '展開'} #${parent} 的子票`)
197
+ button.setAttribute(
198
+ 'aria-label',
199
+ open ? t('fold.collapse', { n: parent }) : t('fold.expand', { n: parent }),
200
+ )
128
201
  button.textContent = open ? '−' : '+'
129
202
  }
130
203
  paint()
@@ -147,56 +220,76 @@ function paintFolded(): void {
147
220
 
148
221
  function renderHeader(): void {
149
222
  const stats = pick('stats')
223
+ stats.innerHTML = ''
150
224
  const tiles: { label: string; value: number; unit?: string; tone?: string }[] = [
151
- { label: '現在可動', value: counts.ready, tone: 'ready' },
152
- { label: '有人接手', value: counts.active, tone: 'active' },
153
- { label: '被前置擋住', value: counts.blocked, unit: `/ ${openCount}`, tone: 'blocked' },
154
- { label: '規格未定案', value: counts.triage, tone: 'triage' },
155
- { label: '關鍵路徑', value: criticalPath, unit: '張' },
225
+ { label: t('stat.ready'), value: counts.ready, tone: 'ready' },
226
+ { label: t('stat.active'), value: counts.active, tone: 'active' },
227
+ { label: t('stat.blocked'), value: counts.blocked, unit: `/ ${openCount}`, tone: 'blocked' },
228
+ { label: t('stat.triage'), value: counts.triage, tone: 'triage' },
229
+ {
230
+ label: t('stat.critical'),
231
+ value: criticalPath,
232
+ unit: t('stat.critical.unit', { n: criticalPath }),
233
+ },
156
234
  ]
157
235
  for (const tile of tiles) {
158
236
  const box = document.createElement('div')
159
237
  box.className = 'stat'
160
238
  if (tile.tone) box.dataset.tone = tile.tone
161
- const unit = tile.unit ? `<small>${tile.unit}</small>` : ''
162
- box.innerHTML = `<b>${tile.value}${unit}</b><span>${tile.label}</span>`
239
+ const unit = tile.unit ? `<small>${esc(tile.unit)}</small>` : ''
240
+ box.innerHTML = `<b>${tile.value}${unit}</b><span>${esc(tile.label)}</span>`
163
241
  stats.appendChild(box)
164
242
  }
165
243
 
166
244
  // 抬頭全部照資料寫,換 repo 或換標籤字彙才不會留著上一個專案的字。
167
- const heading = `${repo.split('/').pop() || '開發地圖'} 開發地圖`
245
+ const name = repo.split('/').pop()
246
+ const heading = name ? t('title.withRepo', { repo: name }) : t('title.plain')
168
247
  pick('page-title').textContent = heading
169
248
  document.title = heading
170
- const when = raw.generatedAt
171
- ? ` · ${new Date(raw.generatedAt).toLocaleString('zh-TW', { hour12: false })}`
172
- : ''
249
+ document.documentElement.lang = locale()
250
+ pick('lang').setAttribute('aria-label', t('lang.label'))
251
+ const when = raw.generatedAt ? ` · ${dateTime(raw.generatedAt)}` : ''
173
252
  pick('eyebrow').textContent = repo + when
174
253
 
175
254
  pick('lede').innerHTML = lede()
255
+ renderFooter()
256
+ }
257
+
258
+ function renderFooter(): void {
259
+ pick('foot-truth').textContent = t('foot.truth')
260
+ const code = (command: string) => `<code>${esc(command)}</code>`
261
+ pick('foot-refresh').innerHTML = t('foot.refresh', {
262
+ build: code('bun run issue-map'),
263
+ serve: code('bun run issue-map:serve'),
264
+ })
176
265
 
177
266
  const vocab = raw.labels ?? { ready: [], unready: [] }
267
+ // textContent:標籤名是 repo 給的字,不經過 innerHTML 就不必逃脫。
178
268
  pick('foot-config').textContent = vocab.ready.length
179
- ? `這一次的判準:掛 ${vocab.ready.join('')} 才算可動;掛 ${vocab.unready.join('/')} 或沒掛角色標籤算未定案。`
180
- : '這個 repo 沒有在用 triage 標籤,狀態只看阻擋與接手。'
269
+ ? t('foot.vocab', {
270
+ ready: vocab.ready.join(t('join.or')),
271
+ unready: vocab.unready.join(t('join.slash')),
272
+ })
273
+ : t('foot.noVocab')
181
274
  }
182
275
 
183
276
  /** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
184
277
  function lede(): string {
185
- if (!openCount) return '這個 repo 沒有未完成的票。'
278
+ if (!openCount) return t('lede.allDone')
186
279
  const frontline = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
187
- const parts = [`${openCount} 張未完成。`]
280
+ const parts = [t('lede.open', { n: openCount })]
188
281
  if (frontline.length) {
189
282
  const first = frontline
190
283
  .slice(0, 3)
191
284
  .map((i) => `<a href="${esc(i.url)}" target="_blank" rel="noopener">#${i.number}</a>`)
192
- .join('')
193
- parts.push(`<strong>現在可動 ${frontline.length} 張,最前面是 ${first}</strong>。`)
285
+ .join(t('join.items'))
286
+ parts.push(t('lede.ready', { n: frontline.length, issues: first }))
194
287
  } else {
195
- parts.push('<strong>現在沒有可動的票</strong>——每一張都在等前置或等 triage。')
288
+ parts.push(t('lede.none'))
196
289
  }
197
- if (counts.blocked) parts.push(`${counts.blocked} 張被前置擋住。`)
198
- if (counts.triage) parts.push(`${counts.triage} 張規格還沒定案,要先 triage。`)
199
- if (criticalPath > 1) parts.push(`關鍵路徑 ${criticalPath} 張,那是最少要幾輪才收得完。`)
290
+ if (counts.blocked) parts.push(t('lede.blocked', { n: counts.blocked }))
291
+ if (counts.triage) parts.push(t('lede.triage', { n: counts.triage }))
292
+ if (criticalPath > 1) parts.push(t('lede.critical', { n: criticalPath }))
200
293
  return parts.join(' ')
201
294
  }
202
295
 
@@ -308,7 +401,7 @@ function stationFor(issue: MapIssue, q: Point): SVGAElement {
308
401
  station.setAttribute('rel', 'noopener')
309
402
  station.setAttribute(
310
403
  'aria-label',
311
- `#${issue.number} ${issue.title},${STATUS_LABEL[issue.status]}`,
404
+ t('node.aria', { n: issue.number, title: issue.title, status: statusLabel(issue.status) }),
312
405
  )
313
406
  station.dataset.status = issue.status
314
407
  station.dataset.number = String(issue.number)
@@ -344,10 +437,15 @@ function mapFor(shown: Shown): HTMLDivElement {
344
437
  layout.tracks.forEach((chain, index) => {
345
438
  const terminus = chain[chain.length - 1]
346
439
  if (chain.length < 2 || terminus === undefined) return
347
- trackLabel(svg, MAP.top + index * MAP.row, `→ #${terminus}`, `${chain.length} 站`)
440
+ trackLabel(
441
+ svg,
442
+ MAP.top + index * MAP.row,
443
+ `→ #${terminus}`,
444
+ t('map.stations', { n: chain.length }),
445
+ )
348
446
  })
349
447
  if (layout.islandRows) {
350
- trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, '無前置', '可各自開工')
448
+ trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, t('map.islandName'), t('map.islandSub'))
351
449
  }
352
450
 
353
451
  const seenTo = new Map<number, number>()
@@ -377,14 +475,17 @@ function mapFor(shown: Shown): HTMLDivElement {
377
475
  function mapKey(): HTMLDivElement {
378
476
  const key = document.createElement('div')
379
477
  key.className = 'map-key'
478
+ const shapes: readonly [string, string][] = [
479
+ ['k-ready', t('legend.ready')],
480
+ ['k-active', t('legend.active')],
481
+ ['k-blocked', t('legend.blocked')],
482
+ ['k-triage', t('legend.triage')],
483
+ ['k-done', t('legend.done')],
484
+ ]
380
485
  key.innerHTML =
381
- '<span><i class="k-ready"></i>現在可動</span>' +
382
- '<span><i class="k-active"></i>有人接手</span>' +
383
- '<span><i class="k-blocked"></i>等前置</span>' +
384
- '<span><i class="k-triage"></i>規格未定案</span>' +
385
- '<span><i class="k-done"></i>已關閉</span>' +
386
- '<span>實線=還沒解開的前置</span>' +
387
- '<span>虛線=前置已關</span>'
486
+ shapes.map(([mark, label]) => `<span><i class="${mark}"></i>${esc(label)}</span>`).join('') +
487
+ `<span>${esc(t('legend.solid'))}</span>` +
488
+ `<span>${esc(t('legend.dashed'))}</span>`
388
489
  return key
389
490
  }
390
491
 
@@ -392,6 +493,7 @@ const TRACK_COLOURS = ['--t1', '--t2', '--t3', '--t4']
392
493
 
393
494
  function renderGroups(): void {
394
495
  const groupsEl = pick('groups')
496
+ groupsEl.innerHTML = ''
395
497
  const shownGroups: Shown[] = (raw.groups ?? []).map((group, index) => ({
396
498
  group,
397
499
  members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
@@ -405,7 +507,7 @@ function renderGroups(): void {
405
507
 
406
508
  const header = document.createElement('div')
407
509
  header.className = 'group-head'
408
- header.innerHTML = `<h2>${esc(shown.group.title)}</h2><span class="sub">${groupSub(shown)}</span>`
510
+ header.innerHTML = `<h2>${esc(groupTitle(shown.group))}</h2><span class="sub">${groupSub(shown)}</span>`
409
511
  section.appendChild(header)
410
512
 
411
513
  const body = mapFor(shown)
@@ -423,15 +525,33 @@ function renderGroups(): void {
423
525
  }
424
526
  }
425
527
 
528
+ /** 一群票的標題。主票那一群用主票標題(真資料),其他三種是頁面自己的分類。 */
529
+ function groupTitle(group: Group): string {
530
+ switch (group.name.kind) {
531
+ case 'spec':
532
+ return group.name.title
533
+ case 'orphan':
534
+ return t('group.orphan', { n: group.name.parent })
535
+ case 'linked':
536
+ return t('group.linked')
537
+ case 'island':
538
+ return t('group.island')
539
+ }
540
+ }
541
+
426
542
  function groupSub(shown: Shown): string {
427
543
  const parent = shown.group.parent
428
- if (parent === null) return '互不阻擋,可各自開工'
544
+ // 「其他依賴鏈」與「獨立票」都沒有主票,但只有後者互不阻擋——同一句話蓋兩種群會說謊。
545
+ if (shown.group.name.kind === 'linked') return esc(t('group.linkedSub'))
546
+ if (parent === null) return esc(t('group.islandSub'))
429
547
  const done = shown.members.filter((m) => m.status === 'done').length
430
548
  const spec = issueAt(parent)
431
549
  const specLink = spec
432
- ? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">#${parent} parent spec</a> · `
550
+ ? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">${esc(
551
+ t('group.spec', { n: parent }),
552
+ )}</a> · `
433
553
  : ''
434
- return `${specLink}子票 ${done} / ${shown.members.length} 已完成 · 全關後才關 parent`
554
+ return specLink + esc(t('group.progress', { done, total: shown.members.length }))
435
555
  }
436
556
 
437
557
  // ---- 詳細 ----
@@ -459,20 +579,42 @@ function link(n: number): string {
459
579
  }
460
580
 
461
581
  function pill(issue: MapIssue): string {
462
- return `<span class="pill" data-status="${issue.status}">${STATUS_LABEL[issue.status]}</span>`
582
+ return `<span class="pill" data-status="${issue.status}">${esc(statusLabel(issue.status))}</span>`
583
+ }
584
+
585
+ /** 下一步的句子。模型只說是哪一種,話在這裡才組出來。 */
586
+ function stepText(step: NextStep): string {
587
+ switch (step.kind) {
588
+ case 'none':
589
+ return '—'
590
+ case 'command':
591
+ return step.command
592
+ case 'manual':
593
+ return t('step.manual')
594
+ case 'active':
595
+ return step.who.length ? step.who.join(t('join.slash')) : t('step.active')
596
+ case 'waitIssues':
597
+ return t('step.waitIssues', { issues: step.issues.map(hash).join(' ') })
598
+ case 'waitChildren':
599
+ return t('step.waitChildren', { n: step.count })
600
+ case 'parentReady':
601
+ return t('step.parentReady')
602
+ }
463
603
  }
464
604
 
465
605
  /**
466
- * 下一步是斜線指令的時候做成按鈕,按了把「指令票號」整句複製走——真正要貼進去的是那一
467
- * 整句,只顯示指令的話還得自己補票號。不是指令的(接手的人、等哪幾張)就純文字。
606
+ * 是指令的時候做成按鈕,按了把「指令票號」整句複製走——真正要貼進去的是那一整句,只顯示
607
+ * 指令的話還得自己補票號。其他的(接手的人、等哪幾張)就純文字。
468
608
  */
469
609
  function stepCell(issue: MapIssue): string {
470
- if (!issue.nextStep) return '<span class="next">—</span>'
471
- if (!issue.nextStep.startsWith('/')) return `<span class="next">${esc(issue.nextStep)}</span>`
472
- const command = `${issue.nextStep} #${issue.number}`
610
+ const step = issue.nextStep
611
+ const text = stepText(step)
612
+ if (step.kind !== 'command') return `<span class="next">${esc(text)}</span>`
613
+ const command = `${step.command} #${issue.number}`
614
+ const title = esc(t('copy.title', { command }))
473
615
  return (
474
- `<button type="button" class="copy" data-copy="${esc(command)}" title="複製 ${esc(command)}">` +
475
- `<span class="copy-text">${esc(issue.nextStep)}</span></button>`
616
+ `<button type="button" class="copy" data-copy="${esc(command)}" title="${title}">` +
617
+ `<span class="copy-text">${esc(step.command)}</span></button>`
476
618
  )
477
619
  }
478
620
 
@@ -482,26 +624,33 @@ function detailHTML(number: number): string {
482
624
  if (!issue) return ''
483
625
  const settled = issue.blockedBy.filter((b) => !issue.waitingFor.includes(b))
484
626
  const opens = (unlocks.get(number) ?? []).filter((n) => issueAt(n)?.status !== 'done')
485
- const closed = issue.closedAt
486
- ? ` ${new Date(issue.closedAt).toLocaleDateString('zh-TW')} 關閉`
487
- : ''
627
+ const closed = issue.closedAt ? esc(t('detail.closedAt', { date: dateOnly(issue.closedAt) })) : ''
488
628
  const rows: [string, string][] = [
489
- ['狀態', pill(issue) + closed],
490
- ['下一步', stepCell(issue)],
629
+ [t('detail.status'), pill(issue) + closed],
630
+ [t('detail.next'), stepCell(issue)],
491
631
  ]
492
- if (issue.author) rows.push(['開票', esc(issue.author)])
493
- if (issue.parent !== null) rows.push(['Parent', link(issue.parent)])
494
- if (issue.waitingFor.length) rows.push(['等誰', issue.waitingFor.map(link).join(' ')])
495
- if (settled.length) rows.push(['已解鎖的前置', settled.map(link).join(' ')])
496
- if (opens.length) rows.push(['關掉後解鎖', opens.map(link).join(' ')])
632
+ if (issue.author) rows.push([t('detail.author'), esc(issue.author)])
633
+ if (issue.parent !== null) rows.push([t('detail.parent'), link(issue.parent)])
634
+ if (issue.waitingFor.length) {
635
+ rows.push([t('detail.waiting'), issue.waitingFor.map(link).join(' ')])
636
+ }
637
+ if (settled.length) rows.push([t('detail.settled'), settled.map(link).join(' ')])
638
+ if (opens.length) rows.push([t('detail.unlocks'), opens.map(link).join(' ')])
497
639
  if (issue.labels.length) {
498
- rows.push(['標籤', issue.labels.map((l) => `<span class="label">${esc(l)}</span>`).join('')])
640
+ rows.push([
641
+ t('detail.labels'),
642
+ issue.labels.map((l) => `<span class="label">${esc(l)}</span>`).join(''),
643
+ ])
644
+ }
645
+ if (issue.assignees.length) {
646
+ rows.push([t('detail.assignees'), esc(issue.assignees.join(', '))])
499
647
  }
500
- if (issue.assignees.length) rows.push(['接手', esc(issue.assignees.join(', '))])
501
648
  return (
502
649
  `<h3><span class="num">#${issue.number}</span>${esc(issue.title)}</h3>` +
503
- `<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener">在 GitHub 開啟 ↗</a>` +
504
- `<dl class="rows">${rows.map(([dt, dd]) => `<dt>${dt}</dt><dd>${dd}</dd>`).join('')}</dl>`
650
+ `<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener">${esc(
651
+ t('detail.open'),
652
+ )}</a>` +
653
+ `<dl class="rows">${rows.map(([dt, dd]) => `<dt>${esc(dt)}</dt><dd>${dd}</dd>`).join('')}</dl>`
505
654
  )
506
655
  }
507
656
 
@@ -511,7 +660,7 @@ function renderDetail(number: number | undefined): void {
511
660
  const issue = number === undefined ? undefined : issueAt(number)
512
661
  if (!issue) {
513
662
  detail.dataset.status = ''
514
- detail.innerHTML = '<h3>沒有 issue</h3>'
663
+ detail.innerHTML = `<h3>${esc(t('detail.empty'))}</h3>`
515
664
  return
516
665
  }
517
666
  detail.dataset.status = issue.status
@@ -635,7 +784,9 @@ function rowHTML(row: Row): string {
635
784
  ? ` title="${esc(issue.waitingFor.map(hash).join(' '))}"`
636
785
  : ''
637
786
  const waits = issue.waitingFor.length
638
- ? `<span class="waits"${waitsTitle}>等 ${blockers(issue.waitingFor)}</span>`
787
+ ? `<span class="waits"${waitsTitle}>${t('row.waits', {
788
+ list: blockers(issue.waitingFor),
789
+ })}</span>`
639
790
  : ''
640
791
  return (
641
792
  `<div class="row" tabindex="0" data-status="${issue.status}" data-number="${issue.number}"${parent}${kids}>` +
@@ -651,7 +802,9 @@ function rowHTML(row: Row): string {
651
802
 
652
803
  function renderRows(): void {
653
804
  const rows = rowOrder()
654
- pick('list-sub').textContent = `${rows.filter((r) => !r.issue.isParent).length} 張`
805
+ const shown = rows.filter((r) => !r.issue.isParent).length
806
+ pick('list-title').textContent = t('list.title')
807
+ pick('list-sub').textContent = t('list.count', { n: shown })
655
808
  rowsEl.innerHTML = rows.map(rowHTML).join('')
656
809
  // 主票的收合鈕:innerHTML 重建過,鈕要重新放進去。
657
810
  for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-haskids="true"]')) {
@@ -661,9 +814,11 @@ function renderRows(): void {
661
814
 
662
815
  function renderTabs(): void {
663
816
  const tabs = pick('tabs')
817
+ tabs.innerHTML = ''
818
+ tabs.setAttribute('aria-label', t('tabs.aria'))
664
819
  const defs: [Status | 'all', string, number][] = [
665
- ['all', '全部', work.length],
666
- ...STATUS_ORDER.map((s) => [s, STATUS_LABEL[s], counts[s]] as [Status, string, number]),
820
+ ['all', t('tabs.all'), work.length],
821
+ ...STATUS_ORDER.map((s) => [s, statusLabel(s), counts[s]] as [Status, string, number]),
667
822
  ]
668
823
  for (const [key, label, count] of defs) {
669
824
  const tab = document.createElement('button')
@@ -742,7 +897,7 @@ function legacyCopy(text: string): Promise<void> {
742
897
  box.select()
743
898
  const ok = document.execCommand('copy')
744
899
  box.remove()
745
- return ok ? Promise.resolve() : Promise.reject(new Error('複製失敗'))
900
+ return ok ? Promise.resolve() : Promise.reject(new Error('execCommand copy failed'))
746
901
  }
747
902
 
748
903
  function copyText(text: string): Promise<void> {
@@ -763,12 +918,12 @@ function handleCopy(button: HTMLElement): void {
763
918
  copyText(command).then(
764
919
  () => {
765
920
  button.dataset.copied = 'true'
766
- flash(label, '已複製', 1200, () => {
921
+ flash(label, t('copy.done'), 1200, () => {
767
922
  delete button.dataset.copied
768
923
  label.textContent = was
769
924
  })
770
925
  },
771
- () => flash(label, '複製不了', 1600, () => (label.textContent = was)),
926
+ () => flash(label, t('copy.fail'), 1600, () => (label.textContent = was)),
772
927
  )
773
928
  }
774
929
 
@@ -816,10 +971,21 @@ document.addEventListener('keydown', (ev) => {
816
971
 
817
972
  // ---- 起動 ----
818
973
 
819
- renderHeader()
820
- renderGroups()
821
- renderTabs()
822
- renderRows()
823
- onFold.push(paintFolded)
824
- paintFolded()
825
- renderDetail(defaultPick())
974
+ /**
975
+ * 整頁重畫。換語言就是走這裡——每一段都會先清掉自己那一塊,所以重畫一次不會留下上一種語言的
976
+ * 殘骸。選取與收合是狀態不是文字,重畫時保留。
977
+ */
978
+ function render(): void {
979
+ onFold.length = 0
980
+ renderHeader()
981
+ renderGroups()
982
+ renderTabs()
983
+ renderRows()
984
+ paintFolded()
985
+ // paintSelection 收尾會畫詳細,沒有選取時它自己退回預設那一張。
986
+ paintSelection()
987
+ }
988
+
989
+ setLocale(readLocale())
990
+ mountLangPicker()
991
+ render()
@@ -47,7 +47,8 @@ async function page(): Promise<string> {
47
47
  // 樣板是 artifact 用的片段;本機直接看要補上完整文件與 charset。
48
48
  const head =
49
49
  '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
50
- return `<!doctype html><html lang="zh-Hant"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
50
+ // `lang` 是預設語言;頁面上換語言時畫面那一支會改掉 `documentElement.lang`。
51
+ return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
51
52
  }
52
53
 
53
54
  const server = Bun.serve({