SPB Git forge

spb/uqo-imm1003

Public
7commits 1branches 0releases
189.5 MBsize
maindefault branch
7 h agolast push
JavaScript 68% CSS 32%
32.7 KB · 481 lines python
Raw Blame History
1#!/usr/bin/env python32"""build.py — génère les sites statiques des cours UQO dans dist/<code>/ (v2 : widgets, jeux, rapport, marque)."""3from __future__ import annotations45import glob6import html7import json8import os9import re10import shutil11import subprocess12import sys13import time1415HERE = os.path.dirname(os.path.abspath(__file__))16sys.path.insert(0, HERE)17SITE = os.path.abspath(os.path.join(HERE, ".."))18ROOT = os.path.abspath(os.path.join(SITE, ".."))1920from latex2html import Ctx, InlineConverter, ChapterState, convert_chapter, resolve_refs, strip_comments, read_group, plain_text, slugify  # noqa: E40221from bib import parse_bib, format_entry  # noqa: E40222import tikz  # noqa: E40223import templates as T  # noqa: E40224import brand  # noqa: E40225import rapport as RP  # noqa: E40226import videos as VD  # noqa: E40227from courses import COURSES  # noqa: E402282930def log(msg):31    print(msg, file=sys.stderr, flush=True)323334def human_size(n: float) -> str:35    for unit in ("o", "Ko", "Mo", "Go"):36        if n < 1024:37            return f"{n:.0f} {unit}" if unit == "o" else f"{n:.1f} {unit}".replace(".", ",")38        n /= 102439    return f"{n:.1f} To"404142def pdf_pages(path: str) -> int:43    try:44        out = subprocess.run(["pdfinfo", path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=20).stdout45        m = re.search(r"Pages:\s+(\d+)", out)46        return int(m.group(1)) if m else 047    except Exception:  # noqa: BLE00148        return 0495051def render_math(items):52    if not items:53        return []54    payload = json.dumps([{"mode": m, "tex": t} for m, t in items])55    p = subprocess.run(["node", os.path.join(HERE, "render_math.mjs")], input=payload.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=SITE)56    if p.returncode != 0:57        raise RuntimeError("render_math.mjs : " + p.stderr.decode()[:800])58    out = json.loads(p.stdout.decode())59    for i, o in enumerate(out):60        if o["error"]:61            log(f"  KaTeX : {o['error'][:90]} || {items[i][1][:100]}")62    return [o["html"] for o in out]636465def _norm(s: str) -> str:66    import unicodedata67    t = unicodedata.normalize("NFD", s)68    return "".join(ch for ch in t if not unicodedata.combining(ch)).lower().replace("’", "'")697071def parse_glossary(ctx: Ctx, tex: str):72    tex = strip_comments(tex)73    st = ChapterState(ctx, "annexe-glossaire", "B", "/glossaire/")74    inl = InlineConverter(st)75    m_intro = re.search(r"\\label\{annB\}(.*?)\\section\*", tex, re.S)76    intro_html = f"<p>{inl.convert(m_intro.group(1).strip())}</p>" if m_intro else ""77    entries, letter = [], ""78    pat = re.compile(r"\\section\*\{([^}]*)\}|\\subsection\*\{")79    matches = list(pat.finditer(tex))80    for i, m in enumerate(matches):81        if m.group(1) is not None:82            letter = plain_text(inl.convert(m.group(1))).strip()[:1].upper()83            continue84        term, k = read_group(tex, m.end() - 1)85        end = matches[i + 1].start() if i + 1 < len(matches) else len(tex)86        body = re.sub(r"\\(index|label)\{[^}]*\}", "", tex[k:end])87        term_html = inl.convert(term)88        term_text = plain_text(term_html)89        paras = [p.strip() for p in re.split(r"\n\s*\n", body.strip()) if p.strip()]90        body_html = "".join(f"<p>{inl.convert(p)}</p>" for p in paras)91        eid, base, j = "g-" + slugify(term_text), "g-" + slugify(term_text), 292        while any(x["id"] == eid for x in entries):93            eid = f"{base}-{j}"94            j += 195        entries.append({"term": term_text, "term_html": term_html, "html": body_html, "letter": letter or term_text[:1].upper(), "id": eid,96                        "norm": re.sub(r"[^a-z0-9 ]", "", _norm(term_text)).strip(), "text": plain_text(body_html)})97    return entries, intro_html9899100def insert_widgets(h: str, widgets, warn):101    """Insère chaque widget à la fin de la première sous-section de la section visée (au niveau supérieur du flux)."""102    count = 0103    for sid, w in widgets:104        m = re.search(rf'<h[23] id="{re.escape(sid)}"[^>]*>.*?</h[23]>', h, re.S)105        if not m:106            warn(f"widget {w} : section {sid} introuvable")107            continue108        heads = [x for x in re.finditer(r'<h[23] id="s-', h) if x.start() > m.end()]109        if m.group(0).startswith("<h2"):110            # fin de la première sous-section : la 2e en-tête qui suit (ou la 1re si c'est un h2)111            if heads and heads[0].group(0).startswith("<h3") and len(heads) > 1:112                at = heads[1].start()113            elif heads:114                at = heads[0].start()115            else:116                at = h.find('<hr class="sep">', m.end())117                at = at if at != -1 else len(h)118        else:119            at = heads[0].start() if heads else len(h)120        h = h[:at] + f'<div class="widget" data-widget="{w}"></div>\n' + h[at:]121        count += 1122    return h, count123124125def clean_val(v: str) -> str:126    return html.escape(v.replace("~", "\u00a0").replace("\\%", "%").replace("\\$", "$").replace("--", "–"))127128129# -----------------------------------------------------------------------------130def build_course(code: str, out_root: str, skip_tikz: bool):131    course = COURSES[code]132    t0 = time.time()133    log(f"\n=== {course['code']} — {course['title']}")134    src_course = os.path.join(ROOT, course["src"])135    src_notes = os.path.join(src_course, course["notes_dir"], "sources")136    out = os.path.join(out_root, code)137    if os.path.exists(out):138        shutil.rmtree(out)139    os.makedirs(out)140    warnings = []141    warn = warnings.append142143    bib = parse_bib(open(os.path.join(src_notes, "references.bib"), encoding="utf-8").read())144    ctx = Ctx(course["code"], bib)145    chapters = []146    for i in range(1, 15):147        key = f"ch{i:02d}"148        chapters.append(convert_chapter(ctx, key, str(i), f"/seance/{i:02d}/", open(os.path.join(src_notes, "chapters", key + ".tex"), encoding="utf-8").read()))149    formulaire = convert_chapter(ctx, "annexe-formulaire", "A", "/aide-memoire/", open(os.path.join(src_notes, "chapters", "annexe-formulaire.tex"), encoding="utf-8").read())150    glossary, gloss_intro = parse_glossary(ctx, open(os.path.join(src_notes, "chapters", "annexe-glossaire.tex"), encoding="utf-8").read())151    log(f"  chapitres convertis : 14 + annexes ; math={len(ctx.math)} tikz={len(ctx.tikz)} glossaire={len(glossary)}")152153    # --- rapport marché154    has_report = os.path.exists(os.path.join(ROOT, RP.REPORT_DIR, "sources", "rapport_immobilier_quebec.tex"))155    report = RP.build_report(course["code"], ROOT, out, os.path.join(SITE, "cache"), None) if has_report else None156    if report:157        log(f"  rapport : {len(report['sections'])} sections, {len(report['images'])} figures, {len(report['ctx'].math)} formules, HPI {len(report['hpi']['series'])} marchés, FRED {len(report['fred'])} séries")158159    # --- quiz160    quiz_path = os.path.join(SITE, "data", f"quiz-{code}.json")161    quiz = json.load(open(quiz_path, encoding="utf-8")) if os.path.exists(quiz_path) else {"chapters": {}}162    quiz["titles"] = {f"{i:02d}": chapters[i - 1].title_text for i in range(1, 15)}163    for k, qs in quiz["chapters"].items():164        for q in qs:165            q.setdefault("url", f"/seance/{k}/")166    n_quiz = sum(len(v) for v in quiz["chapters"].values())167168    # --- TikZ169    fig_dir = os.path.join(out, "assets", "fig")170    os.makedirs(fig_dir, exist_ok=True)171    tikz_map = {}172    if ctx.tikz:173        res = tikz.render_all(ctx.tikz, os.path.join(SITE, "cache", "tikz"), workers=6, log_dir=os.path.join(SITE, "cache", "tikz-logs"))174        for idx, r in enumerate(res):175            if r["ok"]:176                shutil.copy2(r["path"], os.path.join(fig_dir, r["hash"] + ".svg"))177                w, h = (r.get("w") or 0), (r.get("h") or 0)178                disp_w = int(w * 1.45) if w else 600179                attrs = f' width="{int(w)}" height="{int(h)}"' if w and h else ""180                tikz_map[idx] = f'<img src="/assets/fig/{r["hash"]}.svg" alt="Figure"{attrs} style="width:min(100%,{disp_w}px)" loading="lazy" decoding="async">'181            else:182                tikz_map[idx] = f'<div class="box box-att"><div class="box-body">Figure non rendue : {html.escape(r.get("err", ""))[:200]}</div></div>'183                warn(f"TikZ échec #{idx}: {r.get('err', '')[:200]}")184185    # --- math (cours + rapport)186    all_math = list(ctx.math) + (list(report["ctx"].math) if report else [])187    log(f"  KaTeX : {len(all_math)} formules…")188    math_html = render_math(all_math)189    off = len(ctx.math)190191    def finalize(h: str, current_url: str = "", c: Ctx = ctx, math_offset: int = 0, images=None) -> str:192        h = re.sub(r"@@M(\d+)@@", lambda m: math_html[int(m.group(1)) + math_offset], h)193        h = re.sub(r"@@TIKZ(\d+)@@", lambda m: tikz_map.get(int(m.group(1)), ""), h)194        if images is not None:195            h = re.sub(r"@@IMG(\d+)@@", lambda m: images.get(int(m.group(1)), ""), h)196        h = resolve_refs(h, c, current_url)197        return T.inject_box_icons(h)198199    # --- fichiers publiés200    files_dir = os.path.join(out, "files")201    os.makedirs(os.path.join(files_dir, "slides"), exist_ok=True)202    os.makedirs(os.path.join(files_dir, "tp"), exist_ok=True)203    res_rows = []204    notes_pdf_src = os.path.join(src_course, course["notes_dir"], "pdf", "notes_de_cours.pdf")205    shutil.copy2(notes_pdf_src, os.path.join(files_dir, f"{course['code']}_notes_de_cours.pdf"))206    notes_url = f"/files/{course['code']}_notes_de_cours.pdf"207    res_rows.append({"group": "Manuel et plan de cours", "kind": "pdf", "label": f"Notes de cours {course['code']} — manuel complet", "url": notes_url, "size": human_size(os.path.getsize(notes_pdf_src)), "desc": f"{pdf_pages(notes_pdf_src)} pages · 14 séances, aide-mémoire, glossaire, bibliographie, index"})208    plan_src = os.path.join(src_course, "01_Plan_de_cours", "plan_de_cours.pdf")209    shutil.copy2(plan_src, os.path.join(files_dir, f"{course['code']}_plan_de_cours.pdf"))210    plan_url = f"/files/{course['code']}_plan_de_cours.pdf"211    res_rows.append({"group": "Manuel et plan de cours", "kind": "pdf", "label": f"Plan de cours officiel {course['full_code']}", "url": plan_url, "size": human_size(os.path.getsize(plan_src)), "desc": f"{course['term']} · objectifs, calendrier, évaluation, politiques"})212    slides = []213    for p in sorted(glob.glob(os.path.join(src_course, course["slides_glob"]))):214        m = re.search(r"seance(\d{2})", os.path.basename(p))215        if not m:216            continue217        n = int(m.group(1))218        dst = f"seance{n:02d}.pdf"219        shutil.copy2(p, os.path.join(files_dir, "slides", dst))220        slides.append({"n": n, "url": f"/files/slides/{dst}", "title": chapters[n - 1].title_text, "title_html": chapters[n - 1].title_html, "size": human_size(os.path.getsize(p))})221        res_rows.append({"group": "Diapositives (14 séances)", "kind": "pdf", "label": f"Séance {n} — {chapters[n - 1].title_text}", "url": f"/files/slides/{dst}", "size": human_size(os.path.getsize(p))})222    slide_by_n = {s["n"]: s["url"] for s in slides}223    for tp in course["tps"]:224        for kind, key in (("pdf", "enonce"), ("xlsx", "gabarit")):225            srcp = os.path.join(src_course, tp["dir"], tp[key])226            if not os.path.exists(srcp):227                warn(f"fichier TP manquant : {srcp}")228                continue229            dst = os.path.basename(srcp)230            shutil.copy2(srcp, os.path.join(files_dir, "tp", dst))231            res_rows.append({"group": "Travaux pratiques (énoncés et gabarits Excel)", "kind": kind, "label": f"{tp['name']} — {'énoncé (PDF)' if kind == 'pdf' else 'gabarit Excel Dashboard'} · {tp['pond']} · remise {tp['remise'].lower()}", "url": f"/files/tp/{dst}", "size": human_size(os.path.getsize(srcp)), "desc": tp["desc"] if kind == "pdf" else ""})232        for extra in tp.get("extras", []):233            srcp = os.path.join(src_course, tp["dir"], extra["file"])234            if not os.path.exists(srcp):235                warn(f"fichier TP manquant : {srcp}")236                continue237            dst = extra.get("dst", os.path.basename(srcp))238            shutil.copy2(srcp, os.path.join(files_dir, "tp", dst))239            res_rows.append({"group": "Travaux pratiques (énoncés et gabarits Excel)", "kind": extra.get("kind", "pdf"), "label": f"{tp['name']} — {extra['label']}", "url": f"/files/tp/{dst}", "size": human_size(os.path.getsize(srcp)), "desc": extra.get("desc", "")})240    report_files = []241    if report:242        os.makedirs(os.path.join(files_dir, "rapport"), exist_ok=True)243        rp_pdf = os.path.join(ROOT, RP.REPORT_DIR, "rapport_immobilier_quebec.pdf")244        for src, dst, label, desc, kind in (245            (rp_pdf, "rapport_immobilier_quebec.pdf", "Rapport complet (PDF)", f"{pdf_pages(rp_pdf)} pages · texte, 67 figures, 28 tableaux", "pdf"),246            (os.path.join(report["src_dir"], "data", "Not Seasonally Adjusted (M).xlsx"), "HPI_ACI_mensuel_non_desaisonnalise.xlsx", "Données HPI ACI mensuelles (xlsx)", "Indices et prix de référence par marché, 2005–2026", "xlsx"),247            (os.path.join(report["src_dir"], "data", "Seasonally Adjusted (M).xlsx"), "HPI_ACI_mensuel_desaisonnalise.xlsx", "Données HPI ACI désaisonnalisées (xlsx)", "Séries mensuelles désaisonnalisées", "xlsx"),248            (os.path.join(report["src_dir"], "code", "analyse_hpi_quebec.py"), "analyse_hpi_quebec.py", "Script Python d’analyse", "Reproduit figures et tableaux à partir des données", "py"),249        ):250            if os.path.exists(src):251                shutil.copy2(src, os.path.join(files_dir, "rapport", dst))252                url = f"/files/rapport/{dst}"253                res_rows.append({"group": "Rapport — Le marché de l’habitation au Québec", "kind": kind, "label": label, "url": url, "size": human_size(os.path.getsize(src)), "desc": desc})254                report_files.append({"url": url, "label": label, "desc": desc})255256    # --- statistiques257    words = sum(len(en.text.split()) for ch in chapters for en in ch.search)258    stats = {"def": len([d for d in ctx.definitions if d["chapter"].isdigit()]), "form": len([f for f in ctx.formulas if f["chapter"].isdigit()]),259             "ex": len(ctx.examples), "exo": len(ctx.exercises), "fig": sum(c.counts["tab"] + c.counts["fig"] for c in chapters) + len(ctx.tikz),260             "quiz": n_quiz, "words": words, "gloss": len(glossary)}261262    def write(path: str, h: str):263        full = os.path.join(out, path.lstrip("/"))264        os.makedirs(os.path.dirname(full), exist_ok=True)265        with open(full, "w", encoding="utf-8") as f:266            f.write(h)267268    for ch in chapters:269        ch.title_html = finalize(ch.title_html)270    formulaire.title_html = finalize(formulaire.title_html)271272    # --- séances vidéo (enregistrements Zoom)273    videos = VD.build_videos(code, SITE, out, os.path.join(SITE, "cache"), chapters, log, warn)274    course["has_videos"] = bool(videos)275    video_by_n = {v["n"]: v["url"] for v in videos}276    stats["videos"] = len(videos)277278    cards_data, search_data = [], []279    for d in ctx.definitions:280        if d["chapter"].isdigit():281            cards_data.append({"id": f"def-{d['id']}", "type": "def", "ch": f"{int(d['chapter']):02d}", "num": f"Définition {d['num']}", "front": finalize(d["title_html"] or f"Définition {d['num']}"), "back": finalize(d["html"]), "url": d["url"]})282    for f in ctx.formulas:283        if f["chapter"].isdigit():284            cards_data.append({"id": f"form-{f['id']}", "type": "form", "ch": f"{int(f['chapter']):02d}", "num": f"Formule {f['num']}", "front": finalize(f["title_html"] or f"Formule {f['num']}"), "back": finalize(f["html"]), "url": f["url"]})285    for g in glossary:286        g["html"] = finalize(g["html"])287        g["term_html"] = finalize(g["term_html"])288        cards_data.append({"id": g["id"], "type": "gloss", "ch": "G", "num": "", "front": g["term_html"], "back": g["html"], "url": f"/glossaire/#{g['id']}"})289    stats["cards"] = len(cards_data)290291    # --- rapport : KPI et pages292    kpis_html = ""293    if report:294        v = {k: clean_val(x) for k, x in report["vals"].items()}295        obs = v.get("VcDerniereObs", "")296        up = lambda s: "up" if s.startswith("+") else ("down" if s.startswith("-") or s.startswith("−") else "")  # noqa: E731297        kpis_html = "".join([298            f'<span hidden data-obs="{obs}"></span>',299            T.kpi("IPP composite — Québec", v.get("VcQcHPI", ""), f"janv. 2005 = 100 · {obs}", "", "#0f8b8d"),300            T.kpi("Prix de référence — Québec", v.get("VcQcBench", ""), f"variation 12 mois {v.get('VcQcYoY', '')}", up(v.get("VcQcYoY", "")), "#0f8b8d"),301            T.kpi("Croissance annuelle 2005–2026 — Québec", v.get("VcQcCagr", ""), f"multiplication par {v.get('VcQcMult', '')} en 21 ans", "", "#0f8b8d"),302            T.kpi("Prix de référence — Canada", v.get("VcCanBench", ""), f"variation 12 mois {v.get('VcCanYoY', '')}", up(v.get("VcCanYoY", "")), "#2976bb"),303            T.kpi("RMR de Montréal", v.get("VcMtlBench", ""), f"12 mois {v.get('VcMtlYoY', '')} · croissance {v.get('VcMtlCagr', '')}/an", up(v.get("VcMtlYoY", "")), "#2976bb"),304            T.kpi("RMR de Québec", v.get("VcQccBench", ""), f"variation 12 mois {v.get('VcQccYoY', '')}", up(v.get("VcQccYoY", "")), "#2976bb"),305            T.kpi("Correction depuis le sommet — Canada", v.get("VcCanDD", ""), f"Toronto {v.get('VcTorDD', '')} · Vancouver {v.get('VcVanDD', '')}", "down", "#b22a20"),306            T.kpi("Depuis février 2020 — Québec vs Canada", v.get("VcQcDepuisVingt", ""), f"Canada : {v.get('VcCanDepuisVingt', '')}", "up", "#107c4e"),307            T.kpi("Taux 3 mois / 10 ans", f"{v.get('VcTauxTroisMois', '')} / {v.get('VcTauxDixAns', '')}", "Banque du Canada, obligations fédérales", "", "#cca424"),308            T.kpi("Inflation · chômage · PIB", f"{v.get('VcInflation', '')} · {v.get('VcChomage', '')} · {v.get('VcPibYoY', '')}", "IPC 12 mois · taux · PIB réel 12 mois", "", "#cca424"),309            T.kpi("Prix réels — Québec", f"× {v.get('VcQcReelMult', '')}", f"croissance réelle {v.get('VcQcReelCagr', '')}/an depuis 2005", "", "#6b4fbb"),310            T.kpi("Saisonnalité — Québec", f"{v.get('VcSaisonMin', '')} à {v.get('VcSaisonMax', '')}", "facteurs saisonniers mensuels", "", "#6b4fbb"),311        ])312313    # --- accueil314    write("index.html", T.home_page(course, chapters, stats, {"notes": notes_url, "plan": plan_url, "notes_pages": pdf_pages(notes_pdf_src)}, kpis_html, bool(report)))315316    # --- séances317    total_widgets = 0318    for i, ch in enumerate(chapters, 1):319        url = f"/seance/{i:02d}/"320        body_html = finalize(ch.html, url)321        body_html, nw = insert_widgets(body_html, [(sid, w) for sid, w in course.get("widgets", []) if sid.split("-")[1] == str(i)], warn)322        total_widgets += nw323        n_words = sum(len(en.text.split()) for en in ch.search)324        write(f"seance/{i:02d}/index.html", T.chapter_page(course, chapters, ch, i, body_html, {"minutes": max(5, round(n_words / 180)), "words": n_words, "widgets": nw}, slide_by_n.get(i), bool(report), video_by_n.get(i)))325        for en in ch.search:326            search_data.append({"id": en.id, "url": f"{url}#{en.id}", "ch": f"{i:02d}", "chTitle": ch.title_text, "kind": en.kind, "title": en.title if en.kind != "p" else (en.title or ch.title_text), "text": en.text, "where": f"Séance {i} — {ch.title_text}"})327    stats["widgets"] = total_widgets328329    # --- aide-mémoire330    form_index = [f'<li><span class="fi-ch">{f["num"]}</span><a href="{f["url"]}">{finalize(f["title_html"]) or "Formule " + f["num"]}</a></li>' for f in ctx.formulas if f["chapter"].isdigit()]331    extra = f'<hr class="sep"><h2 class="h-section" id="index-formules"><span class="h-text">Toutes les formules numérotées du cours</span></h2><p>Les {len(form_index)} formules encadrées dans les séances, dans leur contexte.</p><ul class="form-index">{"".join(form_index)}</ul>'332    side = T.toc_side(formulaire.toc, "Aide-mémoire", '<h4>Voir aussi</h4><div class="toc-extra"><a href="#index-formules">Index des formules des séances</a><a href="/outils/">Calculateurs</a><a href="/entrainement/">Entraînement</a><a href="/fiches/">Fiches « formules »</a></div>')333    write("aide-memoire/index.html", T.two_col_page(course, chapters, title=formulaire.title_html, eyebrow="Annexe A", lead="Toutes les formules du cours, classées par thème, avec la définition de chaque variable et les repères chiffrés utiles.", content_html=finalize(formulaire.html, "/aide-memoire/") + extra, side_html=side, active="reviser", canonical="/aide-memoire/", has_report=bool(report)))334    for en in formulaire.search:335        search_data.append({"id": en.id, "url": f"/aide-memoire/#{en.id}", "ch": "A", "chTitle": "Aide-mémoire", "kind": en.kind, "title": en.title, "text": en.text, "where": "Annexe A — Aide-mémoire des formules"})336    for g in glossary:337        search_data.append({"id": g["id"], "url": f"/glossaire/#{g['id']}", "ch": "B", "chTitle": "Glossaire", "kind": "gloss", "title": g["term"], "text": g["text"], "where": "Annexe B — Glossaire"})338    write("glossaire/index.html", T.glossary_page(course, chapters, glossary, finalize(gloss_intro), bool(report)))339340    # --- définitions, exercices341    secs = []342    for i, ch in enumerate(chapters, 1):343        defs = [d for d in ctx.definitions if d["chapter"] == str(i)]344        if not defs:345            continue346        items = []347        for d in defs:348            title_part = '<span class="box-title">— ' + finalize(d["title_html"]) + "</span>" if d["title_html"] else ""349            items.append(f'<div class="box box-def" id="{d["id"]}"><div class="box-head"><span class="box-icon" data-icon="book"></span><span class="box-label">Définition {d["num"]}</span>{title_part}<a class="box-anchor" href="{d["url"]}" title="Voir dans la séance">↗</a></div><div class="box-body">{finalize(d["html"])}</div></div>')350        secs.append(f'<h2 class="h-section" id="s-{i}"><span class="h-num">{i}</span><span class="h-text">{ch.title_html}</span></h2>{"".join(items)}')351    side = T.toc_side([(2, str(i), ch.title_html, f"s-{i}") for i, ch in enumerate(chapters, 1) if any(d["chapter"] == str(i) for d in ctx.definitions)], "Séances")352    write("definitions/index.html", T.listing_page(course, chapters, eyebrow="Réviser", title="Toutes les définitions", lead=f"Les {stats['def']} définitions numérotées du manuel, séance par séance — les formulations attendues aux examens.", sections_html=T.inject_box_icons("".join(secs)), active="reviser", canonical="/definitions/", side_html=side, has_report=bool(report)))353    secs = []354    for i, ch in enumerate(chapters, 1):355        m = re.search(r'(<h2 id="s-\d+-\d+" class="h-section">.*?<span class="h-text">Exercices</span>.*?</h2>)(.*?)(?=<hr class="sep">|<section class="box box-synth"|$)', ch.html, re.S)356        if m:357            secs.append(f'<h2 class="h-section" id="s-{i}"><span class="h-num">{i}</span><span class="h-text">{ch.title_html}</span></h2>{finalize(m.group(2), "")}')358    side = T.toc_side([(2, str(i), ch.title_html, f"s-{i}") for i, ch in enumerate(chapters, 1)], "Séances")359    write("exercices/index.html", T.listing_page(course, chapters, eyebrow="S’exercer", title="Banque d’exercices", lead=f"Les {stats['exo']} exercices corrigés du manuel, regroupés. Cherchez d’abord la réponse, puis révélez la solution.", sections_html="".join(secs), active="reviser", canonical="/exercices/", side_html=side, has_report=bool(report)))360361    # --- pages interactives362    write("quiz/index.html", T.quiz_page(course, chapters, n_quiz, bool(report)))363    write("fiches/index.html", T.flash_page(course, chapters, len(cards_data), bool(report)))364    tools_src = os.path.join(SITE, "data", f"outils-{code}.js")365    n_tools = len(re.findall(r"^\s*id:", open(tools_src, encoding="utf-8").read(), re.M)) if os.path.exists(tools_src) else 0366    write("outils/index.html", T.tools_page(course, chapters, n_tools, bool(report)))367    train_src = os.path.join(SITE, "data", f"entrainement-{code}.js")368    n_gen = len(re.findall(r"^\s*id:", open(train_src, encoding="utf-8").read(), re.M)) if os.path.exists(train_src) else 0369    write("entrainement/index.html", T.training_page(course, chapters, n_gen, bool(report)))370    write("jeux/index.html", T.games_page(course, chapters, course.get("sequences", []), bool(report)))371    meta = {"course": f"{course['code']} — {course['title']}", "cards": len(cards_data),372            "chapters": [{"n": f"{i:02d}", "title": ch.title_text, "objectives": len(ch.objectives), "sections": len([t for t in ch.toc if t[0] == 2])} for i, ch in enumerate(chapters, 1)]}373    write("progression/index.html", T.progress_page(course, chapters, json.dumps(meta, ensure_ascii=False), bool(report)))374    map_data = {"chapters": {f"{i:02d}": {"title": ch.title_text, "part": T.part_of(course, i)[1], "objectives": ch.objectives,375                                          "counts": [f"{ch.counts['def']} définitions", f"{ch.counts['form']} formules", f"{ch.counts['ex']} exemples", f"{ch.counts['exo']} exercices"]} for i, ch in enumerate(chapters, 1)}}376    write("carte/index.html", T.map_page(course, chapters, json.dumps(map_data, ensure_ascii=False), bool(report)))377    write("recherche/index.html", T.search_page(course, chapters, bool(report)))378    write("ressources/index.html", T.resources_page(course, chapters, res_rows, bool(report)))379    write("diapositives/index.html", T.slides_page(course, chapters, slides, bool(report)))380    if videos:381        write("videos/index.html", T.videos_index_page(course, chapters, videos, bool(report)))382        for v in videos:383            write(f"videos/{v['nn']}/index.html", T.video_page(course, chapters, v, videos, bool(report)))384            for c in v["chapters"]:385                excerpt = " ".join(b["text"] for b in v["blocks"] if c["t"] <= b["start"] < c["end"])[:600]386                search_data.append({"id": f"video-{v['nn']}-{c['id']}", "url": f"{v['url']}?t={int(c['t'])}", "ch": v["nn"], "chTitle": v["title"], "kind": "video", "title": c["title"],387                                    "text": (c.get("desc", "") + " " + excerpt).strip(), "where": f"Séance vidéo {v['n']} — {VD.hms(c['t'], True)}"})388389    # --- bibliographie (cours + rapport)390    bib_items = []391    all_bib = dict(bib)392    cited = {k: set(v) for k, v in ctx.cited.items()}393    if report:394        for k, en in report["bib"].items():395            all_bib.setdefault(k, en)396        for k, v in report["ctx"].cited.items():397            cited.setdefault(k, set()).update({"rapport"})398    for key, en in sorted(all_bib.items(), key=lambda kv: (_norm(kv[1]["short"]), kv[1]["year"])):399        chs = sorted({int(c[2:]) for c in cited.get(key, set()) if c.startswith("ch")})400        tags = [f"séance {c}" for c in chs] + (["rapport marché"] if "rapport" in cited.get(key, set()) else [])401        cited_html = f'<span class="bib-cited">Citée : {", ".join(tags)}</span>' if tags else ('<span class="bib-cited">Citée en annexe</span>' if key in cited else "")402        bib_items.append(f'<li id="{key}">{format_entry(en)}{cited_html}</li>')403    write("bibliographie/index.html", T.bib_page(course, chapters, bib_items, len(all_bib), bool(report), " et dans le rapport sur le marché de l’habitation" if report else ""))404    write("404.html", T.not_found_page(course, chapters, bool(report)))405406    # --- rapport : pages407    if report:408        secs = report["sections"]409        for s in secs:410            s.title_html = finalize(s.title_html, "", report["ctx"], off, report["images"])411        cover = ('<p>Auteur : <strong>Simon-Pierre Boucher</strong>, professeur, Département des sciences administratives, UQO. Données : ACI/CREA, indice des prix des propriétés MLS®, janvier 2005 – juin 2026 ; FRED (Federal Reserve Bank of St. Louis). '412                 'Ressource transversale aux deux cours : IMM1003 (marché et cycles) et IMM1033 (contexte de coûts et de valeur). Les figures et tableaux sont générés par un script Python reproductible, téléchargeable ci-dessous avec les données.</p>')413        write("rapport/index.html", T.report_index_page(course, chapters, secs, kpis_html, report_files, cover))414        for idx, s in enumerate(secs):415            body_html = finalize(s.html, f"/rapport/{s.num}/", report["ctx"], off, report["images"])416            write(f"rapport/{s.num}/index.html", T.report_section_page(course, chapters, secs, s, idx, body_html))417            for en in s.search:418                search_data.append({"id": en.id, "url": f"/rapport/{s.num}/#{en.id}", "ch": "R", "chTitle": s.title_text, "kind": en.kind, "title": en.title if en.kind != "p" else (en.title or s.title_text), "text": en.text, "where": f"Rapport marché — {('Annexe ' if s.appendix else 'Chapitre ') + s.num} · {s.title_text}"})419        report["ctx"].warnings and [warn("rapport : " + w) for w in sorted(set(report["ctx"].warnings))]420421    # --- données422    data_dir = os.path.join(out, "data")423    os.makedirs(data_dir, exist_ok=True)424    json.dump(search_data, open(os.path.join(data_dir, "search.json"), "w", encoding="utf-8"), ensure_ascii=False)425    json.dump(cards_data, open(os.path.join(data_dir, "cards.json"), "w", encoding="utf-8"), ensure_ascii=False)426    json.dump({"byNorm": {g["norm"]: {"term": g["term"], "html": g["html"], "id": g["id"]} for g in glossary}}, open(os.path.join(data_dir, "glossaire.json"), "w", encoding="utf-8"), ensure_ascii=False)427    json.dump(quiz, open(os.path.join(data_dir, "quiz.json"), "w", encoding="utf-8"), ensure_ascii=False)428    if os.path.exists(tools_src):429        shutil.copy2(tools_src, os.path.join(data_dir, "outils.js"))430    if os.path.exists(train_src):431        shutil.copy2(train_src, os.path.join(data_dir, "entrainement.js"))432    if report:433        json.dump(report["hpi"], open(os.path.join(data_dir, "hpi.json"), "w", encoding="utf-8"), ensure_ascii=False)434        json.dump(report["fred"], open(os.path.join(data_dir, "fred.json"), "w", encoding="utf-8"), ensure_ascii=False)435    json.dump({"course": course["code"], "title": course["title"], "term": course["term"], "built": time.strftime("%Y-%m-%dT%H:%M:%S"), "stats": stats,436               "chapters": [{"n": i, "title": ch.title_text, "counts": ch.counts} for i, ch in enumerate(chapters, 1)]}, open(os.path.join(data_dir, "meta.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)437438    # --- assets, marque, OG439    shutil.copytree(os.path.join(SITE, "assets", "css"), os.path.join(out, "assets", "css"))440    shutil.copytree(os.path.join(SITE, "assets", "js"), os.path.join(out, "assets", "js"))441    katex_src = os.path.join(SITE, "node_modules", "katex", "dist")442    os.makedirs(os.path.join(out, "assets", "katex"), exist_ok=True)443    shutil.copy2(os.path.join(katex_src, "katex.min.css"), os.path.join(out, "assets", "katex", "katex.min.css"))444    shutil.copytree(os.path.join(katex_src, "fonts"), os.path.join(out, "assets", "katex", "fonts"))445    brand_dir = os.path.join(out, "assets", "brand")446    brand.make_icons(brand_dir)447    shutil.copy2(os.path.join(brand_dir, "favicon.svg"), os.path.join(out, "favicon.svg"))448    shutil.copy2(os.path.join(brand_dir, "favicon.ico"), os.path.join(out, "favicon.ico"))449    brand.make_og(course, stats, os.path.join(out, "assets", "og.png"))450    write("manifest.webmanifest", json.dumps({"name": f"{course['code']} — {course['title']}", "short_name": course["code"], "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#0f6180", "lang": "fr-CA",451                                             "icons": [{"src": "/assets/brand/icon-192.png", "sizes": "192x192", "type": "image/png"}, {"src": "/assets/brand/icon-512.png", "sizes": "512x512", "type": "image/png"}, {"src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml"}]}, ensure_ascii=False))452    write("robots.txt", f"User-agent: *\nAllow: /\nSitemap: https://{course['domain']}/sitemap.xml\n")453    urls = ["/", *[f"/seance/{i:02d}/" for i in range(1, 15)], "/aide-memoire/", "/glossaire/", "/definitions/", "/exercices/", "/quiz/", "/entrainement/", "/jeux/", "/fiches/", "/outils/", "/carte/", "/progression/", "/recherche/", "/ressources/", "/diapositives/", "/bibliographie/"]454    if report:455        urls += ["/rapport/"] + [f"/rapport/{s.num}/" for s in report["sections"]]456    if videos:457        urls += ["/videos/"] + [v["url"] for v in videos]458    write("sitemap.xml", '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + "".join(f"<url><loc>https://{course['domain']}{u}</loc></url>" for u in urls) + "</urlset>\n")459460    warn_set = sorted(set(warnings + ctx.warnings))461    if warn_set:462        log(f"  {len(warn_set)} avertissement(s) :")463        for w in warn_set[:40]:464            log("    " + w)465    total_size = sum(os.path.getsize(os.path.join(dp, f)) for dp, _, fs in os.walk(out) for f in fs)466    log(f"  OK → {out} ({human_size(total_size)}) en {time.time() - t0:.1f} s ; stats={stats}")467    return stats468469470def main(argv):471    codes = [a for a in argv if a in COURSES] or list(COURSES)472    out_root = os.path.join(SITE, "dist")473    if "--out" in argv:474        out_root = argv[argv.index("--out") + 1]475    for c in codes:476        build_course(c, out_root, "--skip-tikz" in argv)477478479if __name__ == "__main__":480    main(sys.argv[1:])481