Hello.

article

Add a live Compose demo to a post with the CMS and a git push

2026-11-14

TL;DR A Compose wasm demo does not have to live in the blog. Keep it in a samples repo, let GitHub Actions build it and GitHub Pages host it, and paste its URL into the post in the CMS. The blog accepts one exact frame path, so check the URL with curl -sI before you paste it.

The first version put a 12,653,625-byte wasm bundle inside the blog and needed sh demos/build.sh slider on my machine before every deploy. That broke the point of the CMS. I wanted to "create new blog articles via storyblok alone, not requiring to compile locally."

I want posts that teach by letting the reader do something. So I asked for a blok that embeds a live Compose Multiplatform wasm component. Claude Code writes the code, the workflow and the tests. Publishing and deploying stay with me. The Storyblok steps (schema backup, component, allowlist, read-back diff) are the same as in the Runnable post, so this post covers only what is different for wasm.

A new demo is a push, a post is an edit

The path from a sample to a reader has two walls. The samples repo and Pages own the files. The blog owns only the rule about which frame it will accept.

From a push to a running demo

A new folder next to slider/ is the whole change. The blog repo is not touched.

1 / 6

The blog's CSP has no wasm-unsafe-eval and the blog serves no sample files. The permission for wasm exists only inside the sample's own page.

The first design worked and tied every demo to my machine

In version one the bundle lived in the blog under /demos/slider/. Those files are static assets, so a public/_headers block gave that path its own CSP with wasm-unsafe-eval, and a CORS header. The bundle was 12,653,625 bytes raw, about 4.4 MB gzipped, and one wasm file made up 8.6 MB of it.

log
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=3600
content-security-policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; ...; frame-ancestors 'self'

Without the CORS header the frame stayed blank, with no visible error. With it, the demo rendered. The most likely cause is the sandbox: the frame has an opaque origin, so its wasm fetch goes out as Origin: null and needs Access-Control-Allow-Origin.

It worked, but a cold Gradle build took 3m27s and had to run on my machine before every blog deploy. Every new demo would be a new build, a new 12.6 MB in the blog's deploy, and a deploy. That is the part I rejected.

Pages sends the CORS header and cannot send a CSP

I chose GitHub Pages, created mikepenz/samples-blog-mikepenz-dev for the samples, and set one rule: only that site may be framed, with the highest security requirements. The sample moved there: a Gradle project that takes its versions from the version catalog of mikepenz/convention (Kotlin 2.4.21, Compose Multiplatform 1.12.2), with a wrapper that pins Gradle 9.9.1 by distributionSha256Sum. The CORS trap vanished, because Pages already sends the header without any configuration.

log
== /slider/
HTTP/2 200
content-type: text/html; charset=utf-8
access-control-allow-origin: *
== /slider/slider.js.map
HTTP/2 404

The 404 is deliberate, the workflow deletes source maps. The cost: the GitHub Pages docs describe no way to set response headers, and the sample's responses carry no CSP header. The policy lives in the sample's index.html as a meta tag.

html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'">

MDN says frame-ancestors is not supported in a meta element. Anyone can therefore frame the sample, which is fine for a public demo with no state.

The blog allows one frame path and one URL shape

The blog's CSP gets one frame-src with the path included. In CSP Level 3, a source whose path ends in / matches every URL below that path. With the path in the source, no other github.io project can be framed, not even another of mine.

log
... connect-src 'self' https://api.kotlinlang.org; frame-src https://mikepenz.github.io/samples-blog-mikepenz-dev/; ... frame-ancestors 'none'

The same boundary exists a second time in the allow-list, which runs on the raw string before anything reaches an iframe.

typescript
export const DEMO_BASE = 'https://mikepenz.github.io/samples-blog-mikepenz-dev/';
const OK = /^https:\/\/mikepenz\.github\.io\/samples-blog-mikepenz-dev\/[a-z0-9]+(?:-[a-z0-9]+)*\/$/;
export const demoSrc = (s: unknown): string | null =>
  (typeof s === 'string' && OK.test(s) ? s : null);

The regex allows one lowercase folder name, a trailing slash, no file, no query, no encoded characters and no port. Its test accepts 3 URLs and rejects 46. The converter throws on anything else, for example https://mikepenz.github.io/other/slider/. The iframe itself gets sandbox="allow-scripts" without allow-same-origin, which MDN describes as an origin that always fails the same-origin policy. It also gets referrerpolicy="no-referrer" and allow="".

The blok is a schema, one Astro file and one directive

Four pieces make the blok. The Storyblok steps are the ones from the Runnable post: back up the schema, create the component, add its name to the allowlist of the richtext field (the API still calls it component_whitelist), read both back and diff. The component has four fields:

json
{
  "name": "ComposeDemo", "display_name": "ComposeDemo",
  "is_nestable": true, "is_root": false, "preview_field": "title",
  "schema": {
    "title":   { "type": "text" },
    "src":     { "type": "text", "required": true },
    "height":  { "type": "number", "default_value": "420" },
    "caption": { "type": "text" }
  }
}

The Astro file validates src, renders a placeholder and creates the iframe only on a click. Styles are left out here.

html
---
import { demoSrc } from '../lib/demoSrc';
const { block, editable } = Astro.props;
const src = demoSrc(block.src);
const h = Math.min(1200, Math.max(120, Math.round(Number(block.height)) || 420));
---
{src ? (
  <compose-demo {...editable} data-src={src} data-height={h} data-title={block.title || 'Compose demo'}>
    <figure>
      <div class="frame" style={`min-height:${h}px`}>
        <p class="t">{block.title}</p>
        <p class="note">Compose Multiplatform (wasm). Loads a few MB from GitHub Pages, only when you press the button. Needs JavaScript and a browser with WebAssembly GC.</p>
        <button type="button" data-load hidden>Load demo</button>
      </div>
      {block.caption && <figcaption>{block.caption}</figcaption>}
    </figure>
  </compose-demo>
) : (block.src && <p class="bad">Demo unavailable: invalid src.</p>)}
<script>
  class ComposeDemo extends HTMLElement {
    connectedCallback() {
      const btn = this.querySelector<HTMLButtonElement>('[data-load]')!, box = this.querySelector<HTMLElement>('.frame')!;
      btn.hidden = false;
      btn.onclick = () => {
        const f = document.createElement('iframe');
        f.setAttribute('sandbox', 'allow-scripts');
        f.referrerPolicy = 'no-referrer'; f.allow = '';
        f.title = this.dataset.title ?? 'Compose demo';
        f.height = this.dataset.height ?? '420';
        f.src = this.dataset.src ?? '';
        box.replaceChildren(f); box.classList.add('on'); box.style.minHeight = '';
      };
    }
  }
  customElements.define('compose-demo', ComposeDemo);
</script>

The button starts hidden and the script reveals it, so without JavaScript the reader sees the title and the note. A src that fails the allow-list from the previous section renders a one-line note and no frame. The component takes its fields from block, so the same file serves the rich text and a direct blok.

Registration is one import and one entry in the integration's components map, and the markdown converter gets one directive that fails loudly on a wrong URL or a body:

javascript
if (name === 'compose') { // :::compose title=".." src="<url>" height=420 caption=".."
  if (!/^https:\/\/mikepenz\.github\.io\/samples-blog-mikepenz-dev\/[a-z0-9]+(?:-[a-z0-9]+)*\/$/.test(a.src ?? '')) throw new Error('compose src must be https://mikepenz.github.io/samples-blog-mikepenz-dev/<sample>/ (got ' + a.src + ')');
  if (body.trim()) throw new Error('compose has no body');
  return blok({ component: 'ComposeDemo', title: a.title, src: a.src, height: String(a.height ?? 420), caption: a.caption }); }

A post then embeds a demo with one directive line, :::compose title="..." src="<url>" height=420 caption="...", followed by a closing ::: line. In the Storyblok editor, add the blok to the richtext field and paste the URL into src.

The workflow gives the build no more than it needs

A build that runs Gradle on a public repo is a supply chain. GitHub's hardening guide says to pin actions to a commit SHA and to grant the GITHUB_TOKEN the minimum permissions. This is the trimmed workflow.

yaml
permissions: {}

jobs:
  build:
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1
      - name: Build samples
        run: |
          for g in */gradlew; do
            n=${g%/gradlew}
            case "$n" in *[!a-z0-9-]*) echo "bad sample name: $n" >&2; exit 1;; esac
            (cd "$n" && ./gradlew --no-daemon --console=plain wasmJsBrowserDistribution)
            mkdir "_site/$n"
            cp -R "$n/build/dist/wasmJs/productionExecutable/." "_site/$n/"
          done
          find _site -name '*.map' -delete
  deploy:
    if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
    needs: build
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages

The deploy job is the only one with pages: write and id-token: write, which GitHub's Pages docs list as the minimum for deploy-pages. A pull request builds and never deploys. The folder-name check means a sample cannot choose a path outside [a-z0-9-]. There is no cache, so no build can poison a later one.

The repository settings back the file up, read back with gh api:

json
{"enabled":true,"allowed_actions":"selected",...,"sha_pinning_required":true}
{"github_owned_allowed":true,"patterns_allowed":["gradle/actions/*"],"verified_allowed":false}
{"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}
{"approval_policy":"all_external_contributors"}

The last line means a pull request from a fork waits for my approval before any workflow runs. github_owned_allowed is still true, so any action owned by GitHub may run. The prompt below asks for an exact list instead: only the actions the workflow uses, each at its full commit SHA.

The github-pages environment accepts deployments from main only. Dependabot opened 3 pull requests within a minute of the first push: Gradle wrapper, Kotlin plugin, Compose plugin. Once the versions moved to the catalog, it closed them itself. Dependabot also watches the actions in the workflow, so a SHA pin does not turn into a frozen one.

The sample loads only on click

The sample has a text field, a size slider from 12 to 64 sp and an Uppercase switch. Before you load it, decide: does flipping the switch change the text in the field, or only the preview line above it?

Edit the text, drag the slider

Compose Multiplatform (wasm). Loads a few MB from GitHub Pages (mikepenz.github.io), only when you press the button; it runs in a sandboxed frame. Needs JavaScript and a current browser with WebAssembly GC.

Type in the field, flip Uppercase, drag the slider. Then check your guess against the field.

The preview line changes and the field keeps what you typed. Before the click the page makes 0 requests to github.io and holds no iframe.

After: nothing in the blog, nothing on my machine

Per new demo

Version 1

Version 2

Bytes shipped with the blog deploy

12,653,625

0

Where the sample is built

my machine, 3m27s cold

CI, build job 2m20s

Blog deploy

yes

none

Wasm permission in the blog's CSP

on /demos/*

none

Reader downloads on click, raw / gzip -9

12,653,625 / 4,441,762

12,443,642 / 4,422,939

The latest workflow run on main took 2m20s to build and 16 seconds to deploy. In Chrome the click inserted an iframe with sandbox="allow-scripts", referrerpolicy="no-referrer" and allow="", and the Compose UI rendered inside it.

Try it yourself

Paste this into Claude Code to set up the samples repo and the blog side with placeholders for your names. It reads both repos first and asks before it touches repository settings.

Hardened samples repo and framed demos

Set up live Compose Multiplatform wasm demos for my blog, built by GitHub Actions in a separate samples repo and framed only from there. Read before you write, and ask before anything irreversible.

Placeholders: <owner>, <samples-repo>, <sample> (folder name: lowercase letters, digits, dashes), <site repo> (my blog's repo).

1. Read first. In <samples-repo> and <site repo>: the README, the CSP code (where response headers are set), how the CMS component for embeds is rendered, and the content converter. Tell me what you found before changing anything.

2. Samples repo, .github/workflows/pages.yml:
- top-level `permissions: {}`; build job `contents: read`; deploy job `pages: write` and `id-token: write` with environment `github-pages`
- every action pinned to a full commit SHA with the version in a comment; look the SHAs up, never guess them
- checkout with `persist-credentials: false`; validate the Gradle wrapper; no caches
- build every top-level folder that has a gradlew, fail on any folder name outside [a-z0-9-], delete *.map files, add a robots.txt that disallows all
- pull requests build but never deploy: the deploy job runs only on a push to main
- add .github/dependabot.yml for github-actions and gradle
- repo settings, after my go, each read back with `gh api`: allowed actions `selected` with github_owned and verified off, patterns_allowed only the workflow's actions as `owner/repo@<full sha>`, sha_pinning_required; fork PR approval for all external contributors; default token `read`; interaction limit `contributors_only` (tell me when it expires)
- in each sample's index.html, set a meta Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none' (Pages cannot send headers, and frame-ancestors does not work in a meta tag)

3. Site repo:
- one regex that accepts exactly https://<owner>.github.io/<samples-repo>/<sample>/ and nothing else, used by the converter and by the component; add a test with accepted and rejected URLs
- the iframe is created only after a click, with sandbox="allow-scripts", referrerpolicy="no-referrer", allow=""
- add exactly one frame-src to the site CSP: https://<owner>.github.io/<samples-repo>/ (path included)
- before any CMS schema change, write a backup of the schema to a file. Work on drafts only.

4. Verify and show me the output: run the tests, `curl -sI https://<owner>.github.io/<samples-repo>/<sample>/` (expect 200), and confirm the CSP header has frame-src and no wasm-unsafe-eval.

Never publish, deploy or delete anything. Ask me before you change repository settings; list the exact changes first. Never print secret values.

You need a GitHub repo with Pages enabled, a site with a CSP you control, and a CMS component that takes a URL.

Honest limits

Before you paste a sample URL into a post, run curl -sI on it and expect a 200.