issue-map 0.3.0 → 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.
- package/README.ja.md +85 -108
- package/README.md +88 -108
- package/README.zh-CN.md +77 -83
- package/README.zh-TW.md +77 -81
- package/package.json +5 -4
- package/scripts/issue-map-i18n.ts +11 -5
- package/scripts/issue-map-model.ts +11 -4
- package/scripts/issue-map-page.ts +109 -713
- package/scripts/issue-map-serve.ts +17 -18
- package/scripts/issue-map-view.ts +650 -0
- package/scripts/issue-map.html +31 -5
- package/scripts/issue-map.ts +272 -84
package/scripts/issue-map.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
561
|
+
color: var(--muted);
|
|
545
562
|
font-variant-numeric: tabular-nums;
|
|
546
563
|
}
|
|
547
|
-
.
|
|
548
|
-
font-family: var(--mono);
|
|
564
|
+
.tlabel span {
|
|
549
565
|
font-size: 10px;
|
|
550
|
-
|
|
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 {
|
package/scripts/issue-map.ts
CHANGED
|
@@ -1,34 +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
|
-
*
|
|
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
|
-
* **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx issue-map@latest`
|
|
15
|
-
* 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
|
|
16
|
-
* `CONFIG`,整份對照表在 README。
|
|
17
|
-
*
|
|
18
|
-
* 票名不進地圖。曾經試過在內文加一個 `## 短名` 段落給站點當標籤,但那要每張票靠人維護、
|
|
19
|
-
* 而且是票名的第二個事實來源,改標題不會改它。機械縮短標題也試過,這裡的標題沒有一致結構,
|
|
20
|
-
* 縮出來讀不通。所以站點只掛票號,名字交給清單。
|
|
21
|
-
*
|
|
22
|
-
* 用法:
|
|
23
|
-
* bun run scripts/issue-map.ts # 寫到 dist/issue-map.html
|
|
24
|
-
* bun run scripts/issue-map.ts path/to/out.html
|
|
25
|
-
*
|
|
26
|
-
* 要「重新整理就是最新」,改跑 `scripts/issue-map-serve.ts`:它每個請求都呼叫這裡的
|
|
27
|
-
* `takeSnapshot` 重抓一次。
|
|
12
|
+
* 用法:`bun run scripts/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
|
|
13
|
+
* 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
|
|
28
14
|
*/
|
|
29
15
|
|
|
30
16
|
import { spawnSync } from 'bun'
|
|
31
17
|
|
|
18
|
+
import { LOCALE_NAME, LOCALES, t } from './issue-map-i18n.ts'
|
|
32
19
|
import {
|
|
33
20
|
criticalPathOf,
|
|
34
21
|
groupsOf,
|
|
@@ -37,13 +24,16 @@ import {
|
|
|
37
24
|
type Snapshot,
|
|
38
25
|
type Status,
|
|
39
26
|
} from './issue-map-model.ts'
|
|
27
|
+
import { esc, viewOf } from './issue-map-view.ts'
|
|
40
28
|
|
|
41
29
|
const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
|
|
42
30
|
const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
|
|
43
31
|
const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
|
|
44
32
|
|
|
45
|
-
/**
|
|
33
|
+
/** 一頁的張數。GraphQL 的 `first` 最多就是 100,票再多就靠 cursor 一頁一頁接。 */
|
|
46
34
|
const PAGE = 100
|
|
35
|
+
/** 一次用 alias 指名幾張票。GraphQL 對單一查詢的節點數有上限,這個量級離它還很遠。 */
|
|
36
|
+
const BATCH = 50
|
|
47
37
|
|
|
48
38
|
function labelList(raw: string | undefined, fallback: string): readonly string[] {
|
|
49
39
|
return (raw ?? fallback)
|
|
@@ -55,8 +45,7 @@ function labelList(raw: string | undefined, fallback: string): readonly string[]
|
|
|
55
45
|
/**
|
|
56
46
|
* 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
|
|
57
47
|
*
|
|
58
|
-
* repo
|
|
59
|
-
* `GH_REPO`(`gh` 自己的環境變數,fork 與多 remote 的判斷也一併交給它)。
|
|
48
|
+
* repo 不在這裡:那是 `gh` 自己的 `GH_REPO`,fork 與多 remote 的判斷也一併交給它。
|
|
60
49
|
*/
|
|
61
50
|
const CONFIG = {
|
|
62
51
|
/** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
|
|
@@ -96,50 +85,186 @@ type RawIssue = {
|
|
|
96
85
|
/** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
|
|
97
86
|
type Issue = RawIssue & { readonly parentNumber: number | null }
|
|
98
87
|
|
|
99
|
-
interface
|
|
100
|
-
pageInfo: { hasNextPage: boolean }
|
|
101
|
-
nodes:
|
|
88
|
+
interface Page<T> {
|
|
89
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
|
90
|
+
nodes: T[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 票號查不到(號碼其實是 PR,或那張票不存在)時 GraphQL 回 null。 */
|
|
94
|
+
type MaybeIssue = RawIssue | null
|
|
95
|
+
|
|
96
|
+
const ISSUE_FIELDS = `
|
|
97
|
+
number title state url body closedAt
|
|
98
|
+
author { login }
|
|
99
|
+
parent { number }
|
|
100
|
+
labels(first: 20) { nodes { name } }
|
|
101
|
+
assignees(first: 10) { nodes { login } }
|
|
102
|
+
blockedBy(first: 50) { nodes { number } }
|
|
103
|
+
`
|
|
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
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 跑一次 `gh api graphql`。
|
|
134
|
+
*
|
|
135
|
+
* 票號是我們自己從前一次結果拿到的整數,直接組進查詢字串;只有 cursor 走變數——它是 API 給的
|
|
136
|
+
* 不透明字串,沒有理由自己去逃脫它。
|
|
137
|
+
*/
|
|
138
|
+
function run<T>(query: string, variables: Record<string, string> = {}): T {
|
|
139
|
+
// `{owner}`/`{repo}` 由 gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
|
|
140
|
+
const args = [
|
|
141
|
+
'gh',
|
|
142
|
+
'api',
|
|
143
|
+
'graphql',
|
|
144
|
+
'-f',
|
|
145
|
+
`query=${query}`,
|
|
146
|
+
'-F',
|
|
147
|
+
'owner={owner}',
|
|
148
|
+
'-F',
|
|
149
|
+
'repo={repo}',
|
|
150
|
+
]
|
|
151
|
+
for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
|
|
152
|
+
const result = spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
|
|
153
|
+
return dataOrThrow<T>(result.stdout.toString(), result.stderr.toString())
|
|
102
154
|
}
|
|
103
|
-
|
|
104
|
-
|
|
155
|
+
|
|
156
|
+
const OPEN_QUERY = `
|
|
157
|
+
query($owner: String!, $repo: String!, $after: String) {
|
|
158
|
+
repository(owner: $owner, name: $repo) {
|
|
159
|
+
nameWithOwner
|
|
160
|
+
issues(states: OPEN, first: ${PAGE}, after: $after, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
161
|
+
pageInfo { hasNextPage endCursor }
|
|
162
|
+
nodes { ${ISSUE_FIELDS} }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}`
|
|
166
|
+
|
|
167
|
+
/** open issue 全部都要,所以一路翻到底——票超過一頁不是錯誤,是常態。 */
|
|
168
|
+
function fetchOpen(): { nameWithOwner: string; open: RawIssue[] } {
|
|
169
|
+
type Result = { repository: { nameWithOwner: string; issues: Page<RawIssue> } }
|
|
170
|
+
const open: RawIssue[] = []
|
|
171
|
+
let nameWithOwner = ''
|
|
172
|
+
let after: string | null = null
|
|
173
|
+
for (;;) {
|
|
174
|
+
const variables: Record<string, string> = after ? { after } : {}
|
|
175
|
+
const { repository } = run<Result>(OPEN_QUERY, variables)
|
|
176
|
+
nameWithOwner = repository.nameWithOwner
|
|
177
|
+
open.push(...repository.issues.nodes)
|
|
178
|
+
const { hasNextPage, endCursor } = repository.issues.pageInfo
|
|
179
|
+
if (!hasNextPage || !endCursor) break
|
|
180
|
+
after = endCursor
|
|
181
|
+
}
|
|
182
|
+
return { nameWithOwner, open }
|
|
105
183
|
}
|
|
106
184
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
assignees(first: 10) { nodes { login } }
|
|
115
|
-
blockedBy(first: 50) { nodes { number } }
|
|
185
|
+
/** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
|
|
186
|
+
const alias = (number: number) => `i${number}`
|
|
187
|
+
|
|
188
|
+
function chunks<T>(items: readonly T[], size: number): T[][] {
|
|
189
|
+
const batches: T[][] = []
|
|
190
|
+
for (let index = 0; index < items.length; index += size) {
|
|
191
|
+
batches.push(items.slice(index, index + size))
|
|
116
192
|
}
|
|
117
|
-
|
|
193
|
+
return batches
|
|
194
|
+
}
|
|
118
195
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
196
|
+
/** 指名要哪幾張票,一批 alias 問完。查不到的就當沒有。 */
|
|
197
|
+
function fetchByNumber(numbers: readonly number[]): RawIssue[] {
|
|
198
|
+
type Result = { repository: Record<string, MaybeIssue> }
|
|
199
|
+
const found: RawIssue[] = []
|
|
200
|
+
for (const batch of chunks(numbers, BATCH)) {
|
|
201
|
+
const query = `
|
|
121
202
|
query($owner: String!, $repo: String!) {
|
|
122
203
|
repository(owner: $owner, name: $repo) {
|
|
123
|
-
|
|
124
|
-
open: issues(states: OPEN, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
|
|
125
|
-
closed: issues(states: CLOSED, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
|
|
204
|
+
${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${ISSUE_FIELDS} }`).join('\n ')}
|
|
126
205
|
}
|
|
127
206
|
}`
|
|
207
|
+
const { repository } = run<Result>(query)
|
|
208
|
+
for (const number of batch) {
|
|
209
|
+
const issue = repository[alias(number)]
|
|
210
|
+
if (issue) found.push(issue)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return found
|
|
214
|
+
}
|
|
128
215
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
function childrenQuery(parent: number): string {
|
|
217
|
+
return `
|
|
218
|
+
query($owner: String!, $repo: String!, $after: String) {
|
|
219
|
+
repository(owner: $owner, name: $repo) {
|
|
220
|
+
issue(number: ${parent}) {
|
|
221
|
+
subIssues(first: ${PAGE}, after: $after) {
|
|
222
|
+
pageInfo { hasNextPage endCursor }
|
|
223
|
+
nodes { ${ISSUE_FIELDS} }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 拿這些 parent 底下的子票,為的是把同一組裡**已完成**的兄弟票撈出來當進度。
|
|
232
|
+
*
|
|
233
|
+
* 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
|
|
234
|
+
* 代價)。open 的兄弟不必靠這裡,它們本來就在 open 那包。
|
|
235
|
+
*/
|
|
236
|
+
function fetchChildren(parents: readonly number[]): RawIssue[] {
|
|
237
|
+
type Batch = { repository: Record<string, { subIssues: Page<RawIssue> } | null> }
|
|
238
|
+
type More = { repository: { issue: { subIssues: Page<RawIssue> } | null } }
|
|
239
|
+
const children: RawIssue[] = []
|
|
240
|
+
for (const batch of chunks(parents, BATCH)) {
|
|
241
|
+
const query = `
|
|
242
|
+
query($owner: String!, $repo: String!) {
|
|
243
|
+
repository(owner: $owner, name: $repo) {
|
|
244
|
+
${batch
|
|
245
|
+
.map(
|
|
246
|
+
(number) => `${alias(number)}: issue(number: ${number}) {
|
|
247
|
+
subIssues(first: ${PAGE}) { pageInfo { hasNextPage endCursor } nodes { ${ISSUE_FIELDS} } } }`,
|
|
248
|
+
)
|
|
249
|
+
.join('\n ')}
|
|
250
|
+
}
|
|
251
|
+
}`
|
|
252
|
+
const { repository } = run<Batch>(query)
|
|
253
|
+
for (const number of batch) {
|
|
254
|
+
const page = repository[alias(number)]?.subIssues
|
|
255
|
+
if (!page) continue
|
|
256
|
+
children.push(...page.nodes)
|
|
257
|
+
// 子票破百的 parent 很罕見,就讓它自己續抓,不為了它把整批都變成分頁查詢。
|
|
258
|
+
let after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
|
|
259
|
+
while (after) {
|
|
260
|
+
const next = run<More>(childrenQuery(number), { after }).repository.issue?.subIssues
|
|
261
|
+
if (!next) break
|
|
262
|
+
children.push(...next.nodes)
|
|
263
|
+
after = next.pageInfo.hasNextPage ? next.pageInfo.endCursor : null
|
|
264
|
+
}
|
|
265
|
+
}
|
|
141
266
|
}
|
|
142
|
-
return
|
|
267
|
+
return children
|
|
143
268
|
}
|
|
144
269
|
|
|
145
270
|
/** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
|
|
@@ -150,9 +275,8 @@ function withParent(raw: RawIssue): Issue {
|
|
|
150
275
|
}
|
|
151
276
|
|
|
152
277
|
export function takeSnapshot(): Snapshot {
|
|
153
|
-
const
|
|
154
|
-
const open =
|
|
155
|
-
const closed = repository.closed.nodes.map(withParent)
|
|
278
|
+
const { nameWithOwner, open: rawOpen } = fetchOpen()
|
|
279
|
+
const open = rawOpen.map(withParent)
|
|
156
280
|
|
|
157
281
|
const openNumbers = new Set(open.map((issue) => issue.number))
|
|
158
282
|
const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
|
|
@@ -160,17 +284,15 @@ export function takeSnapshot(): Snapshot {
|
|
|
160
284
|
open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
|
|
161
285
|
)
|
|
162
286
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
),
|
|
173
|
-
]
|
|
287
|
+
// 指名去要而不掃整包 closed,理由見檔頭。
|
|
288
|
+
const referenced = [...new Set([...blockers, ...openParents])].filter(
|
|
289
|
+
(number) => !openNumbers.has(number),
|
|
290
|
+
)
|
|
291
|
+
const closed = dedupe([...fetchByNumber(referenced), ...fetchChildren([...openParents])])
|
|
292
|
+
.filter((issue) => issue.state === 'CLOSED' && !openNumbers.has(issue.number))
|
|
293
|
+
.map(withParent)
|
|
294
|
+
|
|
295
|
+
const kept = [...open, ...closed]
|
|
174
296
|
const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
|
|
175
297
|
const openChildren = new Map<number, number>()
|
|
176
298
|
for (const issue of kept) {
|
|
@@ -187,7 +309,7 @@ export function takeSnapshot(): Snapshot {
|
|
|
187
309
|
.toSorted((a, b) => a.number - b.number)
|
|
188
310
|
return {
|
|
189
311
|
generatedAt: new Date().toISOString(),
|
|
190
|
-
repo:
|
|
312
|
+
repo: nameWithOwner,
|
|
191
313
|
labels: { ready: CONFIG.ready, unready: CONFIG.unready },
|
|
192
314
|
groups: groupsOf(issues),
|
|
193
315
|
criticalPath: criticalPathOf(issues),
|
|
@@ -195,6 +317,11 @@ export function takeSnapshot(): Snapshot {
|
|
|
195
317
|
}
|
|
196
318
|
}
|
|
197
319
|
|
|
320
|
+
/** 同一張票可能同時是某人的阻擋者又是某人的兄弟,用票號收斂成一張。 */
|
|
321
|
+
function dedupe(issues: readonly RawIssue[]): RawIssue[] {
|
|
322
|
+
return [...new Map(issues.map((issue) => [issue.number, issue])).values()]
|
|
323
|
+
}
|
|
324
|
+
|
|
198
325
|
function isNumber(value: number | null): value is number {
|
|
199
326
|
return value !== null
|
|
200
327
|
}
|
|
@@ -266,13 +393,11 @@ function describeIssue(raw: Issue, context: Context): MapIssue {
|
|
|
266
393
|
}
|
|
267
394
|
}
|
|
268
395
|
|
|
269
|
-
/** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
|
|
270
396
|
/**
|
|
271
397
|
* 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
|
|
272
398
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* 不 minify 是因為這是開發用的頁面,讀得懂比小重要。
|
|
399
|
+
* 產出必須是一個檔案,但來源不必——來源是 TypeScript,所以型別跟這裡共用同一份定義,而且純
|
|
400
|
+
* 推導測得到。`format: 'iife'` 是因為它要塞進行內;不 minify 是因為這是開發用的頁面。
|
|
276
401
|
*/
|
|
277
402
|
async function bundleClient(): Promise<string> {
|
|
278
403
|
const built = await Bun.build({
|
|
@@ -281,12 +406,62 @@ async function bundleClient(): Promise<string> {
|
|
|
281
406
|
format: 'iife',
|
|
282
407
|
minify: false,
|
|
283
408
|
})
|
|
284
|
-
if (!built.success) throw new Error(
|
|
409
|
+
if (!built.success) throw new Error(`Failed to bundle ${CLIENT}: ${built.logs.join('\n')}`)
|
|
285
410
|
const [output] = built.outputs
|
|
286
|
-
if (!output) throw new Error(
|
|
411
|
+
if (!output) throw new Error(`Bundling ${CLIENT} produced no output`)
|
|
287
412
|
return output.text()
|
|
288
413
|
}
|
|
289
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)。 */
|
|
290
465
|
export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
291
466
|
const template = await Bun.file(TEMPLATE).text()
|
|
292
467
|
const withData = replaceIn(
|
|
@@ -296,8 +471,9 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
|
296
471
|
JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
|
|
297
472
|
'issue-map-data',
|
|
298
473
|
)
|
|
474
|
+
const withPage = prerender(withData, snapshot)
|
|
299
475
|
return replaceIn(
|
|
300
|
-
|
|
476
|
+
withPage,
|
|
301
477
|
/(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
|
|
302
478
|
// 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
|
|
303
479
|
(await bundleClient()).replace(/<\/script/gi, '<\\/script'),
|
|
@@ -305,19 +481,31 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
|
305
481
|
)
|
|
306
482
|
}
|
|
307
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
|
+
|
|
308
496
|
function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
|
|
309
497
|
const next = html.replace(marker, `$1${body}$2`)
|
|
310
|
-
if (next === html) throw new Error(
|
|
498
|
+
if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
|
|
311
499
|
return next
|
|
312
500
|
}
|
|
313
501
|
|
|
314
502
|
export function describe(snapshot: Snapshot): string {
|
|
315
503
|
const done = snapshot.issues.filter((issue) => issue.status === 'done').length
|
|
316
|
-
return `${snapshot.issues.length - done}
|
|
504
|
+
return `${snapshot.issues.length - done} unfinished, ${done} closed but still referenced (${snapshot.generatedAt})`
|
|
317
505
|
}
|
|
318
506
|
|
|
319
507
|
if (import.meta.main) {
|
|
320
508
|
const snapshot = takeSnapshot()
|
|
321
|
-
await Bun.write(OUTPUT, await
|
|
322
|
-
console.log(
|
|
509
|
+
await Bun.write(OUTPUT, await renderDocument(snapshot))
|
|
510
|
+
console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
|
|
323
511
|
}
|