"])
if cover_link:
cover = f""
if note.video_url:
cover = f"[{cover}]({note.video_url})"
lines.extend(["", cover, "{: .ai-notes-note-header__cover }"])
lines.extend(["", "
", ""])
return lines
def is_single_page_course(course_dir: Path, notes: list[Note]) -> bool:
"""A top-level note such as a book shares its route with the course; its page is the course page."""
return len(notes) == 1 and notes[0].route_dir == course_dir
def course_page_markdown(course_dir: Path, notes: list[Note], title: str, topic: str) -> str:
lines = [f"# {title}", ""]
if topic:
lines.extend([topic, ""])
# A source column that repeats one value on every row only adds noise; state it once instead.
channels = {note.channel for note in notes}
shared_channel = next(iter(channels)) if len(channels) == 1 else None
summary = f"共 {len(notes)} {note_unit(course_dir)}"
if shared_channel:
summary += f",来源:{shared_channel}"
show_date = any(note.date for note in notes)
header = ["讲义"] + (["日期"] if show_date else []) + ([] if shared_channel is not None else ["来源"]) + ["资源"]
lines.extend(
[
f"{summary}。",
"",
'
',
"",
"| " + " | ".join(header) + " |",
"|" + " --- |" * len(header),
]
)
for note in sorted(notes, key=lambda item: natural_key(item.route_dir)):
page_rel = markdown_page_target(course_dir, note.route_dir)
resources = [f"[LaTeX]({github_blob_url(note.rel_tex_path)})"]
if note.pdf_url:
resources.append(f"[备用 PDF]({note.pdf_url})")
cells = [f"[{note.title}]({page_rel})"]
if show_date:
cells.append(note.date or "—")
if shared_channel is None:
cells.append(note.channel or "—")
cells.append(" · ".join(resources))
lines.append("| " + " | ".join(cells) + " |")
lines.extend(["", "
"])
return "\n".join(lines)
def reading_route_markdown(known_routes: set[str]) -> list[str]:
lines = ["## 推荐阅读路线", ""]
for name, steps in READING_ROUTES:
parts = [f"[{label}]({route}/index.md)" if route in known_routes else label for label, route in steps]
lines.append(f"- {name}:" + " → ".join(parts))
return lines
def home_markdown(
notes: list[Note],
courses: OrderedDict[Path, list[Note]],
catalog_entries: list[CatalogEntry],
entries_by_path: dict[str, CatalogEntry],
) -> str:
lines = [
"# AI Course Notes",
"",
f"这里是从 `{len(notes)}` 份 LaTeX 讲义自动生成的网页阅读站。正文直接由 `.tex` 渲染成网页,适合浏览、搜索和连续阅读。",
"",
]
known_routes = {course_dir.as_posix() for course_dir in courses} | {note.route_dir.as_posix() for note in notes}
lines.extend(reading_route_markdown(known_routes))
lines.extend(["", "## 课程地图", ""])
for category, course_dirs in group_courses(courses, catalog_entries).items():
lines.extend([f"### {category_label(category)}", "", '
', ""])
for course_dir in course_dirs:
title = course_title(course_dir, entries_by_path)
topic = topic_for_course(course_dir, entries_by_path)
count = len(courses[course_dir])
detail = f" {html.escape(topic)}" if topic else ""
lines.append(f"- [**{title}**]({course_dir.as_posix()}/index.md){detail} {count} {note_unit(course_dir)}")
lines.extend(["", "
", ""])
return "\n".join(lines)
def css_content() -> str:
return """
:root {
/* theme.font is false: system fonts only. Latin faces come first so Latin text keeps them;
CJK text falls through to the first CJK face installed on the platform. */
--md-text-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC",
"WenQuanYi Micro Hei", sans-serif;
--md-code-font: SFMono-Regular, Menlo, Consolas, "Liberation Mono", "PingFang SC", "Microsoft YaHei",
monospace;
--ai-notes-card-border: rgba(28, 86, 87, 0.18);
--ai-notes-page-gutter: clamp(0.8rem, 2vw, 2rem);
--ai-notes-rule-color: color-mix(in srgb, var(--md-default-fg-color), transparent 86%);
/* 46em of the 0.8rem body text: about 46 CJK characters per line. */
--ai-notes-measure: 36.8rem;
--ai-notes-important-color: #7c4dff;
--md-admonition-icon--important: url('data:image/svg+xml;charset=utf-8,');
}
[data-md-color-scheme="slate"] {
--ai-notes-card-border: color-mix(in srgb, var(--md-default-fg-color), transparent 78%);
}
.ai-notes-sidebar-controls,
.ai-notes-sidebar-toggle {
display: none;
}
.ai-notes-sidebar-toggle {
align-items: center;
background: var(--md-default-bg-color);
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.3rem;
color: var(--md-default-fg-color--light);
cursor: pointer;
font: inherit;
font-size: 0.68rem;
gap: 0.3rem;
min-height: 1.7rem;
padding: 0.25rem 0.5rem;
}
.ai-notes-sidebar-toggle:hover,
.ai-notes-sidebar-toggle:focus-visible {
border-color: var(--md-accent-fg-color);
color: var(--md-default-fg-color);
}
.ai-notes-sidebar-toggle:focus-visible {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: 2px;
}
/* Match Material's separate desktop breakpoints; mobile keeps its native drawers. */
@media screen and (min-width: 60em) {
html[data-ai-sidebar-secondary] .ai-notes-sidebar-controls {
align-items: center;
background: var(--md-default-bg-color);
display: flex;
gap: 0.5rem;
justify-content: space-between;
margin: 0 0 1.1rem;
padding: 0.35rem 0;
position: sticky;
top: var(--md-header-height, 2.4rem);
z-index: 2;
}
.ai-notes-sidebar-toggle[data-ai-sidebar-toggle="secondary"]:not([hidden]) {
display: inline-flex;
margin-left: auto;
}
html[data-ai-sidebar-secondary="closed"] .md-sidebar--secondary {
display: none;
}
html[data-ai-sidebar-secondary] .md-typeset :target {
--md-scroll-margin: calc(var(--md-header-height, 2.4rem) + 3rem);
}
}
@media screen and (min-width: 76.25em) {
.ai-notes-sidebar-toggle[data-ai-sidebar-toggle="primary"]:not([hidden]) {
display: inline-flex;
}
html[data-ai-sidebar-primary="closed"] .md-sidebar--primary {
display: none;
}
.md-grid {
max-width: min(96rem, calc(100vw - 2 * var(--ai-notes-page-gutter)));
}
.md-main__inner {
width: 100%;
}
/* Keep prose at a readable measure, centred in the content column. --ai-notes-gap is the
space left beside the measure; figures may use up to 8rem of it on each side. Browsers
without container query units ignore these rules and keep the full-width column. */
.md-content {
container-type: inline-size;
--ai-notes-gap: max(0px, calc((100cqi - 2.4rem - var(--ai-notes-measure)) / 2));
--ai-notes-bleed: min(var(--ai-notes-gap), 8rem);
}
.md-content > .md-content__inner,
.md-content > .md-path {
padding-left: var(--ai-notes-gap);
padding-right: var(--ai-notes-gap);
}
/* Material sizes figures to fit-content within 100%; let top-level figures span the bleed. */
.md-content__inner.md-typeset > figure {
margin-left: calc(-1 * var(--ai-notes-bleed));
margin-right: calc(-1 * var(--ai-notes-bleed));
max-width: none;
width: auto;
}
/* PDF figures carry LaTeX widths (a share of \\textwidth), so they stay on the text measure. */
.md-content__inner.md-typeset > figure.ai-notes-figure--pdf {
margin-left: auto;
margin-right: auto;
}
}
.md-typeset .ai-notes-course-table td:last-child {
white-space: nowrap;
}
@media screen and (max-width: 44.984375em) {
.md-typeset .ai-notes-course-table td:first-child {
min-width: 12em;
}
.md-typeset .ai-notes-course-table td:last-child {
width: 6em;
white-space: normal;
}
.md-typeset .ai-notes-course-table td:last-child a {
display: inline-block;
white-space: nowrap;
}
}
.md-typeset .course-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.75rem;
margin: 0.75rem 0 1.5rem;
}
.md-typeset .course-grid > ul {
display: contents;
}
[dir] .md-typeset .course-grid li {
list-style: none;
border: 1px solid var(--ai-notes-card-border);
border-radius: 0.35rem;
padding: 0.8rem 0.9rem;
margin: 0;
background: color-mix(in srgb, var(--md-default-bg-color), var(--md-primary-fg-color) 4%);
position: relative;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
/* The title link covers the whole card, so any click on the card opens the course. */
.md-typeset .course-grid li > a:first-child::after {
content: "";
inset: 0;
position: absolute;
}
.md-typeset .course-grid li:hover,
.md-typeset .course-grid li:focus-within {
border-color: var(--md-accent-fg-color);
box-shadow: var(--md-shadow-z1);
}
.md-typeset .course-grid small {
color: var(--md-default-fg-color--light);
}
.md-typeset .ai-notes-note-header {
align-items: flex-start;
display: flex;
gap: 1.2rem;
justify-content: space-between;
margin: -0.35rem 0 1.6rem;
}
.md-typeset .ai-notes-note-header p {
margin: 0;
}
.md-typeset .ai-notes-note-header__meta {
color: var(--md-default-fg-color--light);
font-size: 0.72rem;
}
.md-typeset .ai-notes-note-header__actions {
font-size: 0.72rem;
margin-top: 0.3rem !important;
}
.md-typeset .ai-notes-note-header__cover {
flex: 0 0 auto;
}
.md-typeset .ai-notes-note-header__cover img {
aspect-ratio: 16 / 9;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.3rem;
display: block;
object-fit: cover;
width: 8.4rem;
}
.md-typeset h1 {
color: var(--md-default-fg-color);
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1.18;
margin-bottom: 1.15rem;
}
.md-typeset h2 {
border-top: 1px solid var(--ai-notes-rule-color);
color: var(--md-default-fg-color);
font-size: 1.45rem;
font-weight: 680;
letter-spacing: 0;
line-height: 1.28;
margin-top: 2.4rem;
padding-top: 1.05rem;
}
.md-typeset h3 {
color: var(--md-default-fg-color);
font-size: 1.08rem;
font-weight: 680;
letter-spacing: 0;
line-height: 1.38;
margin-top: 1.7rem;
}
.md-typeset h4 {
color: var(--md-default-fg-color--light);
font-size: 0.92rem;
font-weight: 700;
letter-spacing: 0;
margin-top: 1.25rem;
text-transform: none;
}
.md-typeset p,
.md-typeset li {
line-height: 1.78;
}
.md-typeset code {
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.22rem;
font-size: 0.86em;
padding: 0.04rem 0.24rem;
}
.md-typeset .highlight {
margin: 1rem 0 1.35rem;
}
.md-typeset .highlight pre {
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.4rem;
}
.md-typeset .arithmatex {
overflow-x: auto;
}
.md-typeset > .arithmatex,
.md-typeset .admonition > .arithmatex {
background: color-mix(in srgb, var(--md-default-bg-color), var(--md-primary-fg-color) 3%);
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.42rem;
margin: 1.1rem 0 1.25rem;
padding: 0.7rem 0.9rem;
}
.md-typeset .admonition,
.md-typeset details {
border-radius: 0.45rem;
box-shadow: none;
/* Material uses 0.64rem (12.8px); boxes hold about a third of the text on many pages. */
font-size: 0.76rem;
margin: 1rem 0 1.3rem;
}
.md-typeset .admonition.important,
.md-typeset details.important {
border-color: var(--ai-notes-important-color);
}
.md-typeset .important > .admonition-title,
.md-typeset .important > summary {
background-color: color-mix(in srgb, var(--ai-notes-important-color), transparent 90%);
}
.md-typeset .important > .admonition-title::before,
.md-typeset .important > summary::before {
background-color: var(--ai-notes-important-color);
-webkit-mask-image: var(--md-admonition-icon--important);
mask-image: var(--md-admonition-icon--important);
}
.md-typeset .admonition-title {
font-weight: 700;
letter-spacing: 0;
}
.md-typeset table:not([class]) {
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.38rem;
display: table;
/* em, not rem: tables inside boxes follow the box text size. */
font-size: 0.94em;
line-height: 1.55;
margin: 1rem 0 1.1rem;
overflow: hidden;
width: 100%;
}
.md-typeset table:not([class]) th {
background: color-mix(in srgb, var(--md-default-bg-color), var(--md-primary-fg-color) 6%);
font-weight: 700;
}
.md-typeset table:not([class]) th,
.md-typeset table:not([class]) td {
padding: 0.62rem 0.75rem;
vertical-align: top;
}
.md-typeset blockquote {
border-left: 0.18rem solid var(--md-accent-fg-color);
color: var(--md-default-fg-color--light);
}
.md-typeset img {
border-radius: 0.25rem;
}
.md-typeset .ai-notes-figure {
margin: 1.2rem auto 1.45rem;
text-align: center;
}
.md-typeset .ai-notes-figure__preview {
display: inline-block;
cursor: zoom-in;
}
.md-typeset .ai-notes-figure__preview img {
display: block;
height: auto;
margin: 0 auto;
max-width: 100%;
}
.md-typeset .ai-notes-figure--pdf .ai-notes-figure__preview img {
width: 100%;
/* PDF pages render with a transparent background; keep dark text readable in dark mode. */
background: #fff;
}
.md-typeset figcaption {
/* Material italicizes captions; CJK fonts have no italic, so browsers slant the glyphs. */
font-style: normal;
}
.md-typeset .ai-notes-figure figcaption {
color: var(--md-default-fg-color--light);
max-width: var(--ai-notes-measure);
font-size: 0.78rem;
line-height: 1.55;
margin-top: 0.45rem;
}
.md-typeset .ai-notes-figure__original {
font-size: 0.72rem;
margin-left: 0.35rem;
white-space: nowrap;
}
.md-typeset .ai-notes-figure__source {
/* --lighter measured 2.2:1 against the page; --light (as in captions) meets WCAG AA 4.5:1. */
color: var(--md-default-fg-color--light);
font-size: 0.68rem;
}
/* Slide pairs and subfigures: equal-width images side by side, wrapping when the column is narrow. */
.md-typeset .ai-notes-figure--group {
align-items: flex-start;
display: flex;
flex-flow: row wrap;
gap: 0.6rem;
justify-content: center;
}
.md-typeset .ai-notes-figure--group > .ai-notes-figure__preview {
flex: 1 1 0;
min-width: min(14rem, 100%);
}
.md-typeset .ai-notes-figure--group > .ai-notes-figure__preview img {
width: 100%;
}
.md-typeset .ai-notes-figure--group > figcaption {
flex-basis: 100%;
}
.md-typeset .ai-notes-table-caption {
margin: -0.55rem 0 1.25rem;
color: var(--md-default-fg-color--light);
font-size: 0.78rem;
line-height: 1.55;
text-align: center;
}
""".strip()
def sidebar_script() -> str:
"""Remember desktop sidebar preferences and this tab's per-page scroll position."""
return """
(function () {
function installSidebarControls() {
["primary", "secondary"].forEach(function (side) {
var button = document.querySelector('[data-ai-sidebar-toggle="' + side + '"]');
var sidebar = document.querySelector(".md-sidebar--" + side);
if (!button) {
return;
}
if (!sidebar || sidebar.hidden || !sidebar.querySelector(".md-nav__list")) {
button.hidden = true;
return;
}
sidebar.id = button.getAttribute("aria-controls");
var attribute = "data-ai-sidebar-" + side;
var label = side === "primary" ? "课程导航" : "本页目录";
function updateButton() {
var expanded = document.documentElement.getAttribute(attribute) === "open";
button.setAttribute("aria-expanded", String(expanded));
button.textContent = (expanded ? "收起" : "展开") + label;
}
updateButton();
button.addEventListener("click", function () {
var next = document.documentElement.getAttribute(attribute) === "open" ? "closed" : "open";
document.documentElement.setAttribute(attribute, next);
updateButton();
try {
localStorage.setItem("ai-notes-sidebar-" + side, next);
} catch (_error) {
// The current page still toggles when storage is unavailable.
}
// Material measures sticky sidebar scroll areas on viewport changes.
window.dispatchEvent(new Event("resize"));
});
});
}
function scrollKey() {
return "ai-notes-scroll:" + location.origin + location.pathname + location.search;
}
function saveScrollPosition() {
try {
sessionStorage.setItem(scrollKey(), String(Math.round(window.scrollY || window.pageYOffset || 0)));
} catch (_error) {
return;
}
}
function restoreScrollPosition() {
if (location.hash) {
return;
}
var value;
try {
value = sessionStorage.getItem(scrollKey());
} catch (_error) {
return;
}
var y = Number(value);
if (!Number.isFinite(y) || y <= 0) {
return;
}
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
requestAnimationFrame(function () {
window.scrollTo({ top: y, left: 0, behavior: "auto" });
setTimeout(function () {
window.scrollTo({ top: y, left: 0, behavior: "auto" });
}, 120);
});
}
function installScrollMemory() {
var ticking = false;
window.addEventListener("scroll", function () {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(function () {
saveScrollPosition();
ticking = false;
});
}, { passive: true });
window.addEventListener("pagehide", saveScrollPosition);
restoreScrollPosition();
}
document.addEventListener("DOMContentLoaded", function () {
installSidebarControls();
installScrollMemory();
});
})();
""".strip()
def mathjax_config() -> str:
"""MathJax settings plus a loader that fetches the vendored copy only on pages with math."""
script = "../" + MATHJAX_SITE_DIR.relative_to("assets").as_posix() + "/tex-chtml.js"
return """
window.MathJax = {
tex: {
inlineMath: [["$", "$"], ["\\\\(", "\\\\)"]],
displayMath: [["$$", "$$"], ["\\\\[", "\\\\]"]],
processEscapes: true
},
options: {
ignoreHtmlClass: ".*|",
processHtmlClass: "arithmatex"
}
};
// About a quarter of the pages have no math; they skip MathJax entirely. On the others the
// script loads asynchronously, so the text renders first and the formulas follow.
(function () {
if (!document.querySelector(".arithmatex")) {
return;
}
var script = document.createElement("script");
script.src = new URL("__MATHJAX_SCRIPT__", document.currentScript.src).href;
script.async = true;
document.head.appendChild(script);
})();
""".strip().replace("__MATHJAX_SCRIPT__", script)
def search_index_hook() -> str:
"""MkDocs hook that stores the search index as UTF-8 instead of \\uXXXX escapes."""
return """
# Generated by tools/web/generate_site.py.
# MkDocs writes search/search_index.json with every CJK character escaped as \\uXXXX.
# Rewriting it as UTF-8 cuts the index from 21.0 MB to 12.9 MB on this site.
import json
from pathlib import Path
def on_post_build(config, **kwargs):
path = Path(config["site_dir"]) / "search" / "search_index.json"
if not path.is_file():
return
data = json.loads(path.read_text(encoding="utf-8"))
path.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
""".lstrip()
def theme_main_override() -> str:
"""Material reader controls and on-demand search index loading."""
return """
{% extends "base.html" %}
{% block extrahead %}
{{ super() }}
{% endblock %}
{% block content %}
{{ super() }}
{% endblock %}
{% block scripts %}
{{ super() }}
{% endblock %}
""".lstrip()
def yaml_quote(value: str) -> str:
escaped = value.replace('"', '\\"')
return f'"{escaped}"'
def nav_course_lines(course_dir: Path, notes: list[Note], title: str, indent: str) -> list[str]:
"""One course in the sidebar; with navigation.indexes the section title opens the course page."""
index_page = yaml_quote(course_dir.as_posix() + "/index.md")
if is_single_page_course(course_dir, notes):
return [f"{indent}- {yaml_quote(title)}: {index_page}"]
labels = nav_labels(notes)
lines = [f"{indent}- {yaml_quote(title)}:", f"{indent} - {index_page}"]
for note in sorted(notes, key=lambda item: natural_key(item.route_dir)):
lines.append(f"{indent} - {yaml_quote(labels[note.route_dir])}: {yaml_quote(note.route_dir.as_posix() + '/index.md')}")
return lines
def generate_mkdocs_yml(
courses: OrderedDict[Path, list[Note]],
entries_by_path: dict[str, CatalogEntry],
catalog_entries: list[CatalogEntry],
) -> str:
lines = [
"site_name: AI Course Notes",
f"site_url: {SITE_URL}",
f"repo_url: {REPO_URL}",
"repo_name: hqhq1025/ai-course-notes",
"docs_dir: docs",
"site_dir: site",
"theme:",
" name: material",
" custom_dir: overrides",
# Material loads Roboto from Google Fonts by default; the stylesheet blocks first paint for
# minutes where fonts.googleapis.com is unreachable, and Roboto has no CJK glyphs anyway.
" font: false",
" language: zh",
" palette:",
" - scheme: default",
" primary: teal",
" accent: amber",
" toggle:",
" icon: material/weather-night",
" name: 切换到深色模式",
" - scheme: slate",
" primary: teal",
" accent: amber",
" toggle:",
" icon: material/weather-sunny",
" name: 切换到浅色模式",
" features:",
" - content.code.copy",
" - navigation.footer",
" - navigation.indexes",
" - navigation.path",
# Without prune every page embeds all ~450 sidebar links (about 70% of each HTML file).
" - navigation.prune",
" - navigation.sections",
" - navigation.top",
" - navigation.tracking",
" - search.highlight",
" - search.suggest",
" - toc.follow",
"plugins:",
" - search:",
" lang:",
" - zh",
" - en",
"hooks:",
" - hooks/search_index_utf8.py",
"markdown_extensions:",
" - admonition",
" - attr_list",
" - md_in_html",
" - pymdownx.details",
" - pymdownx.superfences",
" - pymdownx.arithmatex:",
" generic: true",
"extra_css:",
" - assets/stylesheets/ai-notes.css",
"extra_javascript:",
" - assets/javascripts/ai-notes.js",
" - assets/javascripts/mathjax.js",
"nav:",
" - 首页: index.md",
]
for category, course_dirs in group_courses(courses, catalog_entries).items():
label = nav_category_label(category)
titles = {course_dir: course_title(course_dir, entries_by_path) for course_dir in course_dirs}
if len(course_dirs) == 1 and titles[course_dirs[0]] == label:
# One collection named like its category (the articles): avoid repeating the name, and
# keep its 25 links collapsed so they are pruned from every other page's sidebar.
only = course_dirs[0]
titles[only] = f"全部 {len(courses[only])} {note_unit(only)}"
lines.append(f" - {yaml_quote(label)}:")
for course_dir in course_dirs:
lines.extend(nav_course_lines(course_dir, courses[course_dir], titles[course_dir], " "))
return "\n".join(lines) + "\n"
LATEX_COMMAND_PATTERN = re.compile(r"\\[A-Za-z]+")
def latex_command_leaks(markdown: str) -> Counter[str]:
"""\\commands left where readers see them: outside math, code blocks, code spans and HTML tags."""
leaks: Counter[str] = Counter()
def scan(text: str) -> str:
text = re.sub(r"(?\n]*>", " ", text)
leaks.update(LATEX_COMMAND_PATTERN.findall(text))
return text
transform_outside_math_and_code(markdown, scan)
return leaks
def record_latex_leaks(ctx: BuildContext, note: Note, page: str) -> None:
leaks = latex_command_leaks(page)
if leaks:
detail = ", ".join(f"{name}×{count}" for name, count in leaks.most_common())
ctx.warn("latex_leak", f"{sum(leaks.values())} LaTeX commands left in prose of {note.rel_tex_path}: {detail}")
def print_warning_report(warnings: list[WarningEvent], *, verbose: bool) -> None:
if not warnings:
return
labels = {
"latex_leak": "Pages with LaTeX commands left in prose",
"missing_asset": "Missing assets",
"image_compression_failed": "Image compression fallbacks",
"tikz_failed": "TikZ diagrams kept as source",
"tikz_skipped": "TikZ diagrams skipped",
"pdf_figure_failed": "PDF figures kept as links (render failed)",
"pdf_figure_skipped": "PDF figures kept as links (pdftocairo unavailable)",
"pdf_figure_unsupported": "PDF figures kept as links (trim/viewport/angle options)",
}
counts = Counter(warning.kind for warning in warnings)
print("\nWarning summary:")
for kind, count in sorted(counts.items(), key=lambda item: labels.get(item[0], item[0])):
print(f"- {labels.get(kind, kind)}: {count}")
if verbose:
print("\nWarning details:")
for warning in warnings:
print(f"- {warning.message}")
else:
print("- Run with --verbose-warnings for details.")
def resolve_cache_root(output: Path, cache_root: Path | None) -> Path:
return (cache_root if cache_root is not None else output / ".cache").resolve()
def prepare_output(output: Path, cache_root: Path | None = None) -> tuple[Path, Path, Path]:
docs_dir = output / "docs"
resolved_cache_root = resolve_cache_root(output, cache_root)
cache_dir = resolved_cache_root / "tikz"
image_cache_dir = resolved_cache_root / "images"
if docs_dir.exists():
shutil.rmtree(docs_dir)
docs_dir.mkdir(parents=True, exist_ok=True)
mkdocs_yml = output / "mkdocs.yml"
if mkdocs_yml.exists():
mkdocs_yml.unlink()
for generated_dir in (output / "hooks", output / "overrides"):
if generated_dir.exists():
shutil.rmtree(generated_dir)
return docs_dir, cache_dir, image_cache_dir
def prune_unused_cache(ctx: BuildContext) -> None:
"""Drop image/PDF cache entries this full build did not use (old versions, removed sources).
CI restores the cache from older keys, so without pruning stale entries accumulate forever.
"""
directories = []
if ctx.compress_images and ctx.image_cache_dir is not None:
directories.append(ctx.image_cache_dir)
if ctx.pdf_cache_dir is not None and shutil.which("pdftocairo"):
directories.append(ctx.pdf_cache_dir)
for directory in directories:
if directory.is_dir():
for entry in directory.iterdir():
if entry.is_file() and entry not in ctx.used_cache_files:
entry.unlink()
def build_site(
root: Path,
output: Path,
*,
strict: bool,
fail_on_tikz_warnings: bool,
fail_on_pdf_figure_warnings: bool,
fail_on_latex_leaks: bool = False,
render_tikz: bool,
verbose_warnings: bool,
compress_images: bool,
image_max_width: int,
jpeg_quality: int,
cache_root: Path | None,
) -> int:
root = root.resolve()
output = output.resolve()
docs_dir, cache_dir, image_cache_dir = prepare_output(output, cache_root)
ctx = BuildContext(
root=root,
output=output,
docs_dir=docs_dir,
cache_dir=cache_dir,
render_tikz=render_tikz,
compress_images=compress_images,
image_max_width=image_max_width,
jpeg_quality=jpeg_quality,
image_cache_dir=image_cache_dir,
pdf_cache_dir=image_cache_dir.parent / "pdf-figures",
)
catalog_entries = parse_readme_catalog(root)
entries_by_path = {entry.path.as_posix(): entry for entry in catalog_entries}
notes = discover_notes(root)
if not notes:
print("No LaTeX notes found.")
return 1
courses: OrderedDict[Path, list[Note]] = OrderedDict()
for note in sorted(notes, key=lambda item: natural_key(item.route_dir)):
courses.setdefault(note.course_dir, []).append(note)
page = note_page_markdown(ctx, note)
record_latex_leaks(ctx, note, page)
write_text(docs_dir / note.route_dir / "index.md", page)
for course_dir, course_notes in courses.items():
if is_single_page_course(course_dir, course_notes):
continue
if any(note.route_dir == course_dir for note in course_notes):
ctx.warn("route_conflict", f"Course page {course_dir}/index.md would overwrite a note page")
title = course_title(course_dir, entries_by_path)
topic = topic_for_course(course_dir, entries_by_path)
write_text(docs_dir / course_dir / "index.md", course_page_markdown(course_dir, course_notes, title, topic))
write_text(docs_dir / "index.md", home_markdown(notes, courses, catalog_entries, entries_by_path))
write_text(docs_dir / "assets" / "stylesheets" / "ai-notes.css", css_content())
write_text(docs_dir / "assets" / "javascripts" / "ai-notes.js", sidebar_script())
write_text(docs_dir / "assets" / "javascripts" / "mathjax.js", mathjax_config())
shutil.copytree(MATHJAX_VENDOR_DIR, docs_dir / MATHJAX_SITE_DIR)
write_text(output / "hooks" / "search_index_utf8.py", search_index_hook())
write_text(output / "overrides" / "main.html", theme_main_override())
write_text(output / "mkdocs.yml", generate_mkdocs_yml(courses, entries_by_path, catalog_entries))
prune_unused_cache(ctx)
print(f"Generated {len(notes)} note pages across {len(courses)} course pages in {output}")
print_warning_report(ctx.warnings, verbose=verbose_warnings)
if strict and not notes:
return 1
if fail_on_tikz_warnings and any(warning.kind in {"tikz_failed", "tikz_skipped"} for warning in ctx.warnings):
return 1
if fail_on_pdf_figure_warnings and any(warning.kind.startswith("pdf_figure_") for warning in ctx.warnings):
return 1
if fail_on_latex_leaks and any(warning.kind == "latex_leak" for warning in ctx.warnings):
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path("."), help="Repository root")
parser.add_argument("--output", type=Path, default=Path(".web-build"), help="Generated MkDocs workspace")
parser.add_argument("--cache-dir", type=Path, default=None, help="Persistent cache root for generated TikZ SVGs and compressed images")
parser.add_argument("--check-tikz-cache", action="store_true", help="Return zero when every TikZ diagram already has a cached SVG")
parser.add_argument("--strict", action="store_true", help="Return non-zero if no notes can be generated")
parser.add_argument("--skip-tikz", action="store_true", help="Keep TikZ source blocks instead of rendering SVG")
parser.add_argument("--fail-on-tikz-warnings", action="store_true", help="Return non-zero if TikZ diagrams fall back to source blocks")
parser.add_argument("--fail-on-pdf-figure-warnings", action="store_true", help="Return non-zero if PDF figures fall back to links")
parser.add_argument("--fail-on-latex-leaks", action="store_true", help="Return non-zero if LaTeX commands are left in page prose")
parser.add_argument("--verbose-warnings", action="store_true", help="Print detailed warning messages")
parser.add_argument("--no-compress-images", action="store_true", help="Copy JPG/PNG assets without resizing or recompressing")
parser.add_argument("--image-max-width", type=int, default=1600, help="Maximum width for copied JPG/PNG assets")
parser.add_argument("--jpeg-quality", type=int, default=82, help="JPEG quality for copied JPG assets")
args = parser.parse_args()
if args.check_tikz_cache:
root = args.root.resolve()
output = args.output.resolve()
cache_root = resolve_cache_root(output, args.cache_dir)
docs_dir = output / "docs"
ctx = BuildContext(
root=root,
output=output,
docs_dir=docs_dir,
cache_dir=cache_root / "tikz",
render_tikz=True,
compress_images=not args.no_compress_images,
image_max_width=args.image_max_width,
jpeg_quality=args.jpeg_quality,
image_cache_dir=cache_root / "images",
)
notes = discover_notes(root)
if args.strict and not notes:
print("No LaTeX notes found.")
return 1
return print_tikz_cache_status(ctx, notes)
return build_site(
args.root,
args.output,
strict=args.strict,
fail_on_tikz_warnings=args.fail_on_tikz_warnings,
fail_on_pdf_figure_warnings=args.fail_on_pdf_figure_warnings,
fail_on_latex_leaks=args.fail_on_latex_leaks,
render_tikz=not args.skip_tikz,
verbose_warnings=args.verbose_warnings,
compress_images=not args.no_compress_images,
image_max_width=args.image_max_width,
jpeg_quality=args.jpeg_quality,
cache_root=args.cache_dir,
)
if __name__ == "__main__":
raise SystemExit(main())