diff --git a/.github/workflows/deploy-sandboxed-preview.yml b/.github/workflows/deploy-sandboxed-preview.yml index a3546774d..a47017061 100644 --- a/.github/workflows/deploy-sandboxed-preview.yml +++ b/.github/workflows/deploy-sandboxed-preview.yml @@ -5,6 +5,12 @@ on: branches: [main] paths: - 'hub-client/quarto-hub-sandboxed-preview/**' + # The bundle embeds the renderer from preview-renderer source and + # vendored resources; rebuild when they change. + - 'ts-packages/preview-renderer/**' + - 'resources/js/**' + - 'resources/revealjs/**' + - 'resources/attribution/**' - '.github/workflows/deploy-sandboxed-preview.yml' workflow_dispatch: @@ -30,10 +36,18 @@ jobs: with: node-version: '24' cache: 'npm' - cache-dependency-path: hub-client/quarto-hub-sandboxed-preview/package-lock.json + cache-dependency-path: | + hub-client/quarto-hub-sandboxed-preview/package-lock.json + package-lock.json + + # The bundle imports @quarto/preview-renderer *source*, whose bare + # deps (tiptap, reveal.js, katex, …) resolve from the repo-root + # node_modules — install the workspaces first. + - name: Install workspace dependencies + run: npm ci # This package is deliberately a standalone npm project (own lockfile), - # not part of the root npm workspaces — install inside it. + # not part of the root npm workspaces — install inside it too. - name: Install dependencies run: npm ci working-directory: hub-client/quarto-hub-sandboxed-preview diff --git a/.github/workflows/github-pages.md b/.github/workflows/github-pages.md index c4baf22ff..4cecbba82 100644 --- a/.github/workflows/github-pages.md +++ b/.github/workflows/github-pages.md @@ -1,8 +1,10 @@ # GitHub Pages deployment `deploy-sandboxed-preview.yml` builds `hub-client/quarto-hub-sandboxed-preview/` -(`npm ci` + `npm run build`) and publishes its `dist/` — a self-contained -`index.html` plus `serviceWorker.js` — to **https://quarto-dev.github.io/q2/**, +(root `npm ci` for the workspace deps the renderer source needs, then +`npm ci` + `npm run build` inside the package) and publishes its `dist/` — +`index.html`, hashed `assets/` (renderer bundle, KaTeX fonts), and +`serviceWorker.js` — to **https://quarto-dev.github.io/q2/**, where hub-client's `Q2SandboxedPreviewIframe.tsx` loads it cross-origin as its iframe `src` (override with `VITE_Q2_SANDBOXED_PREVIEW_URL`). It runs on any push to `main` touching that package, or manually via diff --git a/.github/workflows/hub-client-e2e.yml b/.github/workflows/hub-client-e2e.yml index 6c832195b..de42e5cf1 100644 --- a/.github/workflows/hub-client-e2e.yml +++ b/.github/workflows/hub-client-e2e.yml @@ -133,12 +133,20 @@ jobs: - name: Build TypeScript packages (ts-packages + hub-client) env: VITE_E2E: '1' + # Point the sandboxed-preview iframe at the freshly built + # same-origin copy in public/ (built by build:sandboxed below) + # instead of the GitHub Pages deployment — PR CI must test this + # branch's renderer, not the last deployed one. + VITE_Q2_SANDBOXED_PREVIEW_URL: 'q2-sandboxed-preview/index.html' run: | for pkg in ts-packages/*/; do if [ -f "$pkg/package.json" ]; then npm run build --workspace "$pkg" --if-present fi done + # The sandboxed renderer dist must land in hub-client/public/ + # before hub-client's vite build copies public/ into dist/. + npm run build:sandboxed --workspace hub-client npm run build --workspace hub-client --if-present # globalSetup launches the hub via `cargo run --bin hub` with a 120s diff --git a/claude-notes/designs/q2-sandboxed-preview-separate-domain.md b/claude-notes/designs/q2-sandboxed-preview-separate-domain.md index 2cbfddaaa..ec2c3e9a7 100644 --- a/claude-notes/designs/q2-sandboxed-preview-separate-domain.md +++ b/claude-notes/designs/q2-sandboxed-preview-separate-domain.md @@ -1,186 +1,147 @@ -# q2-sandboxed-preview.html Separate Domain Design +# q2-sandboxed-preview Separate Origin Design -## Problem - -The `q2-sandboxed-preview.html` iframe renders raw AST JSON in a sandboxed context. For security isolation, it should be served from a separate domain (cross-origin) rather than the same origin as the main hub-client application. - -## Solution +> Updated 2026-09-10 as part of the q2-preview → sandboxed-preview port +> (`claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md`, +> epic bd-r9yr0hbe). The original document described a prototype that +> rendered raw AST JSON; the sandboxed frame now runs the full +> `@quarto/preview-renderer` renderer. -### Production Architecture - -``` -Main app: https://your-hub-domain.com/ - ├─ Serves the main React app - ├─ WebSocket connection to sync server - └─ Contains Q2SandboxedPreviewIframe component - -q2-sandboxed-preview: https://raw.your-hub-domain.com/q2-sandboxed-preview.html - └─ Single static HTML file - ├─ No external dependencies - ├─ Inline JavaScript only - └─ Communicates via postMessage -``` - -### Security Benefits - -1. **Origin isolation**: q2-sandboxed-preview.html runs in a completely separate origin -2. **No cookie access**: raw domain can't access hub cookies -3. **No localStorage access**: raw domain has separate storage -4. **Minimal attack surface**: Single static file, no build artifacts -5. **CSP enforcement**: Strict CSP on raw domain prevents XSS +## Problem -### Local Development +The sandboxed preview renders untrusted document content (including user +TSX components) in an iframe. For security isolation it is served from a +**separate origin** (cross-origin) rather than the same origin as the +main hub-client application. -For local development, we simulate the separate domain using ports: +## Architecture ``` -Main app: http://127.0.0.1:8080/ (local-prod.sh) -q2-sandboxed-preview: http://127.0.0.1:8081/ (q2-sandboxed-preview-server.mjs) +Main app: https://your-hub-domain.com/ + ├─ Serves the main React app (owns WASM + VFS) + └─ Q2SandboxedPreviewIframe (parent side of the protocol) + +Sandbox: https://quarto-dev.github.io/q2/ (GitHub Pages) + ├─ index.html + q2-preview-assets/* (renderer bundle, KaTeX fonts) + ├─ serviceWorker.js (asset proxy) + └─ Communicates ONLY via postMessage ``` -This mimics the cross-origin setup and allows testing the postMessage communication. - -## Implementation Details - -### Files - -- **`hub-client/q2-sandboxed-preview.html`**: The sandboxed HTML file -- **`scripts/q2-sandboxed-preview-server.mjs`**: Dedicated static server for local-prod -- **`hub-client/src/components/render/q2-sandboxed-preview/Q2SandboxedPreviewIframe.tsx`**: React component that loads the iframe - -### Environment Variables - -- **`VITE_Q2_SANDBOXED_PREVIEW_URL`**: URL to load q2-sandboxed-preview.html from - - Dev: `q2-sandboxed-preview.html` (served by Vite from same origin) - - Local-prod: `http://127.0.0.1:8081/q2-sandboxed-preview.html` - - Production: `https://raw.your-hub-domain.com/q2-sandboxed-preview.html` (configure as needed) - -### Build Configuration - -```bash -# Local-prod build -VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html npm run build - -# Production build (use your actual raw domain) -VITE_Q2_SANDBOXED_PREVIEW_URL=https://raw.your-hub-domain.com/q2-sandboxed-preview.html npm run build -``` - -### Nginx Configuration - -#### Main domain - -```nginx -server { - listen 443 ssl http2; - server_name your-hub-domain.com; - - # Normal hub-client serving - location / { - root /var/www/hub-client/dist; - try_files $uri $uri/ /index.html; - } -} -``` - -#### Raw subdomain (separate for security isolation) - -```nginx -server { - listen 443 ssl http2; - server_name raw.your-hub-domain.com; - - # Only serve q2-sandboxed-preview.html - location = /q2-sandboxed-preview.html { - root /var/www/hub-client/dist; - - # Strict security headers - add_header X-Frame-Options "ALLOWALL" always; - add_header Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" always; - add_header X-Content-Type-Options "nosniff" always; - - # No caching - add_header Cache-Control "no-cache, no-store, must-revalidate" always; - } - - # Deny all other requests - location / { - return 404; - } -} -``` - -## Communication Protocol - -The iframe communicates with the parent via postMessage: +- Deployment: `.github/workflows/deploy-sandboxed-preview.yml` publishes + `hub-client/quarto-hub-sandboxed-preview/dist/` to GitHub Pages (see + `.github/workflows/github-pages.md`). +- Local dev fallback: the build also copies dist/ to + `hub-client/public/q2-sandboxed-preview/` (gitignored); point + `VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html` at it. +- local-prod: `scripts/q2-sandboxed-preview-server.mjs` serves the dist + dir on port 8081 to simulate the separate origin + (`VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/`). + +### Security posture + +1. **Origin isolation**: the frame runs on a separate origin — no + cookies, no localStorage, no DOM reach into the parent, no WASM/VFS + access. +2. **`sandbox="allow-scripts allow-same-origin"` + separate origin**: + `allow-same-origin` is load-bearing (an opaque-origin frame cannot + register the service worker); the isolation comes from the separate + origin, not the sandbox attribute. +3. **Source checks both ways**: the parent ignores messages whose + `event.source` is not the iframe's `contentWindow`; the frame ignores + messages whose `event.source` is not `window.parent` (including the + page bridge's `url_response` listener — a forged response would + inject attacker bytes as document assets). +4. **Pinned target origins**: the parent posts to the sandbox origin + derived from the iframe URL; the frame pins the parent origin from + the first accepted message. Only the pre-contact `IFRAME_READY` and + SW-bridge `url` posts use `'*'` (they go to `window.parent`, which + only the embedder receives, and carry no document content). +5. **`allow="clipboard-write"`** is delegated so the renderer's + code-copy button works cross-origin. +6. **No strict CSP yet**: user TSX components load as blob-URL module + imports inside the frame, which a `script-src` without `blob:` would + break. A CSP for the Pages origin is deferred (see the port plan's + "Deferred" section). + +## Communication protocol + +All parent-side handling lives in +`hub-client/src/components/render/q2-sandboxed-preview/Q2SandboxedPreviewIframe.tsx`; +all frame-side handling in `hub-client/quarto-hub-sandboxed-preview/src/` +(`entry.tsx`, `registerServiceWorker.ts`, `serviceWorker.ts`). ### Parent → iframe -```typescript -iframe.contentWindow.postMessage({ - type: 'UPDATE_AST', - payload: { astJson: string } -}, '*') -``` - -### iframe → Parent - -```typescript -// Ready signal -window.parent.postMessage({ type: 'IFRAME_READY' }, '*') - -// VFS read request (currently unused by q2-sandboxed-preview, kept for compatibility) -window.parent.postMessage({ - type: 'url', - path: string -}, '*') -``` +| type | payload | +|---|---| +| `UPDATE_AST` | `{ payload: { astJson, currentFilePath, assetManifest, projectFilePaths?, pendingAnchor?, pendingAnchorEpoch?, renderedContent?, untransformedAstJson?, currentActor?, commentsMode?, unlockNestingCursor?, richText?, nestedEditBuffers? } }` — post-pipeline AST (the format maps to the preview `pipeline_kind`) | +| `UPDATE_THEME` | `{ cssText: string \| null, fingerprint }` — compiled theme as **text** (blob URLs are origin-scoped; the frame mints its own, and rewrites relative `url()` refs into the proxy namespace) | +| `LOAD_CUSTOM_COMPONENTS` | `{ componentsCode: Record }` | +| `SET_SLIDE` | `{ index }` | +| `SCROLL_TO_LINE` | `{ line }` — the frame does the `data-loc` lookup itself | +| `url_response` | `{ id, path, success, content?, error?, isBinary }` — answer to a VFS proxy request | + +### Iframe → parent + +| type | payload | +|---|---| +| `IFRAME_READY` | `{}` (posted after the service worker is registered) | +| `AST_RENDERED` | `{}` | +| `NAVIGATE_TO_DOCUMENT` | `{ path, anchor }` | +| `SET_AST` | `{ ast }` (block edits / richtext) | +| `SLIDE_CHANGED` | `{ index }` | +| `PREVIEW_SCROLLED` | `{ ratio }` (preview→editor scroll sync) | +| `CLICK_AT_LINE` | `{ line, iframeY }` (parent adds its iframe rect top for `hostY`) | +| `url` | `{ id, path }` — **the core of the asset path**: VFS proxy request relayed from the service worker | +| `hub-client-save` | `{}` (Cmd+S, from the shared link handlers) | + +### Asset proxying (any relative path, bd-00bgt5cy) + +**Any in-scope path outside the frame's own app files is served from the +VFS.** The service worker exempts only the page itself, `serviceWorker.js`, +and `q2-preview-assets/*` (the hashed renderer chunks + KaTeX fonts — the +dir is deliberately not `assets/` so a project's own `assets/` folder is +proxied normally); every other same-origin GET is relayed `SW → page → parent → WASM VFS → back`, +correlated by request id with timeouts at each hop. The URL path relative +to the SW scope IS the VFS path. + +The parent still resolves AST image targets against `currentFilePath` +(mirroring q2-preview's `assetWalker`) and ships a manifest of +`origPath → ` page-relative URLs — that's what keeps +`../` and subdirectory images correct, and same-named files in different +directories distinct. Paths the manifest never saw (an `` in raw +HTML or navbar chrome) are proxied too; the parent retries them against +the current document's directory on a VFS miss. Theme CSS relative +`url()` refs are rewritten to absolute page URLs against +`.quarto/project-artifacts`, so theme fonts resolve (they don't in +q2-preview's blob-based ``). + +Known limitation, deliberate: project files literally named `index.html`, +`serviceWorker.js`, or under `q2-preview-assets/` at the VFS root are +shadowed by the app-file exemption (they fall through to the network). +The asset dir is named `q2-preview-assets` precisely so no real project +trips over this. + +Policy (interception namespace, binary classification, MIME table, +CSS rewriting) is a single module shared by the SW, the page bridge, +and the parent responder: +`quarto-hub-sandboxed-preview/src/assetPolicy.ts`. ## Testing -### Local-prod mode - -```bash -# Build with local-prod URL -cd hub-client -npm run build:local-prod - -# Start all servers -cd .. -./scripts/local-prod.sh - -# Open http://127.0.0.1:8080 -# Navigate to a document with q2-sandboxed-preview format -``` - -### Verification - -1. Open browser DevTools → Network tab -2. Find the q2-sandboxed-preview.html request -3. Verify it loads from `http://127.0.0.1:8081` -4. Check Console for postMessage events -5. Verify AST renders correctly - -## Deployment Checklist - -- [ ] Choose subdomain for raw rendering (e.g., `raw.your-hub-domain.com`) -- [ ] Set up DNS: subdomain → server IP -- [ ] TLS certificate for raw subdomain -- [ ] Nginx config for raw subdomain -- [ ] Build with production URL: `VITE_Q2_SANDBOXED_PREVIEW_URL=https://raw.your-hub-domain.com/q2-sandboxed-preview.html` -- [ ] Deploy q2-sandboxed-preview.html to raw subdomain -- [ ] Test cross-origin postMessage -- [ ] Verify CSP headers -- [ ] Check iframe sandbox attributes - -## Future Enhancements - -1. **Subdomain isolation for other renderers**: Apply same pattern to q2-debug, q2-preview -2. **CDN deployment**: Serve from CDN for global edge caching -3. **Version pinning**: URL with hash for cache-busting (`q2-sandboxed-preview-abc123.html`) -4. **Multiple raw formats**: Extend pattern to other simple renderers - -## References - -- MDN: [Window.postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) -- MDN: [iframe sandbox attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox) -- OWASP: [Clickjacking Defense](https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html) +- Unit/protocol: `hub-client/src/components/render/q2-sandboxed-preview/*.test.ts(x)` + (policy round-trips, manifest resolution, protocol forwarding, source + checks) and `scrollClickBridge.test.ts`. +- End-to-end: `hub-client/e2e/q2-sandboxed-preview.spec.ts` — real hub + + WASM pipeline; asserts themed render, KaTeX, and an image decoded + through the SW proxy inside the frame. +- local-prod: `npm run local-prod:fresh` (or `:nginx`), open a document + with `format: q2-sandboxed-preview`. + +## Future enhancements + +1. Strict CSP on the Pages origin (needs a story for blob-URL module + imports used by custom components). +2. SW caching/offline (disabled in commit `103af4445`). +3. Version pinning / cache-busting for the Pages deployment. +4. Routing `format: revealjs` through the sandboxed frame (RevealDeck is + bundled but has no production route today). diff --git a/claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md b/claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md new file mode 100644 index 000000000..693f24e4f --- /dev/null +++ b/claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md @@ -0,0 +1,326 @@ +# Port q2-preview functionality into q2-sandboxed-preview + +## Overview + +Bring the full `q2-preview` renderer experience to the `q2-sandboxed-preview` +format, which runs in a **cross-origin iframe** (GitHub Pages, +`https://quarto-dev.github.io/q2/`) with real origin isolation and a +service-worker asset proxy. + +**Hard constraint: q2-preview is not modified.** No behavior changes to +`ts-packages/preview-renderer`'s q2-preview surface, `Q2PreviewIframe`, +`q2-preview/entry.tsx`, or hub-client's q2-preview wiring. Everything the +sandboxed path needs that can't be imported unmodified gets **copied into +`hub-client/quarto-hub-sandboxed-preview/`** (iframe side) or into +`hub-client/src/components/render/q2-sandboxed-preview/` (parent side). + +### Why this port is not a file copy + +q2-preview's parent/iframe split leans on same-origin access in exactly three +places; everything else is already postMessage + pure React-over-AST-JSON: + +1. **Assets**: parent-minted blob URLs (`assetWalker.ts`, theme CSS in + `Q2PreviewIframe.tsx:463-468`) are origin-scoped — unusable from the + cross-origin iframe. Replacement: the existing service-worker proxy + (SW intercepts fetch → postMessage to iframe page → parent → WASM VFS → + back down), which also covers cases blob manifests never did (CSS + `url()` font refs, `` inside chrome HTML strings). +2. **Scroll sync + click-to-line**: `Q2PreviewIframe.tsx:210-269` reads + `iframe.contentDocument` directly. Replacement: re-host the pure-DOM + helpers (`scrollSyncDom.ts`) *inside* the iframe and add postMessage + commands/reports. +3. **Clipboard**: `codeCopy.ts` uses `navigator.clipboard` — restricted in + the cross-origin frame; proxy through the parent. + +### Key decisions + +- **Reuse `@quarto/preview-renderer` unmodified, copy only boundary files.** + The iframe-side surface (`framework/**`, `q2-preview/**` minus + `assetWalker.ts`, `utils/*`) is decoupled from WASM and same-origin — it + consumes AST JSON + a URL manifest. The sandboxed project imports these + modules directly from package **source** via a Vite alias into + `../../ts-packages/preview-renderer/src` (zero changes to the package, no + exports-map edit needed). Copied-and-adapted files: `entry.tsx` (iframe + side) and `Q2PreviewIframe.tsx` (parent side, becomes the grown + `Q2SandboxedPreviewIframe.tsx`). + - Requires in the sandboxed project: React 18 → 19, `resolve.conditions: + ['source', …]`, the `virtual:quarto-attribution-viewer-css` plugin + (copy from `hub-client/vite.config.ts:36-52`), `server.fs.allow` for + the repo-root `resources/**` `?raw`/CSS imports, plus the package's + runtime deps (tiptap, reveal.js, `@babel/standalone`, morphdom, katex). +- **Drop the single-file build; deploy the full multi-file `dist/`.** + `vite-plugin-singlefile` would have to base64-inline tiptap, prosemirror, + Babel standalone, reveal CSS, Bootstrap and ~60 KaTeX fonts into one HTML + (tens of MB). GitHub Pages already serves a directory (it deploys + `dist/` today — `index.html` + `serviceWorker.js`); a normal build with + `/assets/*` chunks works cross-origin because they're same-origin *to the + iframe*. The SW skips its own origin's real app assets by path prefix. + Local fallbacks (`scripts/q2-sandboxed-preview-server.mjs`, the + `hub-client/public/` copy) switch from two files to the dist dir. +- **Theme CSS travels as text**, not a blob URL: parent reads + `/.quarto/project-artifacts/styles.css` and posts the CSS string; the + iframe mints its *own* blob URL (same-origin to itself) for the + `` swap. Relative `url()` refs inside the CSS then + resolve against the iframe origin and are picked up by the SW proxy — + fonts work for the first time. +- **Pipeline parity is a prerequisite**: `q2-sandboxed-preview` currently + maps to `("html", None)` (`crates/quarto-core/src/format.rs:125`) and is + absent from `pipelineKindForFormat` + (`ts-packages/preview-runtime/src/pipelineKind.ts:27-34`), so it gets a + raw parse-only AST — no highlight spans, no chrome metadata, no theme + fingerprint. Both mappings change to match `q2-preview`'s + `Some("preview")`. (This touches shared files but only the + sandboxed-format entries — q2-preview behavior unchanged.) + +### Existing proxy defects fixed as part of Phase 2 + +- SW extension allowlist (`gif|png|jpg` only) disagrees with the parent's + binary list — the in-code TODO pair. Unify into one shared module. +- `registerServiceWorker.ts:41` strips URLs to their basename → same-named + files in different directories collide; forward full pathnames and + resolve against `currentFilePath` (reuse `utils/vfsPaths.ts` logic). +- Per-request `message` listener leaks in `serviceWorker.ts:80` and + `registerServiceWorker.ts` (no timeout/rejection); add request ids + + timeout. +- No `event.origin` validation anywhere; all posts use `'*'` (Phase 5). + +## Phases and work items + +### Phase 0 — pipeline parity + test scaffolding ✅ (d6df9500a, bd-jgpz4hfq) + +- [x] Test first: mapping tests on both sides + (`test_from_format_string_q2_sandboxed_preview` in format.rs, + pipelineKind.test.ts) — verified failing before the fix. +- [x] Rust: `format.rs` `q2-sandboxed-preview` → `("html", Some("preview"))`; + `wasm-quarto-hub-client`'s `coerce_format_for_print` needs no change + (printable fallback to html stays correct). +- [x] TS: add `q2-sandboxed-preview` to `pipelineKindForFormat`. +- [x] `cargo nextest run --workspace` (39 pre-existing environmental + failures, identical with change stashed — pandoc missing on this + machine) + `cargo xtask verify --skip-rust-tests` fully green. + +### Phase 1 — real renderer inside the sandboxed bundle + +- [x] Sandboxed project deps/config: React 19, preview-renderer source + alias (+ `@quarto/preview-runtime` **stub** at + `src/stubs/preview-runtime.ts` — the q2-preview barrel drags + parent-side WASM-coupled modules into the graph), attribution-CSS + virtual plugin, dedupe react/react-dom/katex, drop `viteSingleFile`, + emit normal `dist/` with `base: './'`. +- [x] Copy-and-adapt `entry.tsx` → `src/entry.tsx` (PreviewRoot/registry + imported unmodified; promise-ordered dispatcher copied as + `iframeMessageDispatch.ts` with `UPDATE_THEME.cssText`; local + blob-URL theme minting; SW init gates IFRAME_READY). +- [x] Delete `basicRenderer.tsx` + toy `App.tsx`; removed stale committed + artifacts (`hub-client/public/q2-sandboxed-preview.html`, + `hub-client/public/serviceWorker.js`, root leftover); gitignored + `hub-client/public/q2-sandboxed-preview/`. +- [x] Deploy targets: GH Pages workflow adds root `npm ci` + wider path + triggers (preview-renderer, resources); dist-dir server script; + `build-local-prod.sh` URL → `http://127.0.0.1:8081/`; docs updated + (project README, scripts/README, github-pages.md). +- [x] Parent minimum viable wiring (TDD, tests first, 4 failing → green): + `Q2SandboxedPreviewIframe` now takes `currentFilePath` + + three-way `themeFingerprint`, posts `UPDATE_THEME {cssText}`; + ReactRenderer passes both. +- [x] Smoke (end-to-end, headless chromium): served + `hub-client/public/q2-sandboxed-preview/` via + `scripts/q2-sandboxed-preview-server.mjs`, posted + `UPDATE_AST {astJson, currentFilePath}` + `UPDATE_THEME {cssText: + 'h1 { color: rgb(1,2,3) }'}` → observed `

Hello sandbox

`, + `

Rendered by PreviewRoot.

`, computed h1 color + `rgb(1, 2, 3)`, `` present, SW `active`. + Output inspected; recorded 2026-09-01. + +### Phase 2 — asset proxying, done properly + +Design refinement over the original sketch: instead of extension-guessing +interception, proxied assets live in an explicit **page-relative namespace** +`__q2_vfs__/`. The parent resolves image targets against +`currentFilePath` at manifest-build time (mirroring `assetWalker`'s +resolution exactly), so the full resolved path rides in the URL — in-scope +under `/q2/` on Pages, immune to basename collisions, and app assets +(`assets/*`, fonts, the page) are never touched. + +- [x] Tests first (19 new, red → green): proxy URL round-trip (subdirs, + spaces, basename-collision regression), namespace misses, binary + classification, MIME table, CSS `url()` rewriting, manifest + resolution (`../`, root-absolute, external skip), parent responder + id correlation, manifest-in-payload. +- [x] Shared `quarto-hub-sandboxed-preview/src/assetPolicy.ts` used by the + SW, the page bridge, and the parent responder (kills the skewed-list + TODO pair). +- [x] SW rework: intercepts only `__q2_vfs__` GETs; one persistent message + listener + request-id map (fixes per-request listener leak); 10s + timeout → 504; miss → 404; text vs binary bodies. +- [x] Page bridge rework: full-path forwarding (no basename stripping), + id-correlated with timeout + listener cleanup. +- [x] Parent responder: id-keyed `url`/`url_response`, shared + `isBinaryPath`; ships `buildProxyAssetManifest(astJson, + currentFilePath)` in the UPDATE_AST payload (no VFS reads at + manifest time — bytes on demand). +- [x] Theme fonts: `rewriteThemeCssUrls` rewrites relative `url()` refs in + the posted theme CSS into the proxy namespace against + `.quarto/project-artifacts` (q2-preview loses these entirely; + absolute/data:/blob:/# refs untouched). +- [x] End-to-end smoke (headless chromium, harness parent on :8098, + renderer iframe on :8099): AST with `images/pic.png` + + manifest entry → `` + → SW intercept → bridge request `project/sub/images/pic.png` → + harness returns PNG bytes → image decodes at natural size 1×1. + Output inspected; recorded 2026-09-01. + +### Phase 3 — parent feature parity (scroll sync, click-to-line) + +- [x] Tests first (12 new, red → green): bridge unit tests + (PREVIEW_SCROLLED ratio on scroll, CLICK_AT_LINE with line + block + top, silent on unlocated / included-file clicks, SCROLL_TO_LINE + scrolling with visibility check) + parent protocol tests (handle + posts SCROLL_TO_LINE / cached getScrollRatio, hostY = iframeY + + iframe top, NAVIGATE/SET_AST/SLIDE_CHANGED/AST_RENDERED forwarding, + LOAD_CUSTOM_COMPONENTS, deduped SET_SLIDE incl. echo guard, full + UPDATE_AST payload). +- [x] Iframe side: `scrollClickBridge.ts` imports + `findElementForLine`/`isElementVisible`/`lineForClickTarget` + unmodified from `scrollSyncDom`; entry installs the bridge at module + top and handles `SCROLL_TO_LINE`. +- [x] Parent side: `Q2SandboxedPreviewIframe` grown to the full + `Q2PreviewIframe` surface (same `Q2PreviewIframeHandle` interface; + `getScrollRatio` reads the last `PREVIEW_SCROLLED` ratio, null + before first report; SET_SLIDE echo-dedup; state reset on + IFRAME_READY). +- [x] `ReactRenderer.tsx` sandboxed branch passes the full prop set, + mirroring the q2-preview branch (custom-components *generation* gate + still excludes the sandboxed format — Phase 4). +- [x] End-to-end smoke (headless chromium, harness + iframe on separate + ports, 80-paragraph document, data-loc stamped on two blocks): + SCROLL_TO_LINE 61 scrolled the frame 0 → 1865px; smooth scroll + emitted PREVIEW_SCROLLED ratios; pointerup on the located block + posted CLICK_AT_LINE {line: 11, iframeY: 156}. Output inspected; + recorded 2026-09-10. + +### Phase 4 — remaining functionality + +- [x] `LOAD_CUSTOM_COMPONENTS` (user TSX): ReactRenderer's + component-transpile gate extended to `q2-sandboxed-preview` (TDD: + failing ReactRenderer integration test first, plus a routing test). + Browser smoke: LOAD_CUSTOM_COMPONENTS with a Para override → + blob-URL `import()` inside the allow-scripts frame → override + rendered (`CUSTOM PARA` sentinel observed). No strict CSP on the + Pages origin yet — blob script imports depend on that staying true + (deferred item unchanged). +- [x] Rich text / tiptap (`richText`, `nestedEditBuffers`, `SET_AST` edit + payloads): fully wired through the full-surface props + protocol + tests (Phase 3); interactive editing verified in Phase 5's real + session, not separately here. +- [x] Slides: `SET_SLIDE`/`SLIDE_CHANGED` unit-tested both sides + (echo-dedup included); RevealDeck + reveal CSS are in the bundle. + Note: in hub-client, `format: revealjs` routes to `Q2PreviewIframe`, + not the sandboxed frame, and the sandboxed pseudo-format is + html-based (`isSlides` false) — so a sandboxed reveal deck has no + production route today; the machinery is ported and inert. +- [x] `hub-client-save` (Cmd+S): posted by the unmodified + `installLinkHandlers` inside the frame; `App.tsx`'s window-level + listener is origin-agnostic. No change needed. +- [x] Clipboard: `allow="clipboard-write"` delegated on the iframe so the + unmodified `codeCopy.ts` can call `navigator.clipboard.writeText` + cross-origin (test-first). No COPY_TO_CLIPBOARD proxy message needed. +- [x] Comments mode / attribution (`currentActor`, `commentsMode`, + `untransformedAstJson`, `renderedContent`): shipped in the Phase 3 + payload; covered by the full-payload protocol test. + +### Phase 5 — hardening + verification + +- [x] Source/origin checks (TDD, negative test first): the parent ignores + messages whose `event.source !== iframe.contentWindow` (forged + SET_AST cannot reach document state); the frame ignores messages + whose `event.source !== window.parent` — including the page + bridge's `url_response` listener (forged responses would inject + attacker bytes as document assets). Target origins pinned: parent → + sandbox origin derived from the iframe URL; frame → parent origin + pinned from first accepted message. `'*'` remains only on the + pre-contact IFRAME_READY and the SW-bridge `url` request (both go + to `window.parent`, which only the embedder receives, and carry no + document content). +- [x] Debug logs: the SW/bridge rewrites (Phase 2) already dropped the + per-request logs; the one-time SW registration logs are kept + (parity with q2-preview's own logging). +- [x] Docs: sandboxed README rewritten in Phase 1; + `claude-notes/designs/q2-sandboxed-preview-separate-domain.md` + rewritten (full protocol table, `url` as the core asset path, + security posture incl. CSP/blob note, testing pointers). +- [x] **End-to-end, real pipeline**: new Playwright spec + `hub-client/e2e/q2-sandboxed-preview.spec.ts` — real hub server + (globalSetup `cargo run --bin hub`), real WASM preview pipeline, + project with `_quarto.yml` + `images/dot.png` (binary) + a + `format: q2-sandboxed-preview` doc. Observed inside the sandboxed + frame: title block `

Sandboxed Doc

`, content + heading with a real `data-loc` stamp, KaTeX `.katex` markup, + ``, and + `` decoded at natural size + 1×1 through the SW proxy. `npx playwright test + e2e/q2-sandboxed-preview.spec.ts` → 1 passed (5.0s), 2026-09-10. + Wiring: `test:e2e` script + the CI e2e workflow now build the + sandboxed bundle and set + `VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html` + so CI tests this branch's renderer, not the last Pages deploy. + Additional harness smokes re-run against the hardened bundle: + proxy+theme (incl. theme-CSS `url()` font round trip through the + SW), scroll/click, custom components — all green. + Not exercised in a real browser session: interactive richtext + editing and Cmd+S inside the sandboxed frame (machinery identical + to q2-preview's, protocol unit-tested; verify by hand in + local-prod when convenient). +- [x] `cargo xtask verify --skip-rust-tests` (Rust untouched since + Phase 0's fully-verified commit) green at phase close. + +## Post-epic amendment (2026-09-10, bd-00bgt5cy) + +Any relative path is now proxied from the VFS — the `__q2_vfs__/` URL +prefix is gone. The SW exempts only the frame's app files (the page, +`serviceWorker.js`, and the bundle asset dir) and treats every other +in-scope path as a VFS path; the manifest ships bare resolved paths +(``), and non-manifest fetches (raw-HTML / +chrome images) are retried against the current document's directory on a +VFS miss. The bundle asset dir was renamed `assets/` → +`q2-preview-assets/` (vite `build.assetsDir` + `APP_ASSET_DIR` in +assetPolicy.ts, kept in sync) so a project's own `assets/` folder is +never shadowed; the residual shadowing surface is just `index.html`, +`serviceWorker.js`, and `q2-preview-assets/` at the project root. +Verified: 43 sandboxed unit tests (incl. exemption, assets/-proxied, and +fallback cases), full unit tier, and the e2e spec +(`img[src="images/dot.png"]` decoded through the proxy) green. + +## Deferred / out of scope + +- Strict CSP on the Pages origin (blocked by blob-script custom components). +- SW caching/offline (deliberately disabled in `103af4445`; unchanged). +- Retiring the old q2-preview path in favor of the sandboxed one — separate + decision once parity is proven. +- `q2 preview` (the CLI's q2-preview-spa) migration to this model — the + embedded single-server design needs a second-origin story first; noted in + the 2026-09-01 exploration but not part of this port. + +## Bookkeeping + +- [x] Epic bd-r9yr0hbe with per-phase strands bd-jgpz4hfq (0), + bd-44ohor2w (1), bd-k9tfhsst (2), bd-xqg0t494 (3), bd-rhn8wi8r (4), + bd-5fwm3zju (5). All closed 2026-09-10; commits d6df9500a, + 5684cfead, e7f9fcc2a, 0d5967f2a, 9f7efd8a9, 14de97b62 (+ changelog + commits) on branch q2-sandboxed-preview. + +## Reference: exploration findings (2026-09-01) + +- Full q2-preview postMessage protocol + prop surface: + `Q2PreviewIframe.tsx:310-476`, `entry.tsx:249-282,459`; + dispatch in `ReactRenderer.tsx:257-341`. +- Existing SW proxy chain: + `quarto-hub-sandboxed-preview/src/serviceWorker.ts:61-95`, + `registerServiceWorker.ts:4-52`, + `Q2SandboxedPreviewIframe.tsx:25-68`. +- Blob URLs are origin-scoped; the parent's cannot be fetched from the + cross-origin iframe — that's why the SW proxy exists. +- `sandbox="allow-scripts allow-same-origin"` on the sandboxed iframe is + load-bearing: without `allow-same-origin` the frame is opaque-origin and + `serviceWorker.register` throws. Isolation comes from the separate + origin (github.io), not the sandbox attribute. diff --git a/crates/quarto-core/src/format.rs b/crates/quarto-core/src/format.rs index 7f71a12b3..9ebed4db5 100644 --- a/crates/quarto-core/src/format.rs +++ b/crates/quarto-core/src/format.rs @@ -122,7 +122,10 @@ fn builtin_pseudo_format(name: &str) -> Option<(&'static str, Option<&'static st "q2-slides" => Some(("html", Some("preview"))), "q2-debug" => Some(("html", None)), "q2-preview" => Some(("html", Some("preview"))), - "q2-sandboxed-preview" => Some(("html", None)), + // Sandboxed-preview port (bd-jgpz4hfq): same preview pipeline as + // q2-preview — the sandboxed renderer needs the post-pipeline AST + // (highlight spans, chrome metadata, theme fingerprint). + "q2-sandboxed-preview" => Some(("html", Some("preview"))), _ => None, } } @@ -861,6 +864,21 @@ mod tests { assert_eq!(f.pipeline_kind, Some("preview")); } + #[test] + fn test_from_format_string_q2_sandboxed_preview() { + let f = Format::from_format_string("q2-sandboxed-preview").unwrap(); + assert_eq!(f.identifier, FormatIdentifier::Html); + assert_eq!(f.target_format, "q2-sandboxed-preview"); + assert_eq!(f.extension_name, None); + assert_eq!(f.output_extension, "html"); + assert!(f.native_pipeline); + // Sandboxed-preview port (bd-jgpz4hfq): the sandboxed renderer + // consumes the same post-pipeline AST as q2-preview (highlight + // spans, chrome metadata, theme fingerprint), so it uses the + // preview pipeline_kind rather than the raw parse-only path. + assert_eq!(f.pipeline_kind, Some("preview")); + } + #[test] fn test_lua_format_for_maps_preview_pseudo_formats() { // HTML-emulating pseudo-formats resolve to `html` so diff --git a/hub-client/.gitignore b/hub-client/.gitignore index 030b33c59..9197307a2 100644 --- a/hub-client/.gitignore +++ b/hub-client/.gitignore @@ -13,6 +13,10 @@ dist-ssr dist-preview-embed *.local +# Generated by quarto-hub-sandboxed-preview's build (same-origin fallback +# copy of the sandboxed renderer dist) +public/q2-sandboxed-preview/ + # Playwright playwright-report/ test-results/ diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 5575eb2bd..dfdc93eb0 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -25,6 +25,9 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-09-10 +- [`14de97b62`](https://github.com/quarto-dev/q2/commits/14de97b62): The sandboxed preview now validates the sender of every cross-frame message, so other windows can't inject content into the preview or the document. +- [`9f7efd8a9`](https://github.com/quarto-dev/q2/commits/9f7efd8a9): Custom render-components (user TSX overrides) and the code-copy button now work in the sandboxed preview. +- [`0d5967f2a`](https://github.com/quarto-dev/q2/commits/0d5967f2a): The sandboxed preview now supports editor-preview scroll sync, click-to-source navigation, document links, slide navigation, and live editing — matching the standard preview. - [`0f17a8b2c`](https://github.com/quarto-dev/q2/commits/0f17a8b2c): q2-preview task lists: the checkbox and its item text render on one line again, and loose (blank-line-separated) task items now get checkboxes too ### 2026-09-09 @@ -46,6 +49,8 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-09-01 +- [`e7f9fcc2a`](https://github.com/quarto-dev/q2/commits/e7f9fcc2a): Images and theme fonts now load correctly in the sandboxed preview, including same-named images in different folders and pictures in subdirectories. +- [`5684cfead`](https://github.com/quarto-dev/q2/commits/5684cfead): The `q2-sandboxed-preview` format now renders documents with the real preview renderer (headers, paragraphs, math, code highlighting, theme CSS) instead of the placeholder box view. - [`a4c3fb9c`](https://github.com/quarto-dev/q2/commits/a4c3fb9c): The experimental branch bar's Merge button now uses the app's standard accent color in both themes, and its branch-name input shows the standard keyboard focus ring. - [`41da392c`](https://github.com/quarto-dev/q2/commits/41da392c): Branch chips in the experimental branch bar now activate on click and via the keyboard, and deleting a branch no longer also switches to it. - [`e4a72819`](https://github.com/quarto-dev/q2/commits/e4a72819): Math rendering upgraded to KaTeX 0.18.4 everywhere — the sandboxed preview bundle, the hub client, and the CDN link rendering emits for `html-math-method: katex` stay aligned on one version. diff --git a/hub-client/e2e/q2-sandboxed-preview.spec.ts b/hub-client/e2e/q2-sandboxed-preview.spec.ts new file mode 100644 index 000000000..0932caccc --- /dev/null +++ b/hub-client/e2e/q2-sandboxed-preview.spec.ts @@ -0,0 +1,105 @@ +/** + * E2E test for the q2-sandboxed-preview format: the full q2-preview + * renderer running inside the sandboxed iframe against a real hub + + * WASM render pipeline. + * + * Covers the port end-to-end (2026-09-01 sandboxed-preview port plan): + * - Phase 0: the format runs the preview pipeline (theme fingerprint → + * `` inside the frame; KaTeX math from the real + * renderer). + * - Phase 1: the real PreviewRoot renders the document (heading text). + * - Phase 2 + bd-00bgt5cy: images resolve through the service-worker + * VFS proxy (bare manifest-resolved path in the src; decoded natural + * size). + * + * The iframe is the same-origin copy at public/q2-sandboxed-preview/ + * (`VITE_Q2_SANDBOXED_PREVIEW_URL` is set by the test:e2e script and the + * CI workflow) — same bundle the GitHub Pages origin serves in + * production, without depending on the last deployed version. + */ + +import { test, expect } from '@playwright/test'; +import { + bootstrapProjectSet, + createProjectOnServer, + seedProjectInBrowser, + getServerUrl, +} from './helpers/projectFactory'; + +// 1x1 red PNG. +const DOT_PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +const qmdContent = `--- +title: Sandboxed Doc +format: q2-sandboxed-preview +--- + +# Hello Sandbox + +Inline math: $e = mc^2$ + +![A tiny dot](images/dot.png) +`; + +test.describe('q2-sandboxed-preview format', () => { + test('renders a themed document with math and proxied images in the sandboxed iframe', async ({ + page, + }) => { + const serverUrl = getServerUrl(); + + const indexDocId = await createProjectOnServer(serverUrl, [ + { + path: '_quarto.yml', + content: 'project:\n type: default\n', + contentType: 'text', + }, + { + path: 'images/dot.png', + content: DOT_PNG_B64, + contentType: 'binary', + mimeType: 'image/png', + }, + { + path: 'sandboxed.qmd', + content: qmdContent, + contentType: 'text', + }, + ]); + + await bootstrapProjectSet(page, serverUrl); + const localId = await seedProjectInBrowser(page, indexDocId, serverUrl); + + await page.goto( + `/#/p/${localId}/file/${encodeURIComponent('sandboxed.qmd')}`, + ); + + const iframe = page.frameLocator('iframe[title="q2-sandboxed-preview Renderer"]'); + + // Phase 1: the real renderer commits the document — both the title + // block and the content heading (which carries a data-loc stamp). + await expect( + iframe.getByRole('heading', { name: 'Hello Sandbox' }), + ).toBeVisible({ timeout: 45000 }); + await expect( + iframe.getByRole('heading', { name: 'Sandboxed Doc' }), + ).toBeVisible(); + + // Phase 0: preview pipeline ran — KaTeX markup (not raw $…$) and the + // compiled theme applied as inside the frame. + await expect(iframe.locator('.katex').first()).toBeVisible(); + await expect(iframe.locator('link[data-q2-theme]')).toHaveCount(1, { + timeout: 20000, + }); + + // Phase 2 + bd-00bgt5cy: the image goes through the service-worker + // VFS proxy with the bare manifest-resolved path, and actually decodes. + const img = iframe.locator('img[src="images/dot.png"]'); + await expect(img).toBeVisible(); + await expect + .poll(async () => img.evaluate((el: HTMLImageElement) => el.naturalWidth), { + timeout: 20000, + }) + .toBe(1); + }); +}); diff --git a/hub-client/package.json b/hub-client/package.json index 9fd1d0ecf..ad2924896 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -29,8 +29,8 @@ "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:wasm": "vitest run --config vitest.wasm.config.ts", "test:wasm:watch": "vitest --config vitest.wasm.config.ts", - "test:e2e": "npm run build:wasm && VITE_E2E=1 npm run build && playwright test", - "test:e2e:ui": "npm run build:wasm && VITE_E2E=1 npm run build && playwright test --ui", + "test:e2e": "npm run build:wasm && npm run build:sandboxed && VITE_E2E=1 VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html npm run build && playwright test", + "test:e2e:ui": "npm run build:wasm && npm run build:sandboxed && VITE_E2E=1 VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html npm run build && playwright test --ui", "test:harness": "npm run build:wasm && VITE_E2E=1 npm run build && playwright test --config playwright.harness.config.ts", "test:ci": "npm run test && npm run test:integration && npm run test:wasm", "test:all": "npm run build:wasm && npm run test && npm run test:integration && npm run test:wasm && npm run test:e2e", diff --git a/hub-client/public/q2-sandboxed-preview.html b/hub-client/public/q2-sandboxed-preview.html deleted file mode 100644 index b142986e5..000000000 --- a/hub-client/public/q2-sandboxed-preview.html +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - - q2-raw Renderer - - - - - -
Loading q2-raw renderer...
- - - - \ No newline at end of file diff --git a/hub-client/public/serviceWorker.js b/hub-client/public/serviceWorker.js deleted file mode 100644 index e3b0639f2..000000000 --- a/hub-client/public/serviceWorker.js +++ /dev/null @@ -1 +0,0 @@ -(function(){"use strict";console.log("sandboxed preview service worker started"),self.addEventListener("install",async()=>{console.log("done installing sandboxed preview service worker")}),self.addEventListener("activate",e=>{e.waitUntil(self.clients.claim())});const a=e=>{const s=atob(e),n=new Uint8Array(s.length);for(let t=0;t{var t;const s=(t=e.split(".").pop())==null?void 0:t.toLowerCase();return{png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",svg:"image/svg+xml",webp:"image/webp",ico:"image/x-icon",pdf:"application/pdf",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",wasm:"application/wasm",ttf:"font/ttf",woff:"font/woff",woff2:"font/woff2"}[s||""]||"application/octet-stream"},c=e=>new Promise(async s=>{const n=e.request.url,t=await self.clients.get(e.clientId),i=p=>{const o=p.data;o.type==="response"&&o.url===n&&(console.log("RESOLVING TO",o),s(new Response(a(o.content),{status:200,headers:{"Content-Type":r(n)}})),self.removeEventListener("message",i))};self.addEventListener("message",i),t.postMessage({type:"request",url:n})}),l=e=>e.endsWith("gif")||e.endsWith("png")||e.endsWith("jpg");self.addEventListener("fetch",function(e){console.log("REQUEST:",e.clientId,e.request.url),e.request.method==="GET"&&l(e.request.url)&&e.respondWith(c(e))})})(); diff --git a/hub-client/q2-sandboxed-preview.html b/hub-client/q2-sandboxed-preview.html deleted file mode 100644 index 2e15420ba..000000000 --- a/hub-client/q2-sandboxed-preview.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - q2-raw Renderer - - - -
Loading q2-raw renderer...
- - - diff --git a/hub-client/quarto-hub-sandboxed-preview/README.md b/hub-client/quarto-hub-sandboxed-preview/README.md index cd7b7e9fc..5d6e7570e 100644 --- a/hub-client/quarto-hub-sandboxed-preview/README.md +++ b/hub-client/quarto-hub-sandboxed-preview/README.md @@ -1,38 +1,72 @@ # quarto-hub-sandboxed-preview -A standalone Vite project that builds a self-contained, sandboxed iframe renderer for Quarto Hub. +A standalone Vite project that builds the sandboxed iframe renderer for +Quarto Hub — the full `q2-preview` renderer (via `@quarto/preview-renderer`) +packaged to run cross-origin. ## Purpose -This project bundles React, KaTeX, and other dependencies into a single HTML file (`q2-sandboxed-preview.html`) that can run in a sandboxed iframe **without** `allow-same-origin`. This provides better security isolation. +This project bundles the real preview renderer (PreviewRoot, registry, +KaTeX, Bootstrap chrome JS) into a self-contained multi-file `dist/` that is +served from a **separate origin** (GitHub Pages in production), giving the +document content real origin isolation from the hub-client app: no cookie +access, no localStorage access, no reach into the parent DOM or the WASM VFS. +All communication with the parent is postMessage; document assets are proxied +through a service worker (see `src/serviceWorker.ts`). + +Note on the sandbox attribute: the iframe is loaded with +`sandbox="allow-scripts allow-same-origin"`. `allow-same-origin` is +load-bearing — without it the frame gets an opaque origin and the service +worker cannot register. The isolation comes from the *separate origin*, not +from the sandbox attribute. ## Architecture -- **Separate build process**: Has its own `package.json`, `vite.config.ts`, and dependencies -- **Single-file output**: Uses `vite-plugin-singlefile` to inline all JS/CSS into one HTML file -- **Output location**: Builds to `../q2-sandboxed-preview.html` (hub-client root) -- **Sandboxed**: The output HTML runs with only `sandbox="allow-scripts"` (no `allow-same-origin`) +- **Separate build process**: own `package.json` + lockfile (deliberately NOT + part of the root npm workspaces), own `vite.config.ts` +- **Renderer from source**: `@quarto/preview-renderer` is aliased to + `../../ts-packages/preview-renderer/src` (its deps resolve from the + repo-root `node_modules`, so run `npm install` at the repo root first); + `@quarto/preview-runtime` is stubbed (`src/stubs/preview-runtime.ts`) — + the iframe never touches WASM +- **Multi-file output**: normal Vite build with `base: './'` — + `dist/index.html` + `dist/q2-preview-assets/*` + `dist/serviceWorker.js` + (the asset dir is deliberately not `assets/` — the service worker exempts + it from VFS proxying, and a project's own `assets/` folder must not be + shadowed) +- **Output locations**: `dist/` (deployed to GitHub Pages by + `.github/workflows/deploy-sandboxed-preview.yml`) and a copy at + `../public/q2-sandboxed-preview/` (gitignored; same-origin fallback for + hub-client dev via `VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html`) ## Development -It is recommended to not work directly in this directory, but instead use `npm run local-prod:fresh:nginx` to test hub client with the `q2-sandboxed-preview` format. +It is recommended to not work directly in this directory, but instead use +`npm run local-prod:fresh:nginx` to test hub client with the +`q2-sandboxed-preview` format. -This approach is the closest thing we have so far to simulating prod locally. The main issue -with it is that it does not provide HMR, so its harder to develop with. It would be nice to -figure out how to get simulataneous HMR for both the sandboxed preview and the main -app in a way that we don't have to think too much about service worker registration, -but we don't have that for now. +This approach is the closest thing we have so far to simulating prod locally. +The main issue with it is that it does not provide HMR, so its harder to +develop with. It would be nice to figure out how to get simultaneous HMR for +both the sandboxed preview and the main app in a way that we don't have to +think too much about service worker registration, but we don't have that for +now. ## Build Output -The build produces `../q2-sandboxed-preview.html` which is consumed by `Q2SandboxedPreviewIframe.tsx` in the parent hub-client project. +The build produces `dist/`, consumed by `Q2SandboxedPreviewIframe.tsx` in the +parent hub-client project (default iframe src is the GitHub Pages deployment; +see `.github/workflows/github-pages.md`). ## Adding Dependencies -Since this is a separate project, you can freely add dependencies without affecting hub-client: +Since this is a separate project, you can freely add dependencies without +affecting hub-client: ```bash npm install ``` -All dependencies will be bundled into the single HTML output file. +Keep `react`/`react-dom`/`katex` versions aligned with +`@quarto/preview-renderer`'s — they are deduped to this project's copies at +build time (`resolve.dedupe` in vite.config.ts). diff --git a/hub-client/quarto-hub-sandboxed-preview/index.html b/hub-client/quarto-hub-sandboxed-preview/index.html index 392198a11..ecc12300e 100644 --- a/hub-client/quarto-hub-sandboxed-preview/index.html +++ b/hub-client/quarto-hub-sandboxed-preview/index.html @@ -4,13 +4,22 @@ - q2-raw Renderer + q2-sandboxed-preview Renderer + -
Loading q2-raw renderer...
+
Loading q2-sandboxed-preview renderer...
- + - \ No newline at end of file + diff --git a/hub-client/quarto-hub-sandboxed-preview/package-lock.json b/hub-client/quarto-hub-sandboxed-preview/package-lock.json index 129185124..30c0dd282 100644 --- a/hub-client/quarto-hub-sandboxed-preview/package-lock.json +++ b/hub-client/quarto-hub-sandboxed-preview/package-lock.json @@ -9,17 +9,16 @@ "version": "0.0.0", "dependencies": { "katex": "0.18.4", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@types/katex": "^0.16.7", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.6.3", - "vite": "^6.0.11", - "vite-plugin-singlefile": "^2.0.2" + "vite": "^6.0.11" } }, "node_modules/@babel/code-frame": { @@ -1212,32 +1211,24 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.30", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", - "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@vitejs/plugin-react": { @@ -1274,19 +1265,6 @@ "node": ">=6.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1460,19 +1438,6 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1498,20 +1463,11 @@ "node": ">=6.9.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -1556,18 +1512,6 @@ "katex": "cli.js" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1578,33 +1522,6 @@ "yallist": "^3.0.2" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1691,28 +1608,24 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.8" } }, "node_modules/react-refresh": { @@ -1771,13 +1684,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -1816,19 +1726,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -1949,28 +1846,6 @@ } } }, - "node_modules/vite-plugin-singlefile": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", - "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">18.0.0" - }, - "peerDependencies": { - "rollup": "^4.59.0", - "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/hub-client/quarto-hub-sandboxed-preview/package.json b/hub-client/quarto-hub-sandboxed-preview/package.json index 50aaf14d4..bdc0bd803 100644 --- a/hub-client/quarto-hub-sandboxed-preview/package.json +++ b/hub-client/quarto-hub-sandboxed-preview/package.json @@ -11,17 +11,16 @@ "preview": "vite preview" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", "katex": "0.18.4" }, "devDependencies": { "@types/katex": "^0.16.7", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.6.3", - "vite": "^6.0.11", - "vite-plugin-singlefile": "^2.0.2" + "vite": "^6.0.11" } } diff --git a/hub-client/quarto-hub-sandboxed-preview/src/App.tsx b/hub-client/quarto-hub-sandboxed-preview/src/App.tsx deleted file mode 100644 index 67c038c06..000000000 --- a/hub-client/quarto-hub-sandboxed-preview/src/App.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect, useState } from 'react'; -import katex from 'katex'; -// @ts-ignore -import 'katex/dist/katex.min.css'; -import { init } from './registerServiceWorker'; -import { AstRenderer } from './basicRenderer'; - -interface UpdateAstPayload { - astJson: string; - currentFilePath: string; -} - -export function App() { - const [astJson, setAstJson] = useState(''); - // const [dogImage, setDogImage] = useState(''); - - useEffect(() => { - init().then(() => { - window.parent.postMessage({ type: 'IFRAME_READY' }, '*'); - }) - }, []) - - useEffect(() => { - // Listen for messages from parent - const handleMessage = async (event: MessageEvent) => { - console.log('iframe message', event.data) - if (event.data.type === 'UPDATE_AST') { - const payload = event.data.payload as UpdateAstPayload; - setAstJson(payload.astJson); - } - }; - window.addEventListener('message', handleMessage); - - return () => window.removeEventListener('message', handleMessage); - }, []); - - if (!astJson) { - return
Loading q2-sandboxed-preview renderer...
; - } - - try { - const ast = JSON.parse(astJson); - - return ( -
- -
- ); - } catch (err) { - return ( -
- Parse Error: -
{err instanceof Error ? err.message : String(err)}
-
- ); - } -} - -// Example showing katex is available -console.log('KaTeX version:', katex.version); diff --git a/hub-client/quarto-hub-sandboxed-preview/src/assetPolicy.ts b/hub-client/quarto-hub-sandboxed-preview/src/assetPolicy.ts new file mode 100644 index 000000000..40f1aea2a --- /dev/null +++ b/hub-client/quarto-hub-sandboxed-preview/src/assetPolicy.ts @@ -0,0 +1,149 @@ +/** + * Shared asset-proxy policy — the single source of truth for how document + * assets travel between the sandboxed iframe and the parent's WASM VFS. + * + * Consumed by three parties: + * - the service worker (`serviceWorker.ts`) — which fetches to intercept, + * what MIME type to synthesize; + * - the iframe page bridge (`registerServiceWorker.ts`); + * - the parent responder (`Q2SandboxedPreviewIframe.tsx` in hub-client, + * imported by relative path) — binary vs text VFS read. + * + * ## Routing (bd-00bgt5cy) + * + * **Any relative path is served from the VFS.** The service worker + * intercepts every same-origin GET inside its scope EXCEPT the frame's + * own app files: + * + * - the page itself (`/`, `index.html`) + * - `serviceWorker.js` + * - `q2-preview-assets/*` — the hashed renderer bundle chunks and + * KaTeX fonts (deliberately NOT `assets/`, so a project's own + * `assets/` directory is proxied like any other project path; + * `build.assetsDir` in vite.config.ts must stay in sync) + * + * Everything else is treated as a document asset: the URL path relative + * to the SW scope IS the VFS path. The parent resolves AST image targets + * against `currentFilePath` at manifest-build time (same resolution as + * q2-preview's asset walker) and ships bare resolved paths, so e.g. a + * subdirectory image renders as `` — full + * paths in the URL keep same-named files in different directories + * distinct. Paths that never went through the manifest (an `` in + * raw HTML or navbar chrome) are still intercepted; the parent responder + * retries them against the current document's directory on a VFS miss. + * + * Known limitation, deliberate: project files literally named + * `index.html`, `serviceWorker.js`, or under `q2-preview-assets/` at the + * VFS root are shadowed by the app-file exemption (they fall through to + * the network). The app asset dir is named `q2-preview-assets` precisely + * so no real project trips over this. + */ + +/** App files the service worker must NOT proxy (relative to its scope). */ +const APP_FILES = new Set(['', 'index.html', 'serviceWorker.js']); +const APP_ASSET_DIR = 'q2-preview-assets/'; + +/** Page-relative URL for a resolved VFS path (bare path, URI-encoded). */ +export function pageRelativeUrlForVfsPath(vfsPath: string): string { + return encodeURI(vfsPath.replace(/^\/+/, '')); +} + +/** + * Inverse of {@link pageRelativeUrlForVfsPath}: extract the VFS path from + * a request URL, or `null` when the URL should not be proxied — outside + * `scopeUrl`, or one of the frame's own app files. + */ +export function vfsPathForRequestUrl(url: string, scopeUrl: string): string | null { + let pathname: string; + let scopePath: string; + try { + const u = new URL(url); + const s = new URL(scopeUrl); + if (u.origin !== s.origin) return null; + pathname = u.pathname; + scopePath = s.pathname; + } catch { + return null; + } + if (!scopePath.endsWith('/')) scopePath += '/'; + if (!pathname.startsWith(scopePath)) return null; + const rest = decodeURI(pathname.slice(scopePath.length)); + if (APP_FILES.has(rest) || rest.startsWith(APP_ASSET_DIR)) return null; + return rest; +} + +/** + * Whether the parent should read this path with `vfs_read_binary_file` + * (content travels base64) rather than `vfs_read_file` (plain text). + */ +export function isBinaryPath(path: string): boolean { + return /\.(png|jpg|jpeg|gif|webp|ico|pdf|ttf|otf|woff|woff2|eot|zip|wasm)$/i.test(path); +} + +const MIME_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + webp: 'image/webp', + ico: 'image/x-icon', + pdf: 'application/pdf', + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + txt: 'text/plain', + wasm: 'application/wasm', + ttf: 'font/ttf', + otf: 'font/otf', + woff: 'font/woff', + woff2: 'font/woff2', +}; + +export function mimeTypeFor(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase(); + return MIME_TYPES[ext || ''] || 'application/octet-stream'; +} + +/** + * Rewrite relative `url(...)` references in theme CSS into **absolute** + * page URLs, resolved against the directory the CSS artifact lives in + * (`.quarto/project-artifacts`) and anchored at `pageBaseUrl` (the + * iframe document's base). Absolute because the theme is applied through + * a blob-URL stylesheet whose opaque base cannot anchor relative refs. + * The resulting URLs land inside the SW scope, so fonts and background + * images round-trip from the VFS like any other document asset. + * + * Absolute (`http(s):`, `//`), `data:`, `blob:`, root-relative (`/…`), + * and fragment (`#…`) refs pass through untouched. + */ +export function rewriteThemeCssUrls( + cssText: string, + cssDirVfsPath: string, + pageBaseUrl: string, +): string { + const baseSegments = cssDirVfsPath.replace(/^\/+|\/+$/g, '').split('/'); + return cssText.replace( + /url\(\s*(['"]?)([^'")]+)\1\s*\)/g, + (whole, quote: string, ref: string) => { + const trimmed = ref.trim(); + if (/^(https?:|data:|blob:|\/\/|#|\/)/i.test(trimmed)) return whole; + // Resolve ./ and ../ segments against the CSS directory. + const segments = [...baseSegments]; + for (const part of trimmed.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') { + segments.pop(); + } else { + segments.push(part); + } + } + const abs = new URL( + pageRelativeUrlForVfsPath(segments.join('/')), + pageBaseUrl, + ).href; + return `url(${quote}${abs}${quote})`; + }, + ); +} diff --git a/hub-client/quarto-hub-sandboxed-preview/src/basicRenderer.tsx b/hub-client/quarto-hub-sandboxed-preview/src/basicRenderer.tsx deleted file mode 100644 index a7727d1f6..000000000 --- a/hub-client/quarto-hub-sandboxed-preview/src/basicRenderer.tsx +++ /dev/null @@ -1,133 +0,0 @@ -// Simple AST renderer component -export function AstRenderer({ node }: { node: any }) { - if (!node) return null; - - // Handle text content - if (typeof node === 'string') { - return <>{node}; - } - - // Handle arrays of nodes - if (Array.isArray(node)) { - return <>{node.map((child, i) => )}; - } - - // Handle Pandoc AST object structure - if (node.t) { - const type = node.t; - const content = node.c; - - // Inline elements - if (type === 'Str') { - return <>{content}; - } - if (type === 'Space') { - return <> ; - } - if (type === 'SoftBreak') { - return <> ; - } - if (type === 'LineBreak') { - return
; - } - if (type === 'Emph') { - return ( - - - - ); - } - if (type === 'Strong') { - return ( - - - - ); - } - if (type === 'Code') { - return ( - - - - ); - } - if (type === 'Link') { - return ( - - - - ); - } - if (type === 'Image') { - // content: [attrs, alt_text, [url, title]] - const url = content[2][0]; - return ; - } - - // Block elements - if (type === 'Para') { - return ( -
- -
- ); - } - if (type === 'Plain') { - return ( -
- -
- ); - } - if (type === 'Header') { - // content: [level, attrs, inlines] - return ( -
- -
- ); - } - if (type === 'CodeBlock') { - return ( -
- -
- ); - } - if (type === 'BulletList' || type === 'OrderedList') { - return ( -
- -
- ); - } - if (type === 'BlockQuote') { - return ( -
- -
- ); - } - if (type === 'Div') { - return ( -
- -
- ); - } - - // Default: render with border for any unknown block/inline - return ( -
- -
- ); - } - - // Handle document root with blocks array - if (node.blocks) { - return ; - } - - return null; -} diff --git a/hub-client/quarto-hub-sandboxed-preview/src/entry.tsx b/hub-client/quarto-hub-sandboxed-preview/src/entry.tsx new file mode 100644 index 000000000..a5ba4e8c5 --- /dev/null +++ b/hub-client/quarto-hub-sandboxed-preview/src/entry.tsx @@ -0,0 +1,406 @@ +/** + * Entry point for the q2-sandboxed-preview renderer iframe. + * + * Adapted copy of `@quarto/preview-renderer/q2-preview/entry` (which is + * NOT modified by the sandboxed-preview port — see + * claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md). + * The renderer itself (PreviewRoot, registry, framework) is imported + * unmodified from the package source; only the boundary differs. The + * differences from the q2-preview entry, all consequences of running + * cross-origin (GitHub Pages) instead of same-origin: + * + * 1. `UPDATE_THEME` carries the compiled CSS **text**, not a + * parent-minted blob URL — blob URLs are origin-scoped and + * unreachable from this frame. The entry mints its own blob URL + * (same-origin to itself) and swaps it into the + * `` element. + * + * 2. A service worker proxies document asset fetches (images, fonts + * referenced by theme CSS) back to the parent over postMessage — + * registered in `init()` before IFRAME_READY is posted, so the + * first painted document already has interception in place. + * + * 3. Message handling goes through the promise-ordered + * `makeIframeMessageDispatcher` (the q2-debug dispatcher) instead + * of the original entry's 50ms-polling LOAD/UPDATE gate. + */ + +import { createRoot } from 'react-dom/client'; +import React from 'react'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; +// Bootstrap 5 bundled JS, headroom, and quarto-nav, vendored at the +// repo's `resources/js/`. Embedded as raw text and injected as inline +// `