from typing import TypedDict from pathlib import Path import subprocess import tomllib import shutil import json import os # TODO: # list handouts without solutions # list handouts that are not published # lint: always commit without `nosolutions` # use latexmk ROOT: Path = Path(os.getcwd()) ### CONFIGURATION OUT_DIR: Path = ROOT / "output" TYPST_PATH: str = "typst" XETEX_PATH: str = "xelatex" ### END CONFIGURATION # Allow path override _env = os.environ.get("TYPST_PATH") if isinstance(_env, str): TYPST_PATH = _env def log(msg): print(f"[BUILD.PY] {msg}") log(f"Running in {ROOT}") if not ROOT.is_dir(): log("Root is not a directory, cannot continue") exit(1) log(f"Output dir is {OUT_DIR}") if OUT_DIR.exists(): log("Output dir exists, removing") shutil.rmtree(OUT_DIR) OUT_DIR.mkdir(parents=True) IndexEntry = TypedDict( "IndexEntry", {"title": str, "group": str, "handout_file": str, "solutions_file": str | None}, ) MetaToml = TypedDict( "MetaToml", {"title": str, "publish_handout": bool, "publish_solutions": bool} ) def sanitize_file_name(file: str) -> str: return file.replace("?", "") def read_meta_toml(file: Path) -> MetaToml: with file.open("rb") as f: base = tomllib.load(f) meta = base.get("metadata") if not isinstance(meta, dict): log("Invalid meta.toml: `metadata` should be a table") exit(1) title = meta.get("title") if not isinstance(title, str): log("Invalid meta.toml: `title` should be a string") exit(1) pub = base.get("publish") if not isinstance(pub, dict): log("Invalid meta.toml: `publish` should be a table") exit(1) pub_handout = pub.get("handout") if not isinstance(pub_handout, bool): log("Invalid meta.toml: `publish.handout` should be a boolean") exit(1) pub_solutions = pub.get("solutions") if not isinstance(pub_solutions, bool): log("Invalid meta.toml: `publish.solutions` should be a boolean") exit(1) return { "title": title, "publish_handout": pub_handout, "publish_solutions": pub_solutions, } def log_error(res): stdout = res.stdout.decode("utf-8").strip() stderr = res.stderr.decode("utf-8").strip() log(f"Build failed with code {res.returncode}") if stdout == "": log("Stdout: ") else: log("Stdout:") print(stdout) if stderr == "": log("Stderr: ") else: log("Stderr:") print(stderr) exit(1) def build_typst(source_dir: Path, out_subdir: Path) -> IndexEntry | None: if not (source_dir / "main.typ").is_file(): # log(f"No main.typ, skipping {source_dir}") return None meta_path = source_dir / "meta.toml" if not meta_path.is_file(): log(f"No meta.toml, skipping {source_dir}") return None meta = read_meta_toml(meta_path) handout_file = sanitize_file_name(f"{meta['title']}.pdf") solutions_file = sanitize_file_name(f"{meta['title']}.sols.pdf") # Do nothing if not published if not meta["publish_handout"]: return None # Build handout log(f"Building typst (handout) : {source_dir}") out = OUT_DIR / out_subdir out.mkdir(parents=True, exist_ok=True) res = subprocess.run( [ TYPST_PATH, "compile", "--package-path", f"{ROOT}/lib/typst", "--ignore-system-fonts", "--input", "show_solutions=false", "main.typ", f"{out}/{handout_file}", ], cwd=source_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if res.returncode != 0: log_error(res) # Build solutions if meta["publish_solutions"]: log(f"Building typst (solutions): {source_dir}") res = subprocess.run( [ TYPST_PATH, "compile", "--package-path", f"{ROOT}/lib/typst", "--ignore-system-fonts", "main.typ", f"{out}/{solutions_file}", ], cwd=source_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if res.returncode != 0: log_error(res) return { "title": meta["title"], "group": str(out_subdir), "handout_file": str(out / handout_file), "solutions_file": ( str(out / solutions_file) if meta["publish_solutions"] else None ), } def build_xetex(source_dir: Path, out_subdir: Path) -> IndexEntry | None: if not (source_dir / "main.tex").is_file(): # log(f"No main.tex, skipping {source_dir}") return None meta_path = source_dir / "meta.toml" if not meta_path.is_file(): log(f"No meta.toml, skipping {source_dir}") return None meta = read_meta_toml(meta_path) handout_file = sanitize_file_name(f"{meta['title']}.pdf") solutions_file = sanitize_file_name(f"{meta['title']}.sols.pdf") # Do nothing if not published if not meta["publish_handout"]: return None # Build handout log(f"Building xetex (handout) : {source_dir}") out = OUT_DIR / out_subdir out.mkdir(parents=True, exist_ok=True) res = subprocess.run( [ XETEX_PATH, "-interaction=batchmode", ], input=b"\\def\\argNoSolutions{1}\\input{main.tex}", cwd=source_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: shutil.copy(source_dir / "main.pdf", f"{out}/{handout_file}") except Exception as e: log(f"Error: {e}") log_error(res) if res.returncode != 0: log_error(res) # Build solutions if meta["publish_solutions"]: log(f"Building xetex (solutions): {source_dir}") res = subprocess.run( [ XETEX_PATH, "-interaction=batchmode", ], input=b"\\def\\argYesSolutions{1}\\input{main.tex}", cwd=source_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: shutil.copy(source_dir / "main.pdf", f"{out}/{solutions_file}") except Exception as e: log(f"Error: {e}") log_error(res) if res.returncode != 0: log_error(res) return { "title": meta["title"], "group": str(out_subdir), "handout_file": str(out / handout_file), "solutions_file": ( str(out / solutions_file) if meta["publish_solutions"] else None ), } # Try to build handouts in all subdirs of `base`, # skipping those that do not contain a handout. # # This method does _not_ recurse into subdirectories. def build_dir(base: str, out_sub: str, index: list[IndexEntry]): builders = [build_typst, build_xetex] for d_str in os.listdir(base): d = base / Path(d_str) if not d.is_dir(): continue # Try each builder, # stopping at the first success done = False for builder in builders: res = builder(d, Path(out_sub)) if res is not None: # Check for duplicate titles for i in index: if i["title"] == res["title"]: log(f'Duplicate title "{i["title"]}", cannot continue') exit(1) # Save to index and exit index.append(res) done = True if done: break index: list[IndexEntry] = [] build_dir("src/Advanced", "Advanced", index) build_dir("src/Intermediate", "Intermediate", index) build_dir("src/Warm-Ups", "Warm-Ups", index) with open(OUT_DIR / "index.json", "w") as f: f.write(json.dumps(index))