|
| 1 | +/** |
| 2 | + * Generate a JSON index and print to stdout. |
| 3 | + */ |
| 4 | + |
| 5 | +import assert from "node:assert"; |
| 6 | + |
| 7 | +import type { IssueMeta, PullMeta } from "./check_issues.ts"; |
| 8 | +import { extraArgs } from "./config.ts"; |
| 9 | +import { typst } from "./typst.ts"; |
| 10 | + |
| 11 | +type Priority = "ok" | "advanced" | "basic" | "broken" | "tbd" | "na"; |
| 12 | +type GeneralPriority = Priority | "(inherited)"; |
| 13 | + |
| 14 | +// Parsed types |
| 15 | + |
| 16 | +type Babel = { en: string; "zh-Hans": string }; |
| 17 | + |
| 18 | +type Heading = { |
| 19 | + level: number; |
| 20 | + body: Babel; |
| 21 | + label?: string; |
| 22 | +}; |
| 23 | +type Section = { |
| 24 | + title: Babel; |
| 25 | + level: number; |
| 26 | + label?: string; |
| 27 | + priority: GeneralPriority; |
| 28 | + links: ( |
| 29 | + | ({ type: "issue" } & IssueMeta) |
| 30 | + | ({ type: "pull" } & PullMeta) |
| 31 | + | ({ type: "workaround" } & WorkaroundMeta) |
| 32 | + )[]; |
| 33 | +}; |
| 34 | + |
| 35 | +// Typst elements |
| 36 | + |
| 37 | +type HeadingElem = { |
| 38 | + func: "heading"; |
| 39 | + level: number; |
| 40 | + body: ContentElem; |
| 41 | + label?: string; |
| 42 | +}; |
| 43 | +type SpaceElem = { func: "space" }; |
| 44 | +type TextElem = { func: "text"; text: string }; |
| 45 | +type RawElem = { func: "raw"; text: string }; |
| 46 | +type Sequence<T> = { |
| 47 | + func: "sequence"; |
| 48 | + children: T[]; |
| 49 | +}; |
| 50 | +/** Same as `Sequence<ContentElem>`, but without self-reference. */ |
| 51 | +type SequenceElem = { |
| 52 | + func: "sequence"; |
| 53 | + children: ContentElem[]; |
| 54 | +}; |
| 55 | +type StyledElem = { |
| 56 | + func: "styled"; |
| 57 | + child: ContentElem; |
| 58 | + styles: ".."; |
| 59 | +}; |
| 60 | +/** |
| 61 | + * The body of a heading element. |
| 62 | + * |
| 63 | + * Only relevant fields and combinations are included. |
| 64 | + */ |
| 65 | +type ContentElem = |
| 66 | + | StyledElem |
| 67 | + | SequenceElem |
| 68 | + | SpaceElem |
| 69 | + | TextElem |
| 70 | + | { |
| 71 | + func: "elem"; |
| 72 | + tag: "span"; |
| 73 | + attrs: { lang: "en" | "zh-Hans" } | { style: string }; |
| 74 | + body: TextElem | Sequence<TextElem | RawElem | SpaceElem>; |
| 75 | + }; |
| 76 | + |
| 77 | +type WorkaroundMeta = { |
| 78 | + dest: string; |
| 79 | + note: string | null; |
| 80 | +}; |
| 81 | + |
| 82 | +function* _parseContent( |
| 83 | + it: ContentElem | RawElem | Sequence<TextElem | RawElem | SpaceElem>, |
| 84 | +): Generator< |
| 85 | + | { action: "pop"; text: string } |
| 86 | + | { action: "switch-lang"; next: "en" | "zh-Hans" } |
| 87 | +> { |
| 88 | + const pop = (text: string) => ({ action: "pop" as const, text }); |
| 89 | + const switchLang = (lang: "en" | "zh-Hans") => ({ |
| 90 | + action: "switch-lang" as const, |
| 91 | + next: lang, |
| 92 | + }); |
| 93 | + |
| 94 | + switch (it.func) { |
| 95 | + // Structural elements |
| 96 | + case "sequence": |
| 97 | + for (const child of it.children) { |
| 98 | + yield* _parseContent(child); |
| 99 | + } |
| 100 | + break; |
| 101 | + case "styled": |
| 102 | + yield* _parseContent(it.child); |
| 103 | + break; |
| 104 | + |
| 105 | + // Basic elements |
| 106 | + case "text": |
| 107 | + yield pop(it.text); |
| 108 | + break; |
| 109 | + case "space": |
| 110 | + yield pop(" "); |
| 111 | + break; |
| 112 | + case "raw": |
| 113 | + yield pop(`“${it.text}”`); |
| 114 | + break; |
| 115 | + |
| 116 | + // HTML elements |
| 117 | + case "elem": |
| 118 | + if ("lang" in it.attrs) { |
| 119 | + const lang = it.attrs.lang; |
| 120 | + assert( |
| 121 | + lang === "en" || lang === "zh-Hans", |
| 122 | + `Invalid language: ${lang} in ${JSON.stringify(it)}`, |
| 123 | + ); |
| 124 | + yield switchLang(lang); |
| 125 | + } |
| 126 | + yield* _parseContent(it.body); |
| 127 | + break; |
| 128 | + |
| 129 | + default: |
| 130 | + throw new Error(`Reached unexpected element: ${JSON.stringify(it)}`); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +function parseHeading(heading: HeadingElem): Heading { |
| 135 | + const { level, body, label } = heading; |
| 136 | + const parser = _parseContent(body); |
| 137 | + |
| 138 | + const parsed = { en: "", "zh-Hans": "" }; |
| 139 | + let lang: keyof typeof parsed = "en"; |
| 140 | + |
| 141 | + for (const p of parser) { |
| 142 | + switch (p.action) { |
| 143 | + case "pop": |
| 144 | + parsed[lang] += p.text; |
| 145 | + break; |
| 146 | + case "switch-lang": |
| 147 | + // Remove the space added by `babel` between languages. |
| 148 | + parsed[lang] = parsed[lang].trim(); |
| 149 | + lang = p.next; |
| 150 | + break; |
| 151 | + default: |
| 152 | + throw new Error(`Invalid action: ${JSON.stringify(p)}`); |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + return { level, body: parsed, label }; |
| 157 | +} |
| 158 | + |
| 159 | +const data = (JSON.parse( |
| 160 | + await typst([ |
| 161 | + "query", |
| 162 | + "index.typ", // This cannot be main.typ, or there will be an additional heading (`outline`'s title). |
| 163 | + [ |
| 164 | + "selector.or(heading, <priority>, <issue>, <pull>, <workaround>)", |
| 165 | + ".after(outline)", |
| 166 | + ".before(<addendum>, inclusive: false)", |
| 167 | + ].join(""), |
| 168 | + "--target=html", |
| 169 | + ...extraArgs.pre, |
| 170 | + ]), |
| 171 | +) as ( |
| 172 | + | HeadingElem |
| 173 | + | ( |
| 174 | + & { func: "metadata" } |
| 175 | + & ( |
| 176 | + | { value: Priority; label: "<priority>" } |
| 177 | + | { value: IssueMeta; label: "<issue>" } |
| 178 | + | { value: PullMeta; label: "<pull>" } |
| 179 | + | { value: WorkaroundMeta; label: "<workaround>" } |
| 180 | + ) |
| 181 | + ) |
| 182 | +)[]).map((el) => { |
| 183 | + if (el.func === "heading") { |
| 184 | + return parseHeading(el); |
| 185 | + } |
| 186 | + return el; |
| 187 | +}); |
| 188 | + |
| 189 | +const sections: Section[] = []; |
| 190 | + |
| 191 | +for (const it of data) { |
| 192 | + if ("level" in it) { |
| 193 | + const { body: title, level, label } = it; |
| 194 | + sections.push({ title, level, label, priority: "(inherited)", links: [] }); |
| 195 | + } else { |
| 196 | + const last = sections.at(-1); |
| 197 | + assert( |
| 198 | + last !== undefined, |
| 199 | + `Metadata describe the heading before them, but there is no heading before this one: ${ |
| 200 | + JSON.stringify(it) |
| 201 | + }`, |
| 202 | + ); |
| 203 | + |
| 204 | + switch (it.label) { |
| 205 | + case "<priority>": |
| 206 | + assert( |
| 207 | + last.priority === "(inherited)", |
| 208 | + `There are multiple priority levels marking the same section: heading = ${ |
| 209 | + JSON.stringify(last.title) |
| 210 | + }, priority = [${last.priority}, ${it.value}, …]`, |
| 211 | + ); |
| 212 | + last.priority = it.value; |
| 213 | + break; |
| 214 | + |
| 215 | + case "<issue>": |
| 216 | + last.links.push({ type: "issue", ...it.value }); |
| 217 | + break; |
| 218 | + case "<pull>": |
| 219 | + last.links.push({ type: "pull", ...it.value }); |
| 220 | + break; |
| 221 | + case "<workaround>": |
| 222 | + last.links.push({ type: "workaround", ...it.value }); |
| 223 | + break; |
| 224 | + |
| 225 | + default: |
| 226 | + throw new Error(`Reached unexpected element: ${JSON.stringify(it)}`); |
| 227 | + } |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +console.log(JSON.stringify({ version: "2025-11-21", sections }, null, 2)); |
0 commit comments