Generated Provenance for Chapters, Units & Figures
Content Registry
Chapter and figure provenance – entirely derived from git history, the filesystem, chapter frontmatter, and existing quality-gate reports by scripts/generate-content-registry.py, regenerated on every build. Nothing on this page is hand-maintained; if a fact can’t be derived this way it isn’t shown. See docs-internal/ops/content-registry.md for the full derivation notes and the first real-run findings.
Show code
Auth = {const deadline =Date.now() +4000;while (!(window.Auth&&typeofwindow.Auth.getCurrentUser==="function") &&Date.now() < deadline) {awaitnewPromise((r) =>setTimeout(r,30)); }returnwindow.Auth|| {};}colors = ({navy:'#2C3E50',teal:'#16A085',orange:'#E67E22',red:'#C0392B',gray:'#7F8C8D'})currentUser = {const user = Auth.getCurrentUser();if (!user) returnnull;// B8: the admin role is a database fact -- public.is_admin(), backed by the// user_roles table and evaluated by the same RLS policies that guard every// query below. Auth.isUserAdmin() only string-matches the adminUsers list// that ships in public JavaScript, so it is not a control.const isAdmin =window.IoTClassSupabase?.isAdminServerSide?awaitwindow.IoTClassSupabase.isAdminServerSide():false;return { ...user, isAdmin };}registry = {if (!currentUser ||!currentUser.isAdmin) returnnull;try {const res =awaitfetch('/assets/data/content-registry.json');if (!res.ok) returnnull;returnawait res.json(); } catch (error) {console.error('Error loading content registry:', error);returnnull; }}mutable viewState = ({tab:'chapters',search:'',module:'all',sortBy:'path',sortDir:'asc',figureFilter:'all'})chaptersArr = registry ?Object.values(registry.chapters) : []figuresArr = registry ?Object.values(registry.figures) : []moduleOptions = {if (!registry) return [];const mods =newSet(); chaptersArr.forEach(c => mods.add(c.module)); figuresArr.forEach(f => mods.add(f.scope.startsWith('module:') ? f.scope.slice(7) :null)); mods.delete(null);returnArray.from(mods).sort();}gateFindingCount = (rec) => rec.gate_findings?Object.values(rec.gate_findings).reduce((a,b) => a+b,0) :0sortRows = (rows, sortBy, sortDir) => {const dir = sortDir ==='desc'?-1:1;return [...rows].sort((a, b) => {const av = a[sortBy], bv = b[sortBy];if (av ==null&& bv ==null) return0;if (av ==null) return1;if (bv ==null) return-1;if (typeof av ==='number'&&typeof bv ==='number') return (av - bv) * dir;returnString(av).localeCompare(String(bv)) * dir; });}filteredChapters = {if (!registry) return [];const vs = viewState;let rows = chaptersArr;if (vs.module!=='all') rows = rows.filter(c => c.module=== vs.module);if (vs.search) {const q = vs.search.toLowerCase(); rows = rows.filter(c => c.path.toLowerCase().includes(q) || (c.title||'').toLowerCase().includes(q) || (c.nav_title||'').toLowerCase().includes(q) ); }returnsortRows(rows, vs.sortBy, vs.sortDir);}filteredFigures = {if (!registry) return [];const vs = viewState;let rows = figuresArr;if (vs.module!=='all') rows = rows.filter(f => f.scope===`module:${vs.module}`);if (vs.figureFilter==='orphan') rows = rows.filter(f => f.orphan);if (vs.figureFilter==='gate-flagged') rows = rows.filter(f =>gateFindingCount(f) >0);if (vs.search) {const q = vs.search.toLowerCase(); rows = rows.filter(f => f.path.toLowerCase().includes(q)); }returnsortRows(rows, vs.sortBy, vs.sortDir);}
Show code
html`${(!Auth.isUserAuthenticated||!Auth.isUserAuthenticated() ||!currentUser?.isAdmin) ?` <div class="access-denied"> <div style="font-size: 72px; margin-bottom: 20px;">🔒</div> <h2 style="color: ${colors.navy}; margin-bottom: 16px;">Access Denied</h2> <p style="color: ${colors.gray}; margin-bottom: 24px;"> You must be an administrator to access this page. </p> </div>`:''}`
Show code
html`${currentUser?.isAdmin&&!registry ?` <div class="access-denied"> <div style="font-size: 48px; margin-bottom: 12px;">⚠️</div> <p style="color: ${colors.gray};"> Could not load <code>/assets/data/content-registry.json</code>. Run <code>python3 scripts/generate-content-registry.py</code> to generate it. </p> </div>`:''}`
renderIssueList = (items, formatter, emptyLabel) => items.length? items.slice(0,100).map(formatter).map(s =>`<div>${s}</div>`).join('') + (items.length>100?`<div style="opacity:.7">… ${items.length-100} more (see docs-internal/reports/content-registry-check.tsv)</div>`:''):`<div style="opacity:.7">${emptyLabel}</div>`html`${currentUser?.isAdmin&& registry ?` <div class="integrity-panel"> <div class="integrity-card ${registry.integrity.dangling_figure_refs.length?'dirty':'clean'}"> <h4>Dangling Figure References <span class="integrity-count">${registry.integrity.dangling_figure_refs.length}</span></h4> <div style="font-size:12px; color:${colors.gray};">Chapter references a figure file that does not exist -- the live-404 bug class.</div> <div class="integrity-list">${renderIssueList( registry.integrity.dangling_figure_refs, d =>`${d.chapter}:${d.line} → ${d.src}`,'None found.' )}</div> </div> <div class="integrity-card ${registry.integrity.path_escapes_repo.length?'dirty':'clean'}"> <h4>Figure Paths Escaping the Repo <span class="integrity-count">${registry.integrity.path_escapes_repo.length}</span></h4> <div style="font-size:12px; color:${colors.gray};">A relative figure path resolves outside the repository root.</div> <div class="integrity-list">${renderIssueList( registry.integrity.path_escapes_repo, d =>`${d.chapter}:${d.line} → ${d.src}`,'None found.' )}</div> </div> <div class="integrity-card ${registry.integrity.broken_prereqs.length?'dirty':'clean'}"> <h4>Unit Prereqs Naming a Non-Chapter <span class="integrity-count">${registry.integrity.broken_prereqs.length}</span></h4> <div style="font-size:12px; color:${colors.gray};">Prereq resolves to a redirect stub or nothing -- a stale prerequisite graph.</div> <div class="integrity-list">${renderIssueList( registry.integrity.broken_prereqs, d =>`${d.chapter} → '${d.prereq}'`,'None found.' )}</div> </div> <div class="integrity-card ${registry.integrity.broken_registry_sources.length?'dirty':'clean'}"> <h4>assets/data/*.json With a Missing Source <span class="integrity-count">${registry.integrity.broken_registry_sources.length}</span></h4> <div style="font-size:12px; color:${colors.gray};">A registry's declared generation-source file no longer exists.</div> <div class="integrity-list">${renderIssueList( registry.integrity.broken_registry_sources, d =>`${d.registry} (${d.field}) → ${d.declared_source}`,'None found.' )}</div> </div> </div>`:''}`
Show code
// NOTE ON EVENT HANDLING: this deliberately builds a real DOM node and// wires real addEventListener calls rather than inline on-star HTML// attributes. htl's `html` tag only wires an interpolated FUNCTION value up// as a real listener when that function is interpolated directly at the// tag's own top level; here the markup is composed as an ordinary (nested)// template string first, so by the time it reaches `html` any interpolated// function has already been coerced to its source-code TEXT via// .toString() -- inert as an attribute value, never invoked. Delegated// listeners on a stable container sidestep that entirely and are the// standard, reliable pattern for a dynamic list of controls like this.filterBarNode = {if (!(currentUser?.isAdmin&& registry)) returnhtml`<div></div>`;// Preserve focus/cursor across the re-render this cell does on every// keystroke in the search box (it re-runs whenever `viewState` mutates,// including `.search`, since OJS mutable-cell reactivity is whole-object,// not per-property) -- without this, typing a second character would// find the input already replaced and focus lost.const prevActive =document.activeElement;const wasSearchFocused =Boolean(prevActive && prevActive.matches&& prevActive.matches('[data-action="search"]'));const prevSelStart = wasSearchFocused ? prevActive.selectionStart:null;const prevSelEnd = wasSearchFocused ? prevActive.selectionEnd:null;const container =html`<div class="filter-bar"></div>`; container.innerHTML=` <div class="tab-row"> <button class="tab-btn ${viewState.tab==='chapters'?'active':''}" data-action="tab" data-value="chapters"> 📖 Chapters (${chaptersArr.length}) </button> <button class="tab-btn ${viewState.tab==='figures'?'active':''}" data-action="tab" data-value="figures"> 🖼️ Figures (${figuresArr.length}) </button> </div> <input type="text" class="filter-input" data-action="search" placeholder="${viewState.tab==='chapters'?'Search chapter path or title...':'Search figure path...'}" value="${viewState.search}" /> <select class="filter-select" data-action="module"> <option value="all" ${viewState.module==='all'?'selected':''}>All Modules</option>${moduleOptions.map(m =>`<option value="${m}" ${viewState.module=== m ?'selected':''}>${m}</option>`).join('')} </select>${viewState.tab==='figures'?` <select class="filter-select" data-action="figureFilter"> <option value="all" ${viewState.figureFilter==='all'?'selected':''}>All Figures</option> <option value="orphan" ${viewState.figureFilter==='orphan'?'selected':''}>Orphans Only</option> <option value="gate-flagged" ${viewState.figureFilter==='gate-flagged'?'selected':''}>Gate-Flagged Only</option> </select> `:''} `; container.addEventListener('click', (event) => {const btn =event.target.closest('[data-action="tab"]');if (!btn) return; mutable viewState = { ...mutable viewState,tab: btn.dataset.value,sortBy:'path',sortDir:'asc' }; }); container.addEventListener('input', (event) => {if (event.target.matches('[data-action="search"]')) { mutable viewState = { ...mutable viewState,search:event.target.value }; } }); container.addEventListener('change', (event) => {if (event.target.matches('[data-action="module"]')) { mutable viewState = { ...mutable viewState,module:event.target.value }; }if (event.target.matches('[data-action="figureFilter"]')) { mutable viewState = { ...mutable viewState,figureFilter:event.target.value }; } });if (wasSearchFocused) {requestAnimationFrame(() => {const input = container.querySelector('[data-action="search"]');if (!input) return; input.focus();if (prevSelStart !=null) input.setSelectionRange(prevSelStart, prevSelEnd); }); }return container;}
Derived, and shown: chapter/unit frontmatter (title, difficulty, est_minutes, prereqs), word counts, per-chapter counts of knowledge checks (<iot-knowledge-check>), quizzes (<iot-matching/ordering/label/code-quiz>) and interactives (<iot-embed>/<iot-simulator>), figure dimensions/bytes, and for every chapter and figure: created date, last-updated date, commit count, and last author, all read from a single git log --name-only pass over content/ and images/ history.
Deliberately NOT included: anything that would require a human to keep a fact updated by hand. There is no “status” or “priority” field anyone has to set – see docs-internal/ops/content-registry.md for the full list of scope decisions (why renamed files show a fresh “created” date, why pages outside content/<module>/*.qmd aren’t scanned for figure references, and which existing CI gates could not be attached because their reports aren’t keyed to a source path).
Regenerate with python3 scripts/generate-content-registry.py; runs automatically in scripts/post-render.sh and is checked in scripts/ci-quality-gates.sh.