#!/usr/bin/env python3
# Data-driven Adom component-page generator.
# Reads a chip-fetcher chip dir (info/stock/extracted/provenance + SVGs + CAD) and
# renders a self-contained, Adom-UI-compliant standalone HTML page with class-aware
# specs and REAL provenance (per-pin + per-dimension datasheet page citations, with
# cropped page images). No emoji, no em/en-dashes, brand fonts, monochrome icons.
import json, os, re, base64, glob, subprocess, sys

LIBROOT="/home/adom/project/chip-fetcher/library"
# Per-chip config: published wiki slug (=> embed LIVE viewers), hero, mfr resources, family.
CFG={
 "nRF54L15":{"slug":"nrf54l15","wiki":"https://wiki.adom.inc/john/nrf54l15",
   "hero":"/home/adom/project/wiki-nrf54l15/screenshots/hero.png",
   "resources":[("Nordic nRF54L15 product page","https://www.nordicsemi.com/Products/nRF54L15"),
     ("Product Specification (845-page datasheet)","https://docs.nordicsemi.com/bundle/ps_nrf54L15/page/keyfeatures_html5.html"),
     ("nRF Connect SDK","https://www.nordicsemi.com/Products/Development-software/nRF-Connect-SDK"),
     ("nRF54L15 DK (dev kit)","https://www.nordicsemi.com/Products/Development-hardware/nRF54L15-DK"),
     ("Nordic DevZone (support)","https://devzone.nordicsemi.com/")],
   "family":"nRF54L10 / nRF54L05 (same QFN-48 pinout, less NVM/RAM); broader nRF54H / nRF53 / nRF52 series."},
 "DMG2305UX-7":{"resources":[("Diodes Inc DMG2305UX product page","https://www.diodes.com/part/view/DMG2305UX/")],
   "family":"DMG3415UX, DMG2301L and other Diodes P-channel SOT-23 MOSFETs."},
 "LM358D":{"resources":[("TI LM358 product page","https://www.ti.com/product/LM358"),("LM358 datasheet","https://www.ti.com/lit/ds/symlink/lm358.pdf")],
   "family":"LM358DR (tape and reel), LM2904 (automotive), LM324 (quad)."},
 "BME688":{"resources":[("Bosch BME688 product page","https://www.bosch-sensortec.com/products/environmental-sensors/gas-sensors/bme688/")],
   "family":"BME680 (no AI gas), BME280 (T/RH/P, no gas)."},
 "TPS25751SRSMR":{"resources":[("TI TPS25751 product page","https://www.ti.com/product/TPS25751")],
   "family":"TPS25750, TPS65987 USB-PD controllers."},
 "WSL2010R0500FEA":{"resources":[("Vishay WSL2010 product page","https://www.vishay.com/en/product/30100/")],
   "family":"WSL series current-sense resistors (WSL1206, WSL2512)."},
}
FONTS="/home/adom/datasheet-visualizer/public/fonts"
OUT="/tmp/nrf-site"  # served dir

def b64f(p): return base64.b64encode(open(p,'rb').read()).decode()
def rd(p):
    try: return open(p,encoding='utf-8').read()
    except: return ""
def jload(p):
    try: return json.load(open(p,encoding='utf-8'))
    except: return None
def scrub(s):
    if s is None: return ""
    s=str(s)
    for a,b in [("—","-"),("–","-"),("−","-"),("…","..."),("·"," ")]:
        s=s.replace(a,b)
    return s

FAM5=b64f(f"{FONTS}/familjen-grotesk-500-normal.woff2")
FAM7=b64f(f"{FONTS}/familjen-grotesk-700-normal.woff2")
SAT4=b64f(f"{FONTS}/satoshi-400-normal.woff2")
SAT5=b64f(f"{FONTS}/satoshi-500-normal.woff2")
SAT7=b64f(f"{FONTS}/satoshi-700-normal.woff2")

P={
 "cube":'<path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 22V12M21 7l-9 5L3 7"/>',
 "symbol":'<rect x="4" y="4" width="16" height="16" rx="1"/><path d="M4 9h3M4 15h3M17 9h3M17 15h3"/>',
 "footprint":'<rect x="4" y="4" width="16" height="16" rx="1"/><circle cx="8" cy="8" r="1"/><circle cx="16" cy="8" r="1"/><circle cx="8" cy="16" r="1"/><circle cx="16" cy="16" r="1"/>',
 "doc":'<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',
 "cart":'<circle cx="9" cy="20" r="1"/><circle cx="18" cy="20" r="1"/><path d="M2 3h3l2.4 12.4a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L21 7H6"/>',
 "download":'<path d="M12 3v12M7 11l5 5 5-5M5 21h14"/>',
 "list":'<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
 "grid":'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
 "dollar":'<path d="M12 1v22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
 "box":'<rect x="3" y="7" width="18" height="13" rx="1"/><path d="M3 11h18M9 7V4h6v3"/>',
 "building":'<path d="M4 21V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v16M16 8h3a1 1 0 0 1 1 1v12"/><path d="M8 8h.01M12 8h.01M8 12h.01M12 12h.01M8 16h.01M12 16h.01"/>',
 "layers":'<path d="M12 2l9 5-9 5-9-5z"/><path d="M3 12l9 5 9-5M3 17l9 5 9-5"/>',
 "link":'<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>',
 "open":'<path d="M15 3h6v6M10 14 21 3M21 14v7H3V3h7"/>',
 "warn":'<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h16.9a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
 "shield":'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
 "chip":'<rect x="6" y="6" width="12" height="12" rx="1"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
}
def ic(n,s=18):
    return f'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="{s}" height="{s}" style="vertical-align:middle;flex:none">{P[n]}</svg>'

def detect_class(info, sym):
    t=((info.get('family') or '')+' '+(info.get('description') or '')+' '+(sym.get('category') or '')).lower()
    if 'mosfet' in t or ('fet' in t and 'channel' in t): return 'mosfet'
    if 'op-amp' in t or 'op amp' in t or 'opamp' in t or 'amplifier' in t: return 'opamp'
    if 'resistor' in t or 'capacitor' in t or 'inductor' in t: return 'passive'
    if 'sensor' in t or 'imu' in t or 'gyro' in t or 'accel' in t: return 'sensor'
    if any(k in t for k in ('pd controller','type-c','charger','buck','boost','dc-dc','dc/dc','ldo','regulator','power switch','gate driver','pmic')): return 'power'
    if 'adc' in t or 'converter' in t: return 'converter'
    if 'mcu' in t or 'soc' in t or 'cortex' in t or 'microcontroller' in t or 'radio' in t: return 'mcu'
    return 'generic'

def find(d, *pats):
    s=(d.get('description') or '')+' '+(d.get('family') or '')
    for p in pats:
        m=re.search(p, s, re.I)
        if m: return scrub(m.group(0))
    return None

def spec_rows(cls, info, sym):
    d=info; desc=scrub(d.get('description') or '')
    rows=[]
    if cls=='mcu':
        rows=[("Class","Wireless MCU / SoC"),("Core",find(d,r'cortex[- ]m\w+[^,.;]*') or "see datasheet"),
              ("Memory",find(d,r'\d+\.?\d*\s?MB\s?(NVM|flash)[^,.;]*') or find(d,r'\d+\s?KB[^,.;]*') or "see datasheet"),
              ("Radio",find(d,r'2\.4\s?GHz[^,.;]*') or find(d,r'(BLE|Bluetooth)[^,.;]*') or "2.4 GHz"),
              ("Package",scrub(d.get('package'))),("Pins",str(len(sym.get('pins',[])))+" extracted")]
    elif cls=='mosfet':
        rows=[("Class","MOSFET"),("Channel",find(d,r'[PN]-channel') or "see datasheet"),
              ("Vds",find(d,r'-?\d+\.?\d*\s?V\s?Vds') or find(d,r'Vds[^,.;]*') or "see datasheet"),
              ("Id (cont.)",find(d,r'-?\d+\.?\d*\s?A\s?(continuous)?[^,.;]*') or "see datasheet"),
              ("Rds(on)",find(d,r'Rds\(on\)[^,.;]*') or "see datasheet"),
              ("Vgs(th)",find(d,r'(low-?vgs\(th\)|Vgs\(th\)[^,.;]*)') or "see datasheet"),
              ("Package",scrub(d.get('package')))]
    elif cls=='opamp':
        rows=[("Class","Operational amplifier"),("Channels",find(d,r'(dual|single|quad)') or "see datasheet"),
              ("GBW",find(d,r'[\d.]+\s?MHz\s?GBW') or find(d,r'GBW[^,.;]*') or "see datasheet"),
              ("Slew rate",find(d,r'[\d.]+\s?V/[uµ]s') or "see datasheet"),
              ("Supply",find(d,r'(single|split)?\s?[±]?[\d.]+\s?V[^,.;]*') or "see datasheet"),
              ("Package",scrub(d.get('package')))]
    elif cls=='sensor':
        rows=[("Class","Sensor"),("Type",scrub(d.get('family') or sym.get('category') or "see datasheet")),
              ("Interface",find(d,r'(I.?2.?C|SPI|UART|analog)[^,.;]*') or "see datasheet"),
              ("Supply",find(d,r'[\d.]+\s?V[^,.;]*') or "see datasheet"),
              ("Package",scrub(d.get('package'))),("Pins",str(len(sym.get('pins',[])))+" extracted")]
    elif cls=='passive':
        rows=[("Class","Passive"),("Value",find(d,r'[\d.]+\s?(m?[ΩROhm]|[munpf]F|[munp]?H)[^,.;]*') or find(d,r'[\d.]+\s?m[ΩO][^,.;]*') or "see datasheet"),
              ("Tolerance",find(d,r'[±]?[\d.]+\s?%') or "see datasheet"),
              ("Rating",find(d,r'[\d.]+\s?W[^,.;]*') or find(d,r'[\d.]+\s?V[^,.;]*') or "see datasheet"),
              ("Package",scrub(d.get('package'))) ]
    elif cls=='power':
        rows=[("Class","Power / controller"),("Function",scrub(d.get('family') or sym.get('category') or "see datasheet")),
              ("Vin",find(d,r'[\d.]+\s?V?\s?to\s?[\d.]+\s?V[^,.;]*') or find(d,r'[\d.]+\s?V\s?(input|Vin)[^,.;]*') or "see datasheet"),
              ("Iout",find(d,r'[\d.]+\s?A[^,.;]*') or "see datasheet"),
              ("Package",scrub(d.get('package'))),("Pins",str(len(sym.get('pins',[])))+" extracted")]
    else:
        rows=[("Class",scrub(sym.get('category') or info.get('family') or "Component")),
              ("Package",scrub(d.get('package'))),("Pins",str(len(sym.get('pins',[])))+" extracted")]
    return rows

def crop_pages(pdf, pages, mpn):
    """Crop cited datasheet pages to base64 PNGs. Returns {page:b64}. Cap at 5."""
    out={}
    if not pdf or not os.path.exists(pdf): return out
    for pg in sorted(set(pages))[:5]:
        png=f"/tmp/gen/{mpn}-p{pg}.png"
        try:
            subprocess.run(["pdftoppm","-png","-r","120","-f",str(pg),"-l",str(pg),pdf,f"/tmp/gen/{mpn}-pp{pg}"],
                           check=True, capture_output=True, timeout=60)
            src=glob.glob(f"/tmp/gen/{mpn}-pp{pg}*.png")
            if not src: continue
            subprocess.run(["convert",src[0],"-resize","850x",png],check=True,capture_output=True,timeout=60)
            out[pg]=b64f(png)
        except Exception as e:
            pass
    return out

def gen(mpn, slug):
    D=f"{LIBROOT}/{mpn}"
    info=jload(f"{D}/info.json") or {}
    stock=jload(f"{D}/stock.json")
    sym=jload(glob.glob(f"{D}/*-symbol.extracted.json")[0]) if glob.glob(f"{D}/*-symbol.extracted.json") else {}
    prov=jload(glob.glob(f"{D}/*provenance*.json")[0]) if glob.glob(f"{D}/*provenance*.json") else {}
    sym_svg=scrub(re.sub(r'<\?xml[^>]*\?>','', rd(glob.glob(f"{D}/{mpn}-symbol.svg")[0]) if glob.glob(f"{D}/{mpn}-symbol.svg") else ""))
    fp_svg=scrub(re.sub(r'<\?xml[^>]*\?>','', rd(glob.glob(f"{D}/{mpn}-footprint.svg")[0]) if glob.glob(f"{D}/{mpn}-footprint.svg") else ""))
    pdf=(glob.glob(f"{D}/*.pdf") or [None])
    pdf=sorted(pdf, key=lambda p:-(os.path.getsize(p) if p and os.path.exists(p) else 0))[0] if pdf and pdf[0] else None
    cls=detect_class(info, sym)
    pins=sym.get('pins',[])
    pin_prov={str(c.get('pin_number')):c for c in (prov.get('symbol',{}).get('pin_provenance') or [])}
    dim_prov=prov.get('footprint',{}).get('dimension_provenance') or []
    review=prov.get('review_required') or []
    cited=set()
    for c in pin_prov.values():
        if c.get('page'): cited.add(int(c['page']))
    for dp in dim_prov:
        if dp.get('page'): cited.add(int(dp['page']))
    crops=crop_pages(pdf, cited, mpn)
    tooling=prov.get('tooling',{})
    ds=prov.get('datasheet',{})

    # ---- build pin rows grouped ----
    groups={}
    for p in pins:
        groups.setdefault(p.get('group','OTHER'),[]).append(p)
    pin_html=""
    for g,ps in groups.items():
        pin_html+=f'<tr><td colspan="4" style="color:var(--purple);font-weight:600;padding-top:12px">{scrub(g)} ({len(ps)})</td></tr>'
        for p in ps:
            num=str(p.get('number','')); c=pin_prov.get(num,{})
            pg=c.get('page'); cap=scrub(c.get('table_caption') or '')
            cite=f'<span class="evlink" onclick="LB(EV[{pg}],\'{scrub(cap)} (p.{pg})\')">p.{pg}</span>' if pg and pg in crops else (f'p.{pg}' if pg else '')
            pin_html+=f'<tr><td class="k">{scrub(p.get("name"))}</td><td class="k">{num}</td><td>{scrub(p.get("description"))[:200]}</td><td class="ev">{cite}</td></tr>'

    # ---- dimension provenance rows ----
    dim_html=""
    for dp in dim_prov:
        pg=dp.get('page')
        cite=f'<span class="evlink" onclick="LB(EV[{pg}],\'{scrub(dp.get("figure_label"))} (p.{pg})\')">p.{pg}</span>' if pg and pg in crops else (f'p.{pg}' if pg else '')
        dim_html+=f'<tr><td class="k">{scrub(dp.get("field"))}</td><td class="k">{scrub(dp.get("value"))}</td><td>{scrub(dp.get("figure_label"))}</td><td class="ev">{cite}</td></tr>'

    # ---- review flags ----
    rev_html=""
    for r in review:
        rev_html+=f'<li>{scrub(r.get("reason"))}: {scrub(", ".join(r.get("pins",[])) if r.get("pins") else r.get("package",""))[:160]}</li>'

    # ---- spec rows ----
    specs=spec_rows(cls, info, sym)
    spec_html="".join(f'<tr><th>{scrub(k)}</th><td>{scrub(v)}</td></tr>' for k,v in specs)

    # ---- sourcing ----
    src_html=""
    if stock and stock.get('mouser'):
        m=stock['mouser']; tiers=m.get('price_tiers',[])
        qty="".join(f'<th>{t["qty"]}</th>' for t in tiers); pr="".join(f'<td>{scrub(t["price"])}</td>' for t in tiers)
        src_html=f'''<div class="ctxrow"><span class="ctx">Mouser {scrub(m.get("mouser_pn"))}, <b>{m.get("stock")} in stock</b>, lifecycle {scrub(m.get("lifecycle"))}, {scrub(m.get("rohs"))}</span><span class="src real">real, {scrub(stock.get("checked_at","")[:10])}</span></div>
        <table><tr><th>Qty</th>{qty}</tr><tr><td>Unit</td>{pr}</tr></table>
        <p style="margin-top:8px"><a href="{m.get("product_url")}" target="_blank">Buy on Mouser</a></p>'''
    else:
        src_html='<p class="src mock">No stock.json for this part. TODO: fetch live price and stock from Mouser / DigiKey.</p>'

    # ---- provenance evidence images ----
    ev_js="var EV={"+",".join(f'{pg}:"data:image/png;base64,{b}"' for pg,b in crops.items())+"};"
    prov_imgs="".join(f'<div style="margin-top:12px"><div class="viewer-cap"><span>Datasheet p.{pg}</span></div><img class="provimg" src="data:image/png;base64,{b}" onclick="LB(EV[{pg}],\'Datasheet p.{pg}\')"></div>' for pg,b in list(crops.items())[:3])

    cls_label={'mcu':'Wireless MCU / SoC','mosfet':'MOSFET','opamp':'Op-amp','converter':'Data converter','passive':'Passive','sensor':'Sensor','power':'Power / controller','generic':'Component'}[cls]
    desc=scrub(info.get('description') or '')
    pkg=scrub(info.get('package') or '')
    mfr=scrub(info.get('manufacturer') or '')
    has_glb=bool(glob.glob(f"{D}/{mpn}.glb"))
    cad=[("KiCad symbol",f"{mpn}.kicad_sym"),("KiCad footprint",f"{mpn}.kicad_mod"),("STEP",f"{mpn}.step"),("Fusion .lbr",f"{mpn}.lbr")]
    if has_glb: cad.append(("GLB (web 3D)",f"{mpn}.glb"))
    cad_html="".join(f'<div class="dlcard"><div class="n">{f}</div><div class="d">{scrub(lbl)}</div></div>' for lbl,f in cad if glob.glob(f"{D}/{f}"))

    cfg=CFG.get(mpn,{})
    pub=cfg.get("slug")  # published wiki slug => live viewers
    wikiurl=cfg.get("wiki","#")
    # ---- viewers: live iframe when published, else inline static SVG ----
    def liveviewer(kind,label):
        u=f"https://wiki.adom.inc/viewer/{kind}/component/{pub}?embed=1"
        return f'<div class="viewer-cap"><span class="live">{label} (live)</span><span>drag, zoom, layer toggle</span></div><iframe class="viewer" src="{u}" loading="lazy"></iframe>'
    # Prefer the viewer HTML generated by the actual adom-symbol / adom-footprint apps
    # (mkviewers.mjs -> <slug>/symview.html, fpview.html), embedded same-origin.
    def appviewer(fname,label,extra=""):
        return f'<div class="viewer-cap"><span class="live">{label}</span><span>generated by the app, embedded{extra}</span></div><iframe class="viewer" src="{fname}" loading="lazy"></iframe>'
    if os.path.exists(f"{OUT}/{slug}/symview.html"):
        symview=appviewer("symview.html","adom-symbol SymView")
    elif pub: symview=liveviewer("symbol","adom-symbol SymView")
    else: symview=f'<div class="viewer-cap"><span class="src real">adom-symbol render</span></div><div class="render">{sym_svg}</div>'
    if os.path.exists(f"{OUT}/{slug}/fpview.html"):
        fpview=appviewer("fpview.html","adom-footprint FpView",", pin 1 top-left, toggle solder blobs")
    elif pub: fpview=liveviewer("footprint","adom-footprint FpView")
    else: fpview=f'<div class="viewer-cap"><span class="src real">adom-footprint render</span></div><div class="render">{fp_svg}</div>'
    d3view  = (liveviewer("3d","Adom 3D viewer (Babylon)") if pub else f'<p style="color:var(--text-2);font-size:14px">{("GLB present" if has_glb else "STEP present")}; live Babylon viewer when published.</p>')
    # ---- hero ----
    heroimg=""
    hp=cfg.get("hero")
    if hp and os.path.exists(hp): heroimg="data:image/png;base64,"+b64f(hp)
    elif glob.glob(f"{D}/{mpn}-3d-iso-named.png"): heroimg="data:image/png;base64,"+b64f(glob.glob(f"{D}/{mpn}-3d-iso-named.png")[0])
    hero_disp = f'<div class="hero"><img src="{heroimg}" alt="{mpn}"></div>' if heroimg else ""
    # ---- toolbar buttons ----
    mu=(stock or {}).get("mouser",{}).get("product_url") if stock else None
    dk=f"https://www.digikey.com/en/products/result?keywords={mpn}"
    ICc,ICs,ICf,ICd,ICdl,ICca=ic('cube'),ic('symbol'),ic('footprint'),ic('doc',14),ic('download',16),ic('cart',16)
    if pub:
        direct=f'<a class="btn go" target="_blank" href="https://wiki.adom.inc/viewer/3d/component/{pub}">{ICc} 3D viewer</a><a class="btn go" target="_blank" href="https://wiki.adom.inc/viewer/symbol/component/{pub}">{ICs} Symbol viewer</a><a class="btn go" target="_blank" href="https://wiki.adom.inc/viewer/footprint/component/{pub}">{ICf} Footprint viewer</a><a class="btn go" target="_blank" href="{wikiurl}">{ICd} Wiki page</a>'
        directsrc='<span class="src real">real wiki viewers</span>'
    else:
        direct='<span class="btn dis">3D viewer</span><span class="btn dis">Symbol viewer</span><span class="btn dis">Footprint viewer</span><span class="src mock" style="align-self:center">live when published</span>'
        directsrc='<span class="src mock">publish to enable</span>'
    dlbtns="".join(f'<span class="btn" data-tip="{scrub(lbl)}">{ICdl} {f.split(".")[-1].upper()}</span>' for lbl,f in cad if glob.glob(f"{D}/{f}"))
    buybtns=(f'<a class="btn buy" target="_blank" href="{mu}">{ICca} Mouser</a>' if mu else '<span class="btn dis">Mouser</span>')+f'<a class="btn buy" target="_blank" href="{dk}">{ICca} DigiKey</a>'
    # ---- reel note ----
    reel=("This part number ends in -R: Tape and Reel." if mpn.upper().endswith(("R","-R","R7")) or "R" in mpn[-2:] else "Check the distributor packaging (Cut Tape / Tape and Reel / Tube / Tray).")
    # ---- resources ----
    res=cfg.get("resources",[])
    if res:
        resources='<ul class="r">'+"".join(f'<li><a href="{u}" target="_blank">{scrub(t)}</a></li>' for t,u in res)+'<li class="src mock">Errata, app notes, reference designs: auto-list all supplemental docs (TODO).</li></ul>'
    else:
        resources='<p class="src mock">Manufacturer resource links not configured for this part (TODO: pull from info.json / product page).</p>'
    # ---- variants (from ds2sf symbol.variants) ----
    vs=sym.get('variants') or []
    if vs:
        rows="".join(f'<tr><td class="k">{scrub(v.get("package"))}</td><td>{scrub(v.get("pin_count"))} pins</td><td>{"pin-compatible" if v.get("compatible_pinout") else "NOT pin-compatible"}</td><td>{scrub((v.get("notes") or ""))[:160]}</td></tr>' for v in vs)
        variants=f'<table><tr><th>Package</th><th>Pins</th><th>Compatibility</th><th>Notes</th></tr>{rows}</table><div class="cite"><b>Source:</b> ds2sf variant extraction.</div>'
    else:
        variants='<p class="src mock">No variant data extracted. TODO: list package / temperature / speed variants.</p>'
    related=f'<p style="color:var(--text-2);font-size:14px"><b>Family:</b> {scrub(cfg.get("family","see manufacturer portfolio."))}</p><p style="color:var(--text-2);font-size:14px;margin-top:8px"><b>Molecules using this chip:</b> <span class="src mock">query adom-molecules; none indexed yet. TODO: auto backlink index.</span></p>'

    HTML=TEMPLATE
    reps={
     "__MPN__":mpn,"__MFR__":mfr,"__PKG__":pkg,"__DESC__":desc,"__CLS__":cls_label,
     "__PINCOUNT__":str(len(pins)),"__SYM__":sym_svg,"__FP__":fp_svg,
     "__SPECS__":spec_html,"__PINS__":pin_html,"__DIMS__":dim_html or '<tr><td colspan="4" class="src mock">No dimension provenance.</td></tr>',
     "__REVIEW__":rev_html or "<li>None flagged.</li>","__SRC__":src_html,"__CAD__":cad_html,
     "__DSPAGES__":str(ds.get('page_count','?')),"__DSSHA__":scrub((ds.get('sha256') or '')[:16]),
     "__TOOL__":scrub(tooling.get('extractor','ds2sf'))+" "+scrub(tooling.get('extractor_version',''))+", "+scrub(tooling.get('claude_model','')),
     "__PROVIMGS__":prov_imgs,"__EVJS__":ev_js,
     "__WIKIURL__":wikiurl,"__HERO__":hero_disp,"__DIRECTSRC__":directsrc,"__DIRECTBTNS__":direct,
     "__DLBTNS__":dlbtns,"__BUYBTNS__":buybtns,"__SYMVIEW__":symview,"__FPVIEW__":fpview,"__3DVIEW__":d3view,
     "__REELNOTE__":scrub(reel),"__RESOURCES__":resources,"__VARIANTS__":variants,"__RELATED__":related,
     "__IC_OPEN14__":ic("open",14),"__IC_BOX__":ic("box"),"__IC_BUILD__":ic("building"),"__IC_LAYERS__":ic("layers"),"__IC_LINK__":ic("link"),
     "__FAM5__":FAM5,"__FAM7__":FAM7,"__SAT4__":SAT4,"__SAT5__":SAT5,"__SAT7__":SAT7,
     "__IC_CHIP__":ic("chip"),"__IC_OPEN__":ic("open",16),"__IC_SYM__":ic("symbol"),"__IC_FP__":ic("footprint"),
     "__IC_CUBE__":ic("cube"),"__IC_GRID__":ic("grid"),"__IC_LIST__":ic("list"),"__IC_DOLLAR__":ic("dollar"),
     "__IC_BOX__":ic("box"),"__IC_BUILD__":ic("building"),"__IC_LAYERS__":ic("layers"),"__IC_LINK__":ic("link"),
     "__IC_SHIELD__":ic("shield"),"__IC_DL__":ic("download",16),"__IC_DOC__":ic("doc",14),"__IC_WARN__":ic("warn",16),"__IC_CART__":ic("cart",16),
    }
    for k,v in reps.items(): HTML=HTML.replace(k,v)
    bad=[c for c in HTML if c in "—–…−" or ord(c)>0x1F000]
    print(f"{mpn}: forbidden chars:", sorted(set(bad)) if bad else "NONE", "| cited pages cropped:", sorted(crops.keys()), "| class:", cls, "| pins:", len(pins))
    os.makedirs(f"{OUT}/{slug}",exist_ok=True)
    open(f"{OUT}/{slug}/index.html","w",encoding="utf-8").write(HTML)
    # copy CAD downloads into served dir
    for lbl,f in cad:
        srcf=glob.glob(f"{D}/{f}")
        if srcf:
            import shutil; shutil.copy(srcf[0], f"{OUT}/{slug}/{f}")
    print(f"  wrote {OUT}/{slug}/index.html ({len(HTML)} bytes)")

TEMPLATE=open('/tmp/gen/template.html',encoding='utf-8').read()

if __name__=="__main__":
    gen("nRF54L15","nrf54l15")
    gen("DMG2305UX-7","dmg2305")
    gen("LM358D","lm358d")
    gen("BME688","bme688")
    gen("TPS25751SRSMR","tps25751")
    gen("WSL2010R0500FEA","wsl2010")
