From d6df9500a6d9abcf835050c77b3e7e223d242ef9 Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 1 Sep 2026 17:34:00 -0400 Subject: [PATCH 01/13] q2-sandboxed-preview: use the preview pipeline (Phase 0 of port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandboxed preview format previously mapped to ("html", None) so ReactPreview fed it the raw parse-only AST — no highlight spans, no chrome metadata, no theme fingerprint. Give it the same pipeline_kind as q2-preview on both sides of the mirror (builtin_pseudo_format in quarto-core, pipelineKindForFormat in preview-runtime), tests first. Phase 0 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md (bd-jgpz4hfq, epic bd-r9yr0hbe). Also adds the plan document. Verification: cargo nextest run --workspace (39 pre-existing environmental failures — pandoc not on PATH etc. — identical set with change stashed); cargo xtask verify --skip-rust-tests fully green. Co-Authored-By: Claude Fable 5 --- ...-port-q2-preview-into-sandboxed-preview.md | 218 ++++++++++++++++++ crates/quarto-core/src/format.rs | 20 +- .../preview-runtime/src/pipelineKind.test.ts | 4 + .../preview-runtime/src/pipelineKind.ts | 5 + 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md 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..d097c8718 --- /dev/null +++ b/claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md @@ -0,0 +1,218 @@ +# 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 + +- [ ] Test first: hub-client integration test asserting the + `q2-sandboxed-preview` format receives post-pipeline AST (highlight + spans present, `theme_fingerprint` set) — expect fail. +- [ ] Rust: `format.rs` `q2-sandboxed-preview` → `("html", Some("preview"))`; + check `wasm-quarto-hub-client/src/lib.rs:716` agrees. +- [ ] TS: add `q2-sandboxed-preview` to `pipelineKindForFormat`. +- [ ] `cargo nextest run --workspace` + WASM rebuild (`npm run build:wasm`) + + test green. + +### Phase 1 — real renderer inside the sandboxed bundle + +- [ ] Sandboxed project deps/config: React 19, preview-renderer source + alias, `resolve.conditions: ['source']`, attribution-CSS virtual + plugin, `fs.allow`, drop `viteSingleFile`, emit normal `dist/` + (relative `base: './'`). +- [ ] Copy-and-adapt `entry.tsx` → `src/entry.tsx`: keep `PreviewRoot`, + registry, link handlers, Bootstrap/nav script injection (all imported + unmodified); replace the 50ms-polling LOAD/UPDATE gate with the + promise-ordered dispatcher (copy `iframeMessageDispatch.ts`); theme + handler takes CSS **text** and mints a local blob URL. +- [ ] Delete `basicRenderer.tsx` + toy `App.tsx` (replaced by the entry). +- [ ] Update deploy targets: GH Pages workflow publishes full `dist/`; + `q2-sandboxed-preview-server.mjs` and the `hub-client/public/` copy + serve/hold the dist dir; `build:sandboxed` script updated. +- [ ] Smoke: iframe renders a themed document from a hand-posted + `UPDATE_AST` + `UPDATE_THEME` (vitest with jsdom where possible). + +### Phase 2 — asset proxying, done properly + +- [ ] Test first: SW unit tests for path forwarding (full pathname, no + basename stripping; two same-named images in different dirs), MIME + table, allowlist, request-id matching + timeout. +- [ ] Shared `assetPolicy.ts` (single extension/interception policy) + used by SW, iframe bridge, and parent responder. +- [ ] SW: intercept same-origin GETs *except* `/assets/*`, the page, and + `serviceWorker.js`; forward full path + `currentFilePath` context; + request ids; listener cleanup. +- [ ] Parent responder: resolve project-relative paths the same way + `assetWalker.ts` does (`resolveRelativePath`), read via + `vfs_read_file`/`vfs_read_binary_file`, respond keyed by request id. +- [ ] Theme fonts: verify CSS `url()` refs round-trip through the SW. +- [ ] Asset manifest: sandboxed manifest maps `origPath → origPath` + (identity) so `` fetches hit the SW; external URLs pass through + as today. + +### Phase 3 — parent feature parity (scroll sync, click-to-line) + +- [ ] Test first: protocol tests for the new messages + (`SCROLL_TO_LINE`, `REPORT_SCROLL_RATIO`, `PREVIEW_SCROLLED {ratio}`, + `CLICK_AT_LINE {line, iframeY}`). +- [ ] Iframe side: import `scrollSyncDom.ts` unmodified, drive it from new + message handlers; attach the `pointerup` + `scroll` listeners inside + the iframe; report `iframeY` (parent adds its own + `iframe.getBoundingClientRect().top` for `hostY`). +- [ ] Parent side: grow `Q2SandboxedPreviewIframe.tsx` (copy-adapt from + `Q2PreviewIframe.tsx`) to the full 17-prop surface; implement + `Q2PreviewIframeHandle` (`scrollToLine`/`getScrollRatio`) over + postMessage (async ratio → small protocol change or cached last + ratio). +- [ ] `ReactRenderer.tsx` sandboxed branch passes the full prop set + (mirroring the `q2-preview` branch). + +### Phase 4 — remaining functionality + +- [ ] `LOAD_CUSTOM_COMPONENTS` (user TSX): parent transpile path already + exists; iframe-side blob-`import()` works under `allow-scripts` — + verify, and note CSP implications (no strict CSP yet). +- [ ] Rich text / tiptap editing (`richText`, `nestedEditBuffers`, + `SET_AST` edit payloads) — should work as-is via postMessage; verify. +- [ ] Slides: `SET_SLIDE` / `SLIDE_CHANGED`, `RevealDeck` + reveal CSS. +- [ ] `hub-client-save` (Cmd+S) forwarding — already postMessage; verify + reaches `App.tsx` handler cross-origin. +- [ ] Clipboard: `COPY_TO_CLIPBOARD` message → parent executes + `navigator.clipboard.writeText`. +- [ ] Comments mode / attribution (`currentActor`, `commentsMode`, + `untransformedAstJson`, `renderedContent`). + +### Phase 5 — hardening + verification + +- [ ] Origin checks: parent accepts messages only from the sandbox origin + + `event.source === iframe.contentWindow`; iframe checks + `event.source === window.parent` and pins the parent origin after + first contact; replace `'*'` target origins where the origin is known. +- [ ] Strip debug `console.log`s from SW/bridge. +- [ ] Update stale docs: sandboxed README (`allow-same-origin` reality — + isolation comes from the separate origin, which the SW requires; + output path), `claude-notes/designs/q2-sandboxed-preview-separate-domain.md` + (the `url` message is now the core asset path; CSP note re: blob + script imports). +- [ ] End-to-end (required before declaring success): real browser session + via `npm run local-prod:fresh:nginx`, document with + `format: q2-sandboxed-preview` — verify themed render, images in + subdirectories, KaTeX math, code highlighting, scroll sync both ways, + click-to-line, link navigation, Cmd+S. Record invocation + observed + output in this plan. +- [ ] `cargo xtask verify` (full, since quarto-core/WASM touched) + + `npm run build:all` from hub-client. + +## 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 + +- [ ] File a braid epic for this port with one strand per phase + (`--deps parent-child`), referencing this plan file. + +## 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/ts-packages/preview-runtime/src/pipelineKind.test.ts b/ts-packages/preview-runtime/src/pipelineKind.test.ts index b19ca55dc..d6f9d5c60 100644 --- a/ts-packages/preview-runtime/src/pipelineKind.test.ts +++ b/ts-packages/preview-runtime/src/pipelineKind.test.ts @@ -12,6 +12,10 @@ describe('pipelineKindForFormat', () => { expect(pipelineKindForFormat('q2-preview')).toBe('preview'); }); + it('returns "preview" for q2-sandboxed-preview (sandboxed-preview port, bd-jgpz4hfq)', () => { + expect(pipelineKindForFormat('q2-sandboxed-preview')).toBe('preview'); + }); + it('returns undefined for q2-debug (parser-only path, not the q2-preview pipeline)', () => { expect(pipelineKindForFormat('q2-debug')).toBeUndefined(); }); diff --git a/ts-packages/preview-runtime/src/pipelineKind.ts b/ts-packages/preview-runtime/src/pipelineKind.ts index c0501ecb9..d18964d1f 100644 --- a/ts-packages/preview-runtime/src/pipelineKind.ts +++ b/ts-packages/preview-runtime/src/pipelineKind.ts @@ -28,6 +28,11 @@ export function pipelineKindForFormat(format: string): PipelineKind | undefined switch (format) { case 'q2-preview': return 'preview'; + // Sandboxed-preview port (bd-jgpz4hfq): the sandboxed renderer consumes + // the same post-pipeline AST as q2-preview. Mirrors + // `builtin_pseudo_format` in crates/quarto-core/src/format.rs. + case 'q2-sandboxed-preview': + return 'preview'; default: return undefined; } From 5684cfeade36a9eda8dc391761eaa1780b84159f Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 1 Sep 2026 17:52:34 -0400 Subject: [PATCH 02/13] sandboxed-preview: bundle the real q2-preview renderer (Phase 1 of port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the toy AstRenderer in quarto-hub-sandboxed-preview with the actual @quarto/preview-renderer renderer, imported unmodified from package source via a Vite alias (q2-preview itself is untouched): - src/entry.tsx: adapted copy of the q2-preview iframe entry. Differences are all cross-origin consequences: UPDATE_THEME carries CSS *text* (parent blob URLs are origin-scoped and unreachable from the sandbox) and the entry mints its own blob URL; the asset-proxy service worker registers before IFRAME_READY; message handling uses the promise-ordered dispatcher (copied as iframeMessageDispatch.ts) instead of the original 50ms polling gate. - src/stubs/preview-runtime.ts: the q2-preview barrel drags the parent-side Q2PreviewIframe/assetWalker (WASM-coupled) into the module graph; stub @quarto/preview-runtime so the sandboxed bundle resolves without any WASM machinery. - Build: React 18→19 (match preview-renderer), drop vite-plugin- singlefile in favor of a normal multi-file dist (base './'), copied to hub-client/public/q2-sandboxed-preview/ (now gitignored; the old committed single-file artifacts are removed). - Parent side (TDD, tests first): Q2SandboxedPreviewIframe now takes currentFilePath + three-way themeFingerprint and posts UPDATE_THEME {cssText}; ReactRenderer passes both. - Deploy/serve: GH Pages workflow gains a root npm ci (renderer source resolves deps from root node_modules) and path triggers for preview-renderer/resources; q2-sandboxed-preview-server.mjs serves the dist dir; docs updated. Verified end-to-end: headless-chromium smoke against the served dist posted UPDATE_AST + UPDATE_THEME and rendered

Hello sandbox

with the posted theme color and an active service worker. hub-client: typecheck, vitest (133 passed incl. 4 new red-first tests), and npm run build:all all green. Phase 1 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md (bd-44ohor2w, epic bd-r9yr0hbe). Co-Authored-By: Claude Fable 5 --- .../workflows/deploy-sandboxed-preview.yml | 18 +- .github/workflows/github-pages.md | 6 +- ...-port-q2-preview-into-sandboxed-preview.md | 67 ++-- hub-client/.gitignore | 4 + hub-client/public/q2-sandboxed-preview.html | 307 --------------- hub-client/public/serviceWorker.js | 1 - hub-client/q2-sandboxed-preview.html | 69 ---- .../quarto-hub-sandboxed-preview/README.md | 61 ++- .../quarto-hub-sandboxed-preview/index.html | 17 +- .../package-lock.json | 175 ++------- .../quarto-hub-sandboxed-preview/package.json | 11 +- .../quarto-hub-sandboxed-preview/src/App.tsx | 60 --- .../src/basicRenderer.tsx | 133 ------- .../src/entry.tsx | 365 ++++++++++++++++++ .../src/global.d.ts | 6 + .../src/iframeMessageDispatch.ts | 114 ++++++ .../src/index.css | 13 - .../quarto-hub-sandboxed-preview/src/main.tsx | 11 - .../src/stubs/preview-runtime.ts | 34 ++ .../tsconfig.json | 7 + .../vite.config.ts | 84 +++- .../src/components/render/ReactRenderer.tsx | 6 +- .../Q2SandboxedPreviewIframe.test.tsx | 111 +++++- .../Q2SandboxedPreviewIframe.tsx | 69 +++- scripts/README.md | 8 +- scripts/build-local-prod.sh | 2 +- scripts/q2-sandboxed-preview-server.mjs | 86 +++-- 27 files changed, 978 insertions(+), 867 deletions(-) delete mode 100644 hub-client/public/q2-sandboxed-preview.html delete mode 100644 hub-client/public/serviceWorker.js delete mode 100644 hub-client/q2-sandboxed-preview.html delete mode 100644 hub-client/quarto-hub-sandboxed-preview/src/App.tsx delete mode 100644 hub-client/quarto-hub-sandboxed-preview/src/basicRenderer.tsx create mode 100644 hub-client/quarto-hub-sandboxed-preview/src/entry.tsx create mode 100644 hub-client/quarto-hub-sandboxed-preview/src/global.d.ts create mode 100644 hub-client/quarto-hub-sandboxed-preview/src/iframeMessageDispatch.ts delete mode 100644 hub-client/quarto-hub-sandboxed-preview/src/index.css delete mode 100644 hub-client/quarto-hub-sandboxed-preview/src/main.tsx create mode 100644 hub-client/quarto-hub-sandboxed-preview/src/stubs/preview-runtime.ts 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/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 index d097c8718..f3571e589 100644 --- 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 @@ -86,34 +86,51 @@ places; everything else is already postMessage + pure React-over-AST-JSON: ## Phases and work items -### Phase 0 — pipeline parity + test scaffolding - -- [ ] Test first: hub-client integration test asserting the - `q2-sandboxed-preview` format receives post-pipeline AST (highlight - spans present, `theme_fingerprint` set) — expect fail. -- [ ] Rust: `format.rs` `q2-sandboxed-preview` → `("html", Some("preview"))`; - check `wasm-quarto-hub-client/src/lib.rs:716` agrees. -- [ ] TS: add `q2-sandboxed-preview` to `pipelineKindForFormat`. -- [ ] `cargo nextest run --workspace` + WASM rebuild (`npm run build:wasm`) - + test green. +### 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 -- [ ] Sandboxed project deps/config: React 19, preview-renderer source - alias, `resolve.conditions: ['source']`, attribution-CSS virtual - plugin, `fs.allow`, drop `viteSingleFile`, emit normal `dist/` - (relative `base: './'`). -- [ ] Copy-and-adapt `entry.tsx` → `src/entry.tsx`: keep `PreviewRoot`, - registry, link handlers, Bootstrap/nav script injection (all imported - unmodified); replace the 50ms-polling LOAD/UPDATE gate with the - promise-ordered dispatcher (copy `iframeMessageDispatch.ts`); theme - handler takes CSS **text** and mints a local blob URL. -- [ ] Delete `basicRenderer.tsx` + toy `App.tsx` (replaced by the entry). -- [ ] Update deploy targets: GH Pages workflow publishes full `dist/`; - `q2-sandboxed-preview-server.mjs` and the `hub-client/public/` copy - serve/hold the dist dir; `build:sandboxed` script updated. -- [ ] Smoke: iframe renders a themed document from a hand-posted - `UPDATE_AST` + `UPDATE_THEME` (vitest with jsdom where possible). +- [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 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/public/q2-sandboxed-preview.html b/hub-client/public/q2-sandboxed-preview.html deleted file mode 100644 index 75355d9d6..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 0c44ad452..000000000 --- a/hub-client/public/serviceWorker.js +++ /dev/null @@ -1 +0,0 @@ -(function(){"use strict";console.log("sandboxed preview service worker started");const r=async e=>Promise.all((await e.keys()).map(t=>e.delete(t))),c=["./","./serviceWorker.js"],a="previewOffline";self.addEventListener("install",async e=>{e.waitUntil((async()=>{const t=await caches.open(a);await r(t),await t.addAll(c)})()),console.log("done installing sandboxed preview service worker")}),self.addEventListener("activate",e=>{e.waitUntil(self.clients.claim())});const l=e=>{const t=atob(e),n=new Uint8Array(t.length);for(let s=0;s{var s;const t=(s=e.split(".").pop())==null?void 0:s.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"}[t||""]||"application/octet-stream"},f=e=>new Promise(async t=>{const n=e.request.url,s=await self.clients.get(e.clientId),i=d=>{const o=d.data;o.type==="response"&&o.url===n&&(console.log("RESOLVING TO",o),t(new Response(l(o.content),{status:200,headers:{"Content-Type":p(n)}})),self.removeEventListener("message",i))};self.addEventListener("message",i),s.postMessage({type:"request",url:n})}),g=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"&&(g(e.request.url)?e.respondWith(f(e)):e.respondWith((async()=>{const t=await caches.match(e.request);if(console.log(`[Service Worker] Fetching resource: ${e.request.url} ${t}`),t)return t;const n=await fetch(e.request),s=await caches.open(a);return console.log(`[Service Worker] Caching new resource: ${e.request.url}`,e.request),s.put(e.request,n.clone()),n})()))})})(); 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..a340046ac 100644 --- a/hub-client/quarto-hub-sandboxed-preview/README.md +++ b/hub-client/quarto-hub-sandboxed-preview/README.md @@ -1,38 +1,69 @@ # 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/assets/*` + `dist/serviceWorker.js` +- **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/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..920226947 --- /dev/null +++ b/hub-client/quarto-hub-sandboxed-preview/src/entry.tsx @@ -0,0 +1,365 @@ +/** + * 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 +// `