Single-File HTML Builder
Overview
Turn a multi-file static site (a downloaded template from 模板王/mobanwang, ThemeForest, or similar) into one self-contained .html where every CSS, JS, font, and image is inlined as a data: URI. The result opens correctly from file:// on any machine with no network and no missing resources.
This skill also covers the harder second half of the job: repairing an HTML snapshot exported by a visual/WYSIWYG editor. Those exports look fine in the editor but break in a browser — carousels multiply, icons vanish, containers go empty, stylesheet links get corrupted. The repair workflow below is battle-tested against real exports.
When to Use
Trigger on any of these:
- "把这个模板做成一个单文件 HTML" / "能不能离线打开" / "图片显示不出来(本地打开时)"
- User downloaded a template (
在线预览 - XXX - 模板王.html+_files/directory) and wants it customized for a subject - The page must be openable by double-click with
file://, on a machine without internet - User edited the page in a visual editor and now reports: 轮播图数量变多 / 图标空白 / 某一块内容没了 / 字体没生效
- Need to embed images/fonts so the file survives being moved or emailed
Do Not Use
- The site is meant to run on a server with separate asset files — inlining just bloats it
- The task is only "port the design to React/Vue" — that is a rewrite, not an inlining job
Workflow
Step 0 — Reconnaissance (never skip)
Before writing any code, map the working directory. The layout determines everything downstream.
ls -la "<project_dir>"
ls -la "<project_dir>/<模板名>_files" # template assets live here
Then read all of these, if present:
| File | Why it matters |
|---|---|
build.py |
The inlining logic — asset names, placeholder tokens, output path |
compress_images.py |
Image sizing/quality rules, EXIF handling |
fix_site.py / fix_site2.py |
Post-processing repairs (icons, alignment, remote images) |
pw_verify*.py |
How the page was verified; browser executable path |
template.html |
The source skeleton with {{TOKEN}} placeholders — the real editable source |
.workbuddy/memory/*.md |
Prior session notes; usually contains the hard-won pitfalls verbatim |
Key insight: the generated X.html is a build artifact. Never hand-edit it as the source of truth — edit template.html and rebuild. If no template.html exists, extract one by replacing each inlined blob with a token.
Step 1 — Understand the asset extension trap
Templates saved by "Save page as" often have JS files renamed to *.js.下载 (the browser appended .下载 because the server sent Content-Disposition: attachment). Chrome refuses to execute scripts with unknown extensions under file://, so these never run.
Two fixes, in order of preference:
- Inline them into
<script>blocks (what this skill does) — also removes the network dependency. - Rename to
.jsand reference with a relative path — only if you are shipping a folder, not a single file.
The same trap applies to CSS and to css2 (a Google Fonts stylesheet saved with no extension).
Step 2 — Compress the images
Source photos are routinely 1–2 MB each. Run scripts/compress_images.py (adapt the nums list and paths).
Rules that matter:
- Long edge ≤ 1600 px, JPEG
quality=82,optimize=True,progressive=True. Roughly 10 MB → 1.9 MB. - Always call
ImageOps.exif_transpose(im)first. Phone photos carry EXIF orientation; without this, a portrait photo stores as landscape and renders sideways once the EXIF tag is dropped by the resize. .convert("RGB")before saving as JPEG, or palette/CMYK sources raiseOSError.- Keep a higher quality for line art. Handwritten signatures, logos, and text screenshots compress badly at q82 — use
q=90for those specific files. - Crop/downscale only; never upscale.
Verify the mapping between numbered source files and subject matter before committing to an assignment. Generate a contact sheet — a grid montage with index numbers burned in — and read it as an image. This catches letterboxing, wrong rotation, and mismatched content in a single pass, far cheaper than inspecting files one by one.
Step 3 — Inline everything with the build script
Copy scripts/build_single_file.py into the project and run it. It performs four inlining jobs:
- CSS →
<style>blocks (Bootstrap, animate.css, FontAwesome, meanmenu, magnific-popup, owl.carousel, style.css, responsive.css, Google Fonts) - JS →
<script>blocks (jQuery, Bootstrap, meanmenu, appear, waypoints, counterup, owl.carousel, magnific-popup, wow, main.js) - Fonts → data URIs inside FontAwesome's
@font-face(../fonts/fa-solid-900.woff2etc.) - Images → data URIs for both
<img src>and CSSurl(...)references
Critical details the script already handles — do not "simplify" them away:
- Escape
</scriptinside JS payloads. Write it as<\/script. A literal</scriptinside a string literal inmain.jscloses the tag early and destroys the page. This is the single most common cause of a "blank page after inlining". - Replace missing template images with a transparent GIF rather than leaving dead references.
style.csscommonly references../img/banner-bg.jpgand../img/dot.svgthat were never saved. Givebanner-bga real photo anddot.svga hand-written inline SVG; sweep the rest with a regex replacement to a 1×1 transparent GIF. - Assert zero leftover
{{TOKEN}}placeholders after substitution. A silent miss produces a page with the literal text{{IMG_7}}on screen. - Inline every font format, then fail hard on leftovers. A FontAwesome
@font-facedeclares.eot(with?#iefix),.woff2,.woff,.ttf, and.svg#name, and filenames may carry a cache-buster (fa-solid-900d41d.eot). Map each to the best available file and treat any survivingurl(...fonts/...)as a build failure, not a warning. - Strip remote webfonts AFTER token substitution. A CSS token payload can itself contain an
@font-facepointing atfonts.googleapis.com, and a template<link>may hold an entire stylesheet in itshref. Strip before substitution and the inlined CSS puts the remote reference straight back. Note a corruptedhrefspans newlines — usere.Swith lazy matching. - Note the size blow-up. Reusing one image in five places inlines five copies of the base64. 1.9 MB of compressed images can become 7.8 MB of HTML. If size matters, define the image once as a CSS custom property or use a small JS snippet to assign
srcfrom a single base64 constant.
Step 4 — Repair a visual-editor export (if applicable)
If the user later round-trips the page through a visual editor, the exported HTML will contain serialized runtime DOM, not clean source. Run scripts/repair_editor_export.py, which fixes the four known classes of damage. See references/visual-editor-repairs.md for the full diagnosis and CSS for each.
Summary of the four:
- Owl Carousel clone pollution — the editor serializes
owl-stagewith itsowl-item clonedduplicates and staleowl-dots. On reload,main.jsre-initializes and treats clones as real content: 3 slides show 6 dots. Fix: rebuild each.owl-carouseldown to its non-clonedoriginals, drop dots/nav, restoreclass="owl-carousel owl-theme", let JS re-init. - Malformed
<link href>— the editor dumps an entire Google Fonts CSS file into thehrefattribute. The browser requests it as a URL, gets 404, and the webfont silently never loads. Fix: extract the attribute, HTML-unescape, convert to an inline<style>. - Emptied containers — media wrappers that the editor could not model get cleared. Fix: refill with a chosen image set and apply explicit sizing.
- Missing FontAwesome glyphs — FA5 ships no
fa-tiktok/fa-bilibili; those<i>elements render as empty circles. Fix: swap the class for a masked-SVG pseudo-element using the FA6 path data.
Layout policy belongs here, not in the build script. build_single_file.py inlines what the template declares; equalising grid heights (.portfolio-single-item img { height: 300px; object-fit: cover }) and column alignment are content decisions. Put them in the template's custom <style> block or the repair config's inject_css. A build that skips this step renders a ragged gallery — measured column height spread of 811px — and it is the most common "the layout looks wrong" report.
Step 5 — Verify with a real browser
Screenshots from a headless shell are not reliable for this: compositing does not repaint after scroll/fragment navigation (a trivial page screenshots fully white), and long pages only capture the first viewport.
Use Playwright with an explicit executable_path pointing at an already-downloaded Chrome/Chromium (the system install works — C:/Program Files/Google/Chrome/Application/chrome.exe on Windows). Run scripts/verify_page.py, which asserts:
- JS console errors = 0
- Failed images = 0 (
!img.complete || img.naturalWidth === 0) - Every carousel has a non-zero authored slide count, and any visible dots match it
- Masked icons have non-zero rendered size (proves the
maskrule resolved) - Intended column bottoms are equal (fragment alignment checks, e.g. gallery columns, side-by-side text/image blocks)
Two measurement traps that produce false failures — both handled in the script, both worth knowing:
- Carousels. After Owl initialises, authored slides are wrapped in
.owl-stage. Querying:scope > .owl-itemreturns 0 and a healthy carousel looks broken. Count.owl-stage > .owl-item:not(.cloned). Clones are normal runtime behaviour; only visible dots (offsetParent !== null) matter, since arrow-driven carousels leave adisableddot container in the DOM. - Alignment. Assert against the column (
.row > .col-lg-6:last-child), not an inner wrapper. A wrapper inset by the Bootstrap gutter reports a permanent phantom15pxoffset even when the layout is pixel-perfect.diff = 0on the column pair plus ~15px on the wrapper pair is the signature of a correct result.
Loop until all checks are clean. Then take a full-page screenshot and read it as an image for a final human-eye check.
Prerequisite checklist
Confirm before starting:
- [ ] Python 3 with
Pillowandbeautifulsoup4installed - [ ] Playwright installed, and a Chrome/Chromium binary path known. A system install is fine — no Playwright browser download needed if you pass
executable_pathexplicitly (on Windows,C:/Program Files/Google/Chrome/Application/chrome.exe). - [ ] The template's
_filesdirectory is intact (CSS/JS/images all present) - [ ] FontAwesome
woff2/wofffiles available — if absent from the template, fetch FA 5.15.4 from a reachable CDN. Sandboxed environments intermittently block jsdelivr, raw.githubusercontent, and unpkg; try them in turn and fall back to a local mirror rather than assuming the asset is unavailable. - [ ] The subject's factual content (bio, quotes, dates) has traceable sources — keep a 资料来源 line in the footer.
Resources
scripts/compress_images.py— EXIF-aware batch resize/compress tosite_build/img/scripts/build_single_file.py— the inliner: CSS/JS/fonts/images → one HTMLscripts/repair_editor_export.py— repairs the four visual-editor export defectsscripts/verify_page.py— Playwright assertions + full-page screenshotreferences/template-anatomy.md— how 模板王-style templates are structured, plus the full asset inventoryreferences/visual-editor-repairs.md— detailed diagnosis and CSS for each export defectreferences/pitfalls.md— the full list of traps and their symptoms, including whybs4must not touch<script>bodies