|
| 1 | +/** |
| 2 | + * Check latest versions of packages. |
| 3 | + * You may update `preprocessor.typst-extra-docs.download` in `book.toml` according to the output of this script. |
| 4 | + * |
| 5 | + * Usage: |
| 6 | + * 1. Run `just download`. |
| 7 | + * 2. Set the `$GITHUB_TOKEN` env var. (no scope required) |
| 8 | + * 3. Run this script with node v24+. |
| 9 | + */ |
| 10 | + |
| 11 | +import assert from "node:assert"; |
| 12 | +import fs from "node:fs"; |
| 13 | +import path from "node:path"; |
| 14 | +import { env } from "node:process"; |
| 15 | +import { fileURLToPath } from "node:url"; |
| 16 | + |
| 17 | +/** A map from repositories to files (as their original paths). */ |
| 18 | +type Catalog = Map<string, string[]>; |
| 19 | + |
| 20 | +/** Collect the catalog from `meta.json` generated by `just download`. */ |
| 21 | +function collectCatalog(): Catalog { |
| 22 | + const srcDir = path.join( |
| 23 | + path.dirname(fileURLToPath(import.meta.url)), |
| 24 | + "../src", |
| 25 | + ); |
| 26 | + |
| 27 | + const meta: { dates: Record<string, string>; map: Record<string, string> } = |
| 28 | + JSON.parse( |
| 29 | + fs.readFileSync(path.join(srcDir, "meta.json"), { encoding: "utf-8" }), |
| 30 | + ); |
| 31 | + |
| 32 | + const catalog: Catalog = new Map( |
| 33 | + Object.keys(meta.dates).map((repoUrl) => { |
| 34 | + // `/OWNER/REPO/blob/COMMIT` |
| 35 | + const repo = new URL(repoUrl).pathname.split("/")[2]; |
| 36 | + return [repo, []]; |
| 37 | + }), |
| 38 | + ); |
| 39 | + |
| 40 | + for (const fileUrl of Object.values(meta.map)) { |
| 41 | + // `/OWNER/REPO/blob/COMMIT/*` |
| 42 | + const fileUrlPath = new URL(fileUrl).pathname.split("/"); |
| 43 | + const repo = fileUrlPath[2]; |
| 44 | + const filePath = fileUrlPath.slice(5).join("/"); |
| 45 | + catalog.get(repo)!.push(filePath); |
| 46 | + } |
| 47 | + |
| 48 | + return catalog; |
| 49 | +} |
| 50 | + |
| 51 | +function* _buildQuery(catalog: Catalog): Generator<string> { |
| 52 | + yield "query {"; |
| 53 | + |
| 54 | + for (const [repo, files] of catalog) { |
| 55 | + yield ` ${repo}: repository(owner: "typst", name: "${repo}") {`; |
| 56 | + yield ' ref(qualifiedName: "refs/heads/main") {'; |
| 57 | + yield " target {"; |
| 58 | + yield " ... on Commit {"; |
| 59 | + |
| 60 | + for (const file of files) { |
| 61 | + const tab = " ".repeat(5); |
| 62 | + const fileSafe = file.replaceAll(/[\./-]/g, "_"); |
| 63 | + yield `${tab}${fileSafe}: history(first: 1, path: "${file}") {`; |
| 64 | + yield `${tab} nodes {`; |
| 65 | + yield `${tab} oid`; |
| 66 | + yield `${tab} authoredDate`; |
| 67 | + yield `${tab} message`; |
| 68 | + yield `${tab} }`; |
| 69 | + yield `${tab}}`; |
| 70 | + } |
| 71 | + |
| 72 | + yield " }"; |
| 73 | + yield " }"; |
| 74 | + yield " }"; |
| 75 | + yield " }"; |
| 76 | + } |
| 77 | + |
| 78 | + yield "}"; |
| 79 | +} |
| 80 | + |
| 81 | +type QueryResult = { |
| 82 | + [repo: string]: { |
| 83 | + ref: { |
| 84 | + target: { |
| 85 | + [fileSafe: string]: { |
| 86 | + nodes: [{ |
| 87 | + oid: string; |
| 88 | + authoredDate: string; |
| 89 | + message: string; |
| 90 | + }] | []; |
| 91 | + }; |
| 92 | + }; |
| 93 | + }; |
| 94 | + }; |
| 95 | +}; |
| 96 | + |
| 97 | +/** Build the GraphQL query from the catalog. */ |
| 98 | +function buildQuery(catalog: Catalog): string { |
| 99 | + return Array.from(_buildQuery(catalog)).join("\n"); |
| 100 | +} |
| 101 | + |
| 102 | +/** Query GitHub GraphQL API, requires the `$GITHUB_TOKEN` env var. */ |
| 103 | +async function queryGitHub<T>(query: string): Promise<T> { |
| 104 | + const result = await fetch("https://api.github.com/graphql", { |
| 105 | + method: "POST", |
| 106 | + headers: { |
| 107 | + "Content-Type": "application/json", |
| 108 | + Authorization: `Bearer ${env.GITHUB_TOKEN}`, |
| 109 | + }, |
| 110 | + body: JSON.stringify({ query }), |
| 111 | + }).then((res) => res.json()); |
| 112 | + if (result.errors || !result.data) { |
| 113 | + throw new Error(`GitHub API error: ${JSON.stringify(result)}`); |
| 114 | + } |
| 115 | + return result.data; |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * A map from repositories to their versions. |
| 120 | + * |
| 121 | + * Similar to `preprocessor.typst-extra-docs.download` in `book.toml`. |
| 122 | + */ |
| 123 | +type PackageVersions = Map<string, PackageVersion>; |
| 124 | +type PackageVersion = { |
| 125 | + commit_hash: string; |
| 126 | + author_date: string; |
| 127 | + comment: string; |
| 128 | +}; |
| 129 | + |
| 130 | +function determineLatestVersions(queryResult: QueryResult): PackageVersions { |
| 131 | + const versions: PackageVersions = new Map(); |
| 132 | + |
| 133 | + for (const [repo, { ref: { target } }] of Object.entries(queryResult)) { |
| 134 | + let latest: PackageVersion | null = null; |
| 135 | + |
| 136 | + for (const { nodes } of Object.values(target)) { |
| 137 | + if (nodes.length === 0) { |
| 138 | + continue; |
| 139 | + } |
| 140 | + |
| 141 | + const node = nodes[0]; |
| 142 | + if ( |
| 143 | + !latest || |
| 144 | + new Date(node.authoredDate) > new Date(latest.author_date) |
| 145 | + ) { |
| 146 | + latest = { |
| 147 | + commit_hash: node.oid, |
| 148 | + author_date: node.authoredDate, |
| 149 | + comment: node.message.split("\n\n")[0], // Only keep the first line |
| 150 | + }; |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + if (latest) { |
| 155 | + versions.set(repo, latest); |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + return versions; |
| 160 | +} |
| 161 | + |
| 162 | +function _runTests(tests: { [testName: string]: () => void }): void { |
| 163 | + let failed = 0; |
| 164 | + for (const [testName, testFn] of Object.entries(tests)) { |
| 165 | + try { |
| 166 | + testFn(); |
| 167 | + console.log(`Test "${testName}" passed`); |
| 168 | + } catch (error) { |
| 169 | + failed += 1; |
| 170 | + console.error(`Test "${testName}" failed:`, error); |
| 171 | + } |
| 172 | + } |
| 173 | + if (failed > 0) { |
| 174 | + throw new Error(`${failed} test(s) failed`); |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +_runTests({ |
| 179 | + testBuildQuery() { |
| 180 | + const catalog: Catalog = new Map([ |
| 181 | + ["typst", ["docs/dev/architecture.md", "README.md"]], |
| 182 | + ]); |
| 183 | + const query = buildQuery(catalog); |
| 184 | + assert.strictEqual( |
| 185 | + query, |
| 186 | + `query { |
| 187 | + typst: repository(owner: "typst", name: "typst") { |
| 188 | + ref(qualifiedName: "refs/heads/main") { |
| 189 | + target { |
| 190 | + ... on Commit { |
| 191 | + docs_dev_architecture_md: history(first: 1, path: "docs/dev/architecture.md") { |
| 192 | + nodes { |
| 193 | + oid |
| 194 | + authoredDate |
| 195 | + message |
| 196 | + } |
| 197 | + } |
| 198 | + README_md: history(first: 1, path: "README.md") { |
| 199 | + nodes { |
| 200 | + oid |
| 201 | + authoredDate |
| 202 | + message |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | +}`, |
| 210 | + ); |
| 211 | + }, |
| 212 | + testDetermineLatestVersions() { |
| 213 | + const queryResult: QueryResult = { |
| 214 | + "typst": { |
| 215 | + "ref": { |
| 216 | + "target": { |
| 217 | + "docs_dev_architecture_md": { |
| 218 | + "nodes": [ |
| 219 | + { |
| 220 | + "oid": "3c47607cbf2e8e5c050317e4d948970d23b38925", |
| 221 | + "authoredDate": "2025-12-10T12:48:07Z", |
| 222 | + "message": |
| 223 | + "Fix wrong path to `typst-eval` in dev docs (#7549)\n\nCo-authored-by: Laurenz <laurmaedje@gmail.com>", |
| 224 | + }, |
| 225 | + ], |
| 226 | + }, |
| 227 | + "readme_md": { |
| 228 | + "nodes": [ |
| 229 | + { |
| 230 | + "oid": "9a4316c6b112e7ed1b0ab85c2607753a6fe04481", |
| 231 | + "authoredDate": "2026-01-19T16:12:37Z", |
| 232 | + "message": "Fix grammar in README (#7718)", |
| 233 | + }, |
| 234 | + ], |
| 235 | + }, |
| 236 | + }, |
| 237 | + }, |
| 238 | + }, |
| 239 | + "hayagriva": { |
| 240 | + "ref": { |
| 241 | + "target": { |
| 242 | + "license_mit": { |
| 243 | + "nodes": [ |
| 244 | + { |
| 245 | + "oid": "6073e44a8c793225e347adfcb35104df063eee8c", |
| 246 | + "authoredDate": "2021-01-17T18:57:15Z", |
| 247 | + "message": |
| 248 | + "Move some things around 🚛\n\nCo-authored-by: Laurenz Mädje <laurmaedje@gmail.com>", |
| 249 | + }, |
| 250 | + ], |
| 251 | + }, |
| 252 | + }, |
| 253 | + }, |
| 254 | + }, |
| 255 | + }; |
| 256 | + |
| 257 | + const latest = determineLatestVersions(queryResult); |
| 258 | + assert.deepStrictEqual( |
| 259 | + latest, |
| 260 | + new Map([ |
| 261 | + [ |
| 262 | + "typst", |
| 263 | + { |
| 264 | + commit_hash: "9a4316c6b112e7ed1b0ab85c2607753a6fe04481", |
| 265 | + author_date: "2026-01-19T16:12:37Z", |
| 266 | + comment: "Fix grammar in README (#7718)", |
| 267 | + }, |
| 268 | + ], |
| 269 | + [ |
| 270 | + "hayagriva", |
| 271 | + { |
| 272 | + commit_hash: "6073e44a8c793225e347adfcb35104df063eee8c", |
| 273 | + author_date: "2021-01-17T18:57:15Z", |
| 274 | + comment: "Move some things around 🚛", |
| 275 | + }, |
| 276 | + ], |
| 277 | + ]), |
| 278 | + ); |
| 279 | + }, |
| 280 | +}); |
| 281 | + |
| 282 | +const catalog = collectCatalog(); |
| 283 | +const query = buildQuery(catalog); |
| 284 | +const result = await queryGitHub<QueryResult>(query); |
| 285 | +const versions = determineLatestVersions(result); |
| 286 | +console.log("Latest versions:", versions); |
0 commit comments