CLI to scaffold a Firebase Hosting project from a live Weebly site. The
crawled mirror (wget) is the source of truth: HTML, CSS, images and fonts
are pulled from it directly. An unzipped Weebly theme export is an
optional overlay — drop one into reference/WeeblyExport/ to upgrade
the dumped CSS/JS with cleaner authored source (un-minified LESS, individual
JS modules). Zero deps (Node built-ins only; wget for the crawler).
git clone https://github.com/copperdesign/weebly-to-firebase.git
cd weebly-to-firebase
npm link # one-time — makes `weebly-to-firebase` and `w2f` available globallyOr invoke directly without linking:
node /path/to/weebly-to-firebase/cli.mjs [command] [options]From an empty project folder:
weebly-to-firebase # interactive — prompts for everything…or fully non-interactive:
w2f init --yes \
--name "My Site" \
--firebase-project my-site-web \
--hosting-site my-site \
--live-domain example.weebly.com \
--github-repo your-org/my-site \
--setup-firebase # create the Firebase project + hosting site via CLIweebly-to-firebase and w2f are the same binary — w2f is just the short
alias. Examples below use whichever reads more clearly in context.
w2f # 1. scaffold + crawl + port-all (one command, end to end)
npm install # 2. inside the scaffolded project
npm run build # 3. posthtml + less + js → public/
npm run deploy # 4. firebase hostingAfter confirming the config at the "Proceed?" prompt, the wizard runs the
whole pipeline automatically: scaffold → wget mirror (sitemap-seeded) →
port every page + dump CSS/fonts/images → optional WeeblyExport overlay
(only if reference/WeeblyExport/ has content) → git init + initial commit
→ scaffold the Sveltia CMS layer + lift page content (its own second
commit). The commit order is deliberate: the pre-lift ported HTML lands in
git first, so an already-lifted page can always be restored from history.
Opt out per step with --skip-crawl, --skip-port, --skip-convert,
--skip-cms, --skip-git.
For re-runs / iteration:
w2f crawl # refresh the wget mirror
w2f port --all # re-port every page in the mirror
w2f port kontakt # re-extract a single page (faster iteration on cleanup)
w2f convert # overlay a Weebly theme export dropped in laterBetween build and deploy, clean up by hand in src/html/ and src/less/ —
port is a starter, not a finished port (see port options).
convert is optional. Without a WeeblyExport, port alone produces a
buildable project: dumped src/less/_w2f-*.less files cover the styles,
fonts land in src/less/_fonts.less, and images go to public/assets/gfx/.
With a WeeblyExport present, convert overlays structured source files
(variables.less, _global.less, individual JS modules) that override the
dump rule-by-rule — progressively replace the _w2f-* files as you migrate.
weebly-to-firebase [command] [options]
Commands:
init Scaffold project (default if no command given)
convert Migrate WeeblyExport assets into src/{less,js,html}
crawl Mirror the live Weebly site into reference/
port First-pass extract from crawled mirror → src/html/ + public/assets/img/
forms Finalize the contact-form handler scaffolded by `port`
(npm install + sitekey + HCAPTCHA_SECRET)
cms Scaffold a Sveltia CMS layer + lift ported page content into
src/content/*.yml
help [cmd] Show help (or help for a specific command)
version Print version
Run weebly-to-firebase help <command> for command-specific options.
| flag | meaning |
|---|---|
--target <path> |
Project root (default: process.cwd()) |
-y, --yes |
Accept all defaults; skip confirmations |
-h, --help |
Show help |
-v, --version |
Print version |
| flag | meaning |
|---|---|
--name <string> |
Project display name |
--slug <string> |
npm package slug |
--description <string> |
One-line description |
--firebase-project <id> |
Firebase project ID (required) |
--hosting-site <name> |
firebase.json hosting.site |
--live-domain <domain> |
Live Weebly domain (for mirror) |
--github-repo <owner/name> |
GitHub repo for the scaffolded project |
--setup-firebase |
Create Firebase project + hosting site via CLI (requires firebase login) |
--skip-crawl |
Don't run the live-site crawl (primary source) |
--skip-port |
Don't auto-port pages after crawl |
--skip-convert |
Don't offer the WeeblyExport overlay step |
--skip-git |
Don't init git |
| flag | meaning |
|---|---|
--skip-styles |
Skip copying LESS |
--skip-js |
Skip copying JS |
--skip-html |
Skip generating .html partial/page skeletons |
| flag | meaning |
|---|---|
--domain <domain> |
Domain to mirror (overrides cached config) |
crawl also accepts a positional: weebly-to-firebase crawl example.com.
The crawl is seeded from /sitemap.xml when reachable. Weebly's
navigation is JS-rendered, so a recursive walk from the homepage alone
typically misses most pages — feeding the sitemap's <loc> entries to
wget as seeds catches them. Both bare and www. variants are accepted
as in-scope so mixed internal links don't get dropped. wget prints the
number of HTML files at the mirror root when it finishes so under-counts
are visible immediately.
| flag | meaning |
|---|---|
--domain <domain> |
Override the cached liveDomain (which mirror to read from) |
--all |
Port every .html file in the mirror; scaffold skeletons for unknown pages |
--force |
Replace partials and page main slot even if hand-edited (cms-lifted pages stay protected — their content lives in src/content/) |
port also accepts a positional page name (default index):
weebly-to-firebase port kontakt.
With --all, the partials/global setup runs once against the index page,
then every other .html file at the mirror root is ported in turn. Pages
not already in src/html/ (i.e. anything beyond convert's fixed list of
index/404/impressum/datenschutz/kontakt) get a skeleton scaffolded
automatically before their <main> block is extracted. Nested mirror
directories (e.g. blog post folders) are skipped — port them by hand if
needed.
First run extracts _meta.html, _nav.html, _footer.html (partials are
only written once — subsequent ports leave them alone unless --force).
Each page run replaces the <main>…</main> slot in src/html/<page>.html
with the extracted content. Referenced images are downloaded straight into
public/assets/gfx/ and URLs are rewritten to /assets/gfx/<filename>.
Cache-buster query strings (?1560895278) and URL-encoded path noise
(%3F…, %20…) are stripped from filenames so the disk stays clean.
port fetches the linked stylesheets from the page head (including external
CDNs that crawl skips):
@font-faceblocks are harvested; font files downloaded intopublic/assets/fonts/;src/less/_fonts.lesswritten with the cleaned block.- Same-origin stylesheets are dumped as
src/less/_w2f-<name>.lessso the project builds without a WeeblyExport —wgetis the source of truth.url(…)references inside are rewritten to/assets/gfx/<name>and the referenced images are downloaded too. - Weebly chrome stylesheets (Fancybox skin, social-icons skin, commerce
skin, VideoJS, Select2) are skipped — they wrap features no migrated
site uses. The deny-list lives in
lib/weebly-chrome.mjs. - Weebly chrome sprites (fancybox sprites, social-share sprites,
commerce/cart, blog-comment, loaders, decorative bars,
@2x-s<hash>retina sprites, and a few dozen more) are skipped at download time for the same reason — they end up unreferenced once the user retires the_w2f-*.lesscompat layer. A summary count prints at the end ofport.
After fonts, port rewrites src/less/main.less with the canonical import
order (variables → _fonts → _w2f-<dumps> → _resets → _global →
_ui-kit → …), pulling in only the files that actually exist in
src/less/. The rewrite is gated on a Generated by w2f marker comment —
hand-edited main.less files are left alone unless --force is set.
The extraction is intentionally lossy — Weebly markup is full of inline tracking, render-blocking scripts, and CDN-bound stylesheets. The output is a starter you clean up by hand, not a finished port.
| flag | meaning |
|---|---|
--sitekey <key> |
Real hCaptcha sitekey — replaces the test sitekey across src/html/. Bare positional accepted: w2f forms <sitekey> |
--skip-install |
Don't run npm install in functions/ |
--skip-secret |
Don't run firebase functions:secrets:set HCAPTCHA_SECRET |
forms only runs after port has scaffolded the functions/ directory
(triggered automatically on first form detection). Three independent steps
in order:
npm installinfunctions/— auto-skipped whennode_modulesis already populated, so re-runs are cheap.- Sitekey rewrite — replaces the official test sitekey
(
10000000-ffff-…-0001) with your real one from the hCaptcha dashboard across everysrc/html/*.htmlfile. Idempotent: re-running with the same key is a no-op. firebase functions:secrets:set HCAPTCHA_SECRET— interactive: stdio is inherited so the firebase CLI's own paste prompt drives the flow. Never pass the secret on argv (shell history would leak it). Auto-skipped when stdin isn't a TTY (CI, piped runs, agent sessions) — the firebase prompt would block forever with no one to type into it. Run it yourself from a real terminal in that case.
Preconditions for step 3: firebase-tools on PATH, firebase login, and
the Blaze plan enabled on the project. Failures in any step are surfaced
but don't abort the others — a Blaze-pending project can still benefit
from step 1+2 landing locally.
The CMS layer is built on Sveltia CMS
(docs: sveltiacms.app) — a lightweight, git-backed
CMS loaded from a single pinned script; saving in /admin is a git commit.
| flag | meaning |
|---|---|
--force |
Re-lift a page's content where still possible, and re-render scaffold files even if unchanged |
--skip-cms |
(init only) Don't run the cms step as part of the pipeline |
cms also accepts one or more page-name positionals (no args = every page
in src/html/, excluding _-prefixed partials — the same exclusion
posthtml's build glob uses): weebly-to-firebase cms kontakt. A named page
that doesn't exist, or is still the port skeleton, throws instead of
silently skipping — an explicit ask deserves an explicit failure.
Per page, cms splits the ported <main> at h1/h2 boundaries into
sections (content before the first heading becomes section 0 with an empty
heading; a heading-less page gets one section from <title>, TODO-flagged)
and converts each to markdown in src/content/<page>.yml. Lifting stops at
the first <form> — form markup and everything after it is left exactly as
port/forms left it. The lifted markup in src/html/<page>.html is
replaced with a single <!-- @render:sections --> marker; a build-time
scripts/render-content.mjs (shipped into the scaffolded project) swaps it
back for rendered HTML on every npm run build.
Once per run, cms also scaffolds public/admin/index.html (the Sveltia
shell), public/admin/config.yml (the pages content model — every
processed page gets an entry under a shared &page_fields schema),
scripts/render-content.mjs, docs/cms.md, and wires the scaffolded
project's package.json (&& node scripts/render-content.mjs appended to
build:html, plus yaml/marked devDependencies).
Idempotent like port: a page already carrying the render marker is left
alone — its content is gone from the HTML, so there's nothing left to
re-lift even with --force; a page whose src/content/<page>.yml already
exists is skipped unless --force. Re-running after hand-restoring a
page's HTML from git (clearing the marker) re-lifts it from scratch with
--force.
/admin needs an auth relay before it can save anything to GitHub —
cms intentionally doesn't scaffold one (v1 is docs-only here). See
docs/cms.md in the scaffolded project for the two documented options
(the official sveltia-cms-auth
Cloudflare Worker, or a Firebase Function OAuth relay) plus the
personal-access-token shortcut if you're the only editor.
<project-root>/
package.json # build:html (posthtml), build:css (lessc), build:js (rollup), deploy
firebase.json # hosting only, cleanUrls, src/ + reference/ ignored
.firebaserc # default → <firebase-project>
.posthtmlrc.js # posthtml-include config (root → src/html)
.gitignore .gitattributes
README.md
.weebly-migrate.json # cached answers for re-runs (gitignored)
.github/workflows/
firebase-hosting-merge.yml # only when --github-repo + --firebase-project set
src/
html/ # pages + Sass-style `_*.html` partials → compiled to public/
# <!-- @render:sections --> marker replaces lifted <main>
# content once `cms` has processed a page
less/ # → public/assets/css/ (includes _w2f-*.less mirror dumps,
# plus the opt-in _lightbox partial)
js/ # → public/assets/js/ (includes the opt-in lightbox module +
# cookie-consent.md wiring pointer)
gfx/ # graphics — deployable images committed, design sources
# (PSD/AFD/etc.) sit alongside but are stripped by .gitignore
content/ # per-page section YAML lifted by `cms` — the
# content the admin panel + render-content.mjs read
public/
assets/{css,js,gfx,fonts,files}/ # files/ = CMS media_folder (client
# uploads + cms-copied images)
admin/
index.html # Sveltia CMS shell (writeIfMissing)
config.yml # pages content model (`cms` appends new pages)
scripts/
render-content.mjs # build step: src/content/*.yml → public/<page>.html
docs/
cms.md # auth wiring, "saving is publishing", how to add a page
reference/
WeeblyExport/ # the original theme, moved out of src/
<domain>/ # wget mirror of the live site (gitignored)
A couple of things every Weebly migration tends to need land in src/ from
init. They're scaffolded unused — neither app.js nor main.less
imports them — so they cost nothing until you wire one up. Each ships with
an @docs MD sibling explaining the HTML contract and wiring.
| Module | Replaces | Why scaffolded |
|---|---|---|
src/js/cookie-consent.md |
Weebly's "single global OK" cookie banner | Pointer, not code — third-party embed consent is handled by the standalone @copperdesign/easy-cookie-consent package (click-to-load gate; iframe stays out of the DOM until consent; i18n EN/DE, optional global modal, MIT, ~6 KB, zero-dep). The doc carries the install + wiring recipe. |
src/js/lightbox.js + LESS |
Fancybox + jQuery | Dependency-free (no jQuery) drop-in on the same rel="lightbox[group]" HTML hook the Weebly theme used, so existing gallery markup works untouched. The chrome deny-list strips the Fancybox sprite assets at port time; this fills the gap. |
To wire the lightbox up, add import "./lightbox.js" in src/js/app.js
and @import "_lightbox.less" in src/less/main.less. See the sibling
.md for the full HTML contract. For embed consent, follow
cookie-consent.md.
Earlier scaffolds shipped an email-hider.js that obfuscated mailto:
links at runtime. It's no longer scaffolded by default — on-page
obscurity hurts legibility, accessibility, and copy/paste, and spam
resistance belongs at the MX layer (SPF/DKIM/DMARC, provider filtering),
not the markup. Prefer plain, visible email addresses.
It's still available for one narrow case: a crawl of a Cloudflare-fronted
Weebly site carries over data-cfemail-obfuscated links that render broken
on Firebase. Pass w2f init --with-email-hider to scaffold the recovery
module (the cleaner fix is to decode those to plain addresses at port time
and skip it). See src/js/email-hider.md once scaffolded.
When both --firebase-project and --github-repo are set, init scaffolds
.github/workflows/firebase-hosting-merge.yml — pushes to main run
npm ci && npm run build then deploy public/ via
FirebaseExtended/action-hosting-deploy. The workflow references a repo
secret named FIREBASE_SERVICE_ACCOUNT_<PROJECT> (uppercased, dashes →
underscores) — create it once via firebase init hosting:github or by
pasting a service-account JSON into a secret of that name.
port lands a buildable project — pages render, fonts load, the LESS
compiles — but it's still wearing the Weebly skin. The natural arc after
that is to strip the compat layer and rewrite the pages on your own small
vocabulary. From a real migration:
- Delete the dumped CSS dumps (
_w2f-*.less) and the original Weebly partials you don't want (_blog.less,_commerce.less,_ui-kit.less, large parts of_responsive.less). These exist for the buildable-out- of-the-box property; once your own page-types/global rules cover what you actually use, they're noise. - Rewrite pages on a small editorial vocabulary —
.block,.cols,.hero, whatever the design wants. The point of the rewrite is to own the markup; trying to preserve Weebly's class soup forever is the expensive path. - Drop the Fancybox dependency and switch image galleries to the
scaffolded
lightbox.js— the HTML hook (rel="lightbox[group]") is unchanged. - Cross-check
public/assets/gfx/againstsrc/less/*.lessandsrc/html/*.htmlreferences after the strip — anything orphaned is fair game to delete. The chrome deny-list catches the predictable Weebly sprite categories at download time, but project-specific cruft can still linger.
Track this work in its own commit (or PR) — keep the structural changes separate from per-page content edits so the diff stays reviewable. The port output is the starter; the rewrite is the project becoming itself.
Two-phase workflow: port lays down the scaffold (works out of the box with
hCaptcha test keys, so the local build is functional); forms finalizes the
project with real hCaptcha credentials when you're ready to go live.
If port detects a Weebly form (<form> carrying wsite-form-* markers) in
any extracted nav / footer / page body, it auto-scaffolds a Firebase Cloud
Function handler so the form keeps working without a Weebly backend:
- Rewrites every
<form action>→/api/submit-formand forcesmethod="POST" - Injects an hCaptcha widget (ships with the official test sitekey so
the scaffold works immediately — replace before going live) and a
_gotchahoneypot input - Drops
functions/index.js— a v2onRequesthandler that verifies hCaptcha via siteverify, writes submissions to Firestore (formSubmissions/), and redirects the visitor back to the source page with?ok=1 - Mutates
firebase.jsonto add the/api/submit-formrewrite +functionsblock, and addsdeploy:functionsto the project'spackage.json
If no forms are detected, no scaffold lands — the project stays hosting-only.
w2f forms <real-hcaptcha-sitekey>Three steps, each independently skippable:
npm installinfunctions/(auto-skipped when already installed)- Swap the hCaptcha test sitekey for your real one across every page in
src/html/(no-op when no sitekey is passed) firebase functions:secrets:set HCAPTCHA_SECRET(interactive — pastes into the firebase CLI's own prompt; never via argv). When stdin isn't a TTY, step 3 auto-skips with a "run this yourself" message so non-interactive callers (CI, agent sessions) don't hang on the prompt.
Flags: --sitekey <key>, --skip-install, --skip-secret.
forms is split from port so iterations on HTML cleanup don't pay the
npm-install + Firebase-roundtrip cost every time, and so port stays
offline-safe. Requires the Blaze plan + firebase login for step 3.
See the scaffolded functions/README.md for the full walkthrough, including
the email-notification stub.
init is safe to re-run. Existing files are never overwritten. Prior answers
cache to <target>/.weebly-migrate.json and are offered as defaults next time.
- convert — skips files that already exist in
src/{less,js,html}/. - crawl — wget's timestamp-aware
--mirrormode skips unchanged files. - port — partials only written if still the skeleton TODO marker; page
<main>slots replaced only while still the skeleton; image downloads skip existing files inpublic/assets/gfx/;_w2f-*.lessdumps regenerate only while theGenerated by w2fmarker is present.--forceoverrides all four. Forms scaffold (functions/) lands on first form detection; subsequent ports skip existing files and check before mutatingfirebase.json/package.json. - cms — a page carrying the
<!-- @render:sections -->marker is never re-lifted (its content is gone from the HTML,--forcecan't bring it back);src/content/<page>.ymlis skipped once it exists, unless--force. Scaffold files (public/admin/index.html,docs/cms.md,scripts/render-content.mjs) arewriteIfMissing;public/admin/config.ymlonly ever gets new page entries appended (existing entries and hand edits untouched);package.json'sbuild:htmlrender step +yaml/markeddevDependencies are added once and left alone after.
- Node ≥ 18
wgetfor the crawler (auto-installed via brew by thepostinstallhook on macOS; install manually elsewhere)firebase-toolsin the scaffolded project —npm i -g firebase-tools
- CLI via
node:util.parseArgs. Built into Node 18+, zero deps. Subcommands are dispatched by dynamicimport('./commands/<name>.mjs')— no registry. - Why posthtml + posthtml-include instead of CodeKit
.kit. CLI-driven, installed vianpm install(no GUI dependency), and the include syntax (<include src="_meta.html"></include>) is plain HTML — readable, no template language to learn. Sass-style_prefix on partials lets the build glob (src/html/[!_]*.html) pick up pages cleanly. - Why skeleton
.htmlfiles, not auto-converted partials. Weebly partials are Mustache ({logo},{{#sections}}); posthtml-include is tag-based with different semantics. A half-converted file misleads more than it helps. Each skeleton names the Weebly file to port from. - Why
reference/is outsidesrc/. Clear mental separation:src/is the new source of truth,reference/is the corpus you read while porting. Gitignored (regenerate viacrawl). - Why
public/is checked in.npm run build:html(posthtml) compilessrc/html/*.html→public/locally; the compiled HTML is committed so Firebase deploy doesn't need a build step in CI. - Why the tool lives outside the scaffolded project. The project ships
clean — no
scripts/folder, no migration tooling in its repo. The tool stays useful for the next Weebly site you migrate.
cli.mjs entry point (bin → weebly-to-firebase, w2f)
commands/
init.mjs scaffold + orchestrate firebase/convert/crawl/port/cms/git
convert.mjs WeeblyExport assets → src/
crawl.mjs wget --mirror of the live site
port.mjs extract sections from crawled HTML → src/html/ + public/assets/img/
cms.mjs scaffold Sveltia CMS layer + lift page content → src/content/*.yml
lib/
args.mjs parseArgs wrapper + help text
prompt.mjs readline wrapper (ask / askYesNo / askValid)
target.mjs resolve project root from --target / cwd
templates.mjs file generators for the scaffolded project
firebase.mjs `firebase` CLI driver for --setup-firebase
fs-utils.mjs shared `exists()` helper
extract.mjs regex HTML-extraction toolkit shared by port + cms
html-markdown.mjs zero-dep HTML → markdown converter (cms)
cms-templates.mjs cms scaffold file generators (admin shell, config.yml, render-content.mjs, docs/cms.md)
test/ node:test unit + fixture-driven command tests
PRs and issues welcome. See CONTRIBUTING.md for setup, the PR workflow, and what fits the scope of the tool. The repo follows the Contributor Covenant.
Quick version: fork, branch off main, run your change against a real
Weebly site, open a PR. I (@copperdesign) review and merge.
MIT — see LICENSE.
Created by Christian Fillies.