├── scripts
├── typst-version.json
├── util.ts
├── netlify-build.sh
├── dev.ts
├── config.ts
├── patch-htmldiff.template.js
├── typst.ts
├── download_fonts.sh
├── patch-htmldiff.ts
├── build.ts
├── precompile.ts
├── generate-json-index.ts
└── check_issues.ts
├── src
├── vite-env.d.ts
├── main.ts
├── anchor-highlight.ts
├── respec
│ ├── mod.ts
│ ├── base.override.css
│ ├── language.css
│ ├── structure.css
│ ├── language.ts
│ ├── utils.ts
│ ├── sidebar.ts
│ ├── structure.ts
│ └── base.css
├── anchor-highlight.css
├── util.css
├── theme.ts
├── show-example.css
├── anchor-redirect.ts
├── global.css
└── htmldiff-nav.ts
├── public
├── webapp-misspell.png
├── font-fallback-messy.png
├── vertical-example-modern.jpg
└── vertical-example-ancient.jpg
├── .git-blame-ignore-revs
├── .github
├── dependabot.yml
└── workflows
│ ├── autofix.yml
│ ├── pr-diff.yml
│ ├── cron.yml
│ ├── check.yml
│ └── gh-pages.yml
├── typ
├── respec.typ
├── mode.typ
├── packages
│ ├── vite.typ
│ └── till-next.typ
├── examples
│ └── justification.typ
├── templates
│ ├── html-toolkit.typ
│ ├── html-fix.typ
│ └── template.html
├── prioritization.typ
├── icon.typ
├── util.typ
└── show-example.typ
├── netlify.toml
├── .gitignore
├── tsconfig.json
├── vite.config.ts
├── index.typ
├── package.json
├── README.md
├── README.en.md
└── LICENSE
/scripts/typst-version.json:
--------------------------------------------------------------------------------
1 | "0.14.2"
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
`.
6 | const style = document.createElement("style");
7 | style.textContent = `
8 | pre {
9 | white-space: normal;
10 | }
11 | `;
12 | document.head.appendChild(style);
13 |
14 | // Replace the keyboard navigation script.
15 | const observer = new MutationObserver((_mutations, observer) => {
16 | const old_script = document.querySelector(
17 | `script[src="https://w3c.github.io/htmldiff-nav/index.js"]`,
18 | );
19 | if (old_script !== null) {
20 | old_script.remove();
21 | observer.disconnect();
22 |
23 | const new_script = document.createElement("script");
24 | new_script.src = "{{ HTMLDIFF-NAV-SRC }}";
25 | new_script.type = "module";
26 | document.head.appendChild(new_script);
27 | }
28 | });
29 | observer.observe(document.head, { childList: true });
30 | }
31 |
--------------------------------------------------------------------------------
/index.typ:
--------------------------------------------------------------------------------
1 | #import "/typ/templates/html-toolkit.typ": load-html-template
2 | #import "/typ/templates/html-fix.typ": html-fix
3 | #import "/typ/respec.typ"
4 | #import "/typ/packages/vite.typ"
5 |
6 | /// Wraps the following content with the HTML template.
7 | #show: load-html-template.with("/typ/templates/template.html", extra-head: {
8 | vite.load-files(("src/main.ts", "src/theme.ts#nomodule"))
9 | })
10 |
11 | #show: html.main
12 |
13 | #show outline: respec.toc
14 | #show: html-fix
15 |
16 | #show "W3C": html.abbr("W3C", title: "World Wide Web Consortium")
17 | #show figure.where(kind: table): set figure.caption(position: top)
18 |
19 | #html.h1[
20 | #html.span(style: "display: inline-block;")[
21 | #link("https://www.w3.org/TR/clreq/")[clreq]-#link("https://www.w3.org/TR/clreq-gap/")[gap] for #link("https://typst.app/home")[typst]
22 | ]
23 | #html.span(style: "display: inline-block; font-weight: normal; font-size: 1rem; color: var(--gray-color);")[
24 | #datetime.today().display(), typst v#sys.version
25 | ]
26 | ]
27 |
28 | #include "main.typ"
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clreq-gap-typst",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "description": "Chinese Layout Gap Analysis for Typst",
7 | "dependencies": {
8 | "@std/fmt": "jsr:^1.0.8",
9 | "concurrently": "^9.2.1",
10 | "es-toolkit": "^1.42.0",
11 | "glob-watcher": "^6.0.0"
12 | },
13 | "scripts": {
14 | "build": "tsc && node --experimental-strip-types scripts/build.ts && pnpm generate-json-index",
15 | "dev": "node --experimental-strip-types scripts/dev.ts",
16 | "preview": "vite preview --base=/clreq/",
17 | "check-issues": "node --experimental-strip-types scripts/check_issues.ts",
18 | "generate-json-index": "node --experimental-strip-types scripts/generate-json-index.ts > dist/index.json",
19 | "patch-htmldiff": "node --experimental-strip-types scripts/patch-htmldiff.ts"
20 | },
21 | "devDependencies": {
22 | "@types/glob-watcher": "^5.0.5",
23 | "@types/node": "^24.10.1",
24 | "postcss-nesting": "^13.0.2",
25 | "typescript": "~5.9.3",
26 | "vite": "^7.2.6"
27 | },
28 | "optionalDependencies": {
29 | "netlify-plugin-cache": "^1.0.3"
30 | }
31 | }
--------------------------------------------------------------------------------
/scripts/typst.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 | import { spawn } from "node:child_process";
3 |
4 | /** Whether to use color, passed to typst */
5 | export type Color = "always" | "never" | "auto";
6 |
7 | /**
8 | * Run a typst command with the given arguments.
9 | *
10 | * @param args - Arguments to pass to the typst command
11 | * @returns - Resolves with the output of the typst command
12 | */
13 | export function typst(
14 | args: string[],
15 | { stdin, color = "auto" }: { stdin?: string; color?: Color } = {},
16 | ): Promise {
17 | return new Promise((resolve, reject) => {
18 | const proc = spawn("typst", [`--color=${color}`, ...args]);
19 | if (stdin) {
20 | assert(proc.stdin !== null);
21 | proc.stdin.write(stdin);
22 | proc.stdin.end();
23 | }
24 | assert(proc.stdout !== null);
25 | assert(proc.stderr !== null);
26 |
27 | const result: Buffer[] = [];
28 | proc.stdout.on("data", (data) => {
29 | result.push(data);
30 | });
31 | proc.stderr.on("data", (data) => {
32 | process.stderr.write(data);
33 | });
34 | proc.on("close", (code) => {
35 | if (code !== 0) {
36 | reject(
37 | new Error(
38 | [
39 | `Typst process exited with code ${code}`,
40 | stdin ? ("```typst\n" + stdin + "\n```") : null,
41 | `Args: typst ${args.join(" ")}`,
42 | ].flatMap((x) => x).join("\n"),
43 | ),
44 | );
45 | } else {
46 | resolve(Buffer.concat(result).toString("utf-8"));
47 | }
48 | });
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/.github/workflows/pr-diff.yml:
--------------------------------------------------------------------------------
1 | name: Comment about the difference on PR
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - opened
7 |
8 | jobs:
9 | comment:
10 | runs-on: ubuntu-latest
11 | env:
12 | GH_TOKEN: ${{ github.token }}
13 | permissions:
14 | pull-requests: write
15 | steps:
16 | - uses: actions/checkout@v6
17 |
18 | - name: Generate the comment
19 | shell: python
20 | run: |
21 | from pathlib import Path
22 | from urllib.parse import urlencode
23 |
24 | pr_number = "${{ github.event.pull_request.number }}"
25 |
26 | ref = "https://typst-doc-cn.github.io/clreq/"
27 | pr = f"https://deploy-preview-{pr_number}--clreq-gap-typst.netlify.app/"
28 |
29 | base_text = "https://services.w3.org/htmldiff?"
30 | base_pixel = "https://pianomister.github.io/diffsite/?"
31 |
32 | diff_text = base_text + urlencode({"doc1": ref, "doc2": pr})
33 | diff_pixel = base_pixel + urlencode({"url1": ref, "url2": pr})
34 |
35 | comment = f"""
36 | ### Diff between [#{pr_number}]({pr}) and [main]({ref})
37 |
38 | - [text diff]({diff_text})
39 | - [pixel diff]({diff_pixel})
40 |
41 | [Feedback](https://github.com/typst-doc-cn/clreq/discussions/9)
42 | """.strip()
43 |
44 | Path("comment.md").write_text(comment, encoding="utf-8")
45 |
46 | - name: Write the summary
47 | run: |
48 | cat comment.md >> "$GITHUB_STEP_SUMMARY"
49 |
50 | - name: Comment on the pull request
51 | run: gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
52 | env:
53 | GH_TOKEN: ${{ github.token }}
54 |
--------------------------------------------------------------------------------
/scripts/download_fonts.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euxo pipefail
3 |
4 | mkdir -p fonts
5 | cd fonts
6 |
7 | # Noto Serif CJK SC
8 | if ! typst fonts --font-path . | rg --quiet '^Noto Serif CJK SC$'; then
9 | curl --location --remote-name https://github.com/notofonts/noto-cjk/releases/download/Serif2.003/09_NotoSerifCJKsc.zip
10 | 7z x 09_NotoSerifCJKsc.zip
11 | rm 09_NotoSerifCJKsc.zip
12 | fi
13 |
14 | # Noto Color Emoji, the CBDT/CBLC version
15 | # See https://github.com/typst/typst/issues/6611 for reasons.
16 | if ! typst fonts --font-path . | rg --quiet '^Noto Color Emoji$'; then
17 | curl --location --remote-name https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji.ttf
18 | fi
19 |
20 | # Source Han Serif SC VF
21 | if ! typst fonts --font-path . | rg --quiet '^Source Han Serif SC VF$'; then
22 | curl --location --remote-name https://mirrors.cernet.edu.cn/adobe-fonts/source-han-serif/Variable/OTF/SourceHanSerifSC-VF.otf
23 | fi
24 |
25 | # A specific version of SimSun
26 | if ! typst fonts --font-path . | rg --quiet '^SimSun$'; then
27 | curl --location --remote-name https://github.com/typst-doc-cn/guide/releases/download/files/fonts.7z
28 | 7z x fonts.7z -ofonts
29 | rm fonts.7z
30 | fi
31 |
32 | # MOESongUN
33 | if ! typst fonts --font-path . | rg --quiet '^MOESongUN$'; then
34 | curl --location --remote-name https://language.moe.gov.tw/001/Upload/Files/site_content/M0001/eduSong_Unicode.zip
35 | 7z x eduSong_Unicode.zip -ofonts
36 | rm eduSong_Unicode.zip
37 | # The original filename in the zip has (2024年12月) encoded in Big5, resulting in garbage characters.
38 | mv fonts/eduSong_Unicode*.ttf 'fonts/eduSong_Unicode(2024年12月).ttf'
39 | fi
40 |
41 | # Check
42 | typst fonts --font-path . --ignore-system-fonts
43 |
44 | cd -
45 |
--------------------------------------------------------------------------------
/typ/packages/vite.typ:
--------------------------------------------------------------------------------
1 | /// Integrate typst as a backend for vite
2 | /// https://vite.dev/guide/backend-integration.html
3 |
4 | #import "../templates/html-toolkit.typ": asset-url
5 | #import "../mode.typ": mode
6 |
7 | #let manifest-path = "/dist/.vite/manifest.json"
8 |
9 | /// - raw-path (str):
10 | /// -> (path: str, as-module: bool)
11 | #let _parse-path(raw-path) = {
12 | if raw-path.ends-with("#nomodule") {
13 | (path: raw-path.trim("#nomodule", at: end), as-module: false)
14 | } else {
15 | (path: raw-path, as-module: true)
16 | }
17 | }
18 |
19 | /// Load a file
20 | ///
21 | /// All scripts will be loaded as a module be default.
22 | ///
23 | /// If a script should be executed before DOM is loaded, add a `#nomodule` suffix.
24 | /// Note that it is not officially supported by vite, and does not work in `dev` mode.
25 | ///
26 | /// - paths (array): Path to typescript entry points.
27 | /// -> content
28 | #let load-files(paths) = {
29 | let url(path) = asset-url("/" + path)
30 |
31 | if mode == "dev" {
32 | html.script(type: "module", src: url("@vite/client"))
33 | for raw-path in paths {
34 | let (path, as-module) = _parse-path(raw-path)
35 | html.script(type: "module", src: url(path))
36 | }
37 | } else {
38 | let manifest = json(manifest-path)
39 |
40 | for raw-path in paths {
41 | let (path, as-module) = _parse-path(raw-path)
42 | let chunk = manifest.at(path)
43 |
44 | assert("imports" not in chunk, message: "the `imports` field is not supported yet")
45 |
46 | html.script(src: url(chunk.file), ..if as-module { (type: "module") })
47 |
48 | for css in chunk.at("css", default: ()) {
49 | html.link(rel: "stylesheet", href: url(css))
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/respec/language.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Multilingual, adapted from clreq.
3 | * https://github.com/w3c/clreq/blob/2526efd3a66daa35d453784f9200cb20c6cfff1d/local.css#L132-L152
4 | */
5 |
6 | #lang-switch {
7 | position: fixed;
8 | z-index: 10000;
9 | top: 100px;
10 | right: 10px;
11 |
12 | display: grid;
13 | row-gap: 0.3em;
14 | width: 4.5em;
15 |
16 | > * {
17 | display: block;
18 | padding: 2px 0.8em;
19 | border: 1px solid var(--gray-color);
20 | border-radius: 8px;
21 | color: var(--main-color);
22 | background: var(--nav-bg-color);
23 | }
24 | :root:not(.monolingual) & > [data-lang="zh"]::before {
25 | content: "";
26 | display: inline-block;
27 |
28 | position: relative;
29 | width: 3px;
30 | margin-right: -3px;
31 | right: calc(1.5px + 0.3em);
32 |
33 | height: 1em;
34 | top: 2px;
35 |
36 | background-color: rgba(248, 138, 5, 0.5);
37 | }
38 |
39 | > :hover,
40 | > .checked {
41 | box-shadow: 0 2px 8px var(--gray-color);
42 | }
43 | > .checked {
44 | color: var(--accent);
45 | font-weight: bold;
46 | }
47 | > :active {
48 | background: var(--accent-dark);
49 | transition: outline 0.1s;
50 | }
51 | }
52 | h1 {
53 | /* left room for #lang-switch */
54 | margin-right: 5rem;
55 | }
56 |
57 | :root:not(.monolingual) {
58 | /* https://github.com/w3c/clreq/blob/2526efd3a66daa35d453784f9200cb20c6cfff1d/local.css#L179-L205 */
59 | [its-locale-filter-list="zh"] {
60 | border-left: 3px solid rgba(248, 138, 5, 0.5);
61 | }
62 | span[its-locale-filter-list="zh"] {
63 | display: inline-block;
64 | margin-left: 0.3em;
65 | padding-left: 0.3em;
66 | }
67 | p[its-locale-filter-list="zh"] {
68 | margin-left: calc(-0.4em - 2px);
69 | padding-left: 0.4em;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/typ/examples/justification.typ:
--------------------------------------------------------------------------------
1 | #let cell(align: center, width: 1em, body) = box(width: width, stroke: none, std.align(align, {
2 | // Align with texts outside any cell.
3 | //
4 | // The box’s bottom touches the baseline, which can't be changed.
5 | // Therefore, we have to change the bottom edge of box's content.
6 | set text(bottom-edge: "baseline")
7 |
8 | body
9 | }))
10 |
11 | #let example(page-width: 8em, page-n-rows: 5, headers: (), pages: ()) = {
12 | assert.eq(headers.len(), pages.len())
13 |
14 | set text(
15 | // It's necessary for drawing grids
16 | top-edge: "ascender",
17 | bottom-edge: "descender",
18 | // We cannot mix font.
19 | // Otherwise, numbers and Han chars will not align, because distance(bottom-edge, baseline) = distance(descender, baseline) varies with fonts.
20 | font: "Noto Serif CJK SC",
21 | )
22 |
23 | set raw(lang: "typm")
24 | show raw: set text(0.9em)
25 |
26 | set box(stroke: 0.5pt)
27 |
28 | grid(
29 | columns: (page-width,) * headers.len(),
30 | column-gutter: 2em,
31 | row-gutter: 1em,
32 | ..headers.map(text.with(0.75em, font: "New Computer Modern")).map(grid.cell.with(align: center + horizon)),
33 | ..pages.map(it => {
34 | // Cells
35 | place(top + start, {
36 | let stroke-box = box(stroke: green, width: 1em, height: 1em)
37 | let fill-box = box(stroke: green, fill: green.lighten(50%), width: 1em, height: 1em)
38 | (
39 | (
40 | (stroke-box,) * int(page-width / 1em - 1) + (fill-box,)
41 | )
42 | * page-n-rows
43 | ).join()
44 | })
45 |
46 | // Frame
47 | place(top + start, box(
48 | stroke: (
49 | x: purple.mix(blue).lighten(50%),
50 | y: purple.lighten(50%),
51 | ),
52 | hide(it),
53 | ))
54 |
55 | it
56 | parbreak()
57 | it
58 | })
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/util.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Companion of typ/util.typ.
3 | */
4 |
5 | /* `#prompt` */
6 | article.prompt {
7 | font-size: 90%;
8 | color: var(--gray-color);
9 |
10 | > .license {
11 | font-size: 80%;
12 | }
13 | }
14 |
15 | /* Small links containing icons */
16 | .unbreakable {
17 | display: inline-block;
18 | }
19 |
20 | /* `#unichar` */
21 | /* Inspired by clreq `.uname` and Wikipedia [[Template:Unichar]] */
22 | .unichar {
23 | .code-point {
24 | font-family: monospace;
25 | font-size: 0.8em;
26 | letter-spacing: 0.03em;
27 | }
28 | .small-caps {
29 | font-variant-caps: small-caps;
30 | }
31 | }
32 |
33 | /* `#note` */
34 | aside.note {
35 | margin-block: 1em;
36 | border-left: 3px solid var(--accent);
37 | border-radius: 8px;
38 | padding-inline: 1.5em;
39 | padding-block: 0.5em;
40 |
41 | > .note-title {
42 | margin-top: 0;
43 | font-weight: bold;
44 | color: var(--accent);
45 | }
46 | }
47 |
48 | /* `details.now-fixed` */
49 | details.now-fixed {
50 | margin-block: 1em;
51 | border-left: 3px solid var(--accent);
52 | border-radius: 8px;
53 | padding-inline-start: 1.5em;
54 | padding-block: 0.5em;
55 |
56 | > summary {
57 | cursor: pointer;
58 | margin-left: -0.5em;
59 | margin-bottom: 0.25em;
60 |
61 | display: grid;
62 | grid-template-columns: auto 1fr;
63 | align-items: center;
64 |
65 | font-weight: bold;
66 | color: var(--accent);
67 |
68 | /* Replace ::marker with ::before */
69 | list-style: none;
70 | &::-webkit-details-marker {
71 | display: none;
72 | }
73 |
74 | &::before {
75 | display: flex;
76 | align-items: center;
77 | height: 2em;
78 | width: 2em;
79 | }
80 |
81 | > p {
82 | margin-block: 0;
83 | }
84 | }
85 |
86 | > summary::before {
87 | content: "▶";
88 | }
89 | &[open] > summary::before {
90 | content: "▼";
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/typ/packages/till-next.typ:
--------------------------------------------------------------------------------
1 | /// Apply a function to the following content until next heading or `#till-next`.
2 | ///
3 | /// = Usage
4 | ///
5 | /// == Input
6 | ///
7 | /// ```
8 | /// #show: mark-till-next
9 | ///
10 | /// A
11 | ///
12 | /// #till-next(wrapper)
13 | ///
14 | /// B
15 | ///
16 | /// C
17 | ///
18 | /// = Heading
19 | ///
20 | /// D
21 | /// ```
22 | ///
23 | /// == Equivalent output
24 | ///
25 | /// ```
26 | /// A
27 | ///
28 | /// #wrapper[
29 | /// B
30 | ///
31 | /// C
32 | /// ]
33 | ///
34 | /// = Heading
35 | ///
36 | /// D
37 | /// ```
38 | ///
39 | /// = Known behaviours
40 | ///
41 | // - `#till-next` should be put at the same level of `#show: mark-till-next`, or it will be ignored.
42 | // - If you put two `#till-next` consecutively, then the former `#till-next` will receive a space `[ ]`, not `none`.
43 | #let till-next(fn) = metadata((till-next: fn))
44 |
45 | /// A show rule that makes `#till-next(fn)` effective.
46 | ///
47 | /// Usage: `#show: mark-till-next`
48 | #let mark-till-next(body) = {
49 | // The wrapper function
50 | let fn = none
51 | // The fenced elements to be wrapped
52 | let fenced = ()
53 |
54 | for it in body.children {
55 | let is-the-metadata = it.func() == metadata and it.value.keys().contains("till-next")
56 | let is-heading = it.func() == heading
57 |
58 | if is-the-metadata or is-heading {
59 | if fn != none {
60 | // Complete the last fence
61 | fn(fenced.join())
62 | fn = none
63 | fenced = ()
64 | }
65 | if is-the-metadata {
66 | // Start a new fence
67 | fn = it.value.till-next
68 | } else {
69 | it
70 | }
71 | } else if fn != none {
72 | // Continue the fence
73 | fenced.push(it)
74 | } else {
75 | it // if not in any fence
76 | }
77 | }
78 |
79 | // Complete the last fence
80 | if fn != none {
81 | fn(fenced.join())
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/scripts/patch-htmldiff.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Patch htmldiff
3 | *
4 | * https://services.w3.org/htmldiff is running `htmldiff` (the python script),
5 | * and it will execute `htmldiff.pl` in CLI (rather than CGI) mode.
6 | *
7 | * The commit version might be the following:
8 | * https://github.com/w3c/htmldiff-ui/blob/5eac9b073c66b24422df613a537da2ec2f97f457/htmldiff.pl
9 | *
10 | * ## Improve readability for ``
11 | *
12 | * This is a bug of htmldiff.
13 | *
14 | * When `` and `
` is on the same line, `splitit` should set `$preformatted` to true, but not.
15 | * https://github.com/w3c/htmldiff-ui/blob/main/htmldiff.pl#L406-L412
16 | *
17 | * Additionally, `markit` will drop all characters after `<` for deleted (or replaced) lines.
18 | * As a result, htmldiff does not work as expected even if `$preformatted` has been set to true`.
19 | * https://github.com/w3c/htmldiff-ui/blob/5eac9b073c66b24422df613a537da2ec2f97f457/htmldiff.pl#L167-L168
20 | *
21 | * Therefore, let us ignore it…
22 | *
23 | * ## Replace the keyboard navigation script
24 | *
25 | * Replace it with src/htmldiff-nav.ts
26 | */
27 |
28 | import { readFile, writeFile } from "node:fs/promises";
29 | import path from "node:path";
30 |
31 | import { BUILD_URL_BASE, ROOT_DIR } from "./config.ts";
32 |
33 | const dist = path.join(ROOT_DIR, "dist");
34 |
35 | const manifest = JSON.parse(
36 | await readFile(path.join(dist, ".vite/manifest.json"), { encoding: "utf-8" }),
37 | );
38 | const htmldiff_nav = manifest["src/htmldiff-nav.ts"].file as string;
39 |
40 | const script =
41 | (await readFile(path.join(ROOT_DIR, "scripts/patch-htmldiff.template.js"), {
42 | encoding: "utf-8",
43 | }))
44 | .replace("{{ HTMLDIFF-NAV-SRC }}", `${BUILD_URL_BASE}${htmldiff_nav}`);
45 |
46 | const index_html = path.join(dist, "index.html");
47 |
48 | await writeFile(
49 | index_html,
50 | (await readFile(index_html, { encoding: "utf-8" })).replace(
51 | "",
52 | ``,
53 | ),
54 | );
55 |
--------------------------------------------------------------------------------
/.github/workflows/cron.yml:
--------------------------------------------------------------------------------
1 | name: Scheduled check
2 |
3 | on:
4 | schedule:
5 | - cron: "0 1 28 * *"
6 | # At 01:00 on day-of-month 28. (UTC)
7 | # https://crontab.guru/#0_1_28_*_*
8 | workflow_dispatch:
9 |
10 | permissions:
11 | issues: write
12 |
13 | jobs:
14 | check-issues:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v6
18 | - uses: pnpm/action-setup@v4
19 | with:
20 | version: 10
21 | - uses: actions/setup-node@v6
22 | with:
23 | node-version: 22
24 | cache: pnpm
25 | - name: Load typst-version
26 | run: |
27 | echo "TYPST_VERSION=$(jq . scripts/typst-version.json --raw-output)" >> "$GITHUB_ENV"
28 | - uses: typst-community/setup-typst@v4
29 | with:
30 | typst-version: v${{ env.TYPST_VERSION }}
31 | - run: pnpm install
32 | - name: Check issues and generate a report
33 | id: check
34 | continue-on-error: true
35 | run: |
36 | pnpm check-issues --assert-all-covered
37 | env:
38 | GH_TOKEN: ${{ github.token }}
39 | - name: Find last report
40 | uses: micalevisk/last-issue-action@v2
41 | id: issue
42 | with:
43 | state: open
44 | labels: |
45 | cron-check
46 | - name: Get date
47 | id: date
48 | shell: bash
49 | run: |
50 | echo "date=$(date --utc --iso-8601)" >> "$GITHUB_OUTPUT"
51 | - name: Create or update the report
52 | # If there is no last report and the check failed, create one.
53 | # If there was a report, update it.
54 | if: steps.issue.outputs.has-found == 'true' || steps.check.outcome == 'failure'
55 | uses: peter-evans/create-issue-from-file@v6
56 | with:
57 | title: 🤖 Cron check (${{ steps.date.outputs.date }})
58 | # Update an existing issue if one was found (issue-number),
59 | # otherwise an empty value creates a new issue:
60 | issue-number: "${{ steps.issue.outputs.issue-number }}"
61 | content-filepath: "${{ steps.check.outputs.report-file }}"
62 | labels: |
63 | cron-check
64 |
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: Check
2 |
3 | on:
4 | # Sync this with `./autofix.yml`
5 | push:
6 | branches:
7 | - main
8 | pull_request:
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v6
15 | - uses: pnpm/action-setup@v4
16 | with:
17 | version: 10
18 | - uses: actions/setup-node@v6
19 | with:
20 | node-version: 22
21 | cache: pnpm
22 | - name: Load typst-version
23 | run: |
24 | echo "TYPST_VERSION=$(jq . scripts/typst-version.json --raw-output)" >> "$GITHUB_ENV"
25 | - uses: typst-community/setup-typst@v4
26 | with:
27 | typst-version: v${{ env.TYPST_VERSION }}
28 | - name: Restore cached fonts
29 | uses: actions/cache@v4
30 | id: cache
31 | with:
32 | path: |
33 | fonts/
34 | key: fonts-${{ hashFiles('scripts/download_fonts.sh') }}
35 | restore-keys: |
36 | fonts-${{ hashFiles('scripts/download_fonts.sh') }}
37 | fonts-
38 | - uses: taiki-e/install-action@v2
39 | if: steps.cache.outputs.cache-hit != 'true'
40 | with:
41 | tool: ripgrep
42 | - name: Install fonts
43 | shell: bash
44 | if: steps.cache.outputs.cache-hit != 'true'
45 | run: |
46 | bash scripts/download_fonts.sh
47 | - run: pnpm install
48 | - run: pnpm build
49 | - uses: actions/upload-artifact@v5
50 | with:
51 | name: dist
52 | path: "./dist"
53 | check-issues:
54 | runs-on: ubuntu-latest
55 | steps:
56 | - uses: actions/checkout@v6
57 | - uses: pnpm/action-setup@v4
58 | with:
59 | version: 10
60 | - uses: actions/setup-node@v6
61 | with:
62 | node-version: 22
63 | cache: pnpm
64 | - name: Load typst-version
65 | run: |
66 | echo "TYPST_VERSION=$(jq . scripts/typst-version.json --raw-output)" >> "$GITHUB_ENV"
67 | - uses: typst-community/setup-typst@v4
68 | with:
69 | typst-version: v${{ env.TYPST_VERSION }}
70 | - run: pnpm install
71 | - run: |
72 | pnpm check-issues
73 | env:
74 | GH_TOKEN: ${{ github.token }}
75 |
--------------------------------------------------------------------------------
/src/theme.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Theme toggle.
3 | *
4 | * This script should be executed before DOM is loaded.
5 | * Otherwise, the screen will flash in the dark mode.
6 | */
7 |
8 | type Theme = "auto" | "light" | "dark";
9 |
10 | /** Same as `applyTheme`, but works before DOM is loaded */
11 | function applyThemeWithoutDOM(theme: Theme): void {
12 | // Change the page’s theme
13 | const resolved = theme === "auto" ? getSystemTheme() : theme;
14 | if (resolved === "dark") {
15 | document.documentElement.classList.add("dark");
16 | } else {
17 | document.documentElement.classList.remove("dark");
18 | }
19 | }
20 |
21 | // Load saved theme preference or default to auto (sync system)
22 | if (typeof localStorage !== "undefined") {
23 | const theme = localStorage.getItem("theme") ?? "auto";
24 | applyThemeWithoutDOM(theme as Theme);
25 | }
26 |
27 | document.addEventListener("DOMContentLoaded", () => {
28 | const themeToggle = document.getElementById("theme-toggle") as HTMLElement;
29 | const themeSelections: HTMLDivElement[] = Array.from(
30 | themeToggle.querySelectorAll("div.theme-icon"),
31 | );
32 |
33 | // Load saved theme preference or default to auto (sync system)
34 | if (typeof localStorage !== "undefined") {
35 | const theme = localStorage.getItem("theme") ?? "auto";
36 | applyTheme(theme as Theme);
37 | }
38 |
39 | // Theme toggle functionality
40 | themeToggle.addEventListener("click", () => {
41 | const current = themeSelections.findIndex((icon) => !icon.hidden);
42 | const next =
43 | themeSelections[(current + 1) % themeSelections.length].classList;
44 | const theme = next.contains("auto")
45 | ? "auto"
46 | : next.contains("light")
47 | ? "light"
48 | : "dark";
49 | applyTheme(theme);
50 | });
51 |
52 | function applyTheme(theme: Theme): void {
53 | applyThemeWithoutDOM(theme);
54 |
55 | // Update theme selections
56 | themeSelections.forEach((icon) => {
57 | icon.hidden = !icon.classList.contains(theme);
58 | });
59 |
60 | // Save for future loads
61 | localStorage.setItem("theme", theme);
62 | }
63 | });
64 |
65 | function getSystemTheme(): "dark" | "light" {
66 | const match = window.matchMedia &&
67 | window.matchMedia("(prefers-color-scheme: dark)").matches;
68 | return match ? "dark" : "light";
69 | }
70 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: clreq::gh_pages
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ["main"]
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12 | permissions:
13 | pages: write
14 | id-token: write
15 | contents: read
16 |
17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
19 | concurrency:
20 | group: "pages"
21 | cancel-in-progress: false
22 |
23 | env:
24 | GITHUB_PAGES_BASE: "/${{ github.event.repository.name }}/"
25 |
26 | jobs:
27 | build-gh-pages:
28 | runs-on: ubuntu-latest
29 | environment:
30 | name: github-pages
31 | url: ${{ steps.deployment.outputs.page_url }}
32 | steps:
33 | - uses: actions/checkout@v6
34 | - uses: pnpm/action-setup@v4
35 | with:
36 | version: 10
37 | - uses: actions/setup-node@v6
38 | with:
39 | node-version: 22
40 | cache: pnpm
41 | - name: Load typst-version
42 | run: |
43 | echo "TYPST_VERSION=$(jq . scripts/typst-version.json --raw-output)" >> "$GITHUB_ENV"
44 | - uses: typst-community/setup-typst@v4
45 | with:
46 | typst-version: v${{ env.TYPST_VERSION }}
47 | - name: Restore cached fonts
48 | uses: actions/cache@v4
49 | id: cache
50 | with:
51 | path: |
52 | fonts/
53 | key: fonts-${{ hashFiles('scripts/download_fonts.sh') }}
54 | restore-keys: |
55 | fonts-${{ hashFiles('scripts/download_fonts.sh') }}
56 | fonts-
57 | - uses: taiki-e/install-action@v2
58 | with:
59 | tool: ripgrep
60 | - name: Install fonts
61 | shell: bash
62 | if: steps.cache.outputs.cache-hit != 'true'
63 | run: |
64 | bash scripts/download_fonts.sh
65 | - run: pnpm install
66 | - run: pnpm build
67 | - run: pnpm patch-htmldiff
68 | - name: Setup Pages
69 | uses: actions/configure-pages@v5
70 | - name: Upload artifact
71 | uses: actions/upload-pages-artifact@v4
72 | with:
73 | path: "./dist"
74 | - name: Deploy to GitHub Pages
75 | id: deployment
76 | uses: actions/deploy-pages@v4
77 |
--------------------------------------------------------------------------------
/src/show-example.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Stylize examples created by typ/show-example.typ.
3 | */
4 |
5 | .example {
6 | display: flex;
7 | flex-wrap: wrap;
8 | gap: 1em;
9 |
10 | > * {
11 | flex: auto;
12 | }
13 |
14 | > pre {
15 | overflow-x: auto;
16 | }
17 |
18 | > .preview {
19 | border-radius: 8px;
20 | /* Copied from typst.app/docs `.preview` */
21 | align-items: center;
22 | background: #e4e5ea;
23 | display: flex;
24 | flex-direction: column;
25 | gap: 16px;
26 | justify-content: center;
27 | padding: 12px 16px;
28 |
29 | > svg.typst-frame, img {
30 | /* The visual padding is the sum of CSS padding and SVG inset. See `render-examples` in show-example.typ */
31 | padding: 0.5em;
32 | border-radius: 8px;
33 | /* Copied from typst.app/docs `.preview > *` */
34 | background: #fff;
35 | box-shadow: 0 4px 12px rgba(89, 85, 101, 0.2);
36 | height: auto;
37 | max-height: 100%;
38 | max-width: 100%;
39 | width: auto;
40 | }
41 | }
42 | }
43 | .dark .example > .preview {
44 | background: darkgray;
45 |
46 | > svg.typst-frame {
47 | background: lightgray;
48 | }
49 | }
50 |
51 | /*
52 | * Adapt the syntax highlighting palette to the dark theme
53 | *
54 | * In typst v0.14, `text.fill` in `raw` turn into inline `style="color: #rrggbb" attributes.
55 | * We have to use `!important` to override them.
56 | * https://forum.typst.app/t/how-to-support-syntax-highlighting-for-html-dark-theme-in-v0-14-0-rc-1/6380/2
57 | */
58 | .dark main code span {
59 | /**
60 | * Check all possible colors by executing the following in the browser console:
61 | * [...new Set($$("span[style^='color:']").map((e) => e.outerHTML.match(/style="color: (#[0-9a-f]+?)"/)[1]))].sort()
62 | * Then improve the contrast ratio:
63 | * https://webaim.org/resources/contrastchecker/?bcolor=212737
64 | * (See also https://github.com/typst/typst/blob/v0.14.0-rc.1/crates/typst-library/src/text/raw.rs#L942-L972.)
65 | */
66 | &[style="color: #1d6c76"] {
67 | color: #36bfce !important;
68 | }
69 | &[style="color: #198810"] {
70 | color: #38be13 !important;
71 | }
72 | &[style="color: #4b69c6"] {
73 | color: #92a5dd !important;
74 | }
75 | &[style="color: #6b6b6f"] {
76 | color: #ababab !important;
77 | }
78 | &[style="color: #74747c"] {
79 | color: #c497d8 !important;
80 | }
81 | &[style="color: #b60157"] {
82 | color: #fe90c7 !important;
83 | }
84 | &[style="color: #d73a49"] {
85 | color: #e5858d !important;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/scripts/build.ts:
--------------------------------------------------------------------------------
1 | import { env } from "node:process";
2 | import { fileURLToPath } from "node:url";
3 | import { build as vite_build } from "vite";
4 |
5 | import { extraArgs } from "./config.ts";
6 | import { precompile } from "./precompile.ts";
7 | import { typst } from "./typst.ts";
8 | import { duration_fmt, execFile } from "./util.ts";
9 |
10 | interface GitInfo {
11 | name: string;
12 | commit_url: string;
13 | log_url: string;
14 | /** latest git log */
15 | latest_log: string;
16 | }
17 |
18 | async function git_info(): Promise {
19 | const git_log = execFile("git", [
20 | "log",
21 | "--max-count=1",
22 | "--pretty=fuller",
23 | "--date=iso",
24 | ]).then(({ stdout }) => stdout.trim());
25 |
26 | if (env.GITHUB_ACTIONS === "true") {
27 | // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
28 | const base = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`;
29 | return {
30 | name: `${env.GITHUB_SHA?.slice(0, 8)} (${env.GITHUB_REF_NAME})`,
31 | commit_url: `${base}/commit/${env.GITHUB_SHA}`,
32 | log_url: `${base}/actions/runs/${env.GITHUB_RUN_ID}`,
33 | latest_log: await git_log,
34 | };
35 | } else if (env.NETLIFY === "true") {
36 | // https://docs.netlify.com/configure-builds/environment-variables/
37 | return {
38 | name: `${env.COMMIT_REF?.slice(0, 8)} (${env.HEAD})`,
39 | commit_url: `${env.REPOSITORY_URL}/commit/${env.COMMIT_REF}`,
40 | log_url:
41 | `https://app.netlify.com/sites/${env.SITE_NAME}/deploys/${env.DEPLOY_ID}`,
42 | latest_log: await git_log,
43 | };
44 | } else {
45 | return null;
46 | }
47 | }
48 |
49 | function as_input(info: GitInfo | null): string[] {
50 | return info ? ["--input", `git=${JSON.stringify(info)}`] : [];
51 | }
52 |
53 | if (process.argv[1] === fileURLToPath(import.meta.url)) {
54 | // These steps should be executed sequentially.
55 | // 1. `precompile` writes `/public/generated/` processed by `vite_build`.
56 | // 2. `vite_build` writes `/dist/.vite/manifest.json` depended by `index.typ`.
57 |
58 | await precompile();
59 |
60 | await vite_build();
61 |
62 | const timeStart = Date.now();
63 | await typst([
64 | "compile",
65 | "index.typ",
66 | "dist/index.html",
67 | ...as_input(await git_info()),
68 | ...extraArgs.build,
69 | ]);
70 | console.log(
71 | `🏛️ Built the document successfully in`,
72 | `${duration_fmt(Date.now() - timeStart)}.`,
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/respec/structure.css:
--------------------------------------------------------------------------------
1 | /* Permalink, ./structure.js */
2 | /* https://github.com/speced/respec/blob/eaa1596ef5c4207ab350808c1593d3a39600fbed/src/styles/respec.css.js#L148-L169 */
3 | a.permalink {
4 | margin-left: -0.5em;
5 | width: 1em;
6 |
7 | &:not(:hover) {
8 | text-decoration: none;
9 | color: var(--gray-color);
10 | opacity: 0.5;
11 | }
12 | &::before {
13 | content: "§";
14 | }
15 | }
16 |
17 | /* Summary */
18 | ol#summary {
19 | /* Numbers are inlined into source */
20 | list-style-type: none;
21 | padding-left: 0;
22 |
23 | display: grid;
24 | grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
25 | row-gap: 1em;
26 |
27 | > li > a {
28 | height: 100%;
29 | display: flex;
30 | flex-direction: column;
31 |
32 | padding-left: 1em;
33 | padding-right: 0.5em;
34 |
35 | color: var(--toclink-text);
36 |
37 | /* Switch to using border-bottom */
38 | text-decoration: none;
39 | border-bottom: 3px solid transparent;
40 | margin-bottom: -2px;
41 |
42 | &:hover {
43 | background: var(--a-hover-bg);
44 | border-bottom-color: var(--toclink-underline);
45 | }
46 |
47 | /* heading + v(1fr) + dots + report */
48 | > * {
49 | margin-top: 0;
50 | margin-bottom: 0;
51 | }
52 | > .dots {
53 | margin-top: auto;
54 | }
55 |
56 | > :first-child {
57 | text-indent: 0.8em hanging;
58 | margin-left: -1em;
59 |
60 | > .secno {
61 | display: inline-block;
62 | width: 0.8em;
63 | }
64 | }
65 |
66 | > .dots {
67 | margin-left: -0.25em;
68 |
69 | display: flex;
70 | flex-wrap: wrap-reverse;
71 |
72 | > * {
73 | border-radius: 50%;
74 | display: inline-block;
75 |
76 | border-width: 2px;
77 | width: calc(0.8em - 2px * 2);
78 | height: calc(0.8em - 2px * 2);
79 | margin: 0.25em;
80 | vertical-align: -15%;
81 |
82 | border-style: solid;
83 | border-color: transparent;
84 | }
85 | }
86 |
87 | :root:not(.dark) &:hover > .dots > * {
88 | /* Make sure tbd-level dots are visible */
89 | border-color: var(--main-bg-color);
90 | width: calc(0.8em - 2px);
91 | height: calc(0.8em - 2px);
92 | margin: calc(0.25em - 1px);
93 | vertical-align: -16%;
94 | }
95 |
96 | > .report {
97 | font-size: small;
98 | color: var(--gray-color);
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/typ/templates/html-toolkit.typ:
--------------------------------------------------------------------------------
1 | /// = HTML Toolkit
2 | ///
3 | /// This package provides a set of utility functions for working with HTML export.
4 |
5 | /// CLI sets the `x-url-base` to the base URL for assets. This is needed if you host the website on the github pages.
6 | ///
7 | /// For example, if you host the website on `https://username.github.io/project/`, you should set `x-url-base` to `/project/`.
8 | #let assets-url-base = sys.inputs.at("x-url-base", default: none)
9 | /// The base URL for content.
10 | #let url-base = if assets-url-base != none { assets-url-base } else { "/dist/" }
11 | /// The base URL for assets.
12 | #let assets-url-base = if assets-url-base != none { assets-url-base } else { "/" }
13 |
14 | /// Converts the path to the asset to the URL.
15 | ///
16 | /// - path (str): The path to the asset.
17 | /// -> str
18 | #let asset-url(path) = {
19 | if path != none and path.starts-with("/") {
20 | assets-url-base + path.slice(1)
21 | } else {
22 | path
23 | }
24 | }
25 |
26 | /// Converts the xml loaded data to HTML.
27 | ///
28 | /// - data (dict): The data to convert to HTML.
29 | /// -> content
30 | #let to-html(data, slot: none) = {
31 | let to-html = to-html.with(slot: slot)
32 | if type(data) == str {
33 | let data = data.trim()
34 | if data.len() > 0 {
35 | data
36 | }
37 | } else {
38 | if data.tag == "slot" {
39 | return slot
40 | }
41 | if data.tag.len() == 0 {
42 | return none
43 | }
44 |
45 | let rewrite(data, attr) = {
46 | let value = data.attrs.at(attr, default: none)
47 | if value != none and value.starts-with("/") {
48 | data.attrs.at(attr) = asset-url(value)
49 | }
50 |
51 | data
52 | }
53 |
54 | data = rewrite(data, "href")
55 | data = rewrite(data, "src")
56 |
57 | html.elem(data.tag, attrs: data.attrs, data.children.map(to-html).join())
58 | }
59 | }
60 |
61 | /// Loads the HTML template and inserts the content.
62 | ///
63 | /// - template-path (str): The absolute path to the HTML template.
64 | /// - content (content): The body to insert into the template.
65 | /// - extra-head (content): Additional content to insert into the head.
66 | /// ->
67 | #let load-html-template(template-path, body, extra-head: none) = {
68 | let html-elem = xml(template-path).at(0)
69 | let html-children = html-elem.children.filter(it => type(it) != str)
70 | let head = to-html(html-children.at(0)).body
71 | let body = to-html(html-children.at(1), slot: body)
72 |
73 | html.html(..html-elem.attrs, {
74 | html.head({
75 | head
76 | extra-head
77 | })
78 | body
79 | })
80 | }
81 |
82 | /// Creates an embeded block typst frame.
83 | #let div-frame(content, ..attrs) = html.div(html.frame(content), ..attrs)
84 |
--------------------------------------------------------------------------------
/src/respec/language.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates and handles the language switch.
3 | *
4 | * Adapted from W3C clreq.
5 | *
6 | * https://github.com/w3c/clreq/blob/2526efd3a66daa35d453784f9200cb20c6cfff1d/index.html#L76-L81
7 | * https://github.com/w3c/clreq/blob/2526efd3a66daa35d453784f9200cb20c6cfff1d/src/script.ts
8 | * https://www.w3.org/Consortium/Legal/copyright-software
9 | */
10 |
11 | import { html } from "./utils.ts";
12 |
13 | type Language = "zh" | "en" | "all";
14 |
15 | const $root = document.documentElement;
16 | const $title = $root.querySelector("main > h1") as HTMLHeadingElement;
17 |
18 | function setRoot(lang: Language): void {
19 | $root.lang = lang === "all" ? "zh-CN" : lang;
20 |
21 | // Update styles
22 | if (lang === "all") {
23 | $root.classList.remove("monolingual");
24 | } else {
25 | $root.classList.add("monolingual");
26 | }
27 | }
28 |
29 | function filterElements(lang: Language): void {
30 | /**
31 | * Multilingual elements created with `#babel` and `#bbl` in typst,
32 | * or dynamically created by other scripts.
33 | */
34 | const babelElements = Array.from(
35 | $root.querySelectorAll("[its-locale-filter-list]"),
36 | );
37 |
38 | for (const el of babelElements) {
39 | const match = lang === "all" ||
40 | el.getAttribute("its-locale-filter-list") === lang;
41 | el.hidden = !match;
42 | }
43 | }
44 |
45 | function applyLanguage(lang: Language, menu: HTMLElement): void {
46 | setRoot(lang);
47 | filterElements(lang);
48 |
49 | // Update selection
50 | Array.from(menu.children).forEach((o) => {
51 | // @ts-ignore
52 | if (o.dataset.lang === lang) {
53 | o.classList.add("checked");
54 | o.ariaChecked = "true";
55 | } else {
56 | o.classList.remove("checked");
57 | o.ariaChecked = "false";
58 | }
59 | });
60 |
61 | // Save for future loads
62 | localStorage.setItem("lang", lang);
63 | }
64 |
65 | export function createLanguageSwitch(): void {
66 | const menu = html`
67 |
72 | ` as HTMLElement;
73 | menu.addEventListener("click", (e) => {
74 | // @ts-ignore
75 | const option = e.target.closest("[data-lang]") as HTMLElement;
76 | if (option) {
77 | const lang = option.dataset.lang as Language;
78 | applyLanguage(lang, menu);
79 | }
80 | });
81 |
82 | applyLanguage(
83 | (localStorage.getItem("lang") as Language | null) ?? getSystemLanguage(),
84 | menu,
85 | );
86 |
87 | // Make the menu easily accessible by pressing tab key.
88 | $title.insertAdjacentElement("afterend", menu);
89 | }
90 |
91 | function getSystemLanguage(): Language {
92 | const languages = navigator.languages ?? [navigator.language];
93 |
94 | for (const langFull of languages) {
95 | const lang = langFull.split("-")[0].toLowerCase();
96 |
97 | if (lang === "zh") return "zh";
98 | if (lang === "en") return "en";
99 | }
100 | return "all";
101 | }
102 |
--------------------------------------------------------------------------------
/src/respec/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * As the name implies, this contains a ragtag gang of methods that just don't fit anywhere else.
3 | *
4 | * Adapted from ReSpec.
5 | *
6 | * https://github.com/speced/respec/blob/eaa1596ef5c4207ab350808c1593d3a39600fbed/src/core/utils.js
7 | * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
8 | *
9 | * @module
10 | */
11 |
12 | /**
13 | * A simple html template tag replacement for hyperHTML
14 | *
15 | * @param strings - Template string array.
16 | * @param values - Values to interpolate into the template.
17 | * @returns - The resulting DOM element or fragment.
18 | */
19 | export function html(
20 | strings: TemplateStringsArray,
21 | ...values: (string | Element | DocumentFragment)[]
22 | ): Element | DocumentFragment {
23 | let str = "";
24 | for (let i = 0; i < strings.length; i++) {
25 | str += strings[i];
26 | if (i < values.length) {
27 | const value = values[i];
28 | if (value instanceof Element || value instanceof DocumentFragment) {
29 | // @ts-ignore
30 | str += value.outerHTML;
31 | } else {
32 | str += value;
33 | }
34 | }
35 | }
36 | const template = document.createElement("template");
37 | template.innerHTML = str.trim();
38 | return template.content.children.length === 1
39 | // @ts-ignore
40 | ? template.content.firstElementChild
41 | : template.content;
42 | }
43 |
44 | /**
45 | * Creates and sets an ID to an element (elem) using a specific prefix if
46 | * provided, and a specific text if given.
47 | * @param elem element
48 | * @param pfx prefix
49 | * @param txt text
50 | * @param noLC do not convert to lowercase
51 | * @returns generated (or existing) id for element
52 | */
53 | export function addId(
54 | elem: HTMLElement,
55 | pfx: string | null = "",
56 | txt = "",
57 | noLC = false,
58 | ): string {
59 | if (elem.id) {
60 | return elem.id;
61 | }
62 | if (!txt) {
63 | // @ts-ignore
64 | txt = (elem.title ? elem.title : elem.textContent).trim();
65 | }
66 | let id = noLC ? txt : txt.toLowerCase();
67 | id = id
68 | .trim()
69 | .normalize("NFD")
70 | .replace(/[\u0300-\u036f]/g, "")
71 | .replace(/\W+/gim, "-")
72 | .replace(/^-+/, "")
73 | .replace(/-+$/, "");
74 |
75 | if (!id) {
76 | id = "generatedID";
77 | } else if (/\.$/.test(id) || !/^[a-z]/i.test(pfx || id)) {
78 | id = `x${id}`; // trailing . doesn't play well with jQuery
79 | }
80 | if (pfx) {
81 | id = `${pfx}-${id}`;
82 | }
83 | if (elem.ownerDocument.getElementById(id)) {
84 | let i = 0;
85 | let nextId = `${id}-${i}`;
86 | while (elem.ownerDocument.getElementById(nextId)) {
87 | i += 1;
88 | nextId = `${id}-${i}`;
89 | }
90 | id = nextId;
91 | }
92 | elem.id = id;
93 | return id;
94 | }
95 |
96 | /**
97 | * Changes name of a DOM Element
98 | * @param elem element to rename
99 | * @param newName new element name
100 | *
101 | * @returns new renamed element
102 | */
103 | export function renameElement(
104 | elem: Element,
105 | newName: string,
106 | options = { copyAttributes: true },
107 | ): Element {
108 | if (elem.localName === newName) return elem;
109 | const newElement = elem.ownerDocument.createElement(newName);
110 | // copy attributes
111 | if (options.copyAttributes) {
112 | for (const { name, value } of elem.attributes) {
113 | newElement.setAttribute(name, value);
114 | }
115 | }
116 | // copy child nodes
117 | newElement.append(...elem.childNodes);
118 | elem.replaceWith(newElement);
119 | return newElement;
120 | }
121 |
--------------------------------------------------------------------------------
/typ/prioritization.typ:
--------------------------------------------------------------------------------
1 | /// Utilities on prioritization.
2 | ///
3 | /// Usage: Put `#level.ok`, etc. after a heading.
4 |
5 | #import "templates/html-toolkit.typ": to-html
6 | #import "mode.typ": cache-dir, cache-ready
7 |
8 | /// Configuration of levels, copied from clreq-gap.
9 | #let config = (
10 | ok: (paint: rgb("008000"), human: "OK"),
11 | advanced: (paint: rgb("ffe4b5"), human: "Advanced"),
12 | basic: (paint: rgb("ffa500"), human: "Basic"),
13 | broken: (paint: rgb("ff0000"), human: "Broken"),
14 | tbd: (paint: rgb("eeeeee"), human: "To be done"),
15 | na: (paint: rgb("008000"), human: "Not applicable"),
16 | )
17 |
18 | /// The table explaining priority levels.
19 | ///
20 | /// If cache is ready, use the cached `prioritization.level-table.svg`.
21 | /// Otherwise, draw the table.
22 | #let level-table(lang: "en") = if cache-ready {
23 | to-html(xml(cache-dir + "prioritization.level-table." + lang + ".svg").first())
24 | } else {
25 | // When preparing other caches for the html target, skip this.
26 | show: it => context if target() == "paged" { it }
27 |
28 | let level = config
29 | .pairs()
30 | .map(((k, v)) => (
31 | k,
32 | grid(
33 | columns: 2,
34 | gutter: 0.5em,
35 | circle(radius: 0.5em, stroke: none, fill: v.paint), v.human,
36 | ),
37 | ))
38 | .to-dict()
39 |
40 | set text(font: ("Libertinus Serif", "Source Han Serif SC", "Noto Color Emoji"))
41 |
42 | let bbl(en, zh) = if lang == "en" { en } else { zh }
43 |
44 | table(
45 | columns: 3,
46 | align: (x, y) => (if x == 0 { end } else if y <= 1 { center } else { start }) + horizon,
47 | stroke: none,
48 | table.cell(rowspan: 2, smallcaps[*#bbl[Difficulty to Resolve][解决难度]*]),
49 | table.vline(),
50 | table.cell(colspan: 2, smallcaps[*#bbl[Issue][问题]*]),
51 | table.hline(stroke: 0.5pt),
52 | [🚲 *#bbl[Typical][常见]*],
53 | [🚀 *#bbl[Specialized][专业]*],
54 | table.hline(),
55 | [*#bbl[Works by default][默认即可使用]* 😃], level.ok, level.ok,
56 | [*#bbl[Requires simple config][只需简单设置]* ✅], level.advanced, level.ok,
57 | [*#bbl[Easy but not obvious][容易但不直接]* 🤨], level.basic, level.advanced,
58 | [*#bbl[Hard or fragile][困难而不可靠]* 💀], level.broken, level.basic,
59 | table.hline(stroke: 0.5pt),
60 | ..(
61 | ([*#bbl[Needs further research][等待继续调查]* 🔎], level.tbd),
62 | ([*#bbl[Irrelevant to this script][不涉及此文字]* 🖖], level.na),
63 | )
64 | .map(((k, v)) => (k, table.cell(colspan: 2, box(inset: (left: 27%), width: 100%, v))))
65 | .flatten(),
66 | )
67 | }
68 |
69 | #let paint-level(level) = {
70 | let l = config.at(level)
71 | // In typst v0.14, `html.span` does not support `data-*` attributes, so we have to use `html.elem("span", …)`.
72 | // https://github.com/typst/typst/issues/6870
73 | html.elem(
74 | "span",
75 | {
76 | html.span(
77 | style: "display: inline-block; width: 1em; height: 1em; margin-inline: 0.25em; vertical-align: -5%",
78 | box(
79 | html.frame(circle(radius: 0.5em * 11 / 12, stroke: none, fill: l.paint)),
80 | ),
81 | )
82 | l.human
83 | },
84 | attrs: (
85 | class: "unbreakable",
86 | data-priority-level: level,
87 | ),
88 | )
89 | [#metadata(level)]
90 | }
91 |
92 | /// Priority levels
93 | #let level = (
94 | // Do not use `map` here, or LSP’s completion will be broken.
95 | ok: paint-level("ok"),
96 | advanced: paint-level("advanced"),
97 | basic: paint-level("basic"),
98 | broken: paint-level("broken"),
99 | tbd: paint-level("tbd"),
100 | na: paint-level("na"),
101 | )
102 | #assert.eq(level.len(), config.len())
103 |
--------------------------------------------------------------------------------
/typ/icon.typ:
--------------------------------------------------------------------------------
1 | /// A wrapper for SVG to avoid `html.frame` and support dark theme.
2 | #let _wrapper(paths) = html.span(class: "icon", html.elem(
3 | "svg",
4 | attrs: (viewBox: "0 0 16 16", width: "16", height: "16"),
5 | paths,
6 | ))
7 |
8 | /// https://primer.style/octicons/icon/issue-opened-16/
9 | #let issue-open = _wrapper({
10 | html.elem("path", attrs: (d: "M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"))
11 | html.elem("path", attrs: (d: "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"))
12 | })
13 |
14 | /// https://primer.style/octicons/icon/issue-closed-16/
15 | #let issue-closed = _wrapper({
16 | html.elem("path", attrs: (
17 | d: "M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z",
18 | ))
19 | html.elem("path", attrs: (d: "M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z"))
20 | })
21 |
22 | /// https://primer.style/octicons/icon/git-pull-request-16/
23 | #let git-pull-request = _wrapper({
24 | html.elem(
25 | "path",
26 | attrs: (
27 | d: "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z",
28 | ),
29 | )
30 | })
31 |
32 | /// https://primer.style/octicons/icon/git-pull-request-closed-16/
33 | #let git-pull-request-closed = _wrapper({
34 | html.elem(
35 | "path",
36 | attrs: (
37 | d: "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z",
38 | ),
39 | )
40 | })
41 |
42 | /// https://primer.style/octicons/icon/git-merge-16/
43 | #let git-merge = _wrapper({
44 | html.elem(
45 | "path",
46 | attrs: (
47 | d: "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z",
48 | ),
49 | )
50 | })
51 |
52 | /// https://primer.style/octicons/icon/light-bulb-16/
53 | #let light-bulb = _wrapper(
54 | html.elem(
55 | "path",
56 | attrs: (
57 | d: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z",
58 | ),
59 | ),
60 | )
61 |
62 | /// https://primer.style/octicons/icon/comment-16/
63 | #let comment = _wrapper(
64 | html.elem(
65 | "path",
66 | attrs: (
67 | d: "M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z",
68 | ),
69 | ),
70 | )
71 |
--------------------------------------------------------------------------------
/typ/templates/html-fix.typ:
--------------------------------------------------------------------------------
1 | /// A module providing workarounds for HTML features not supported by typst yet
2 |
3 | #import "./html-toolkit.typ": asset-url
4 |
5 | /// Display the linked URL in a new tab
6 | ///
7 | /// Usage: `#show link: link-in-new-tab`
8 | #let link-in-new-tab(class: none, it) = html.a(
9 | target: "_blank",
10 | href: it.dest,
11 | ..if class != none { (class: class) },
12 | it.body,
13 | )
14 |
15 | /// Use proportional-width apostrophes
16 | ///
17 | /// The main font is for Chinese, so it uses full-width apostrophes by default.
18 | /// For Latin texts, it is better to use proportional-width ones.
19 | /// https://typst-doc-cn.github.io/guide/FAQ/smartquote-font.html#若中西统一使用相同字体
20 | ///
21 | /// Usage: `#show: enable-proportional-width`
22 | #let latin-apostrophe = html.span.with(style: "font-feature-settings: 'pwid';")
23 |
24 | /// Externalize images in /public/
25 | ///
26 | /// Usage: `#show image: external-image`
27 | #let external-image(it) = {
28 | if type(it.source) == str and it.source.starts-with("/public/") {
29 | // Do not enable lazy loading here. Lazy images without explicit sizes will cause the page to flush, making links with hash anchors being unable to locate accurately.
30 | html.img(
31 | src: asset-url(it.source.trim("/public", at: start)),
32 | ..if it.alt != none { (alt: it.alt, title: it.alt) },
33 | ..if type(it.width) == relative { (style: "width:" + repr(it.width.ratio)) },
34 | )
35 | } else {
36 | it
37 | }
38 | }
39 |
40 | /// Parse a string into a length
41 | /// Example: `"58.828pt"` → `58.828pt`
42 | #let _pt(raw) = float(raw.trim("pt", at: end)) * 1pt
43 |
44 | /// Externalize SVGs in /public/
45 | /// Only SVGs generated by typst are tested.
46 | ///
47 | /// Usage: `#show image: external-svg`
48 | #let external-svg(it) = {
49 | if type(it.source) == str and it.source.starts-with("/public/") and it.source.ends-with(".svg") {
50 | let (width, height) = xml(it.source).first().attrs
51 | html.img(
52 | src: asset-url(it.source.trim("/public", at: start)),
53 | loading: "lazy",
54 | // Copy the behaviour of `html.frame`.
55 | // https://github.com/typst/typst/blob/6312c6636064466556fe79bb0bf5479977fafc9b/crates/typst-svg/src/lib.rs#L70-L77
56 | style: "overflow: visible; width: {width}em; height: {height}em;"
57 | .replace("{width}", str(_pt(width) / text.size))
58 | .replace("{height}", str(_pt(height) / text.size)),
59 | )
60 | } else {
61 | it
62 | }
63 | }
64 |
65 | /// Improve references to headings
66 | ///
67 | /// Our headings are bilingual and the numbers need special formatting.
68 | /// Therefore, we have to override the default implementation.
69 | ///
70 | /// Also, we want labeled headings to have anchors for permalinks.
71 | /// https://github.com/typst/typst/issues/7381
72 | ///
73 | /// Usage: `#show: improve-heading-refs`
74 | #let improve-heading-refs(body) = {
75 | show heading: it => {
76 | let has-label = it.at("label", default: none) != none
77 | let tag = "h" + str(it.level + 1)
78 |
79 | if it.numbering == none {
80 | if has-label {
81 | // Add id to headings in html if there exists a label
82 | html.elem(tag, attrs: (id: str(it.label)), it.body)
83 | } else {
84 | it
85 | }
86 | } else {
87 | html.elem(
88 | tag,
89 | // Add id to headings in html if there exists a label
90 | attrs: if has-label { (id: str(it.label)) } else { (:) },
91 | {
92 | // Wrap the numbering with a class
93 | html.span(counter(heading).display(it.numbering), class: "secno")
94 | [ ]
95 | it.body
96 | },
97 | )
98 | }
99 | }
100 | // https://github.com/Glomzzz/typsite/blob/c5f99270eff92cfdad58bbf4a78ea127d1aed310/resources/root/lib.typ#L155-L167
101 | show ref: it => {
102 | let el = it.element
103 | if el != none and el.func() == heading {
104 | // Override heading references.
105 | link(
106 | el.location(),
107 | // `§` is agnostic to the language.
108 | // There should be no space between `§` and the numbers, so we cannot set `heading.supplement`.
109 | numbering("§" + el.numbering, ..counter(heading).at(el.location())),
110 | )
111 | } else {
112 | // Other references as usual.
113 | it
114 | }
115 | }
116 | body
117 | }
118 |
119 | /// A collection of all fixes
120 | #let html-fix(body) = {
121 | show: improve-heading-refs
122 | show image: external-image
123 | body
124 | }
125 |
--------------------------------------------------------------------------------
/scripts/precompile.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs/promises";
2 | import { fileURLToPath } from "node:url";
3 |
4 | import watch from "glob-watcher";
5 |
6 | import { envArgs, extraArgs, ROOT_DIR } from "./config.ts";
7 | import { type Color, typst } from "./typst.ts";
8 | import { duration_fmt } from "./util.ts";
9 |
10 | const CACHE_DIR = `${ROOT_DIR}/public/generated`;
11 |
12 | /**
13 | * The main entrypoint of precompilation.
14 | */
15 | export async function precompile({ color }: { color?: Color } = {}) {
16 | await fs.mkdir(CACHE_DIR, { recursive: true });
17 | await Promise.all([
18 | renderExamples({ color }),
19 | renderPrioritization({ lang: "en", color }),
20 | renderPrioritization({ lang: "zh", color }),
21 | ]);
22 | }
23 |
24 | /**
25 | * Render examples from the main.typ file and compile them into SVG files.
26 | */
27 | async function renderExamples({ color }: { color?: Color }) {
28 | type Example = { id: string; content: string };
29 |
30 | const timeStart = Date.now();
31 |
32 | const examples = JSON.parse(
33 | await typst([
34 | "query",
35 | "main.typ",
36 | "",
37 | "--field=value",
38 | "--diagnostic-format=short",
39 | "--target=html",
40 | ...extraArgs.pre,
41 | ], { color: color }),
42 | ) as Example[];
43 | const timeQueryEnd = Date.now();
44 |
45 | /** @returns cache-hit */
46 | const compileExample = async ({ id, content }: Example): Promise => {
47 | const output = `${CACHE_DIR}/${id}.svg`;
48 | const compiled = await fs.stat(output).then(() => true).catch(() => false);
49 | if (!compiled) {
50 | await typst(["compile", "-", output, ...envArgs], {
51 | stdin: content,
52 | color: color,
53 | });
54 | }
55 | return compiled;
56 | };
57 |
58 | const hit = await Promise.all(examples.map(compileExample));
59 | const timeCompileEnd = Date.now();
60 |
61 | const total = hit.length;
62 | const cached = hit.filter((h) => h).length;
63 | console.log(
64 | `\n✅ Rendered ${total} examples`,
65 | `(${cached} cached, ${total - cached} new)`,
66 | `successfully in ${duration_fmt(timeCompileEnd - timeStart)}`,
67 | `(query ${duration_fmt(timeQueryEnd - timeStart)},`,
68 | `compile ${duration_fmt(timeCompileEnd - timeQueryEnd)}).`,
69 | );
70 | }
71 |
72 | /**
73 | * Render prioritization.level-table into an SVG.
74 | */
75 | async function renderPrioritization(
76 | { lang, color }: { lang: "en" | "zh"; color?: Color },
77 | ): Promise {
78 | const output = `${CACHE_DIR}/prioritization.level-table.${lang}.svg`;
79 | const dep = "typ/prioritization.typ";
80 |
81 | // compiled = output exists and newer than dep
82 | // TODO: Improve cache
83 | const compiled = await fs.stat(output)
84 | .then(async (outStat) => {
85 | const depStat = await fs.stat(dep).catch(() => null);
86 | if (!depStat) return false;
87 | return outStat.mtime > depStat.mtime;
88 | })
89 | .catch(() => false);
90 | if (compiled) {
91 | return;
92 | }
93 |
94 | const svg = await typst([
95 | "compile",
96 | "-",
97 | "-",
98 | "--format=svg",
99 | `--root=${ROOT_DIR}`,
100 | ...extraArgs.pre,
101 | ], {
102 | stdin: `
103 | #set page(height: auto, width: auto, margin: 0.5em, fill: none)
104 | #import "/typ/prioritization.typ": level-table
105 | #level-table(lang: "${lang}")
106 | `.trim(),
107 | color: color,
108 | });
109 |
110 | // Support dark theme
111 | const final = svg.replaceAll(
112 | / (fill|stroke)="#000000"/g,
113 | ' $1="currentColor"',
114 | );
115 |
116 | await fs.writeFile(output, final);
117 | }
118 |
119 | if (process.argv[1] === fileURLToPath(import.meta.url)) {
120 | const argv = process.argv.slice(2);
121 | const watchMode = ["--watch", "-w"].some((sw) => argv.includes(sw));
122 |
123 | if (watchMode) {
124 | // We estimate which files could affect the compilation
125 | const glob = watch(["{index,main}.typ", "typ/**/*"], {
126 | persistent: true,
127 | ignorePermissionErrors: true,
128 | });
129 |
130 | const tryPrecompile = async () => {
131 | try {
132 | await precompile({ color: "always" });
133 | } catch (error) {
134 | console.error("💥 Precompilation failed:", error);
135 | }
136 | };
137 |
138 | glob.on("change", async (_event, path) => {
139 | console.log("Refresh precompilation because of", path);
140 | await tryPrecompile();
141 | });
142 | await tryPrecompile();
143 | } else {
144 | await precompile();
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/anchor-redirect.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Redirect old anchors (`id` attributes) to the current ones.
3 | */
4 |
5 | /** A map from old anchors to current anchors. */
6 | const REDIRECTS: Record = {
7 | // Initial normalization
8 | "#x1-text-direction": "#direction",
9 | "#x1-1-writing-mode": "#writing-mode",
10 | "#vertical-writing-mode": "#vertical",
11 | "#x2-glyph-shaping-positioning": "#h-shaping",
12 | "#x2-1-fonts-selection": "#font-select",
13 | "#writing-chinese-without-configuring-any-font-leads-to-messy-font-fallback": "#font-fallback",
14 | "#wrong-monospace-font-fallback-for-chinese-in-raw-block": "#font-fallback-raw",
15 | "#wrong-font-fallback-for-chinese-in-math-equations": "#font-fallback-math",
16 | "#language-dependant-font-configuration": "#lang-font",
17 | "#size-per-font": "#per-font-size",
18 | "#unable-to-infer-the-writing-script-across-elements-making-locl-sometimes-ineffective-locl": "#across-element-script",
19 | "#x2-3-context-based-shaping-and-positioning": "#glyphs",
20 | "#fake-synthesized-bold": "#synthesized-bold",
21 | "#x2-6-case-other-character-transforms": "#transforms",
22 | "#x3-typographic-units": "#h-units",
23 | "#x3-1-characters-encoding": "#encoding",
24 | "#ideographic-variation-sequence-disappears-at-end-of-line": "#ivs-line-end",
25 | "#links-containing-non-ascii-characters-are-wrong-when-viewing-pdf-in-safari-ascii-safari-pdf": "#link-encoding",
26 | "#x3-2-grapheme-word-segmentation-selection": "#segmentation",
27 | "#x4-punctuation-inline-features": "#h-inline",
28 | "#x4-1-phrase-section-boundaries": "#punctuation-etc",
29 | "#quotation-marks-should-have-different-widths-for-chinese-and-western-text": "#quotation-mark-width",
30 | "#x4-3-emphasis-highlighting": "#emphasis",
31 | "#underline-breaks-when-mixing-chinese-and-western-text": "#underline-misalign",
32 | "#add-support-for-ruby-cjk-e-g-furigana-for-japanese": "#pinyin",
33 | "#x4-6-text-decoration-other-inline-features": "#text-decoration",
34 | "#x4-7-data-formats-numbers": "#data-formats",
35 | "#numbers-in-simplified-chinese": "#number-simplified",
36 | "#numbers-in-traditional-chinese": "#number-traditional",
37 | "#x5-line-and-paragraph-layout": "#h-lines-and-paragraphs",
38 | "#interpuncts-should-not-appear-at-line-start": "#interpunct-line-start",
39 | "#cjk-latin-glues-stretch-only-before-latin-characters": "#cjk-latin-stretch-before",
40 | "#strict-grid-aligned-in-both-horizontal-and-vertical-axes": "#strict-2d-grid",
41 | "#two-em-dashes-should-not-be-overhung": "#two-em-dash-overhung",
42 | "#customize-punctuation-overhang": "#customize-overhang",
43 | "#parenthetical-indication-punctuation-marks-at-the-start-of-paragraphs-are-not-adjusted-sometimes": "#paren-par-start",
44 | "#unexpected-indentation-after-figures-lists-and-block-equations": "#indent-after-block",
45 | "#even-inter-character-spacing": "#even-spacing",
46 | "#x5-3-text-spacing": "#spacing",
47 | "#cjk-latin-spacing-not-working-around-raw-raw": "#cjk-latin-around-raw",
48 | "#cjk-latin-spacing-not-working-around-inline-equations": "#cjk-latin-around-math",
49 | "#redundant-cjk-latin-space-at-manual-line-breaks": "#cjk-latin-manual-linebreak",
50 | "#punctuation-compression-is-interrupted-by-show-show": "#show-interrupt-punct",
51 | "#x5-4-baselines-line-height-etc": "#baselines",
52 | "#default-line-height-is-too-tight-for-chinese": "#default-line-height",
53 | "#box-is-not-aligned-if-text-bottom-edge-is-not-baseline-text-bottom-edge-box": "#box-align-bottom-edge",
54 | "#x5-5-lists-counters-etc": "#lists",
55 | "#list-and-enum-markers-are-not-aligned-with-the-baseline-of-the-item-s-contents-list-enum": "#list-enum-marker-align",
56 | "#too-wide-spacing-between-heading-numbering-and-title": "#heading-spacing-to-numbering",
57 | "#the-auto-hanging-indents-of-multiline-headings-are-inaccurate": "#heading-hanging-indent",
58 | "#x6-page-book-layout": "#h-pages",
59 | "#chinese-size-system-hao-system": "#zihao",
60 | "#directly-setting-the-width-of-the-type-area-instead-of-the-paper-width": "#type-area-width",
61 | "#x6-4-page-headers-footers-etc": "#headers-footers",
62 | "#x6-5-forms-user-interaction": "#interaction",
63 | "#x7-bibliography": "#bibliography",
64 | "#x7-1-citing": "#cite",
65 | "#citation-numbers-are-flying-over-their-brackets": "#cite-number-flying",
66 | "#compression-of-continuous-citation-numbers": "#cite-number-compress",
67 | "#superscript-and-non-superscript-forms-should-coexist": "#paren-cite",
68 | "#cite-with-page-numbers": "#cite-page-number",
69 | "#x7-2-bibliography-listing": "#bib-list",
70 | "#use-et-al-for-english-and-for-chinese-et-al": "#et-al-lang",
71 | "#institution-and-school-are-not-shown-institution-school": "#publisher-alias",
72 | "#discontinuous-page-numbers-are-displayed-incorrectly-missing-a-comma": "#cite-discontinuous-page",
73 | "#chinese-works-should-be-ordered-by-the-pinyin-or-strokes-of-the-authors-for-gb-7714-2015-author-date-gb-7714-2015-author-date": "#bib-order",
74 | "#gb-7714-2015-note-is-totally-broken-gb-7714-2015-note": "#bib-note",
75 | "#x7-3-bibliography-file": "#bib-file",
76 | "#standard-is-not-correctly-interpreted-standard": "#bib-standard-misc",
77 | "#failed-to-load-some-csl-styles-csl": "#csl-load",
78 | "#x8-other": "#h-other",
79 | "#x8-1-culture-specific-features": "#culture-specific",
80 | "#for-references-to-headings-the-supplement-should-not-be-put-before-the-number": "#ref-number-supplement",
81 | "#bilingual-figure-captions": "#bilingual-caption",
82 | "#x8-2-what-else": "#other",
83 | "#ignore-linebreaks-between-cjk-characters-in-source-code-cjk": "#ignore-linebreak",
84 | "#internationalize-warning-and-error-messages": "#i18n-diag",
85 | "#a-chinese-name-for-the-typst-project-typst": "#chinese-name",
86 | "#web-app-issues": "#webapp-issues",
87 | };
88 |
89 | function replaceAnchor(): void {
90 | const old = window.location.hash;
91 | if (old in REDIRECTS) {
92 | window.location.hash = REDIRECTS[old];
93 | }
94 | }
95 |
96 | replaceAnchor();
97 | window.addEventListener("hashchange", replaceAnchor);
98 |
--------------------------------------------------------------------------------
/src/respec/sidebar.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The following script is copied from W3C/ReSpec under CC0-1.0 license
3 | * https://github.com/speced/bikeshed/blob/527e9641607d686e5c439f9999d40360607ee792/bikeshed/spec-data/readonly/boilerplate/footer.include
4 | * https://www.w3.org/scripts/TR/2021/fixup.js
5 | */
6 |
7 | const tocToggleId = "toc-toggle";
8 | const tocJumpId = "toc-jump";
9 | const tocCollapseId = "toc-collapse";
10 | const tocExpandId = "toc-expand";
11 |
12 | interface CreatedElements {
13 | /** The button that toggles ToC sidebar, `#{tocToggleId}` */
14 | toggle: HTMLAnchorElement;
15 | }
16 |
17 | const t = {
18 | collapseSidebar: "Collapse Sidebar",
19 | expandSidebar: "Pop Out Sidebar",
20 | jumpToToc: "Jump to Table of Contents",
21 | };
22 | const collapseSidebarText = ' ' +
23 | `${t.collapseSidebar}`;
24 | const expandSidebarText = ' ' +
25 | `${t.expandSidebar}`;
26 | const tocJumpText = ' ' +
27 | `${t.jumpToToc}`;
28 |
29 | /** `true` means toc-sidebar, `false` means toc-inline */
30 | type SidebarStatus = boolean;
31 |
32 | /** Matches if default to toc-sidebar, does not match if default to toc-inline */
33 | const sidebarMedia = window.matchMedia("screen and (min-width: 78em)");
34 |
35 | function toggleSidebar(
36 | { toggle }: CreatedElements,
37 | /** `SidebarStatus` to select the desired status, `undefined` to toggle between them */
38 | on: SidebarStatus | undefined = undefined,
39 | /** Whether to force to skip scroll */
40 | skipScroll: boolean = false,
41 | ): SidebarStatus {
42 | if (on === undefined) {
43 | on = !document.body.classList.contains("toc-sidebar");
44 | }
45 |
46 | if (!skipScroll) {
47 | /* Don't scroll to compensate for the ToC if we're above it already. */
48 | let headY = 0;
49 | const head = document.querySelector(".head");
50 | if (head) {
51 | // terrible approx of "top of ToC"
52 | headY += head.offsetTop + head.offsetHeight;
53 | }
54 | skipScroll = window.scrollY < headY;
55 | }
56 |
57 | const tocNav = document.getElementById("toc") as HTMLElement;
58 | if (on) {
59 | document.body.classList.add("toc-sidebar");
60 | document.body.classList.remove("toc-inline");
61 |
62 | toggle.innerHTML = collapseSidebarText;
63 | toggle.setAttribute("aria-labelledby", `${tocCollapseId}-text`);
64 |
65 | if (!skipScroll) {
66 | const tocHeight = tocNav.offsetHeight;
67 | window.scrollBy(0, 0 - tocHeight);
68 | }
69 |
70 | tocNav.focus();
71 | } else {
72 | document.body.classList.add("toc-inline");
73 | document.body.classList.remove("toc-sidebar");
74 |
75 | toggle.innerHTML = expandSidebarText;
76 | toggle.setAttribute("aria-labelledby", `${tocExpandId}-text`);
77 |
78 | if (!skipScroll) {
79 | window.scrollBy(0, tocNav.offsetHeight);
80 | }
81 |
82 | if (toggle.matches(":hover")) {
83 | /* Unfocus button when not using keyboard navigation,
84 | because I don't know where else to send the focus. */
85 | toggle.blur();
86 | }
87 | }
88 |
89 | return on;
90 | }
91 |
92 | function createSidebarToggle(): CreatedElements {
93 | /* Create the sidebar toggle in JS; it shouldn't exist when JS is off. */
94 | const toggle = document.createElement("a");
95 | /* This should probably be a button, but appearance isn't standards-track.*/
96 | toggle.id = tocToggleId;
97 | toggle.classList.add("toc-toggle");
98 | toggle.href = "#toc";
99 | toggle.innerHTML = collapseSidebarText;
100 | toggle.setAttribute("aria-labelledby", `${tocCollapseId}-text`);
101 |
102 | /* Get