#!/usr/bin/env python3 """build.py — génère les sites statiques des cours UQO dans dist// (v2 : widgets, jeux, rapport, marque).""" from __future__ import annotations import glob import html import json import os import re import shutil import subprocess import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) SITE = os.path.abspath(os.path.join(HERE, "..")) ROOT = os.path.abspath(os.path.join(SITE, "..")) from latex2html import Ctx, InlineConverter, ChapterState, convert_chapter, resolve_refs, strip_comments, read_group, plain_text, slugify # noqa: E402 from bib import parse_bib, format_entry # noqa: E402 import tikz # noqa: E402 import templates as T # noqa: E402 import brand # noqa: E402 import rapport as RP # noqa: E402 import videos as VD # noqa: E402 from courses import COURSES # noqa: E402 def log(msg): print(msg, file=sys.stderr, flush=True) def human_size(n: float) -> str: for unit in ("o", "Ko", "Mo", "Go"): if n < 1024: return f"{n:.0f} {unit}" if unit == "o" else f"{n:.1f} {unit}".replace(".", ",") n /= 1024 return f"{n:.1f} To" def pdf_pages(path: str) -> int: try: out = subprocess.run(["pdfinfo", path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=20).stdout m = re.search(r"Pages:\s+(\d+)", out) return int(m.group(1)) if m else 0 except Exception: # noqa: BLE001 return 0 def render_math(items): if not items: return [] payload = json.dumps([{"mode": m, "tex": t} for m, t in items]) p = subprocess.run(["node", os.path.join(HERE, "render_math.mjs")], input=payload.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=SITE) if p.returncode != 0: raise RuntimeError("render_math.mjs : " + p.stderr.decode()[:800]) out = json.loads(p.stdout.decode()) for i, o in enumerate(out): if o["error"]: log(f" KaTeX : {o['error'][:90]} || {items[i][1][:100]}") return [o["html"] for o in out] def _norm(s: str) -> str: import unicodedata t = unicodedata.normalize("NFD", s) return "".join(ch for ch in t if not unicodedata.combining(ch)).lower().replace("’", "'") def parse_glossary(ctx: Ctx, tex: str): tex = strip_comments(tex) st = ChapterState(ctx, "annexe-glossaire", "B", "/glossaire/") inl = InlineConverter(st) m_intro = re.search(r"\\label\{annB\}(.*?)\\section\*", tex, re.S) intro_html = f"

{inl.convert(m_intro.group(1).strip())}

" if m_intro else "" entries, letter = [], "" pat = re.compile(r"\\section\*\{([^}]*)\}|\\subsection\*\{") matches = list(pat.finditer(tex)) for i, m in enumerate(matches): if m.group(1) is not None: letter = plain_text(inl.convert(m.group(1))).strip()[:1].upper() continue term, k = read_group(tex, m.end() - 1) end = matches[i + 1].start() if i + 1 < len(matches) else len(tex) body = re.sub(r"\\(index|label)\{[^}]*\}", "", tex[k:end]) term_html = inl.convert(term) term_text = plain_text(term_html) paras = [p.strip() for p in re.split(r"\n\s*\n", body.strip()) if p.strip()] body_html = "".join(f"

{inl.convert(p)}

" for p in paras) eid, base, j = "g-" + slugify(term_text), "g-" + slugify(term_text), 2 while any(x["id"] == eid for x in entries): eid = f"{base}-{j}" j += 1 entries.append({"term": term_text, "term_html": term_html, "html": body_html, "letter": letter or term_text[:1].upper(), "id": eid, "norm": re.sub(r"[^a-z0-9 ]", "", _norm(term_text)).strip(), "text": plain_text(body_html)}) return entries, intro_html def insert_widgets(h: str, widgets, warn): """Insère chaque widget à la fin de la première sous-section de la section visée (au niveau supérieur du flux).""" count = 0 for sid, w in widgets: m = re.search(rf']*>.*?', h, re.S) if not m: warn(f"widget {w} : section {sid} introuvable") continue heads = [x for x in re.finditer(r' 1: at = heads[1].start() elif heads: at = heads[0].start() else: at = h.find('
', m.end()) at = at if at != -1 else len(h) else: at = heads[0].start() if heads else len(h) h = h[:at] + f'
\n' + h[at:] count += 1 return h, count def clean_val(v: str) -> str: return html.escape(v.replace("~", "\u00a0").replace("\\%", "%").replace("\\$", "$").replace("--", "–")) # ----------------------------------------------------------------------------- def build_course(code: str, out_root: str, skip_tikz: bool): course = COURSES[code] t0 = time.time() log(f"\n=== {course['code']} — {course['title']}") src_course = os.path.join(ROOT, course["src"]) src_notes = os.path.join(src_course, course["notes_dir"], "sources") out = os.path.join(out_root, code) if os.path.exists(out): shutil.rmtree(out) os.makedirs(out) warnings = [] warn = warnings.append bib = parse_bib(open(os.path.join(src_notes, "references.bib"), encoding="utf-8").read()) ctx = Ctx(course["code"], bib) chapters = [] for i in range(1, 15): key = f"ch{i:02d}" 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())) formulaire = convert_chapter(ctx, "annexe-formulaire", "A", "/aide-memoire/", open(os.path.join(src_notes, "chapters", "annexe-formulaire.tex"), encoding="utf-8").read()) glossary, gloss_intro = parse_glossary(ctx, open(os.path.join(src_notes, "chapters", "annexe-glossaire.tex"), encoding="utf-8").read()) log(f" chapitres convertis : 14 + annexes ; math={len(ctx.math)} tikz={len(ctx.tikz)} glossaire={len(glossary)}") # --- rapport marché has_report = os.path.exists(os.path.join(ROOT, RP.REPORT_DIR, "sources", "rapport_immobilier_quebec.tex")) report = RP.build_report(course["code"], ROOT, out, os.path.join(SITE, "cache"), None) if has_report else None if report: 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") # --- quiz quiz_path = os.path.join(SITE, "data", f"quiz-{code}.json") quiz = json.load(open(quiz_path, encoding="utf-8")) if os.path.exists(quiz_path) else {"chapters": {}} quiz["titles"] = {f"{i:02d}": chapters[i - 1].title_text for i in range(1, 15)} for k, qs in quiz["chapters"].items(): for q in qs: q.setdefault("url", f"/seance/{k}/") n_quiz = sum(len(v) for v in quiz["chapters"].values()) # --- TikZ fig_dir = os.path.join(out, "assets", "fig") os.makedirs(fig_dir, exist_ok=True) tikz_map = {} if ctx.tikz: res = tikz.render_all(ctx.tikz, os.path.join(SITE, "cache", "tikz"), workers=6, log_dir=os.path.join(SITE, "cache", "tikz-logs")) for idx, r in enumerate(res): if r["ok"]: shutil.copy2(r["path"], os.path.join(fig_dir, r["hash"] + ".svg")) w, h = (r.get("w") or 0), (r.get("h") or 0) disp_w = int(w * 1.45) if w else 600 attrs = f' width="{int(w)}" height="{int(h)}"' if w and h else "" tikz_map[idx] = f'Figure' else: tikz_map[idx] = f'
Figure non rendue : {html.escape(r.get("err", ""))[:200]}
' warn(f"TikZ échec #{idx}: {r.get('err', '')[:200]}") # --- math (cours + rapport) all_math = list(ctx.math) + (list(report["ctx"].math) if report else []) log(f" KaTeX : {len(all_math)} formules…") math_html = render_math(all_math) off = len(ctx.math) def finalize(h: str, current_url: str = "", c: Ctx = ctx, math_offset: int = 0, images=None) -> str: h = re.sub(r"@@M(\d+)@@", lambda m: math_html[int(m.group(1)) + math_offset], h) h = re.sub(r"@@TIKZ(\d+)@@", lambda m: tikz_map.get(int(m.group(1)), ""), h) if images is not None: h = re.sub(r"@@IMG(\d+)@@", lambda m: images.get(int(m.group(1)), ""), h) h = resolve_refs(h, c, current_url) return T.inject_box_icons(h) # --- fichiers publiés files_dir = os.path.join(out, "files") os.makedirs(os.path.join(files_dir, "slides"), exist_ok=True) os.makedirs(os.path.join(files_dir, "tp"), exist_ok=True) res_rows = [] notes_pdf_src = os.path.join(src_course, course["notes_dir"], "pdf", "notes_de_cours.pdf") shutil.copy2(notes_pdf_src, os.path.join(files_dir, f"{course['code']}_notes_de_cours.pdf")) notes_url = f"/files/{course['code']}_notes_de_cours.pdf" 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"}) plan_src = os.path.join(src_course, "01_Plan_de_cours", "plan_de_cours.pdf") shutil.copy2(plan_src, os.path.join(files_dir, f"{course['code']}_plan_de_cours.pdf")) plan_url = f"/files/{course['code']}_plan_de_cours.pdf" 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"}) slides = [] for p in sorted(glob.glob(os.path.join(src_course, course["slides_glob"]))): m = re.search(r"seance(\d{2})", os.path.basename(p)) if not m: continue n = int(m.group(1)) dst = f"seance{n:02d}.pdf" shutil.copy2(p, os.path.join(files_dir, "slides", dst)) 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))}) 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))}) slide_by_n = {s["n"]: s["url"] for s in slides} for tp in course["tps"]: for kind, key in (("pdf", "enonce"), ("xlsx", "gabarit")): srcp = os.path.join(src_course, tp["dir"], tp[key]) if not os.path.exists(srcp): warn(f"fichier TP manquant : {srcp}") continue dst = os.path.basename(srcp) shutil.copy2(srcp, os.path.join(files_dir, "tp", dst)) 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 ""}) for extra in tp.get("extras", []): srcp = os.path.join(src_course, tp["dir"], extra["file"]) if not os.path.exists(srcp): warn(f"fichier TP manquant : {srcp}") continue dst = extra.get("dst", os.path.basename(srcp)) shutil.copy2(srcp, os.path.join(files_dir, "tp", dst)) 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", "")}) report_files = [] if report: os.makedirs(os.path.join(files_dir, "rapport"), exist_ok=True) rp_pdf = os.path.join(ROOT, RP.REPORT_DIR, "rapport_immobilier_quebec.pdf") for src, dst, label, desc, kind in ( (rp_pdf, "rapport_immobilier_quebec.pdf", "Rapport complet (PDF)", f"{pdf_pages(rp_pdf)} pages · texte, 67 figures, 28 tableaux", "pdf"), (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"), (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"), (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"), ): if os.path.exists(src): shutil.copy2(src, os.path.join(files_dir, "rapport", dst)) url = f"/files/rapport/{dst}" 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}) report_files.append({"url": url, "label": label, "desc": desc}) # --- statistiques words = sum(len(en.text.split()) for ch in chapters for en in ch.search) 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()]), "ex": len(ctx.examples), "exo": len(ctx.exercises), "fig": sum(c.counts["tab"] + c.counts["fig"] for c in chapters) + len(ctx.tikz), "quiz": n_quiz, "words": words, "gloss": len(glossary)} def write(path: str, h: str): full = os.path.join(out, path.lstrip("/")) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "w", encoding="utf-8") as f: f.write(h) for ch in chapters: ch.title_html = finalize(ch.title_html) formulaire.title_html = finalize(formulaire.title_html) # --- séances vidéo (enregistrements Zoom) videos = VD.build_videos(code, SITE, out, os.path.join(SITE, "cache"), chapters, log, warn) course["has_videos"] = bool(videos) video_by_n = {v["n"]: v["url"] for v in videos} stats["videos"] = len(videos) cards_data, search_data = [], [] for d in ctx.definitions: if d["chapter"].isdigit(): 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"]}) for f in ctx.formulas: if f["chapter"].isdigit(): 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"]}) for g in glossary: g["html"] = finalize(g["html"]) g["term_html"] = finalize(g["term_html"]) cards_data.append({"id": g["id"], "type": "gloss", "ch": "G", "num": "", "front": g["term_html"], "back": g["html"], "url": f"/glossaire/#{g['id']}"}) stats["cards"] = len(cards_data) # --- rapport : KPI et pages kpis_html = "" if report: v = {k: clean_val(x) for k, x in report["vals"].items()} obs = v.get("VcDerniereObs", "") up = lambda s: "up" if s.startswith("+") else ("down" if s.startswith("-") or s.startswith("−") else "") # noqa: E731 kpis_html = "".join([ f'', T.kpi("IPP composite — Québec", v.get("VcQcHPI", ""), f"janv. 2005 = 100 · {obs}", "", "#0f8b8d"), T.kpi("Prix de référence — Québec", v.get("VcQcBench", ""), f"variation 12 mois {v.get('VcQcYoY', '')}", up(v.get("VcQcYoY", "")), "#0f8b8d"), T.kpi("Croissance annuelle 2005–2026 — Québec", v.get("VcQcCagr", ""), f"multiplication par {v.get('VcQcMult', '')} en 21 ans", "", "#0f8b8d"), T.kpi("Prix de référence — Canada", v.get("VcCanBench", ""), f"variation 12 mois {v.get('VcCanYoY', '')}", up(v.get("VcCanYoY", "")), "#2976bb"), 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"), T.kpi("RMR de Québec", v.get("VcQccBench", ""), f"variation 12 mois {v.get('VcQccYoY', '')}", up(v.get("VcQccYoY", "")), "#2976bb"), T.kpi("Correction depuis le sommet — Canada", v.get("VcCanDD", ""), f"Toronto {v.get('VcTorDD', '')} · Vancouver {v.get('VcVanDD', '')}", "down", "#b22a20"), T.kpi("Depuis février 2020 — Québec vs Canada", v.get("VcQcDepuisVingt", ""), f"Canada : {v.get('VcCanDepuisVingt', '')}", "up", "#107c4e"), T.kpi("Taux 3 mois / 10 ans", f"{v.get('VcTauxTroisMois', '')} / {v.get('VcTauxDixAns', '')}", "Banque du Canada, obligations fédérales", "", "#cca424"), 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"), T.kpi("Prix réels — Québec", f"× {v.get('VcQcReelMult', '')}", f"croissance réelle {v.get('VcQcReelCagr', '')}/an depuis 2005", "", "#6b4fbb"), T.kpi("Saisonnalité — Québec", f"{v.get('VcSaisonMin', '')} à {v.get('VcSaisonMax', '')}", "facteurs saisonniers mensuels", "", "#6b4fbb"), ]) # --- accueil 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))) # --- séances total_widgets = 0 for i, ch in enumerate(chapters, 1): url = f"/seance/{i:02d}/" body_html = finalize(ch.html, url) body_html, nw = insert_widgets(body_html, [(sid, w) for sid, w in course.get("widgets", []) if sid.split("-")[1] == str(i)], warn) total_widgets += nw n_words = sum(len(en.text.split()) for en in ch.search) 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))) for en in ch.search: 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}"}) stats["widgets"] = total_widgets # --- aide-mémoire form_index = [f'
  • {f["num"]}{finalize(f["title_html"]) or "Formule " + f["num"]}
  • ' for f in ctx.formulas if f["chapter"].isdigit()] extra = f'

    Toutes les formules numérotées du cours

    Les {len(form_index)} formules encadrées dans les séances, dans leur contexte.

    ' side = T.toc_side(formulaire.toc, "Aide-mémoire", '

    Voir aussi

    ') 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))) for en in formulaire.search: 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"}) for g in glossary: 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"}) write("glossaire/index.html", T.glossary_page(course, chapters, glossary, finalize(gloss_intro), bool(report))) # --- définitions, exercices secs = [] for i, ch in enumerate(chapters, 1): defs = [d for d in ctx.definitions if d["chapter"] == str(i)] if not defs: continue items = [] for d in defs: title_part = '— ' + finalize(d["title_html"]) + "" if d["title_html"] else "" items.append(f'
    Définition {d["num"]}{title_part}↗
    {finalize(d["html"])}
    ') secs.append(f'

    {i}{ch.title_html}

    {"".join(items)}') 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") 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))) secs = [] for i, ch in enumerate(chapters, 1): m = re.search(r'(

    .*?Exercices.*?

    )(.*?)(?=
    |
    {i}{ch.title_html}{finalize(m.group(2), "")}') side = T.toc_side([(2, str(i), ch.title_html, f"s-{i}") for i, ch in enumerate(chapters, 1)], "Séances") 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))) # --- pages interactives write("quiz/index.html", T.quiz_page(course, chapters, n_quiz, bool(report))) write("fiches/index.html", T.flash_page(course, chapters, len(cards_data), bool(report))) tools_src = os.path.join(SITE, "data", f"outils-{code}.js") n_tools = len(re.findall(r"^\s*id:", open(tools_src, encoding="utf-8").read(), re.M)) if os.path.exists(tools_src) else 0 write("outils/index.html", T.tools_page(course, chapters, n_tools, bool(report))) train_src = os.path.join(SITE, "data", f"entrainement-{code}.js") n_gen = len(re.findall(r"^\s*id:", open(train_src, encoding="utf-8").read(), re.M)) if os.path.exists(train_src) else 0 write("entrainement/index.html", T.training_page(course, chapters, n_gen, bool(report))) write("jeux/index.html", T.games_page(course, chapters, course.get("sequences", []), bool(report))) meta = {"course": f"{course['code']} — {course['title']}", "cards": len(cards_data), "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)]} write("progression/index.html", T.progress_page(course, chapters, json.dumps(meta, ensure_ascii=False), bool(report))) map_data = {"chapters": {f"{i:02d}": {"title": ch.title_text, "part": T.part_of(course, i)[1], "objectives": ch.objectives, "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)}} write("carte/index.html", T.map_page(course, chapters, json.dumps(map_data, ensure_ascii=False), bool(report))) write("recherche/index.html", T.search_page(course, chapters, bool(report))) write("ressources/index.html", T.resources_page(course, chapters, res_rows, bool(report))) write("diapositives/index.html", T.slides_page(course, chapters, slides, bool(report))) if videos: write("videos/index.html", T.videos_index_page(course, chapters, videos, bool(report))) for v in videos: write(f"videos/{v['nn']}/index.html", T.video_page(course, chapters, v, videos, bool(report))) for c in v["chapters"]: excerpt = " ".join(b["text"] for b in v["blocks"] if c["t"] <= b["start"] < c["end"])[:600] 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"], "text": (c.get("desc", "") + " " + excerpt).strip(), "where": f"Séance vidéo {v['n']} — {VD.hms(c['t'], True)}"}) # --- bibliographie (cours + rapport) bib_items = [] all_bib = dict(bib) cited = {k: set(v) for k, v in ctx.cited.items()} if report: for k, en in report["bib"].items(): all_bib.setdefault(k, en) for k, v in report["ctx"].cited.items(): cited.setdefault(k, set()).update({"rapport"}) for key, en in sorted(all_bib.items(), key=lambda kv: (_norm(kv[1]["short"]), kv[1]["year"])): chs = sorted({int(c[2:]) for c in cited.get(key, set()) if c.startswith("ch")}) tags = [f"séance {c}" for c in chs] + (["rapport marché"] if "rapport" in cited.get(key, set()) else []) cited_html = f'Citée : {", ".join(tags)}' if tags else ('Citée en annexe' if key in cited else "") bib_items.append(f'
  • {format_entry(en)}{cited_html}
  • ') 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 "")) write("404.html", T.not_found_page(course, chapters, bool(report))) # --- rapport : pages if report: secs = report["sections"] for s in secs: s.title_html = finalize(s.title_html, "", report["ctx"], off, report["images"]) cover = ('

    Auteur : Simon-Pierre Boucher, 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). ' '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.

    ') write("rapport/index.html", T.report_index_page(course, chapters, secs, kpis_html, report_files, cover)) for idx, s in enumerate(secs): body_html = finalize(s.html, f"/rapport/{s.num}/", report["ctx"], off, report["images"]) write(f"rapport/{s.num}/index.html", T.report_section_page(course, chapters, secs, s, idx, body_html)) for en in s.search: 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}"}) report["ctx"].warnings and [warn("rapport : " + w) for w in sorted(set(report["ctx"].warnings))] # --- données data_dir = os.path.join(out, "data") os.makedirs(data_dir, exist_ok=True) json.dump(search_data, open(os.path.join(data_dir, "search.json"), "w", encoding="utf-8"), ensure_ascii=False) json.dump(cards_data, open(os.path.join(data_dir, "cards.json"), "w", encoding="utf-8"), ensure_ascii=False) 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) json.dump(quiz, open(os.path.join(data_dir, "quiz.json"), "w", encoding="utf-8"), ensure_ascii=False) if os.path.exists(tools_src): shutil.copy2(tools_src, os.path.join(data_dir, "outils.js")) if os.path.exists(train_src): shutil.copy2(train_src, os.path.join(data_dir, "entrainement.js")) if report: json.dump(report["hpi"], open(os.path.join(data_dir, "hpi.json"), "w", encoding="utf-8"), ensure_ascii=False) json.dump(report["fred"], open(os.path.join(data_dir, "fred.json"), "w", encoding="utf-8"), ensure_ascii=False) json.dump({"course": course["code"], "title": course["title"], "term": course["term"], "built": time.strftime("%Y-%m-%dT%H:%M:%S"), "stats": stats, "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) # --- assets, marque, OG shutil.copytree(os.path.join(SITE, "assets", "css"), os.path.join(out, "assets", "css")) shutil.copytree(os.path.join(SITE, "assets", "js"), os.path.join(out, "assets", "js")) katex_src = os.path.join(SITE, "node_modules", "katex", "dist") os.makedirs(os.path.join(out, "assets", "katex"), exist_ok=True) shutil.copy2(os.path.join(katex_src, "katex.min.css"), os.path.join(out, "assets", "katex", "katex.min.css")) shutil.copytree(os.path.join(katex_src, "fonts"), os.path.join(out, "assets", "katex", "fonts")) brand_dir = os.path.join(out, "assets", "brand") brand.make_icons(brand_dir) shutil.copy2(os.path.join(brand_dir, "favicon.svg"), os.path.join(out, "favicon.svg")) shutil.copy2(os.path.join(brand_dir, "favicon.ico"), os.path.join(out, "favicon.ico")) brand.make_og(course, stats, os.path.join(out, "assets", "og.png")) 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", "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)) write("robots.txt", f"User-agent: *\nAllow: /\nSitemap: https://{course['domain']}/sitemap.xml\n") 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/"] if report: urls += ["/rapport/"] + [f"/rapport/{s.num}/" for s in report["sections"]] if videos: urls += ["/videos/"] + [v["url"] for v in videos] write("sitemap.xml", '\n' + "".join(f"https://{course['domain']}{u}" for u in urls) + "\n") warn_set = sorted(set(warnings + ctx.warnings)) if warn_set: log(f" {len(warn_set)} avertissement(s) :") for w in warn_set[:40]: log(" " + w) total_size = sum(os.path.getsize(os.path.join(dp, f)) for dp, _, fs in os.walk(out) for f in fs) log(f" OK → {out} ({human_size(total_size)}) en {time.time() - t0:.1f} s ; stats={stats}") return stats def main(argv): codes = [a for a in argv if a in COURSES] or list(COURSES) out_root = os.path.join(SITE, "dist") if "--out" in argv: out_root = argv[argv.index("--out") + 1] for c in codes: build_course(c, out_root, "--skip-tikz" in argv) if __name__ == "__main__": main(sys.argv[1:])