issue-map 0.3.1 → 0.4.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.
@@ -424,6 +424,7 @@
424
424
  }
425
425
 
426
426
  .map-wrap {
427
+ position: relative;
427
428
  background: var(--card);
428
429
  border: 1px solid var(--line);
429
430
  border-top: 2px solid var(--track, var(--line-strong));
@@ -537,17 +538,42 @@
537
538
  }
538
539
 
539
540
  /* 線名 */
540
- .tname {
541
+ /* 線名。畫在 SVG 外面的一欄,這樣長的名字用 text-overflow 就截得掉——SVG 的 <text> 不會
542
+ 換行也不會自己截斷,長一點就會蓋到第一個站點上。寬度比 MAP.gutter 少一點,留白給站點。 */
543
+ .tlabel {
544
+ position: absolute;
545
+ left: 12px;
546
+ width: 84px;
547
+ transform: translateY(-12px);
541
548
  font-family: var(--mono);
549
+ pointer-events: none;
550
+ }
551
+ .tlabel b,
552
+ .tlabel span {
553
+ display: block;
554
+ overflow: hidden;
555
+ white-space: nowrap;
556
+ text-overflow: ellipsis;
557
+ }
558
+ .tlabel b {
542
559
  font-size: 12px;
543
560
  font-weight: 600;
544
- fill: var(--muted);
561
+ color: var(--muted);
545
562
  font-variant-numeric: tabular-nums;
546
563
  }
547
- .tsub {
548
- font-family: var(--mono);
564
+ .tlabel span {
549
565
  font-size: 10px;
550
- fill: var(--faint);
566
+ color: var(--faint);
567
+ }
568
+
569
+ /* 沒有畫圖的那幾組。 */
570
+ .undrawn {
571
+ margin: 0;
572
+ padding: 14px 12px;
573
+ color: var(--faint);
574
+ font-size: 13px;
575
+ border: 1px dashed var(--line);
576
+ border-radius: 2px;
551
577
  }
552
578
 
553
579
  .map-key {
@@ -1,38 +1,21 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `scripts/issue-map.html` 這份樣板,產出一頁
4
- * 可以直接看的 HTML
4
+ * 可以直接看的 HTML。每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
5
5
  *
6
- * 狀態的權威永遠是 GitHub Issues。這一頁只是**快照**:頁面上不能改狀態,要更新就重跑這支。
7
- * 這樣不會長出第二個事實來源。
6
+ * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue(畫成「已完成」的
7
+ * 節點讓進度看得見,沒人牽著之後自然消失)。closed 是**指名**去要的——阻擋者、parent、parent
8
+ * 底下的子票,不掃整包,因為老 repo 幾千張 closed 裡通常只有個位數會留下。代價:同一組裡已
9
+ * 完成的兄弟票要靠 GitHub 原生 sub-issue 才抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,
10
+ * 那一組的進度會比實際少。
8
11
  *
9
- * 每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
10
- *
11
- * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue。後者畫成「已完成」
12
- * 的節點讓進度看得見,沒人牽著之後自然消失。
13
- *
14
- * closed 是**指名**去要的(阻擋者、parent、parent 底下的子票),不掃整包——老 repo 幾千張
15
- * closed 裡通常只有個位數會留下。代價:同一組裡已完成的兄弟票要靠 GitHub 原生 sub-issue 才
16
- * 抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,那一組的進度會比實際少。
17
- *
18
- * **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx issue-map@latest`
19
- * 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
20
- * `CONFIG`,整份對照表在 README。
21
- *
22
- * 票名不進地圖。曾經試過在內文加一個 `## 短名` 段落給站點當標籤,但那要每張票靠人維護、
23
- * 而且是票名的第二個事實來源,改標題不會改它。機械縮短標題也試過,這裡的標題沒有一致結構,
24
- * 縮出來讀不通。所以站點只掛票號,名字交給清單。
25
- *
26
- * 用法:
27
- * bun run scripts/issue-map.ts # 寫到 dist/issue-map.html
28
- * bun run scripts/issue-map.ts path/to/out.html
29
- *
30
- * 要「重新整理就是最新」,改跑 `scripts/issue-map-serve.ts`:它每個請求都呼叫這裡的
31
- * `takeSnapshot` 重抓一次。
12
+ * 用法:`bun run scripts/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
13
+ * 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
32
14
  */
33
15
 
34
16
  import { spawnSync } from 'bun'
35
17
 
18
+ import { LOCALE_NAME, LOCALES, t } from './issue-map-i18n.ts'
36
19
  import {
37
20
  criticalPathOf,
38
21
  groupsOf,
@@ -41,6 +24,7 @@ import {
41
24
  type Snapshot,
42
25
  type Status,
43
26
  } from './issue-map-model.ts'
27
+ import { esc, viewOf } from './issue-map-view.ts'
44
28
 
45
29
  const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
46
30
  const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
@@ -61,8 +45,7 @@ function labelList(raw: string | undefined, fallback: string): readonly string[]
61
45
  /**
62
46
  * 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
63
47
  *
64
- * repo 不在這裡:`gh` `{owner}`/`{repo}` 佔位符會從 cwd 的 git 推斷,要指定別的 repo 就設
65
- * `GH_REPO`(`gh` 自己的環境變數,fork 與多 remote 的判斷也一併交給它)。
48
+ * repo 不在這裡:那是 `gh` 自己的 `GH_REPO`,fork 與多 remote 的判斷也一併交給它。
66
49
  */
67
50
  const CONFIG = {
68
51
  /** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
@@ -119,6 +102,33 @@ const ISSUE_FIELDS = `
119
102
  blockedBy(first: 50) { nodes { number } }
120
103
  `
121
104
 
105
+ type GraphQLError = { readonly type?: string; readonly message?: string }
106
+
107
+ /**
108
+ * 從 `gh api graphql` 的輸出取出 data。
109
+ *
110
+ * **查不到的票不是錯誤。** 指名去要某個票號時,GitHub 會同時回 `data`(那個 alias 是 `null`)
111
+ * 與一筆 `NOT_FOUND`,而 `gh` 為了那筆錯誤以非零離開。號碼其實是 PR、票被轉移或刪掉、內文
112
+ * 慣例掃出來的誤判——在老 repo 上都是常態,一個掃不到就讓整張圖產不出來並不合理。所以只有
113
+ * `NOT_FOUND` 以外的錯誤才拋,資料照用。
114
+ *
115
+ * 連 data 都沒有(沒登入、網路不通)就拿 stderr 當原因拋出去。
116
+ */
117
+ export function dataOrThrow<T>(stdout: string, stderr: string): T {
118
+ let parsed: { data?: T; errors?: readonly GraphQLError[] } | undefined
119
+ try {
120
+ parsed = stdout ? (JSON.parse(stdout) as typeof parsed) : undefined
121
+ } catch {
122
+ parsed = undefined
123
+ }
124
+ const fatal = (parsed?.errors ?? []).filter((error) => error.type !== 'NOT_FOUND')
125
+ if (!parsed?.data || fatal.length) {
126
+ const why = fatal.length ? JSON.stringify(fatal) : stderr.trim() || stdout.trim()
127
+ throw new Error(`gh api graphql failed: ${why}`)
128
+ }
129
+ return parsed.data
130
+ }
131
+
122
132
  /**
123
133
  * 跑一次 `gh api graphql`。
124
134
  *
@@ -140,10 +150,7 @@ function run<T>(query: string, variables: Record<string, string> = {}): T {
140
150
  ]
141
151
  for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
142
152
  const result = spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
143
- if (result.exitCode !== 0) throw new Error(`gh api graphql failed: ${result.stderr.toString()}`)
144
- const parsed = JSON.parse(result.stdout.toString()) as { data: T; errors?: unknown }
145
- if (parsed.errors) throw new Error(`GraphQL errors: ${JSON.stringify(parsed.errors)}`)
146
- return parsed.data
153
+ return dataOrThrow<T>(result.stdout.toString(), result.stderr.toString())
147
154
  }
148
155
 
149
156
  const OPEN_QUERY = `
@@ -223,9 +230,8 @@ function childrenQuery(parent: number): string {
223
230
  /**
224
231
  * 拿這些 parent 底下的子票,為的是把同一組裡**已完成**的兄弟票撈出來當進度。
225
232
  *
226
- * 只有 GitHub 原生 sub-issue 有值。用內文 `## Parent` 慣例的 repo 這裡是空的,那些已完成的
227
- * 兄弟票就不會出現在圖上——要把它們找回來只能整包掃 closed,而那對老 repo 是幾十次請求換
228
- * 幾張票。open 的兄弟不必靠這裡,它們本來就在 open 那包。
233
+ * 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
234
+ * 代價)。open 的兄弟不必靠這裡,它們本來就在 open 那包。
229
235
  */
230
236
  function fetchChildren(parents: readonly number[]): RawIssue[] {
231
237
  type Batch = { repository: Record<string, { subIssues: Page<RawIssue> } | null> }
@@ -278,9 +284,7 @@ export function takeSnapshot(): Snapshot {
278
284
  open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
279
285
  )
280
286
 
281
- // 進圖的 closed issue 只有 open issue 還牽著的那些:它們的阻擋者、它們的 parent,以及同一個
282
- // parent 底下已完成的兄弟(那一組的進度)。所以不掃整包 closed,而是指名去要——老 repo 的
283
- // 幾千張 closed 裡通常只有個位數會留下,翻完它們是拿幾十次請求換幾張票。
287
+ // 指名去要而不掃整包 closed,理由見檔頭。
284
288
  const referenced = [...new Set([...blockers, ...openParents])].filter(
285
289
  (number) => !openNumbers.has(number),
286
290
  )
@@ -389,13 +393,11 @@ function describeIssue(raw: Issue, context: Context): MapIssue {
389
393
  }
390
394
  }
391
395
 
392
- /** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
393
396
  /**
394
397
  * 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
395
398
  *
396
- * 產出必須是**一個檔案**(artifact 的頁面就是一份 HTML),但來源不必——來源是 TypeScript
397
- * 所以型別跟這裡共用同一份定義,而且純推導測得到。`format: 'iife'` 是因為它要塞進行內;
398
- * 不 minify 是因為這是開發用的頁面,讀得懂比小重要。
399
+ * 產出必須是一個檔案,但來源不必——來源是 TypeScript,所以型別跟這裡共用同一份定義,而且純
400
+ * 推導測得到。`format: 'iife'` 是因為它要塞進行內;不 minify 是因為這是開發用的頁面。
399
401
  */
400
402
  async function bundleClient(): Promise<string> {
401
403
  const built = await Bun.build({
@@ -410,6 +412,56 @@ async function bundleClient(): Promise<string> {
410
412
  return output.text()
411
413
  }
412
414
 
415
+ /**
416
+ * 把樣板裡某個容器的內容填掉。
417
+ *
418
+ * 樣板裡這些容器都是空的(`<div id="x"></div>`),所以只要在開頭標籤與結尾標籤之間插入即可,
419
+ * 不必真的剖析 HTML。填不到就是樣板被改壞了,直接喊。
420
+ */
421
+ function fillById(html: string, id: string, body: string): string {
422
+ // 標籤名要吃得到數字,`h1`、`h2` 都是容器。
423
+ const marker = new RegExp(`(<[a-z][a-z0-9]*[^>]*\\sid="${id}"[^>]*>)(</[a-z][a-z0-9]*>)`)
424
+ const next = html.replace(marker, `$1${body}$2`)
425
+ if (next === html) throw new Error(`Template is missing an empty #${id}: ${TEMPLATE}`)
426
+ return next
427
+ }
428
+
429
+ /**
430
+ * 建置時就把整頁畫好。
431
+ *
432
+ * 標記由 `issue-map-view.ts` 產生,畫面那一支重畫時用的是同一批函式——所以沒有 JS 的環境看到的
433
+ * 是同一份頁面,只是不能互動。少了這一步,擋掉 inline script 的地方(嚴格 CSP、某些預覽窗)
434
+ * 拿到的會是一份空骨架。
435
+ *
436
+ * 預設語言是英文;換語言要有 JS,那本來就不是靜態檔能做的事。
437
+ */
438
+ function prerender(html: string, snapshot: Snapshot): string {
439
+ const view = viewOf(snapshot)
440
+ const foot = view.footerHTML()
441
+ const detail = view.detailPanelHTML(view.defaultPick())
442
+ const rows = view.rowsHTML('all')
443
+ const title = view.title()
444
+ const filled: [string, string][] = [
445
+ ['eyebrow', esc(view.eyebrow())],
446
+ ['page-title', esc(title)],
447
+ ['lede', view.lede()],
448
+ ['stats', view.statsHTML()],
449
+ ['detail', detail.html],
450
+ ['groups', view.groupsHTML()],
451
+ ['list-title', esc(t('list.title'))],
452
+ ['list-sub', esc(t('list.count', { n: rows.shown }))],
453
+ ['tabs', view.tabsHTML('all')],
454
+ ['rows', rows.html],
455
+ ['foot-truth', foot.truth],
456
+ ['foot-refresh', foot.refresh],
457
+ ['foot-config', foot.config],
458
+ ['lang', LOCALES.map((l) => `<option value="${l}">${esc(LOCALE_NAME[l])}</option>`).join('')],
459
+ ]
460
+ const withBody = filled.reduce((acc, [id, body]) => fillById(acc, id, body), html)
461
+ return replaceIn(withBody, /(<title>)[\s\S]*?(<\/title>)/, esc(title), 'title')
462
+ }
463
+
464
+ /** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
413
465
  export async function renderFragment(snapshot: Snapshot): Promise<string> {
414
466
  const template = await Bun.file(TEMPLATE).text()
415
467
  const withData = replaceIn(
@@ -419,8 +471,9 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
419
471
  JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
420
472
  'issue-map-data',
421
473
  )
474
+ const withPage = prerender(withData, snapshot)
422
475
  return replaceIn(
423
- withData,
476
+ withPage,
424
477
  /(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
425
478
  // 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
426
479
  (await bundleClient()).replace(/<\/script/gi, '<\\/script'),
@@ -428,6 +481,18 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
428
481
  )
429
482
  }
430
483
 
484
+ /**
485
+ * 把片段包成一份完整文件。**直接開檔案看的一律走這裡**:少了 doctype 瀏覽器會進 quirks mode。
486
+ * charset 是防禦性的——從 `file://` 開沒有 header 可依靠,而這一頁帶著四種語言的文案。
487
+ *
488
+ * `lang` 是預設語言;頁面上換語言時畫面那一支會改掉 `documentElement.lang`。
489
+ */
490
+ export async function renderDocument(snapshot: Snapshot): Promise<string> {
491
+ const head =
492
+ '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
493
+ return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
494
+ }
495
+
431
496
  function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
432
497
  const next = html.replace(marker, `$1${body}$2`)
433
498
  if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
@@ -441,6 +506,6 @@ export function describe(snapshot: Snapshot): string {
441
506
 
442
507
  if (import.meta.main) {
443
508
  const snapshot = takeSnapshot()
444
- await Bun.write(OUTPUT, await renderFragment(snapshot))
509
+ await Bun.write(OUTPUT, await renderDocument(snapshot))
445
510
  console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
446
511
  }