Skip to content

Commit ee2e2f7

Browse files
feat: add a script to check versions
1 parent 5ee70bd commit ee2e2f7

6 files changed

Lines changed: 301 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Generated by `mdbook build`
22
/book/
33

4-
# Generated by `download.py`
4+
# Generated by `just download`
55
/src/meta.json
66
/src/**/*.md
77
!/src/licenses.md

justfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ rg PATTERN *ARGS:
2929

3030
# Download book sources from GitHub (Please rerun if you meet any network error.)
3131
download:
32-
uv run download.py
32+
uv run scripts/download.py
3333

3434
# Build the book
3535
build: download
@@ -39,3 +39,10 @@ build: download
3939
# Serve and open the book
4040
serve *ARGS: download
4141
mdbook serve {{ ARGS }}
42+
43+
# Check latest version of packages
44+
[group("maintenance")]
45+
check-versions: download
46+
@{{ if env("GITHUB_TOKEN", "") == "" { error("$GITHUB_TOKEN is required to access GitHub GraphQL API, but it is not set.") } else { "" } }}
47+
node scripts/check_versions.ts
48+
# 📝 Now you may update `preprocessor.typst-extra-docs.download` in `book.toml` according to the above output.

scripts/check_versions.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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);

download.py renamed to scripts/download.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
import httpx
1313

14-
src_dir = Path(__file__).parent / "src"
14+
src_dir = Path(__file__).parent.parent / "src"
15+
assert src_dir.exists() and src_dir.is_dir()
1516
logger = logging.getLogger(__name__)
1617

1718
client = httpx.Client(timeout=30.0)

typst-extra-docs/src/issue_link.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub fn link_issues(
6161
// Allow texts like `rpath.rs#L116-L158`.
6262
if s.eat_if("#") && s.peek().is_some_and(|c| c.is_ascii_digit()) {
6363
// We meet `#{num}`. Terminate before `#`.
64-
s.uneat();
64+
s.uneat();
6565
break;
6666
}
6767
}

typst-extra-docs/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ impl ExtraDocs {
2323
}
2424
}
2525

26-
/// `meta.json` generated by `download.py`.
26+
/// `meta.json` generated by `just download`.
2727
#[derive(serde::Deserialize)]
2828
struct Metadata {
2929
/// A mapping from file paths to source URLs.
@@ -38,7 +38,7 @@ impl Preprocessor for ExtraDocs {
3838
}
3939

4040
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
41-
// Read data generated by `download.py`
41+
// Read data generated by `just download`
4242
let meta = ctx.config.book.src.join("meta.json");
4343
let meta: Metadata = serde_json::from_reader(File::open(&meta).inspect_err(|e| {
4444
eprintln!("Failed to open {:?}: {e:?}", &meta);
@@ -51,7 +51,7 @@ impl Preprocessor for ExtraDocs {
5151
return; // Skip draft chapters
5252
};
5353
let Some(source_url) = meta.map.get(file) else {
54-
return; // Skip chapters not generated by `download.py`
54+
return; // Skip chapters not generated by `just download`
5555
};
5656

5757
let markdown_options = MarkdownOptions::default();

0 commit comments

Comments
 (0)