@gridd/embed
v1.4.0
Published
Drop-in script that turns gridd YAML fenced code blocks into Gridd iframe embeds on any static HTML page, plus editable React embed and stylesheet-editor components
Maintainers
Readme
@gridd/embed
Drop-in script that turns ```gridd YAML fenced code blocks into live, read-only Gridd iframe embeds on any static HTML page.
Usage
CDN
Include js-yaml (for YAML blocks; plain JSON works without it), then the embed script. Point data-gridd-base-url at the Gridd app you want to render into.
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4/dist/js-yaml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@gridd/embed/gridd-embed.min.js"
data-gridd-base-url="https://wonderful-smoke-03be4ab0f.5.azurestaticapps.net/embeddable.html"></script>Inline
Copy gridd-embed.min.js (or the full gridd-embed.js) into a <script> tag in your page. Set the base URL via the global before the script runs:
<script>
window.GRIDD_EMBED_BASE_URL = "https://wonderful-smoke-03be4ab0f.5.azurestaticapps.net/embeddable.html";
</script>
<script>/* contents of gridd-embed.min.js */</script>npm
npm install @gridd/embedThen copy or serve node_modules/@gridd/embed/gridd-embed.min.js as a static asset.
Configuration
| Attribute / global | Description | Default |
|---|---|---|
| data-gridd-base-url on the <script> tag | URL of embeddable.html | https://localhost:3000/embeddable.html |
| data-gridd-style-uri on the <script> tag | URI of a shared Gridd stylesheet JSON | (none) |
| window.GRIDD_EMBED_BASE_URL | Same as above, set before the script loads | — |
| window.GRIDD_STYLE_URI | Same as above | — |
Embeds are always read-only — gridd_readonly=1 is appended to every iframe URL automatically.
Markup
Place a <pre><code class="language-gridd"> block anywhere in the page body. The script replaces it with an iframe on DOMContentLoaded.
<pre><code class="language-gridd">
cells: |
| * | **Label** | **Value** |
|---|-----------|-----------|
| * | Revenue | $4.2M |
</code></pre>JSON is also accepted without js-yaml:
<pre><code class="language-gridd">{"cols":["*","*"],"rows":["*"],...}</code></pre>YAML format
cells: | ← markdown table; first row = col weights, first col = row weights
| | 0.5 | 1.5 |
|------|-------|--------------|
| * | **Header** | Body |
borders:
- index: 1 ← row/col index of the border line
isVertical: false
preset: level3 ← visual style defined in the stylesheet
cellStyles:
templates:
MyHeader:
preset: Header
weight: bold
size: 14pt
fontFamily: Arial
color: "#FFFFFF"
isInverted: true ← dark background + light text
textAlign: center
verticalAlign: middle
shape:
type: RoundedRectangle
color: "#1B4F72" ← fill colour
outline: none
radius: 8
margin: 4
marginLeft: 4
MyBody:
preset: Body
weight: normal
size: 11pt
fontFamily: Arial
color: "#333333"
textAlign: left
verticalAlign: middle
paddingLeft: 12
shape:
type: RoundedRectangle
color: "#F4F6F7"
outline: solid
outlineColor: "#D5D8DC"
outlineWeight: 1
radius: 6
margin: 4
marginLeft: 4
"0,0": { template: MyHeader } ← row,col → template name
"0,1": { template: MyHeader }
"1,0": { template: MyBody }
"1,1": { template: MyBody }Cell content supports **bold** and <br> line breaks.
Full example
Save the snippet below as example.html, open it in a browser (internet access required for the CDN scripts and the Gridd app).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Gridd embed example</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
p { color: #555; margin-bottom: 20px; }
/* make the iframe fill the container instead of a fixed 600px */
iframe { width: 100%; height: 360px; border: 0; display: block; }
</style>
</head>
<body>
<h1>Strategic options</h1>
<p>Three paths Meridian Wealth can take with its mass-affluent segment.</p>
<pre><code class="language-gridd">
cells: |
| | 0.45 | 1.5 | 1.1 |
|-----|------------------|----------------------------------------------------------------------------|------------------------------------------------|
| 0.5 | **Option** | **Description** | **Implication** |
| * | **Defend** | Retain the segment with targeted improvements to pricing, UX, and coverage | Lower investment; incremental gains |
| * | **Transform** | Build a competitive hybrid digital offering for mass-affluent clients | Higher investment; structural advantage |
| * | **Exit & Refer** | Wind down the segment; establish structured referral partnerships | Margin improvement; feeder pipeline risk |
borders:
- index: 1
isVertical: false
preset: level3
- index: 1
isVertical: true
preset: level3
- index: 2
isVertical: true
preset: level3
cellStyles:
templates:
TH:
preset: Header
weight: bold
size: 12pt
fontFamily: Arial
color: "#FFFFFF"
textAlign: left
verticalAlign: middle
isInverted: true
paddingLeft: 10
shape:
type: RoundedRectangle
color: "#1B4F72"
outline: none
radius: 0
margin: 2
marginLeft: 2
Option:
preset: SubHeader
weight: bold
size: 12pt
fontFamily: Arial
color: "#1A5276"
textAlign: left
verticalAlign: middle
paddingLeft: 10
shape:
type: RoundedRectangle
color: "#D6EAF8"
outline: solid
outlineColor: "#A9CCE3"
outlineWeight: 1
radius: 0
margin: 2
marginLeft: 2
TD:
preset: Body
weight: normal
size: 11pt
fontFamily: Arial
color: "#333333"
textAlign: left
verticalAlign: middle
paddingLeft: 10
shape:
type: RoundedRectangle
color: "#FFFFFF"
outline: solid
outlineColor: "#E5E8EB"
outlineWeight: 1
radius: 0
margin: 2
marginLeft: 2
"0,0": { template: TH }
"0,1": { template: TH }
"0,2": { template: TH }
"1,0": { template: Option }
"1,1": { template: TD }
"1,2": { template: TD }
"2,0": { template: Option }
"2,1": { template: TD }
"2,2": { template: TD }
"3,0": { template: Option }
"3,1": { template: TD }
"3,2": { template: TD }
</code></pre>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4/dist/js-yaml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@gridd/embed/gridd-embed.min.js"
data-gridd-base-url="https://wonderful-smoke-03be4ab0f.5.azurestaticapps.net/embeddable.html"></script>
</body>
</html>Editable inline mode (React)
The vanilla script above is always read-only. The @gridd/embed/react subpath adds an
editable inline embed: a React component that loads a Gridd document into the app, lets the
user edit it in place, and streams changes back to your app. This is the mode the vanilla script
can't express — it builds the iframe URL as ?gridd_inline=1, appending gridd_readonly=1 when you
pass readOnly or gridd_editable=1 otherwise.
Off-localhost editing is allowlisted. The Gridd app only honours
gridd_editable=1when the embedding page's origin islocalhost(dev) or on the app's trusted-origins allowlist. From any other origin the embed falls back to read-only regardless ofreadOnly. Ask a Gridd maintainer to add your production origin to the allowlist before relying on editing in production.
react is an optional peer dependency (^18 || ^19); installing @gridd/embed purely for the
vanilla script pulls in nothing extra.
import { GriddEmbed } from "@gridd/embed/react";
function Editor({ griddJson, onChange }) {
return (
<GriddEmbed
baseUrl="https://localhost:3000/embeddable.html"
document={griddJson} // object (base64-encoded for you) or a base64 string
readOnly={false} // omit/false → editable; true → read-only
width="100%"
height="100%"
onDocumentChange={(doc) => onChange(doc)} // parsed JSON, debounced ~400ms upstream
/>
);
}Props
| Prop | Type | Description |
|---|---|---|
| baseUrl | string | URL of embeddable.html to load. Its origin is the trust boundary (see Constraint B). |
| document | object \| string | The Gridd document. An object is JSON-stringified and base64-encoded for you; a string is treated as the base64 payload verbatim. |
| readOnly | boolean | Defaults to false (editable). true appends gridd_readonly=1. |
| styleSrc | string? | URI of a shared Gridd stylesheet JSON (passed as gridd_style_uri). |
| styleSheet | GriddStyleSheetData? | Controlled live stylesheet ({ selectedSheet, sheets }). On each identity change it is pushed into the iframe and applied immediately — use it to reflect edits from a stylesheet-editor panel live. Unlike document, this is not load-once. |
| width / height | string \| number | Iframe dimensions. Default "100%". |
| onDocumentChange | (doc) => void | Called with the parsed Gridd JSON whenever the user edits. This is the hook to hang persistence off. |
| onSaveFile | (json, name) => Promise<void>? | Override the "save to file" action. When omitted, the browser's showSaveFilePicker is used. |
| onReady | (info) => void? | Called once the app announces itself, before any edit can arrive. info.version is the app build in the iframe (null from an app older than the announce). Stamp it on the deck — see Versions. |
Imperative snapshot (ref)
GriddEmbed forwards a ref exposing requestSnapshot(), which pulls a static raster of the
embed's current render as a self-contained data: URL. Use it to replace live cross-origin
iframes with <img>s before printing/exporting a deck, so the printed page contains no iframes.
import { useRef } from "react";
import { GriddEmbed, type GriddEmbedHandle } from "@gridd/embed/react";
const ref = useRef<GriddEmbedHandle>(null);
// later, e.g. inside an export handler:
const { dataUrl, width, height } = await ref.current!.requestSnapshot({ scale: 2 });
// -> render <img src={dataUrl} style={{ width, height }} /> in the print view
<GriddEmbed ref={ref} baseUrl={BASE} document={doc} readOnly />The same ref also exposes getEmbeddableVersion(), which returns the app build announced during
the load handshake (or null before it completes). requestSnapshot(opts?) returns
Promise<{ dataUrl, width, height, format }>. width/height are
the logical (pre-scale) box, so the <img> lays out exactly where the iframe was — pass
scale: 2 for a crisp 2× raster without changing pagination. It rejects if the iframe isn't
ready, the grid errors, or it doesn't answer within the timeout (default 3 s), so one hung grid can
never stall a whole export — wrap each call in Promise.allSettled and fall back to the live iframe
for any grid that rejects.
| Option | Type | Default | Description |
|---|---|---|---|
| scale | number | 2 | DPR multiplier for the raster. |
| format | "png" \| "svg" | "png" | Preferred format (the iframe currently always produces PNG). |
| timeoutMs | number | 3000 | Reject if no response arrives within this window. |
Constraints
A — the document is loaded once, not controlled. document is read when the iframe asks for it
at mount, through a ref. Re-rendering with a new document object does not reload the editor —
this is deliberate, so a parent re-render can never clobber in-progress edits. The living document
flows outward via onDocumentChange; treat document as an initial value. To load a different
document into a fresh editor, remount the component with a changed React key.
B — the host is the strict side of the handshake. The iframe posts messages with target origin
"*"; the component only trusts messages whose event.source is its own iframe and whose
event.origin matches baseUrl's origin, and it always replies to that exact origin (never "*").
So baseUrl must be the real origin serving embeddable.html — a mismatch silently drops the
handshake and the embed stays blank.
Offline / self-hosted app
By default the component fetches the Gridd app from a URL. A host that must work with no network
at all — a desktop shell rendering decks offline — cannot do that: the iframe is simply blank.
For those hosts, install the optional companion package @gridd/embeddable-dist, which contains
the built app, and serve it yourself.
npm install @gridd/embed @gridd/embeddable-dist@gridd/embeddable-dist is an optional peer. It is a separate package on purpose: a website or
an Office add-in that loads the app from a URL should not download several megabytes of app bytes
it will never execute.
// Electron main process (or any Node host)
const { embeddableDir, embeddableVersion, assertBuilt } = require("@gridd/embed/embeddable-path");
assertBuilt(); // fails loudly at startup rather than rendering a blank iframe
const dir = embeddableDir(); // absolute path to embeddable.html + all its assets
const appVersion = embeddableVersion();
// Serve `dir` as static files on a loopback port, then point the component at it:
// <GriddEmbed baseUrl={`http://127.0.0.1:${port}/embeddable.html`} ... />Do not reach into node_modules/@gridd/embed/dist/... by hand. The layout is not part of the
package's contract; embeddable-path is.
Why embeddable-path is Node-only
The subpath sits behind a "node" condition in the exports map, with every other condition
resolved to null. A web bundler that tries to import it gets a resolution error at build time
rather than silently pulling fs, path, and the app's bytes into a browser bundle. If webpack
tells you "./embeddable-path" is not exported under the conditions [...], that is the guard
working — move the import into your main/server process.
Serving it correctly
Every asset URL in the build is relative, resolved at runtime from the URL the script itself was
loaded from. That is what lets the same directory be served from http://127.0.0.1:<random-port>/
or a custom scheme. Two consequences for the host:
- Serve the directory at a path root, and serve
embeddable.htmlfor/. Do not rewrite asset paths, and do not inject a<base>tag. - Serve it from a different origin than your host page. Edits push back to you over postMessage, and that bridge is active only when the iframe is cross-origin to the host. Two loopback ports is the simplest way to get there; a same-origin iframe would render fine and quietly never save.
The build makes no runtime network requests — no web fonts, no analytics, no license or feature-flag check. This is not incidental: a Google Fonts fetch that fails offline changes text metrics, and a deck laid out on a fixed logical canvas then exports to a different PDF.
In the Gridd repo:
| Command | What it proves |
|---|---|
| npm run build:embeddable-dist | builds, then fails on a reintroduced beacon host, a remote @font-face, an absolute or root-relative asset URL, a hardcoded webpack public path, or a missing version.json |
| npm run verify:offline | drives the real thing in Chrome from two loopback ports: the grid renders, it is editable, an edit pushes back to the host, and every asset came from disk |
| npm run verify:online | the same protocol check against the dev server, so a packaging change that breaks the hosted app is caught in the same breath |
| npm run serve:embeddable-dist | the manual pass — open the printed host URL with DevTools set to Offline |
Editable origins
The Gridd app decides whether an inline embed may be edited from the real postMessage handshake
origin — never document.referrer, which a Referrer-Policy can empty or trim. The rule is:
| Host origin | gridd_editable=1 sent | Result |
|---|---|---|
| localhost / 127.0.0.1 / [::1], any port | not required | editable |
| on the app's allowlist | required | editable |
| anything else | — | read-only |
It fails closed: a missing, opaque ("null"), or unrecognised origin is never trusted. A host
serving the app from a loopback port therefore gets an editable embed by rule, not by accident.
To add a production origin, either get it added to the app's built-in allowlist, or build the app
with GRIDD_EXTRA_EDITABLE_ORIGINS="https://your.origin" (comma-separated for several). It is still
an allowlist chosen at build time — a runtime origin cannot talk its way in.
Versions
Three version numbers are in play, and they are not the same thing:
| What | How to read it | What it tells you |
|---|---|---|
| This host package | EMBED_PACKAGE_VERSION from @gridd/embed/react, or window.GRIDD_EMBED_VERSION from the vanilla script | which protocol implementation is in your bundle |
| The Gridd app in the iframe | onReady({ version }), ref.current.getEmbeddableVersion(), or window.GRIDD_APP_VERSION | which app build is rendering and writing documents |
| The app on disk (offline hosts) | embeddableVersion() from @gridd/embed/embeddable-path | which build you are about to serve |
Show the app version in an About dialog and stamp it on the deck. Every document the app writes
already carries a griddVersion field for the same reason: offline there is no server to reconcile
a deck edited against one app build and reopened against another, so the build that wrote it has to
travel with it.
Stylesheet editor (React)
GriddStyleSheetEditor embeds the Gridd stylesheet editor (?styleSheet=1) as a reusable panel you
can drop into any editor. It hosts the same origin/security handshake as GriddEmbed (Constraint B),
seeds the editor with a stylesheet on mount, streams debounced draft edits for live preview, and
reports save / cancel back to you. Persistence is host-owned — the component never writes
anything; you decide what save means.
import { GriddEmbed, GriddStyleSheetEditor, type GriddStyleSheetData } from "@gridd/embed/react";
function DeckEditor({ document, committedSheet, setCommittedSheet }) {
// Session-only draft feeds the live preview; never persist it.
const [draft, setDraft] = React.useState<GriddStyleSheetData | null>(null);
const effectiveSheet = draft ?? committedSheet;
return (
<div style={{ display: "flex" }}>
{/* Live preview: controlled styleSheet reflects the draft immediately. */}
<GriddEmbed baseUrl={BASE} document={document} styleSheet={effectiveSheet} readOnly />
{/* The editor panel. */}
<GriddStyleSheetEditor
baseUrl={BASE}
variant="panel"
styleSheet={committedSheet} // controlled: pass the committed sheet, not the draft
onDraftChange={setDraft} // session-only preview state
onSave={(sheet) => { setCommittedSheet(sheet); persist(sheet); setDraft(null); }}
onCancel={() => setDraft(null)} // re-push committed via the controlled prop
/>
</div>
);
}Props
| Prop | Type | Description |
|---|---|---|
| baseUrl | string | URL of embeddable.html. Its origin is the trust boundary (Constraint B). |
| styleSheet | GriddStyleSheetData | The stylesheet to edit ({ selectedSheet, sheets }). Controlled — pulled on mount, re-pushed on identity change. Pass the committed sheet, not the live draft. |
| variant | "panel" | Layout variant passed to the app as variant. Default "panel" (compact chrome + sticky footer). |
| styleSrc | string? | URI of a shared Gridd stylesheet JSON (passed as gridd_style_uri). |
| width / height | string \| number | Iframe dimensions. Default "100%". |
| onDraftChange | (sheet) => void | Debounced draft edits for live preview. Session-only — do not persist. |
| onSave | (sheet) => void | User saved. Persist the sheet and close the panel. |
| onCancel | () => void | User cancelled. Discard the draft and close. |
Dirtiness & cancel
A draft is dirty exactly when your host-side draft state is non-null (draft !== null). Do not
try to infer dirtiness from the sheet's own draftSheets — the app sets abandoned keys to undefined
rather than deleting them, so the keys survive and would read as permanently dirty. On cancel,
re-push the committed sheet (via the controlled styleSheet prop, e.g. by clearing your draft state)
so the editor and any preview snap back to the last saved state.
