A tiny, dependency-free Grist custom widget that becomes any website you give it.
Drop a .zip containing a complete website (a Perchance export, a static site, a hand-rolled
index.html + assets — anything) onto the widget and it unpacks that archive in the browser
and serves it. Nothing is uploaded anywhere. The widget is a static site, so it hosts happily on
GitHub Pages.
Then, if it's inside a Grist document, it can take the next step and hide itself: store the site
in the document and the archive becomes part of the .grist file — the widget opens straight into
your site, for you and for anyone you share the document with. See Storing the site inside the
document below.
This folder (
src/) is the whole widget. Its contents are what gets deployed.
- Add a widget → Custom → Custom widget URL → paste your GitHub Pages URL.
- In the widget's column mapping, map Website ZIP to a column of type
Attachment. Optionally map ZIP URL (Text) to a column holding a link to a.zip, and Start page (Text) to override the page that opens first. - Drop a
.zipinto a cell. The widget follows the selected row.
A .zip, a bare .html file, and a link to either all work — the same code path handles all three.
The widget asks for full document access (requiredAccess: "full"), which is what Grist needs
in order to hand out a read-only access token for attachment downloads. If you'd rather not grant
that, use the ZIP URL column or drop the file in by hand — both work without any access token.
A widget can carry its own configuration data, and Metamorph uses that: the ☰ panel →
This document → Store this site in the document writes the whole archive into the widget's
options (grist.setOption("metamorph", …)), so the site is hosted by the document itself — the
same trick Grist's own custom-widget builder uses for its code.
The consequences are exactly what you'd want:
- The widget then shows that site every time the document is opened, with no attachment, no
URL, and no local file — a collaborator who has never seen your
.zipsees your site. - Refreshing, closing, or sharing the document changes nothing: the site is part of it.
- Metamorph's own bar gets out of the way (see below), so what you see is just the site.
Two things to know:
- Grist keeps widget-option changes as a pending modification. After storing a site, press
Save in Grist — the button that appears next to the section's sort/filter icon — to write it
into the document.
(modified)/(customized)appears under Custom options in that menu. Reverting there removes the stored site again. - The archive is stored as base64 inside the document, so keep it small: Metamorph warns above 1.5 MB and refuses above 6 MB. For big sites, use an Attachment column or a URL instead — the widget stays a few kilobytes and the payload stays out of the document.
Metamorph keeps all of its own settings under a single metamorph key, so a Grist-aware site you
upload can use grist.setOption("something", …) for itself without stepping on our config. (It
also gets the widget's live Grist API — see A Grist-aware site below.)
Open the ☰ panel → Open Widget configuration (onEditOptions). Options are stored per widget:
| Option | Meaning |
|---|---|
zipUrl |
A link to a .zip to load when nothing else supplies one |
entry |
Which HTML file inside the archive to open first |
size |
Virtual window size: auto, 1280x800, 1024x768, 768x1024, 390x844 |
zoom |
Scale factor between 0.25 and 2 |
priority |
embedded (default) = the site stored in the document wins, row = a mapped column wins |
site |
The archive stored in the document: {name, entry, savedAt, z, d} (d = base64, z = deflated?) |
Sources are tried in this order (first match wins): the stored site or the mapped column (by
priority), then the zipUrl option, then the stored site. Outside Grist the last URL you loaded is
remembered in localStorage (clear it with Forget the remembered link), and the last file you
dropped is kept in IndexedDB. That makes the standalone (GitHub Pages) case behave like a real
browser that remembers your site.
Once a site is loaded, Metamorph's toolbar hides itself after a couple of seconds — the widget is the site, so there is nothing to frame. Bring it back with:
- Ctrl/Cmd + Shift + M (works even while the site has focus),
- the small faint dot in the top-right corner,
- Grist's own Open configuration entry in the section menu,
- or
Escto hide it again.
If the site you upload talks to Grist, it gets the real thing: Metamorph rewrites the site's
<script src="…/grist-plugin-api.js"> into a tiny bridge that hands the page the widget's own live
API instance (window.__metamorphGrist === "shared"), so grist.onRecord, grist.docApi, …, — and
the widget's own requiredAccess: "full" grant — all work unchanged. Pages that ship a bundled
copy of the plugin API instead are relayed message-by-message between the page and Grist
(wireGristRelay). Outside Grist, an uploaded page gets a quiet stub API that resolves to empty
data (window.grist.__metamorphStub === true) instead of hanging on a connection that can't happen.
.zip bytes (a Grist attachment, a URL, a dropped file, or the site stored in the document)
│
fflate.unzipSync │ decodeZip()
▼
Map<path, Uint8Array> ← the virtual filesystem
│
Site.rewriteHtml / rewriteCss / rewriteJs ← every local reference is
│ replaced with a blob: URL
▼
page.html → blob:…/… ──▶ <iframe src="blob:…"> (same-origin, no sandbox)
│
same-origin, so the page also reads window.parent.grist
src/metamorph.js is the whole engine; it only knows about Grist through one small bridge:
decodeZip— unzips, normalises path separators, drops__MACOSX/,.DS_Store, and transparently unwraps a single top-level folder (site/index.html→index.html).Site— a memoising, cycle-tolerant transformer. For each file it produces ablob:URL and caches it, so a page's stylesheet, module graph, images and favicons are all rewritten to point at their own blob URLs. Handled:- HTML:
src/hrefon every relevant element,srcset(includingw/xdescriptors),poster,<use href>/xlink:hrefin inline SVG, inline<style>blocks andstyle=""attributes,<base>(removed — the widget is the base), CSP<meta>(removed — blob URLs would be blocked by a strict policy),<meta charset>(ensured), and<script type="importmap">which is rewritten so bare specifiers resolve inside the archive too. - CSS:
@importandurl(…), recursively. - JS:
import … from "…",export … from "…", bareimport "…", dynamicimport("…"),new Worker("…")/new SharedWorker("…"),importScripts("…"), andnew URL("…", import.meta.url).
- HTML:
- The shim —
installShim()is stringified and injected as the first script of every unpacked page. It re-implements the server that isn't there:fetchandXMLHttpRequestare answered from the in-memory files,- element
src/href/poster/datasetters are patched so runtime-created elements resolve inside the archive too, Worker/SharedWorkerconstructors are wrapped,- anchor clicks are intercepted, so client-side navigation between pages works even when the link could not be pre-rewritten (cycles are left as raw hrefs and resolved on click),
localStorage/sessionStorageare emulated in memory when the browser blocks them,- Ctrl/Cmd+Shift+M asks the widget to show its bar,
- uncaught errors and missing-file references are reported back to the widget's diagnostics panel.
- The Grist bridge —
rewriteHtmlspots<script src="…/grist-plugin-api.js">and swaps it for an inline bootstrap (gristBridge) that, when the widget is attached to a document, points the page at the widget's owngristobject instead of loading a second, disconnected copy.
Blob URLs inherit the widget's origin, so the unpacked site runs same-origin with the widget.
That is what makes the shim possible (no server, no service worker, works on GitHub Pages), and it
means an uploaded site is trusted code: it can read the widget's own storage. Treat a .zip you
load the same way you'd treat a program you install.
Two things genuinely cannot work: real service workers, and features that need a live server (server-side rendering, WebSocket backends, cookies for a "same" domain). Everything static — including a Perchance export — works.
| File | Role |
|---|---|
index.html |
The widget document: bar, stage, reveal hotspot, start screen, busy/error states, panel. |
app.js |
UI + state machine + Grist glue (ready/onOptions/onRecord, attachments, widget-option storage, the message relay, the offline stub, IDB/localStorage fallbacks). |
metamorph.js |
The engine described above. Grist-agnostic; usable on its own. |
metamorph.css |
Styling, themed through Grist's CSS variables with a dark-mode fallback. |
demo-site.js |
The built-in demo site (a real site, built in memory and zipped on the fly) used by Try the demo. |
vendor/fflate.min.js |
fflate 0.8.2 UMD, vendored so unzipping needs no network. |
Open the widget without Grist and press Try the demo. The demo site deliberately exercises the
hard parts: a stylesheet with @import, an SVG logo, a module graph (app.js → lib/util.js),
a dynamic import() behind a button, a fetch() of data/items.json, and a second page you
navigate to and back from.
To exercise the Grist paths without a real document, load the page with a fake parent API installed
before the widget's scripts (page_refresh's preambleJs in the editor, or a <script> above
app.js): give it ready/onOptions/onRecord/setOption/mapColumnNames/docApi.getAccessToken,
define it non-writable (Object.defineProperty(window, "grist", {value: api, writable: false})) so
the CDN build can't replace it, then drive it from the console:
const A = window.__metamorphApp; // debug hook, no behaviour depends on it
A.loadDemo(); // a real archive to store
await A.embedSite(); // → window.grist.setOption("metamorph", …)
__fake.onOptionsCb({metamorph: {site: {v: 1, z: 0, d: "…", name: "site.zip"}}}, {accessLevel: "full"});
A.embedSite(); A.clearEmbed(); A.currentIntent(); // round-trip, priority and source ordering__metamorphApp exposes state, els, embedSite, clearEmbed, applyOptions, refreshSource,
currentIntent, openPanel/closePanel/setChrome, loadDemo, useBytes/useFile/useUrl and
packBundle — enough to drive any of the above from a console.