issue-map 0.4.0 → 0.5.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.
package/README.ja.md CHANGED
@@ -57,13 +57,13 @@ CLI 側(生成メッセージ、エラー)は英語のみです。
57
57
 
58
58
  ## 設定
59
59
 
60
- すべて既定値があり、何も設定しなくても動きます。既定値は `scripts/issue-map.ts` の `CONFIG` に
60
+ すべて既定値があり、何も設定しなくても動きます。既定値は `src/issue-map.ts` の `CONFIG` に
61
61
  あります。
62
62
 
63
63
  | 環境変数 | 既定 | 意味 |
64
64
  | -------------------------- | --------------------------------- | ---------------------------------------------------------- |
65
65
  | `GH_REPO` | cwd の git から推測 | 別の repo を描くときに設定(`gh` 自身の変数。fork も同様) |
66
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | サブチケットが本文で親を指す見出し |
66
+ | `ISSUE_MAP_PARENT_HEADING` | 未設定(本文は取得しない) | サブチケットが本文で親を指す見出し。設定すると本文を読む |
67
67
  | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 未評価で、まだ誰にも渡せない |
68
68
  | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 評価済み・着手可 |
69
69
  | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 誰かが対応中(assignee がなくてもよい) |
@@ -82,6 +82,9 @@ CLI 側(生成メッセージ、エラー)は英語のみです。
82
82
  - **コマンド名。** `/implement` と `/triage` は向こうの [`implement`](https://github.com/mattpocock/skills/tree/main/skills/engineering/implement) と
83
83
  [`triage`](https://github.com/mattpocock/skills/tree/main/skills/engineering/triage) skill です。対象 repo に実在するものを指すようにしてください。さもないと、
84
84
  存在しないものを走らせろとマップが指示します。
85
+ - **本文の慣習はオプトイン。** 本文は GitHub のレスポンスの 9 割以上を占めるのに、正規表現
86
+ ひとつを通すだけで、ページの他のどこでも使われません。そのため `ISSUE_MAP_PARENT_HEADING`
87
+ を設定したときだけ本文を取得します。ネイティブ sub-issue は設定不要で、常に優先されます。
85
88
  - **クローズ済みの兄弟にはネイティブ sub-issue が要る。** マップが問い合わせるクローズ済みチケット
86
89
  は、open なチケットがまだ指しているものだけで、子チケットはネイティブの sub-issue 関係から取り
87
90
  ます。`## Parent` の本文慣例を使う repo ではグループ内の**クローズ済み**の子が出てこず、進捗が
@@ -95,15 +98,15 @@ CLI 側(生成メッセージ、エラー)は英語のみです。
95
98
 
96
99
  ## ファイル
97
100
 
98
- | ファイル | 責務 |
99
- | ---------------------------- | ---------------------------------------------------------------------- |
100
- | `scripts/issue-map.ts` | スナップショット取得、状態と次の一手の導出、HTML 生成。設定は `CONFIG` |
101
- | `scripts/issue-map-model.ts` | 純粋なデータモデル:グルーピング、クリティカルパス、レイアウト |
102
- | `scripts/issue-map-i18n.ts` | 4 言語の文言と引き当て |
103
- | `scripts/issue-map-page.ts` | ブラウザ側のコード。ビルド時に HTML へバンドルされる |
104
- | `scripts/issue-map.html` | テンプレート。2 つのプレースホルダが埋められる |
105
- | `scripts/issue-map-serve.ts` | ローカル server。リクエストごとに取り直す |
106
- | `scripts/mutate.ts` | ミューテーションテスト:1 行を壊してテストが赤くなるか見る |
101
+ | ファイル | 責務 |
102
+ | ------------------------ | ---------------------------------------------------------------------- |
103
+ | `src/issue-map.ts` | スナップショット取得、状態と次の一手の導出、HTML 生成。設定は `CONFIG` |
104
+ | `src/issue-map-model.ts` | 純粋なデータモデル:グルーピング、クリティカルパス、レイアウト |
105
+ | `src/issue-map-i18n.ts` | 4 言語の文言と引き当て |
106
+ | `src/issue-map-page.ts` | ブラウザ側のコード。ビルド時に HTML へバンドルされる |
107
+ | `src/issue-map.html` | テンプレート。2 つのプレースホルダが埋められる |
108
+ | `src/issue-map-serve.ts` | ローカル server。リクエストごとに取り直す |
109
+ | `tools/mutate.ts` | ミューテーションテスト:1 行を壊してテストが赤くなるか見る |
107
110
 
108
111
  ## この repo で開発する
109
112
 
@@ -123,10 +126,10 @@ bun test # 純粋なモデル層
123
126
  製品コードに戻し、赤くなることを確かめます:
124
127
 
125
128
  ```bash
126
- bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
129
+ bun run mutate src/issue-map-model.ts tests/issue-map-layout.test.ts
127
130
  ```
128
131
 
129
- **`scripts/issue-map-i18n.ts` を触るとき。** `EN` が原稿であり、キーの定義場所です。3 つの翻訳の型
132
+ **`src/issue-map-i18n.ts` を触るとき。** `EN` が原稿であり、キーの定義場所です。3 つの翻訳の型
130
133
  はそこから導かれるので、キーや `{n}` の差し込み名が 1 つ欠けると `bun run typecheck` が赤くなります。
131
134
  英語で単複を分けるキーは `{ one, other }`、中国語と日本語は 1 本の文字列で構いません。モデル側は文
132
135
  を組み立てません——`nextStep` は `{ kind: 'waitChildren', count: 2 }` のような構造化された値で、
package/README.md CHANGED
@@ -56,12 +56,12 @@ CLI output (build messages, errors) is English only.
56
56
  ## Configuration
57
57
 
58
58
  Everything has a default — it runs with nothing set. The defaults live in `CONFIG` in
59
- `scripts/issue-map.ts`.
59
+ `src/issue-map.ts`.
60
60
 
61
61
  | Environment variable | Default | Meaning |
62
62
  | -------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
63
63
  | `GH_REPO` | inferred from the git in cwd | Map another repo (`gh`'s own variable; forks and multiple remotes are its job) |
64
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | Body heading under which a sub-issue points at its parent |
64
+ | `ISSUE_MAP_PARENT_HEADING` | unset (bodies are not fetched) | Body heading under which a sub-issue points at its parent. Set it to opt in |
65
65
  | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | Not assessed yet, cannot be handed to anyone |
66
66
  | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | Assessed and ready to work on |
67
67
  | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | Somebody is on it, with or without an assignee |
@@ -82,6 +82,9 @@ Three worth knowing:
82
82
  [`implement`](https://github.com/mattpocock/skills/tree/main/skills/engineering/implement) and
83
83
  [`triage`](https://github.com/mattpocock/skills/tree/main/skills/engineering/triage) skills. Point
84
84
  them at something that exists in the target repo, or the map tells people to run what isn't there.
85
+ - **The body convention is opt-in.** Issue bodies are over 90% of what the GitHub response
86
+ weighs, and they feed one regex — nothing else on the page uses them. So they are only fetched
87
+ when `ISSUE_MAP_PARENT_HEADING` is set. Native sub-issues need no setting and always win.
85
88
  - **Closed siblings need native sub-issues.** The map asks GitHub only for the closed issues an
86
89
  open one still points at, and children come from the native sub-issue relation. With the
87
90
  `## Parent` body convention a group's _closed_ children never appear, so its progress looks
@@ -95,15 +98,15 @@ tree.
95
98
 
96
99
  ## Files
97
100
 
98
- | File | Responsibility |
99
- | ---------------------------- | ------------------------------------------------------------------------ |
100
- | `scripts/issue-map.ts` | Snapshot, status and next step, produces the HTML. Knobs in its `CONFIG` |
101
- | `scripts/issue-map-model.ts` | Pure data model: grouping, critical path, layout. Shared by both sides |
102
- | `scripts/issue-map-i18n.ts` | Strings for the four languages, plus the lookup |
103
- | `scripts/issue-map-page.ts` | Browser-side code, bundled into the HTML at build time |
104
- | `scripts/issue-map.html` | The template. Two placeholder blocks get filled in |
105
- | `scripts/issue-map-serve.ts` | Local server, re-fetches on every request |
106
- | `scripts/mutate.ts` | Mutation testing: break one line, see whether a test goes red |
101
+ | File | Responsibility |
102
+ | ------------------------ | ------------------------------------------------------------------------ |
103
+ | `src/issue-map.ts` | Snapshot, status and next step, produces the HTML. Knobs in its `CONFIG` |
104
+ | `src/issue-map-model.ts` | Pure data model: grouping, critical path, layout. Shared by both sides |
105
+ | `src/issue-map-i18n.ts` | Strings for the four languages, plus the lookup |
106
+ | `src/issue-map-page.ts` | Browser-side code, bundled into the HTML at build time |
107
+ | `src/issue-map.html` | The template. Two placeholder blocks get filled in |
108
+ | `src/issue-map-serve.ts` | Local server, re-fetches on every request |
109
+ | `tools/mutate.ts` | Mutation testing: break one line, see whether a test goes red |
107
110
 
108
111
  ## Developing in this repo
109
112
 
@@ -123,10 +126,10 @@ shape, spacing) are deliberately not asserted. A new guard test has to be verifi
123
126
  the defect it claims to catch back into the product code and confirm it goes red:
124
127
 
125
128
  ```bash
126
- bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
129
+ bun run mutate src/issue-map-model.ts tests/issue-map-layout.test.ts
127
130
  ```
128
131
 
129
- **Adding to `scripts/issue-map-i18n.ts`.** `EN` is the original and defines the keys; the three
132
+ **Adding to `src/issue-map-i18n.ts`.** `EN` is the original and defines the keys; the three
130
133
  translations are typed from it, so a missing key or a missing `{n}` placeholder turns
131
134
  `bun run typecheck` red. Keys needing English plurals are written `{ one, other }`; Chinese and
132
135
  Japanese take a single string. The model never builds sentences — `nextStep` is a structured value
package/README.zh-CN.md CHANGED
@@ -54,12 +54,12 @@ CLI 那一侧(产文件消息、错误)只有英文。
54
54
 
55
55
  ## 配置
56
56
 
57
- 全部有默认值,一个都不设也跑得起来。默认值长在 `scripts/issue-map.ts` 的 `CONFIG`。
57
+ 全部有默认值,一个都不设也跑得起来。默认值长在 `src/issue-map.ts` 的 `CONFIG`。
58
58
 
59
59
  | 环境变量 | 默认 | 意思 |
60
60
  | -------------------------- | --------------------------------- | ------------------------------------------------------ |
61
61
  | `GH_REPO` | 从 cwd 的 git 推断 | 要画别的 repo 时设它(`gh` 自己的变量,fork 也交给它) |
62
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在正文指向母票的段落标题 |
62
+ | `ISSUE_MAP_PARENT_HEADING` | 未设(不抓正文) | 子票在正文指向母票的段落标题。设了才会读正文 |
63
63
  | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 还没评估完,不能交给谁做 |
64
64
  | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 评估完、可以动工 |
65
65
  | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 有人在做,不必有 assignee |
@@ -77,6 +77,9 @@ CLI 那一侧(产文件消息、错误)只有英文。
77
77
  做」这个标签。)
78
78
  - **指令名。** `/implement`、`/triage` 就是那边的 [`implement`](https://github.com/mattpocock/skills/tree/main/skills/engineering/implement) 与
79
79
  [`triage`](https://github.com/mattpocock/skills/tree/main/skills/engineering/triage) skill。要指向目标 repo 真的有的东西,不然图上会叫人跑不存在的。
80
+ - **正文惯例要明讲才生效。** 正文占了 GitHub 响应九成以上的重量,而它只喂一条 regex,页面上
81
+ 没有别的地方用得到。所以只有设了 `ISSUE_MAP_PARENT_HEADING` 才会去抓正文。原生 sub-issue
82
+ 不必设置,而且一律优先。
80
83
  - **已完成的兄弟票要靠原生 sub-issue。** 地图只跟 GitHub 要 open 票还牵着的 closed 票,而子票是从
81
84
  原生的 sub-issue 关系拿的。用 `## Parent` 正文惯例的 repo 看不到一组里**已完成**的子票,那一组的
82
85
  进度会比实际少。把子票在票页的 Sub-issues 关联上去一次就会回来;正文惯例可以留着,原生的本来
@@ -88,15 +91,15 @@ CLI 那一侧(产文件消息、错误)只有英文。
88
91
 
89
92
  ## 文件
90
93
 
91
- | 文件 | 职责 |
92
- | ---------------------------- | -------------------------------------------------------- |
93
- | `scripts/issue-map.ts` | 抓快照、算状态与下一步、产出 HTML。配置在里面的 `CONFIG` |
94
- | `scripts/issue-map-model.ts` | 纯数据模型:分组、关键路径、排版。前后端共用 |
95
- | `scripts/issue-map-i18n.ts` | 四种语言的文案与查表 |
96
- | `scripts/issue-map-page.ts` | 浏览器端代码,构建时被打包进 HTML |
97
- | `scripts/issue-map.html` | 模板。两个占位区块会被填入 |
98
- | `scripts/issue-map-serve.ts` | 本机 server,每个请求重抓一次 |
99
- | `scripts/mutate.ts` | 变异测试:改坏一行看测试会不会红 |
94
+ | 文件 | 职责 |
95
+ | ------------------------ | -------------------------------------------------------- |
96
+ | `src/issue-map.ts` | 抓快照、算状态与下一步、产出 HTML。配置在里面的 `CONFIG` |
97
+ | `src/issue-map-model.ts` | 纯数据模型:分组、关键路径、排版。前后端共用 |
98
+ | `src/issue-map-i18n.ts` | 四种语言的文案与查表 |
99
+ | `src/issue-map-page.ts` | 浏览器端代码,构建时被打包进 HTML |
100
+ | `src/issue-map.html` | 模板。两个占位区块会被填入 |
101
+ | `src/issue-map-serve.ts` | 本机 server,每个请求重抓一次 |
102
+ | `tools/mutate.ts` | 变异测试:改坏一行看测试会不会红 |
100
103
 
101
104
  ## 在这个 repo 里开发
102
105
 
@@ -114,10 +117,10 @@ bun test # 纯模型那一层
114
117
  要走反向验证——把它宣称要挡的缺陷放回产品代码,确认它会红:
115
118
 
116
119
  ```bash
117
- bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
120
+ bun run mutate src/issue-map-model.ts tests/issue-map-layout.test.ts
118
121
  ```
119
122
 
120
- **要动 `scripts/issue-map-i18n.ts`。** `EN` 是原稿,也是键的定义处;三份翻译的类型由它推导,少一个
123
+ **要动 `src/issue-map-i18n.ts`。** `EN` 是原稿,也是键的定义处;三份翻译的类型由它推导,少一个
121
124
  键或少一个 `{n}` 代入名,`bun run typecheck` 就会红。英文要分单复数的键写成 `{ one, other }`,中日
122
125
  文写一句字符串就好。模型那一侧不算句子——`nextStep` 是 `{ kind: 'waitChildren', count: 2 }` 这种结构
123
126
  化的值,话在这里才组出来。
package/README.zh-TW.md CHANGED
@@ -54,12 +54,12 @@ CLI 那一側(產檔訊息、錯誤)只有英文。
54
54
 
55
55
  ## 設定
56
56
 
57
- 全部有預設值,一個都不設也跑得起來。預設值長在 `scripts/issue-map.ts` 的 `CONFIG`。
57
+ 全部有預設值,一個都不設也跑得起來。預設值長在 `src/issue-map.ts` 的 `CONFIG`。
58
58
 
59
59
  | 環境變數 | 預設 | 意思 |
60
60
  | -------------------------- | --------------------------------- | ------------------------------------------------------ |
61
61
  | `GH_REPO` | 從 cwd 的 git 推斷 | 要畫別的 repo 時設它(`gh` 自己的變數,fork 也交給它) |
62
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在內文指向母票的段落標題 |
62
+ | `ISSUE_MAP_PARENT_HEADING` | 未設(不抓內文) | 子票在內文指向母票的段落標題。設了才會讀內文 |
63
63
  | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 還沒評估完,不能交給誰做 |
64
64
  | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 評估完、可以動工 |
65
65
  | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 有人在做,不必有 assignee |
@@ -77,6 +77,9 @@ CLI 那一側(產檔訊息、錯誤)只有英文。
77
77
  做」這個標籤。)
78
78
  - **指令名。** `/implement`、`/triage` 就是那邊的 [`implement`](https://github.com/mattpocock/skills/tree/main/skills/engineering/implement) 與
79
79
  [`triage`](https://github.com/mattpocock/skills/tree/main/skills/engineering/triage) skill。要指向目標 repo 真的有的東西,不然圖上會叫人跑不存在的。
80
+ - **內文慣例要明講才生效。** 內文佔了 GitHub 回應九成以上的重量,而它只餵一條 regex,頁面上
81
+ 沒有別的地方用得到。所以只有設了 `ISSUE_MAP_PARENT_HEADING` 才會去抓內文。原生 sub-issue
82
+ 不必設定,而且一律優先。
80
83
  - **已完成的兄弟票要靠原生 sub-issue。** 地圖只跟 GitHub 要 open 票還牽著的 closed 票,而子票是從
81
84
  原生的 sub-issue 關係拿的。用 `## Parent` 內文慣例的 repo 看不到一組裡**已完成**的子票,那一組的
82
85
  進度會比實際少。把子票在票頁的 Sub-issues 關聯上去一次就會回來;內文慣例可以留著,原生的本來
@@ -88,15 +91,15 @@ CLI 那一側(產檔訊息、錯誤)只有英文。
88
91
 
89
92
  ## 檔案
90
93
 
91
- | 檔案 | 責任 |
92
- | ---------------------------- | -------------------------------------------------------- |
93
- | `scripts/issue-map.ts` | 抓快照、算狀態與下一步、產出 HTML。設定在裡面的 `CONFIG` |
94
- | `scripts/issue-map-model.ts` | 純資料模型:分組、關鍵路徑、排版。前後端共用 |
95
- | `scripts/issue-map-i18n.ts` | 四種語言的文案與查表 |
96
- | `scripts/issue-map-page.ts` | 瀏覽器端程式碼,建置時被打包進 HTML |
97
- | `scripts/issue-map.html` | 樣板。兩個佔位區塊會被填入 |
98
- | `scripts/issue-map-serve.ts` | 本機 server,每個請求重抓一次 |
99
- | `scripts/mutate.ts` | 突變測試:改壞一行看測試會不會紅 |
94
+ | 檔案 | 責任 |
95
+ | ------------------------ | -------------------------------------------------------- |
96
+ | `src/issue-map.ts` | 抓快照、算狀態與下一步、產出 HTML。設定在裡面的 `CONFIG` |
97
+ | `src/issue-map-model.ts` | 純資料模型:分組、關鍵路徑、排版。前後端共用 |
98
+ | `src/issue-map-i18n.ts` | 四種語言的文案與查表 |
99
+ | `src/issue-map-page.ts` | 瀏覽器端程式碼,建置時被打包進 HTML |
100
+ | `src/issue-map.html` | 樣板。兩個佔位區塊會被填入 |
101
+ | `src/issue-map-serve.ts` | 本機 server,每個請求重抓一次 |
102
+ | `tools/mutate.ts` | 突變測試:改壞一行看測試會不會紅 |
100
103
 
101
104
  ## 在這個 repo 裡開發
102
105
 
@@ -114,10 +117,10 @@ bun test # 純模型那一層
114
117
  要走反向驗證——把它宣稱要擋的缺陷放回產品碼,確認它會紅:
115
118
 
116
119
  ```bash
117
- bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
120
+ bun run mutate src/issue-map-model.ts tests/issue-map-layout.test.ts
118
121
  ```
119
122
 
120
- **要動 `scripts/issue-map-i18n.ts`。** `EN` 是原稿,也是鍵的定義處;三份翻譯的型別由它推導,少一個
123
+ **要動 `src/issue-map-i18n.ts`。** `EN` 是原稿,也是鍵的定義處;三份翻譯的型別由它推導,少一個
121
124
  鍵或少一個 `{n}` 代入名,`bun run typecheck` 就會紅。英文要分單複數的鍵寫成 `{ one, other }`,中日
122
125
  文寫一句字串就好。模型那一側不算句子——`nextStep` 是 `{ kind: 'waitChildren', count: 2 }` 這種結構
123
126
  化的值,話在這裡才組出來。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issue-map",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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",
@@ -15,27 +15,21 @@
15
15
  "url": "git+https://github.com/gunter1020/issue-map.git"
16
16
  },
17
17
  "bin": {
18
- "issue-map": "./scripts/issue-map-serve.ts",
19
- "issue-map-build": "./scripts/issue-map.ts"
18
+ "issue-map": "./src/issue-map-serve.ts",
19
+ "issue-map-build": "./src/issue-map.ts"
20
20
  },
21
21
  "files": [
22
22
  "README.zh-TW.md",
23
23
  "README.zh-CN.md",
24
24
  "README.ja.md",
25
- "scripts/issue-map.ts",
26
- "scripts/issue-map-i18n.ts",
27
- "scripts/issue-map-model.ts",
28
- "scripts/issue-map-page.ts",
29
- "scripts/issue-map-view.ts",
30
- "scripts/issue-map-serve.ts",
31
- "scripts/issue-map.html"
25
+ "src"
32
26
  ],
33
27
  "type": "module",
34
28
  "scripts": {
35
- "issue-map": "bun run scripts/issue-map.ts",
36
- "issue-map:serve": "ISSUE_MAP_OPEN=0 bun --watch scripts/issue-map-serve.ts",
29
+ "issue-map": "bun run src/issue-map.ts",
30
+ "issue-map:serve": "ISSUE_MAP_OPEN=0 bun --watch src/issue-map-serve.ts",
37
31
  "typecheck": "tsc --noEmit",
38
- "mutate": "bun run scripts/mutate.ts",
32
+ "mutate": "bun run tools/mutate.ts",
39
33
  "format": "oxfmt .",
40
34
  "format:check": "oxfmt --check .",
41
35
  "lint": "oxlint .",
@@ -72,14 +72,36 @@ export type Group = {
72
72
  export type Snapshot = {
73
73
  readonly generatedAt: string
74
74
  readonly repo: string
75
- /** 這一次實際生效的標籤字彙。圖例照它寫,不然改了設定圖例就會說謊。 */
76
- readonly labels: { readonly ready: readonly string[]; readonly unready: readonly string[] }
75
+ /**
76
+ * 這一次實際生效的標籤字彙與閘門。圖例照它寫,不然改了設定圖例就會說謊。
77
+ *
78
+ * `gated` 是**狀態機當下真的有沒有把這些標籤當閘門**。字彙本身一律有預設值、永遠非空,拿
79
+ * 它的長度去推閘門開著沒有的話,repo 還沒導入標籤時票會判成可動,頁尾卻說沒掛標籤等於未定案。
80
+ */
81
+ readonly labels: {
82
+ readonly ready: readonly string[]
83
+ readonly unready: readonly string[]
84
+ readonly gated: boolean
85
+ }
77
86
  readonly groups: readonly Group[]
78
87
  /** 最長的一條依序未完成鏈,也就是最少要幾輪。 */
79
88
  readonly criticalPath: number
80
89
  readonly issues: readonly MapIssue[]
81
90
  }
82
91
 
92
+ /**
93
+ * 什麼都還沒有的快照。畫面那一側解析行內 JSON 時用它補齊缺的欄位——**缺欄位只在那一個邊界
94
+ * 成立**,收在這裡的話下游拿到的一律是完整的 `Snapshot`,不必每個欄位各自決定「缺了算什麼」。
95
+ */
96
+ export const EMPTY_SNAPSHOT: Snapshot = {
97
+ generatedAt: '',
98
+ repo: '',
99
+ labels: { ready: [], unready: [], gated: false },
100
+ groups: [],
101
+ criticalPath: 0,
102
+ issues: [],
103
+ }
104
+
83
105
  /**
84
106
  * 分群:同一張主票底下的子票一群;沒有主票但跟別人有前置關係的合成一群;完全孤立的合成一群。
85
107
  * 一張圖畫一群。
@@ -173,10 +195,8 @@ export type Edge = { readonly from: number; readonly to: number }
173
195
  export type Layout = {
174
196
  /** 每一條線由前到後的站。只有一站的線不標線名。 */
175
197
  readonly tracks: readonly (readonly number[])[]
176
- /** 孤立的票排成幾列月台。 */
198
+ /** 孤立的票排成幾列月台。月台接在最後一條線下面,起點就是 `tracks.length`。 */
177
199
  readonly islandRows: number
178
- /** 月台從第幾列開始。 */
179
- readonly islandFrom: number
180
200
  readonly xy: ReadonlyMap<number, Point>
181
201
  readonly edges: readonly Edge[]
182
202
  readonly width: number
@@ -190,6 +210,21 @@ const ISLAND_MIN_PER_ROW = 4
190
210
  */
191
211
  const ISLAND_MAX_PER_ROW = 12
192
212
 
213
+ /**
214
+ * 一組票內部的阻擋邊。**什麼算一條邊只有這一份定義**——排版照它畫線,頁面也照它判斷這一組畫
215
+ * 出來到底有沒有線;各算一次的話會出現「說要畫圖、畫出來卻沒有線」。
216
+ */
217
+ export function edgesWithin(members: readonly MapIssue[]): Edge[] {
218
+ const inGroup = new Set(members.map((m) => m.number))
219
+ const edges: Edge[] = []
220
+ for (const m of members) {
221
+ for (const from of m.blockedBy) {
222
+ if (inGroup.has(from)) edges.push({ from, to: m.number })
223
+ }
224
+ }
225
+ return edges
226
+ }
227
+
193
228
  /**
194
229
  * 把一組票排成線路圖。
195
230
  *
@@ -201,19 +236,12 @@ const ISLAND_MAX_PER_ROW = 12
201
236
  * 會變成十二條單站線。
202
237
  */
203
238
  export function layoutOf(members: readonly MapIssue[]): Layout {
204
- const inGroup = new Set(members.map((m) => m.number))
205
- const preds = new Map(
206
- members.map((m) => [m.number, m.blockedBy.filter((n) => inGroup.has(n))] as const),
207
- )
239
+ const edges = edgesWithin(members)
240
+ const preds = new Map<number, number[]>(members.map((m) => [m.number, []]))
241
+ for (const edge of edges) preds.get(edge.to)?.push(edge.from)
208
242
  const predsOf = (n: number): readonly number[] => preds.get(n) ?? []
209
243
 
210
- const hasEdge = new Set<number>()
211
- for (const m of members) {
212
- for (const from of predsOf(m.number)) {
213
- hasEdge.add(m.number)
214
- hasEdge.add(from)
215
- }
216
- }
244
+ const hasEdge = new Set(edges.flatMap((edge) => [edge.from, edge.to]))
217
245
  const wired = members.filter((m) => hasEdge.has(m.number))
218
246
  const island = members.filter((m) => !hasEdge.has(m.number))
219
247
 
@@ -257,15 +285,9 @@ export function layoutOf(members: readonly MapIssue[]): Layout {
257
285
  })
258
286
  const islandRows = Math.ceil(island.length / perRow)
259
287
 
260
- const edges: Edge[] = []
261
- for (const m of members) {
262
- for (const from of predsOf(m.number)) edges.push({ from, to: m.number })
263
- }
264
-
265
288
  return {
266
289
  tracks,
267
290
  islandRows,
268
- islandFrom: tracks.length,
269
291
  xy,
270
292
  edges,
271
293
  width: MAP.gutter + perRow * MAP.step + MAP.rightPad,
@@ -8,18 +8,9 @@
8
8
  * 這裡剩下的是 DOM 與事件:讀寫 localStorage 的兩個偏好(語言、收合)、事件代理、選取狀態。
9
9
  */
10
10
 
11
- import {
12
- DEFAULT_LOCALE,
13
- isLocale,
14
- type Locale,
15
- LOCALE_NAME,
16
- LOCALES,
17
- locale,
18
- setLocale,
19
- t,
20
- } from './issue-map-i18n.ts'
21
- import { type Snapshot } from './issue-map-model.ts'
22
- import { esc, type Filter, viewOf } from './issue-map-view.ts'
11
+ import { DEFAULT_LOCALE, isLocale, type Locale, locale, setLocale, t } from './issue-map-i18n.ts'
12
+ import { EMPTY_SNAPSHOT, type Snapshot } from './issue-map-model.ts'
13
+ import { type Filter, langOptionsHTML, viewOf } from './issue-map-view.ts'
23
14
 
24
15
  /** 樣板保證這些節點存在。找不到就是樣板被改壞了,早點喊比畫出半張圖好。 */
25
16
  function pick(id: string): HTMLElement {
@@ -28,9 +19,18 @@ function pick(id: string): HTMLElement {
28
19
  return node
29
20
  }
30
21
 
31
- const snapshot = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
22
+ /**
23
+ * 行內 JSON 是整份快照唯一可能殘缺的地方(沒有資料、被截斷)。缺的欄位在這裡一次補齊,下游
24
+ * 拿到的一律是完整的 `Snapshot`——不然每個用到快照的地方都要自己決定「缺了算什麼」。
25
+ */
26
+ function readSnapshot(): Snapshot {
27
+ const parsed = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
28
+ return { ...EMPTY_SNAPSHOT, ...parsed }
29
+ }
30
+
31
+ const snapshot = readSnapshot()
32
32
  const view = viewOf(snapshot)
33
- const repo = snapshot.repo ?? ''
33
+ const repo = snapshot.repo
34
34
 
35
35
  const detail = pick('detail')
36
36
  const rowsEl = pick('rows')
@@ -40,6 +40,28 @@ const tabsEl = pick('tabs')
40
40
  let selected: number | null = null
41
41
  let filter: Filter = 'all'
42
42
 
43
+ // ---- 偏好 ----
44
+
45
+ /**
46
+ * 偏好記在瀏覽器。**讀不到、寫不進都不是錯誤**:無痕視窗與擋掉儲存的設定裡 `localStorage` 會
47
+ * 直接丟例外,那時偏好只在這一次有效,畫面照樣是完整的——所以存取一律收在這兩支裡。
48
+ */
49
+ function readStored(key: string): string | null {
50
+ try {
51
+ return localStorage.getItem(key)
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ function writeStored(key: string, value: string): void {
58
+ try {
59
+ localStorage.setItem(key, value)
60
+ } catch {
61
+ // 存不了就只在這一次有效。
62
+ }
63
+ }
64
+
43
65
  // ---- 語言 ----
44
66
 
45
67
  /**
@@ -51,31 +73,20 @@ let filter: Filter = 'all'
51
73
  const LOCALE_KEY = 'issue-map:locale'
52
74
 
53
75
  function readLocale(): Locale {
54
- try {
55
- const saved = localStorage.getItem(LOCALE_KEY)
56
- if (isLocale(saved)) return saved
57
- } catch {
58
- // 讀不到就用預設,畫面照樣是完整的。
59
- }
60
- return DEFAULT_LOCALE
76
+ const saved = readStored(LOCALE_KEY)
77
+ return isLocale(saved) ? saved : DEFAULT_LOCALE
61
78
  }
62
79
 
63
80
  /** 語言選單只做一次;換語言是整頁重畫,選單自己不重建,不然焦點會掉。 */
64
81
  function mountLangPicker(): void {
65
82
  const picker = pick('lang')
66
83
  if (!(picker instanceof HTMLSelectElement)) throw new Error('#lang 不是 select')
67
- picker.innerHTML = LOCALES.map(
68
- (option) => `<option value="${option}">${esc(LOCALE_NAME[option])}</option>`,
69
- ).join('')
84
+ picker.innerHTML = langOptionsHTML()
70
85
  picker.value = locale()
71
86
  picker.addEventListener('change', () => {
72
87
  if (!isLocale(picker.value)) return
73
88
  setLocale(picker.value)
74
- try {
75
- localStorage.setItem(LOCALE_KEY, picker.value)
76
- } catch {
77
- // 存不了就只在這一次有效。
78
- }
89
+ writeStored(LOCALE_KEY, picker.value)
79
90
  render()
80
91
  })
81
92
  }
@@ -90,7 +101,7 @@ const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
90
101
 
91
102
  function readFolded(): Set<string> {
92
103
  try {
93
- return new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '[]') as string[])
104
+ return new Set(JSON.parse(readStored(COLLAPSE_KEY) || '[]') as string[])
94
105
  } catch {
95
106
  return new Set()
96
107
  }
@@ -102,14 +113,26 @@ function isFolded(parent: number): boolean {
102
113
  return folded.has(String(parent))
103
114
  }
104
115
 
116
+ /**
117
+ * 掛在某張票底下的東西該不該藏起來。
118
+ *
119
+ * 看的是**整條祖先鏈**,不只直屬主票——三層的鏈收起最上面那張時,第三層的 `data-parent` 指的
120
+ * 是第二層,只比對直屬的話它會單獨留在畫面上。
121
+ */
122
+ function foldedAnywhere(parent: number): boolean {
123
+ const seen = new Set<number>()
124
+ for (let at: number | null = parent; at !== null && !seen.has(at);) {
125
+ if (isFolded(at)) return true
126
+ seen.add(at)
127
+ at = view.issueAt(at)?.parent ?? null
128
+ }
129
+ return false
130
+ }
131
+
105
132
  function setFolded(parent: number, shut: boolean): void {
106
133
  if (shut) folded.add(String(parent))
107
134
  else folded.delete(String(parent))
108
- try {
109
- localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...folded]))
110
- } catch {
111
- // 存不了就只在這一次有效,畫面照樣能開合。
112
- }
135
+ writeStored(COLLAPSE_KEY, JSON.stringify([...folded]))
113
136
  paintFolded()
114
137
  }
115
138
 
@@ -119,7 +142,7 @@ function setFolded(parent: number, shut: boolean): void {
119
142
  */
120
143
  function paintFolded(): void {
121
144
  for (const node of document.querySelectorAll<HTMLElement>('[data-parent]')) {
122
- node.hidden = isFolded(Number(node.dataset.parent))
145
+ node.hidden = foldedAnywhere(Number(node.dataset.parent))
123
146
  }
124
147
  for (const section of document.querySelectorAll<HTMLElement>('section.group[data-fold]')) {
125
148
  section.dataset.folded = String(isFolded(Number(section.dataset.fold)))
@@ -370,10 +393,11 @@ function render(): void {
370
393
  pick('lede').innerHTML = view.lede()
371
394
  pick('stats').innerHTML = view.statsHTML()
372
395
 
396
+ // 三段都是逃脫過的 HTML,跟建置時填進樣板的是同一批字串——用 textContent 塞會把逃脫顯示出來。
373
397
  const foot = view.footerHTML()
374
- pick('foot-truth').textContent = foot.truth
398
+ pick('foot-truth').innerHTML = foot.truth
375
399
  pick('foot-refresh').innerHTML = foot.refresh
376
- pick('foot-config').textContent = foot.config
400
+ pick('foot-config').innerHTML = foot.config
377
401
 
378
402
  groupsEl.innerHTML = view.groupsHTML()
379
403
  tabsEl.setAttribute('aria-label', t('tabs.aria'))
@@ -43,19 +43,34 @@ function openInBrowser(url: string): void {
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * 同時抵達的請求共用同一次抓取。
48
+ *
49
+ * 一次重新整理按兩下、或開著兩個分頁,本來會各自跑一趟完整的 GitHub 抓取;它們要的是同一刻的
50
+ * 狀態,讓後到的等前一趟就好。抓完就清掉,所以「每次重新整理都是最新的」沒有變。
51
+ */
52
+ let inFlight: Promise<string> | null = null
53
+
46
54
  async function page(): Promise<string> {
47
- const snapshot = takeSnapshot()
55
+ const snapshot = await takeSnapshot()
48
56
  console.log(describe(snapshot))
49
57
  return renderDocument(snapshot)
50
58
  }
51
59
 
60
+ function pageShared(): Promise<string> {
61
+ inFlight ??= page().finally(() => {
62
+ inFlight = null
63
+ })
64
+ return inFlight
65
+ }
66
+
52
67
  const server = Bun.serve({
53
68
  port: PORT,
54
69
  async fetch(request) {
55
70
  const { pathname } = new URL(request.url)
56
71
  if (pathname !== '/') return new Response(null, { status: 404 })
57
72
  try {
58
- return new Response(await page(), {
73
+ return new Response(await pageShared(), {
59
74
  headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
60
75
  })
61
76
  } catch (error) {