├── 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 | /// 2 | -------------------------------------------------------------------------------- /public/webapp-misspell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typst-doc-cn/clreq/HEAD/public/webapp-misspell.png -------------------------------------------------------------------------------- /public/font-fallback-messy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typst-doc-cn/clreq/HEAD/public/font-fallback-messy.png -------------------------------------------------------------------------------- /public/vertical-example-modern.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typst-doc-cn/clreq/HEAD/public/vertical-example-modern.jpg -------------------------------------------------------------------------------- /public/vertical-example-ancient.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typst-doc-cn/clreq/HEAD/public/vertical-example-ancient.jpg -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Bump formatter: tinymist 0.13.12 / typstyle 0.13.3 → tinymist 0.13.14 / typstyle 0.13.11. 2 | e6af099e5001332ca8c15abf11895b4750fc1b40 3 | # Bump formatter: … → tinymist 0.13.16 / typstyle 0.13.16. 4 | ba15e5c60dffd03df3e7cec14f80783b8e060ed0 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: npm 8 | directory: "/" 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /scripts/util.ts: -------------------------------------------------------------------------------- 1 | import { format as _format } from "@std/fmt/duration"; 2 | import { execFile as _execFile } from "child_process"; 3 | import { promisify } from "util"; 4 | 5 | export const duration_fmt = (ms: number) => _format(ms, { ignoreZero: true }); 6 | 7 | export const execFile = promisify(_execFile); 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | // CSS styles corresponding to typst modules. 4 | import "./show-example.css"; 5 | import "./util.css"; 6 | 7 | import "./respec/mod.ts"; 8 | 9 | // For better visual effect, anchor-highlight should be loaded after respec. 10 | import "./anchor-highlight.ts"; 11 | import "./anchor-redirect.ts"; 12 | -------------------------------------------------------------------------------- /typ/respec.typ: -------------------------------------------------------------------------------- 1 | /// Loader of the table of contents (a ReSpec module) 2 | 3 | /// The table of contents that will be created by this module 4 | /// 5 | /// Usage: `#show outline: toc` 6 | #let toc = html.nav(id: "toc") 7 | 8 | /// The summary that will be created by this module 9 | /// 10 | /// Usage: `#summary` 11 | #let summary = html.ol(id: "summary") 12 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "bash scripts/netlify-build.sh" 3 | 4 | 5 | [[headers]] 6 | for = "/assets/*" 7 | [headers.values] 8 | Access-Control-Allow-Origin = "*" # Allow CORS for https://services.w3.org/htmldiff 9 | 10 | 11 | [[plugins]] 12 | package = "netlify-plugin-cache" 13 | [plugins.inputs] 14 | paths = ["fonts"] 15 | 16 | [[plugins]] 17 | package = "netlify-plugin-debug-cache" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | 3 | /fonts/ 4 | 5 | /dist/ 6 | /public/generated/ 7 | 8 | /node_modules/ 9 | 10 | # Prefer pnpm-lock.yaml 11 | package-lock.json 12 | yarn.lock 13 | 14 | # The following lines are created by vite. 15 | 16 | # Logs 17 | logs 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | pnpm-debug.log* 23 | lerna-debug.log* 24 | 25 | node_modules 26 | dist 27 | dist-ssr 28 | *.local 29 | 30 | # Editor directories and files 31 | .vscode/* 32 | !.vscode/extensions.json 33 | .idea 34 | .DS_Store 35 | *.suo 36 | *.ntvs* 37 | *.njsproj 38 | *.sln 39 | *.sw? 40 | -------------------------------------------------------------------------------- /src/anchor-highlight.ts: -------------------------------------------------------------------------------- 1 | import "./anchor-highlight.css"; 2 | 3 | function highlightElement(el: Element): void { 4 | const cls = "anchor-highlight"; 5 | el.classList.add(cls); 6 | 7 | el.addEventListener("animationend", () => { 8 | el.classList.remove(cls); 9 | }, { once: true }); 10 | } 11 | 12 | function highlightCurrentAnchor(): void { 13 | const id = decodeURIComponent(window.location.hash.replace(/^#/, "")); 14 | const target = document.getElementById(id); 15 | if (target !== null) { 16 | highlightElement(target); 17 | } 18 | } 19 | 20 | highlightCurrentAnchor(); 21 | window.addEventListener("hashchange", highlightCurrentAnchor); 22 | -------------------------------------------------------------------------------- /scripts/netlify-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | TYPST_VERSION=$(jq . scripts/typst-version.json --raw-output) 5 | 6 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 7 | cargo-binstall "typst-cli@$TYPST_VERSION" ripgrep 8 | # Remarks: `cargo-binstall` works, but `cargo binstall` does not. 9 | 10 | curl -OL https://www.7-zip.org/a/7z2409-linux-x64.tar.xz 11 | tar -xvf 7z2409-linux-x64.tar.xz 7zz && ln -s 7zz 7z 12 | export PATH=$PATH:$(pwd) 13 | 14 | bash scripts/download_fonts.sh 15 | 16 | pnpm install 17 | pnpm build 18 | 19 | pnpm patch-htmldiff 20 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | # Sync this with `./check.yml` 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | autofix: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: taiki-e/install-action@v2 19 | with: 20 | tool: fd-find,typstyle@0.13.16 # typstyle shipped with tinymist v0.13.16 21 | - name: Run typstyle 22 | run: | 23 | typstyle --inplace --line-width 120 . 24 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 25 | -------------------------------------------------------------------------------- /src/respec/mod.ts: -------------------------------------------------------------------------------- 1 | // `base.override.css` should be put _after_ `base.css`. 2 | // Reason: The former overrides the latter. 3 | import "./base.css"; 4 | import "./base.override.css"; 5 | 6 | import "./language.css"; 7 | import { createLanguageSwitch } from "./language.ts"; 8 | 9 | import "./structure.css"; 10 | import { createStructure } from "./structure.ts"; 11 | 12 | import sidebar from "./sidebar.ts"; 13 | 14 | // The order of the following matters. 15 | // Reason: 16 | // - `createStructure` creates a new `#toc` and replaces the original one, discarding all former listeners. 17 | // - `sidebar` add an event listener to `#toc` 18 | createStructure({ maxTocLevel: 2 }); 19 | sidebar(); 20 | 21 | createLanguageSwitch(); 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["src"] 25 | } 26 | -------------------------------------------------------------------------------- /typ/mode.typ: -------------------------------------------------------------------------------- 1 | /// The mode of compilation. 2 | /// 3 | /// - pre: External cache is not ready yet, and typst code should skip these files or create alternatives. `typst query` typically sets `pre`. 4 | /// - build: External cache is ready, and typst code could use cached files. `typst compile` typically sets `build`. 5 | /// - dev: The project is running in watch mode. External cache is ready or will be ready soon, and typst code should could cached files or delegate to the vite dev server. `typst watch` typically sets `dev`. 6 | /// 7 | /// -> str 8 | #let mode = sys.inputs.at("mode", default: "build") 9 | #assert(("pre", "build", "dev").contains(mode)) 10 | 11 | #let cache-ready = ("build", "dev").contains(mode) 12 | 13 | /// Path to the external cache root. 14 | #let cache-dir = "/public/generated/" 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import { defineConfig } from "vite"; 4 | import postcssNesting from "postcss-nesting"; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | 8 | export default defineConfig({ 9 | base: "./", // https://vite.dev/guide/build.html#relative-base 10 | build: { 11 | rollupOptions: { 12 | input: { 13 | main: resolve(__dirname, "src/main.ts"), 14 | theme: resolve(__dirname, "src/theme.ts"), 15 | htmldiff_nav: resolve(__dirname, "src/htmldiff-nav.ts"), 16 | }, 17 | }, 18 | manifest: true, // https://vite.dev/guide/backend-integration.html 19 | sourcemap: true 20 | }, 21 | css: { 22 | postcss: { 23 | plugins: [postcssNesting()] 24 | } 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/respec/base.override.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Disable justification, because it is too narrow 3 | */ 4 | :not(li) > .toc { 5 | text-align: initial; 6 | } 7 | 8 | /* Increase line-height for Chinese */ 9 | .toc { 10 | line-height: 1.5; 11 | } 12 | 13 | /* Sync color theme with ../global.css */ 14 | :root { 15 | --text: var(--main-color); 16 | --bg: var(--main-bg-color); 17 | 18 | --tocnav-normal-text: hsl(0, 0%, 44%); 19 | --tocnav-hover-bg: hsl(0, 0%, 97%); 20 | 21 | --tocsidebar-bg: #f7f8f9; 22 | --tocsidebar-heading-text: hsla(203, 20%, 40%, 0.7); 23 | } 24 | :root.dark { 25 | --tocnav-normal-text: hsl(0, 0%, 56%); 26 | --tocnav-hover-bg: hsl(0, 0%, 3%); 27 | 28 | --tocsidebar-bg: #333b4e; 29 | --tocsidebar-heading-text: hsla(203, 20%, 60%, 0.702); 30 | } 31 | /* Fix the conflict of `nav a:hover` and `.toc a:visited` */ 32 | nav.toc a:hover:visited { 33 | color: var(--main-hover-color); 34 | } 35 | -------------------------------------------------------------------------------- /src/anchor-highlight.css: -------------------------------------------------------------------------------- 1 | .anchor-highlight { 2 | animation: anchor-highlight-fade 2s ease forwards; 3 | 4 | --bg-anchor-highlight-base: rgb(250, 223, 90); 5 | :root.dark & { 6 | --bg-anchor-highlight-base: rgba(210, 188, 78, 0.95); 7 | } 8 | } 9 | 10 | /* Briefly highlight the element, then fade out. */ 11 | @keyframes anchor-highlight-fade { 12 | /* Extend box-shadow to cover the permalink (§). */ 13 | 0% { 14 | --bg: color-mix( 15 | in srgb, 16 | var(--bg-anchor-highlight-base) 95%, 17 | transparent 5% 18 | ); 19 | background-color: var(--bg); 20 | box-shadow: 0 0 0.25em 0.5em var(--bg); 21 | } 22 | 20% { 23 | --bg: color-mix( 24 | in srgb, 25 | var(--bg-anchor-highlight-base) 60%, 26 | transparent 40% 27 | ); 28 | background-color: var(--bg); 29 | box-shadow: 0 0 0.25em 0.5em var(--bg); 30 | } 31 | 100% { 32 | background-color: transparent; 33 | box-shadow: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /scripts/dev.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | 3 | import { concurrently } from "concurrently"; 4 | 5 | import { ASSETS_SERVER_PORT, extraArgs, ROOT_DIR } from "./config.ts"; 6 | 7 | const argv = process.argv.slice(2); 8 | 9 | await fs.mkdir("dist", { recursive: true }); 10 | 11 | concurrently([ 12 | { 13 | name: "precompile", 14 | command: [ 15 | "node", 16 | "--experimental-strip-types", 17 | "scripts/precompile.ts", 18 | "--watch", 19 | ].join(" "), 20 | }, 21 | { 22 | name: "assets", 23 | command: ["vite", `--port=${ASSETS_SERVER_PORT}`].join(" "), 24 | }, 25 | { 26 | name: "main", 27 | command: [ 28 | "typst", 29 | "--color=always", 30 | "watch", 31 | "index.typ", 32 | "dist/index.html", 33 | ...extraArgs.dev, 34 | ...argv, 35 | ].join(" "), 36 | }, 37 | ], { 38 | prefix: "name", 39 | cwd: ROOT_DIR, 40 | killOthersOn: ["failure", "success"], 41 | prefixColors: "auto", 42 | }); 43 | -------------------------------------------------------------------------------- /scripts/config.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from "node:path"; 2 | import { env } from "node:process"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | export const ROOT_DIR = dirname(dirname(fileURLToPath(import.meta.url))); 6 | 7 | export const envArgs = [ 8 | "--features=html", 9 | `--font-path=${ROOT_DIR}/fonts`, 10 | ]; 11 | 12 | export const ASSETS_SERVER_PORT = 5173; 13 | 14 | export const BUILD_URL_BASE = env.NETLIFY === "true" 15 | ? env.DEPLOY_URL + "/" // patch htmldiff, or assets will go to services.w3.org 16 | : (env.GITHUB_PAGES_BASE ?? "/clreq/"); 17 | 18 | /** See typ/mode.typ */ 19 | export const extraArgs = { 20 | pre: [ 21 | "--input", 22 | "mode=pre", 23 | ...envArgs, 24 | ], 25 | build: [ 26 | "--input", 27 | "mode=build", 28 | "--input", 29 | `x-url-base=${BUILD_URL_BASE}`, 30 | ...envArgs, 31 | ], 32 | dev: [ 33 | "--input", 34 | "mode=dev", 35 | "--input", 36 | `x-url-base=http://localhost:${ASSETS_SERVER_PORT}/`, 37 | ...envArgs, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /scripts/patch-htmldiff.template.js: -------------------------------------------------------------------------------- 1 | if ( 2 | window.location.origin + window.location.pathname === 3 | "https://services.w3.org/htmldiff" 4 | ) { 5 | // Improve readability for `
`.
 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