diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e85884e3b..787a1bb5a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,6 +6,7 @@ {"id":"bd-0mji","title":"Phase D follow-up: SPA render-event hook + dep-graph filter regression tests","description":"Two related affordances needed once Phase D's dep-graph filter for re-render is implemented:\n\n1. **SPA render-event hook.** PreviewApp's render useEffect re-fires for every contentTick bump. Today this is invisible at the DOM when neither active-page content nor merged metadata change. A small test-only counter (window.__renderTicks or similar) would let Playwright assert 'this edit did/didn't trigger a re-render' for cases where the rendered output is identical.\n\n2. **Dep-graph filter regression tests.** Once Phase D filters re-renders against ProjectDependencyGraph, we need:\n - Active page re-renders when an edited sibling IS a dependency (positive case).\n - Active page does NOT re-render when an edited sibling is NOT a dependency (negative case — the savings).\n\nReframes the deferred Phase B.4 plan acceptance criterion 3 ('editing an unrelated sibling re-renders the active page only when there's a dep edge'). In Phase B the contract was relaxed to 'always re-renders' because no filter exists; Phase D restores the original strict contract.\n\nPlan: claude-notes/plans/2026-05-13-q2-preview-phase-b.md §B.4 (deferred criterion 3).","status":"closed","priority":3,"issue_type":"task","created_at":"2026-05-13T21:11:41.646912Z","created_by":"cscheid","updated_at":"2026-05-14T19:25:36.371752Z","closed_at":"2026-05-14T19:25:36.371611Z","close_reason":"Both items resolved: (1) window.__renderTicks render-event hook landed in D.3 (bd-kw93.9, commit 1ddcbeaf); (2) dep-graph filter regression tests landed in D.6 (bd-kw93.12, commit 7310d44b) — negative case in dep-graph-filter.spec.ts, positive case in the existing include-shortcode.spec.ts (Phase B.3).","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-0mji","depends_on_id":"bd-kw93","type":"parent-child","created_at":"2026-05-13T21:11:41.646912Z","created_by":"cscheid","metadata":"{}","thread_id":""},{"issue_id":"bd-0mji","depends_on_id":"bd-mrx1","type":"discovered-from","created_at":"2026-05-13T21:11:41.646912Z","created_by":"cscheid","metadata":"{}","thread_id":""}]} {"id":"bd-0tr6","title":"Website projects epic: multi-page sites with sidebars, shared resources, and hub-client integration","description":"Design and implement multi-page website projects for Quarto 2. Covers: typed static-document snapshots (PageSnapshot/DocumentProfile working name), pipeline resumability, ProjectType trait, website sidebars, scoped/relocatable artifact stores (site_libs), cross-doc link rewriting, sitemap/favicon/site-url, incremental rebuilds, and hub-client project rendering.\n\nMVP scope explicitly excludes: search, listings/RSS, aliases, announcements, analytics, 404, reader-mode, repo-actions, breadcrumbs, book project type, quarto preview, freeze.\n\nPlan: claude-notes/plans/2026-04-23-website-project-epic.md\n\nDesign decisions from initial conversation:\n- Snapshot is a typed serializable value produced at a pipeline checkpoint after MetadataMergeStage; downstream code consumes Vec; user filters read but do not mutate.\n- Pipeline stages up to snapshot are resumable (cloneable) to avoid redundant execution across pass 1 (snapshot sweep) and pass 2 (per-file render).\n- ProjectType trait introduced now (website, default). Single-document renders go through DefaultProjectType.\n- Artifact stores scope-aware (Page vs Project) and relocatable; single-doc = project with implicit _quarto.yml.\n- Hub-client caches project nav state; renders active page with cached state. Design must leave room for Q2 'quarto preview' which will be a local hub-client instance.\n- Naming TBD in Phase 0 (DocumentProfile working name; user dislikes Page*/Metadata*).","status":"open","priority":1,"issue_type":"epic","created_at":"2026-04-23T18:42:28.206394Z","created_by":"cscheid","updated_at":"2026-04-23T18:42:28.206394Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-0wyo","title":"Server-precomputed other_metadata_html for default listing","description":"Q1's item-default.ejs.md iterates over fields *not* in the curated set ('title','image','image-alt','date','author','subtitle','description','reading-time','categories') and emits a
per such field. This is dynamic in EJS — there is no equivalent in doctemplate without server-side precomputation.\n\nL3 v1 ships the default listing with curated-fields-only output. This issue picks up the gap by adding an other_metadata_html string to the per-item TemplateValue::Map binding, computed server-side in the listing render transform. The built-in item-default.template gets one more interpolation site: $item.other-metadata-html$.\n\nSource-code TODO marker lands in crates/quarto-core/src/project/listing/templates/item-default.template at the otherFields gap location (added during L3).\n\nDiscovered while writing L3 (bd-ml8z) sub-plan; see D15 in claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md.","status":"open","priority":3,"issue_type":"task","created_at":"2026-05-06T18:26:31.323613Z","created_by":"cscheid","updated_at":"2026-05-06T18:26:31.323613Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-0wyo","depends_on_id":"bd-ml8z","type":"discovered-from","created_at":"2026-05-06T18:26:31.323613Z","created_by":"cscheid","metadata":"{}","thread_id":""}]} +{"id":"bd-0xmt","title":"Phase A.0: Lift WASM JS bridge to @quarto/wasm-js-bridge workspace package","description":"Move hub-client/src/wasm-js-bridge/ to a new @quarto/wasm-js-bridge workspace package. Wire hub-client + q2-preview-spa + preview-renderer test configs to alias /src/wasm-js-bridge -> the package's src. Removes the cross-platform symlink/duplication risk for Phase A.3. See claude-notes/plans/2026-05-13-q2-preview-phase-a.md §A.0.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-05-13T16:53:11.353454Z","created_by":"cscheid","updated_at":"2026-05-13T17:07:44.781502Z","closed_at":"2026-05-13T17:07:44.781355Z","close_reason":"Bridge files moved to @quarto/wasm-js-bridge workspace package; consumers alias /src/wasm-js-bridge. Verified end-to-end (sass compile through bridge proven by hub-client test:wasm; cargo xtask verify 11/11). Commits ea1bb889 + changelog 043f65e1.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-0xmt","depends_on_id":"bd-kw93","type":"parent-child","created_at":"2026-05-13T16:53:11.353454Z","created_by":"cscheid","metadata":"{}","thread_id":""}]} {"id":"bd-1066","title":"HTML comments lost during incremental writes","description":"HTML comments (<\\!-- ... -->) are dropped from the Pandoc AST during parsing. tree-sitter parses them as 'comment' nodes which become IntermediateUnknown and are silently removed. This causes comments to be lost when the incremental writer rewrites blocks containing comments. Block-level standalone comments survive as empty Para blocks (preserved via source spans on KeepBefore), but inline comments within paragraphs are completely gone.","status":"open","priority":1,"issue_type":"bug","created_at":"2026-02-09T15:35:05.022976Z","created_by":"cscheid","updated_at":"2026-02-09T15:35:05.022976Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-11a1","title":"Automerge-backed project set storage","description":"Replace per-browser IndexedDB project list with a synced Automerge document. See claude-notes/plans/2026-03-31-automerge-project-set.md","status":"open","priority":1,"issue_type":"epic","created_at":"2026-03-31T20:37:29.036441Z","created_by":"cscheid","updated_at":"2026-03-31T20:37:29.036441Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-128x","title":"Slide renderer crashes on empty slides document","description":"When a document has format: q2-slides but no slide content (no headers, no title), parseSlides() returns an empty array. SlideAst then calls renderSlide(slides[0]) which is undefined, crashing on slide.type access. The fix should handle the empty slides case gracefully by showing an empty/placeholder slide. Plan: claude-notes/plans/2026-02-26-empty-slides-crash.md","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-26T15:13:14.778662Z","created_by":"cscheid","updated_at":"2026-02-26T16:34:51.200462Z","closed_at":"2026-02-26T16:34:51.200439Z","close_reason":"Fixed: guard renderSlide call and hide nav controls when slides array is empty","source_repo":".","compaction_level":0,"original_size":0} @@ -75,7 +76,10 @@ {"id":"bd-4eyf","title":"Bootstrap JS runtime injection for HTML output","description":"Auto-include Bootstrap 5 JS (bundle, with Popper) for HTML renders that use a Bootstrap-backed theme, mirroring Quarto 1's behavior but via q2's artifact-store pipeline.\n\nPlan: claude-notes/plans/2026-05-04-bootstrap-js-injection.md\n\nScope:\n- Vendor bootstrap.bundle.min.js (5.3.1, ~80KB, includes Popper) under resources/js/bootstrap/.\n- Add BootstrapJsStage in crates/quarto-core/src/stage/stages/, run after CompileThemeCssStage.\n- Predicate: !is_minimal_html(meta) — same condition that triggers Bootstrap CSS compilation.\n- Register js:bootstrap as Project-scoped artifact; ApplyTemplateStage emits the + + diff --git a/q2-preview-spa/package.json b/q2-preview-spa/package.json new file mode 100644 index 000000000..d569a77ce --- /dev/null +++ b/q2-preview-spa/package.json @@ -0,0 +1,37 @@ +{ + "name": "q2-preview-spa", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Standalone SPA host for Quarto's q2-preview format. Mirrors hub-client's preview pane by importing from @quarto/preview-renderer.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -p tsconfig.app.json --noEmit", + "preview": "vite preview", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:e2e": "playwright test" + }, + "dependencies": { + "@quarto/preview-renderer": "*", + "@quarto/preview-runtime": "*", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@quarto/quarto-automerge-schema": "*", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.10.2", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "jsdom": "^26.0.0", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vite-plugin-wasm": "^3.5.0", + "vitest": "^4.0.17" + } +} diff --git a/q2-preview-spa/playwright.config.ts b/q2-preview-spa/playwright.config.ts new file mode 100644 index 000000000..251e601aa --- /dev/null +++ b/q2-preview-spa/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for q2-preview-spa E2E. + * + * The `quarto preview` binary is the SUT. Tests spawn their own + * server instance via `e2e/helpers/previewServer.ts` so each test + * has an isolated tempdir project + fresh samod state. globalSetup + * just sanity-checks that the binary has been built. + * + * Run with `npm run test:e2e` from `q2-preview-spa/`; CI integration + * goes through `cargo xtask verify --e2e`. + */ +export default defineConfig({ + testDir: './e2e', + // Tests spin up their own server each — parallelism is fine but + // bounded by the WASM-render cost of each. Conservative default. + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? 'github' : 'list', + globalSetup: './e2e/helpers/globalSetup.ts', + // Per-test server is spawned inside each test; no baseURL. + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/q2-preview-spa/q2-preview.html b/q2-preview-spa/q2-preview.html new file mode 100644 index 000000000..c387a8671 --- /dev/null +++ b/q2-preview-spa/q2-preview.html @@ -0,0 +1,20 @@ + + + + + + q2-preview Renderer (Sandboxed) + + + + +
Loading q2-preview renderer…
+ + + diff --git a/q2-preview-spa/src/PreviewApp.integration.test.tsx b/q2-preview-spa/src/PreviewApp.integration.test.tsx new file mode 100644 index 000000000..f8c08001c --- /dev/null +++ b/q2-preview-spa/src/PreviewApp.integration.test.tsx @@ -0,0 +1,833 @@ +/** + * Integration test for the SPA's boot path. + * + * Mocks `@quarto/preview-runtime` (the WASM/automerge side), the + * Q2PreviewIframe (the rendering side), and `fetch` (for `/health`) so + * we can assert the wiring end-to-end: PreviewApp fetches + * `index_document_id` from `/health`, calls initWasm + connect, picks + * the first .qmd, calls renderPageInProject, and hands the returned + * astJson + currentFilePath to . + * + * No real WASM, no real samod, no real HTTP — those layers are covered + * by their own tests in @quarto/preview-runtime and quarto-preview's + * Rust-side smoke. This test pins the *seam* the SPA owns: which + * methods get called, in which order, with which arguments, and that + * the iframe ends up with the right props. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import type { FileEntry } from '@quarto/quarto-automerge-schema'; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +// Capture props on every render so we can assert on them +// after async boot completes. +const capturedIframeProps: Array> = []; +vi.mock('@quarto/preview-renderer/iframe/Q2PreviewIframe', () => ({ + Q2PreviewIframe: (props: Record) => { + capturedIframeProps.push(props); + return
; + }, +})); + +// Mock the runtime so the test doesn't need WASM or a sync server. +// Each test fills in fixtures via the `runtimeMockState` below. +type RuntimeMockState = { + files: FileEntry[]; + /** What `renderPageInProject(path)` returns. */ + renderResult: Record; + /** Whether `connect()` should throw. */ + connectError?: string; +}; +let runtimeMockState: RuntimeMockState; + +vi.mock('@quarto/preview-runtime', () => ({ + initWasm: vi.fn().mockResolvedValue(undefined), + isWasmReady: vi.fn(() => true), + connect: vi.fn(async (..._args: unknown[]) => { + if (runtimeMockState.connectError) { + throw new Error(runtimeMockState.connectError); + } + return runtimeMockState.files; + }), + setSyncHandlers: vi.fn(), + renderPageForPreview: vi.fn( + async (_path: string, _grammars?: unknown, _capture?: Uint8Array) => + runtimeMockState.renderResult, + ), + getBinaryDocById: vi.fn(async (_docId: string) => null), + getFilePaths: vi.fn(() => runtimeMockState.files.map((f) => f.path)), +})); + +// Imported after vi.mock so the mocks are in place. +import PreviewApp from './PreviewApp'; + +beforeEach(() => { + vi.clearAllMocks(); + capturedIframeProps.length = 0; + runtimeMockState = { + files: [{ path: 'index.qmd', docId: 'automerge:doc-index' }], + renderResult: { success: true, ast_json: '{"blocks":[]}' }, + }; + // `GET /health` returns the project's index document id — see plan + // §A.5 + Q-A3. PreviewApp uses this on boot instead of a URL + // fragment because the CLI binds + serves before any docId is known + // browser-side. + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.endsWith('/health')) { + return new Response( + JSON.stringify({ + status: 'ok', + index_document_id: 'automerge:test-index-doc', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('not found', { status: 404 }); + }), + ); +}); + +describe('PreviewApp boot path', () => { + it('renders with the first .qmd after connect+render', async () => { + render(); + + // Wait for the async boot chain (initWasm → connect → renderPageInProject + // → Q2PreviewIframe mount). Failure here means one of those steps + // didn't return the value our mock provided. + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + // The latest captured props should match our mocks. + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.currentFilePath).toBe('index.qmd'); + expect(props.astJson).toBe('{"blocks":[]}'); + // setAst is required by Q2PreviewIframe; Phase A's no-op is fine but + // it must at least be a function so the iframe doesn't crash on + // first DOM-stable edit. + expect(typeof props.setAst).toBe('function'); + }); + + it('shows "Initializing" before connect resolves', async () => { + // Make connect resolve only when we tell it to, so the initial + // (loading) view is observable. + let resolveConnect!: (files: FileEntry[]) => void; + const runtime = await import('@quarto/preview-runtime'); + (runtime.connect as ReturnType).mockImplementationOnce( + () => new Promise((res) => { resolveConnect = res; }), + ); + + render(); + // Loading copy. We don't pin the exact wording, just that *some* + // initializing affordance is visible before the iframe appears. + await waitFor(() => { + expect(screen.queryByText(/initializing/i)).not.toBeNull(); + }); + expect(screen.queryByTestId('q2-preview-iframe-mock')).toBeNull(); + + // Resolve and confirm the iframe takes over. + resolveConnect(runtimeMockState.files); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + }); + + it('surfaces a connection error via ', async () => { + runtimeMockState.connectError = 'kaboom: sync server unreachable'; + render(); + await waitFor(() => { + // PreviewErrorOverlay shows "Render Error" in the expanded state; + // we render it with `collapsed={false}` for the error case so + // users see the message immediately. + expect(screen.queryByText(/render error/i)).not.toBeNull(); + }); + expect(screen.queryByText(/kaboom/i)).not.toBeNull(); + expect(screen.queryByTestId('q2-preview-iframe-mock')).toBeNull(); + }); + + it('forwards theme_fingerprint into Q2PreviewIframe so the iframe styles itself', async () => { + // The hub render returns `theme_fingerprint` when a compiled + // theme exists. PreviewApp must thread it into Q2PreviewIframe's + // `themeFingerprint` prop — otherwise the iframe never knows to + // mint a blob URL for /.quarto/project-artifacts/styles.css and + // post UPDATE_THEME, leaving the output unstyled. Two cases: + // (a) string passed through; (b) field absent → `null` (explicit + // "no theme intended" rather than "preserve last"). + runtimeMockState.renderResult = { + success: true, + ast_json: '{"blocks":[]}', + theme_fingerprint: 'abc123', + }; + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const propsWithTheme = capturedIframeProps[capturedIframeProps.length - 1]; + expect(propsWithTheme.themeFingerprint).toBe('abc123'); + }); + + it('passes themeFingerprint=null when render succeeds without a theme', async () => { + runtimeMockState.renderResult = { + success: true, + ast_json: '{"blocks":[]}', + // theme_fingerprint deliberately absent + }; + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.themeFingerprint).toBeNull(); + }); + + it('normalizes a bare /health docId by prefixing automerge:', async () => { + // /health returns the bare id (samod's storage form); connect() + // expects automerge: (automerge-repo's DocumentId form). The + // SPA must bridge the two. This test pins that contract: connect + // should be called with the prefixed form even though /health + // returns the bare form. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response( + JSON.stringify({ status: 'ok', index_document_id: '4ByAxLmGYwAEYN5xZEX7Jq1GxTmU' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )), + ); + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const runtime = await import('@quarto/preview-runtime'); + const calls = (runtime.connect as ReturnType).mock.calls; + expect(calls.length).toBeGreaterThan(0); + const [, docIdArg] = calls[calls.length - 1]; + expect(docIdArg).toBe('automerge:4ByAxLmGYwAEYN5xZEX7Jq1GxTmU'); + }); + + it('re-runs the render when the force-refresh button is clicked', async () => { + // bd-b5hf / Phase A.6 — the epic's resolution #4 ("force-refresh + // invariant") promises an always-visible UI affordance that + // re-runs the render pipeline against current automerge state. + // The dep-graph won't always know that a cross-doc edit affects + // the active page; the button is the user's escape hatch. + // + // What we pin here: clicking the button calls + // `renderPageForPreview` at least once more after the initial + // boot render. We deliberately don't pin the *trigger mechanism* + // (state-bump vs prop change vs direct call) — only the observable + // outcome at the runtime seam. + const runtime = await import('@quarto/preview-runtime'); + const renderMock = runtime.renderPageForPreview as ReturnType; + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const initialCallCount = renderMock.mock.calls.length; + expect(initialCallCount).toBeGreaterThan(0); + + // The button lives in PreviewApp's chrome (outside the sandboxed + // renderer iframe) so it's always reachable even when the inner + // render is misbehaving. Match by accessible name rather than + // by test-id so the UX label is part of the contract. + const refreshButton = screen.getByRole('button', { + name: /refresh|re-render|reload/i, + }); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(renderMock.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + }); + + it('threads a capture payload through to renderPageForPreview when the sidecar has one (Phase C.4)', async () => { + // Phase C.4 (bd-kw93.3): when the IndexDocument's V2 capture + // sidecar carries a captureDocId for the active page, PreviewApp + // resolves the binary doc and forwards its gzipped JSON bytes to + // the WASM renderer. We pin the seam shape: getBinaryDocById is + // called with the captureDocId from onCapturesChange, and the + // returned bytes are passed as the third argument to + // renderPageForPreview. + const runtime = await import('@quarto/preview-runtime'); + const setSyncHandlersMock = runtime.setSyncHandlers as ReturnType; + const getBinaryDocByIdMock = runtime.getBinaryDocById as ReturnType; + const renderMock = runtime.renderPageForPreview as ReturnType; + + const sentinelBytes = new Uint8Array([1, 2, 3, 4]); + getBinaryDocByIdMock.mockImplementation(async (docId: string) => { + // Only resolve the exact id we pre-fed into onCapturesChange; + // anything else returns null so we don't accidentally pass + // bytes for a different doc. + if (docId === 'capture-doc-1') { + return { content: sentinelBytes, mimeType: 'application/x-engine-capture+gzip' }; + } + return null; + }); + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + // Invoke the onCapturesChange handler PreviewApp registered with + // setSyncHandlers — simulating a sync-client capture event. + const handlersArg = setSyncHandlersMock.mock.calls.at(-1)?.[0] as + | { onCapturesChange?: (captures: Record) => void } + | undefined; + expect(handlersArg?.onCapturesChange).toBeTypeOf('function'); + + handlersArg!.onCapturesChange!({ + 'index.qmd': { captureDocId: 'capture-doc-1' }, + }); + + // The render effect re-fires off the new captures state; once it + // has had a turn, getBinaryDocById should have been asked for our + // capture and the bytes should land in renderPageForPreview's + // third arg. + await waitFor(() => { + expect(getBinaryDocByIdMock).toHaveBeenCalledWith('capture-doc-1'); + }); + await waitFor(() => { + const lastCall = renderMock.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + // arg 0: path, arg 1: grammars, arg 2: capture bytes + expect(lastCall![2]).toBe(sentinelBytes); + }); + }); + + it('renders without a capture when the sidecar is empty (Phase C.4 fall-through)', async () => { + // Default state: no onCapturesChange ever fires, so the active + // page renders with `captureGzJson === undefined`. Confirms the + // no-replay path is unchanged from pre-C.4 behaviour. + const runtime = await import('@quarto/preview-runtime'); + const getBinaryDocByIdMock = runtime.getBinaryDocById as ReturnType; + const renderMock = runtime.renderPageForPreview as ReturnType; + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + expect(getBinaryDocByIdMock).not.toHaveBeenCalled(); + const firstCall = renderMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + // arg 2 is the capture; should be undefined. + expect(firstCall![2]).toBeUndefined(); + }); + + it('surfaces a /health failure with an actionable message', async () => { + // Replace the default mock with one that 500s on /health. This is + // the failure mode if the hub crashes before the SPA boots — + // surfacing it visibly beats a blank "Initializing…" screen. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('boom', { status: 500, statusText: 'Internal Server Error' })), + ); + render(); + await waitFor(() => { + expect(screen.queryByText(/render error/i)).not.toBeNull(); + }); + expect(screen.queryByText(/\/health/i)).not.toBeNull(); + expect(screen.queryByTestId('q2-preview-iframe-mock')).toBeNull(); + }); + + // ────────────────────────────────────────────────────────────── + // Phase D.2 (bd-kw93.13): boot URL `?page=` query support + // ────────────────────────────────────────────────────────────── + + it('seeds activeFile from ?page= when the CLI carries a requested page', async () => { + // CLI emits `http://127.0.0.1:N/?page=about.qmd` when the user + // asked for a specific page (or when the project has an + // `index.qmd` at the root). The SPA's pickInitialPage must + // honor it rather than falling through to firstQmd. + runtimeMockState.files = [ + { path: 'intro.qmd', docId: 'automerge:intro' }, + { path: 'about.qmd', docId: 'automerge:about' }, + ]; + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, search: '?page=about.qmd' }, + }); + + try { + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.currentFilePath).toBe('about.qmd'); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); + + it('falls back to firstQmd when ?page= names a file not in the index', async () => { + // Hand-crafted URL with a stale/unknown path must not strand + // the SPA on an empty page — silently fall back to firstQmd. + runtimeMockState.files = [ + { path: 'intro.qmd', docId: 'automerge:intro' }, + { path: 'about.qmd', docId: 'automerge:about' }, + ]; + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, search: '?page=does-not-exist.qmd' }, + }); + + try { + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.currentFilePath).toBe('intro.qmd'); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); + + // ────────────────────────────────────────────────────────────── + // Phase D.4 (bd-kw93.10): non-terminal render errors overlay on + // top of the last-good render + // ────────────────────────────────────────────────────────────── + + it('keeps the previous render visible when a subsequent render fails', async () => { + // Boot succeeds with a real render, then trigger another + // render where the WASM call throws. The iframe (last-good + // astJson) must stay mounted; PreviewErrorOverlay shows on top. + runtimeMockState.files = [{ path: 'index.qmd', docId: 'automerge:i' }]; + + const runtime = await import('@quarto/preview-runtime'); + let renderCallCount = 0; + (runtime.renderPageForPreview as ReturnType).mockImplementation( + async () => { + renderCallCount += 1; + if (renderCallCount === 1) { + return { success: true, ast_json: '{"blocks":[]}' }; + } + // Second call fails as if a malformed qmd hit the parser. + throw new Error('synthetic parse error'); + }, + ); + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + expect(capturedIframeProps.length).toBeGreaterThanOrEqual(1); + + // Trigger a re-render. setSyncHandlers' `onFileContent` callback + // is what would bump contentTick in production; we trigger the + // same effect via the captured handler. + const setHandlersCalls = ( + runtime.setSyncHandlers as ReturnType + ).mock.calls; + const lastHandlers = setHandlersCalls[setHandlersCalls.length - 1][0] as { + onFileContent: (path: string, content: string) => void; + }; + + lastHandlers.onFileContent('index.qmd', 'updated content'); + + await waitFor(() => { + // The overlay must surface (look for the collapsed-mode + // affordance — "Error" button text). + expect(screen.queryByText(/error/i)).not.toBeNull(); + }); + + // The iframe stays mounted with the LAST GOOD ast_json — i.e. the + // render failure does NOT take the user back to "Initializing…". + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + it('clears the render-error overlay when the next render succeeds', async () => { + runtimeMockState.files = [{ path: 'index.qmd', docId: 'automerge:i' }]; + + const runtime = await import('@quarto/preview-runtime'); + let renderCallCount = 0; + (runtime.renderPageForPreview as ReturnType).mockImplementation( + async () => { + renderCallCount += 1; + if (renderCallCount === 1) { + return { success: true, ast_json: '{"blocks":[]}' }; + } + if (renderCallCount === 2) { + // Second render fails … + throw new Error('synthetic parse error'); + } + // … third render succeeds again (user fixed the qmd). + return { success: true, ast_json: '{"blocks":[{"recovered":true}]}' }; + }, + ); + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + const setHandlersCalls = ( + runtime.setSyncHandlers as ReturnType + ).mock.calls; + const lastHandlers = setHandlersCalls[setHandlersCalls.length - 1][0] as { + onFileContent: (path: string, content: string) => void; + }; + + // Trigger failure render. + lastHandlers.onFileContent('index.qmd', 'busted'); + await waitFor(() => { + expect(screen.queryByText(/error/i)).not.toBeNull(); + }); + + // Trigger recovery render. + lastHandlers.onFileContent('index.qmd', 'fixed'); + await waitFor(() => { + // Overlay clears (no "Error" affordance visible). + expect(screen.queryByText(/^error$/i)).toBeNull(); + }); + // And the iframe got the new ast_json. + const propsAfterRecovery = capturedIframeProps[capturedIframeProps.length - 1]; + expect(propsAfterRecovery.astJson).toBe('{"blocks":[{"recovered":true}]}'); + }); + + // ────────────────────────────────────────────────────────────── + // Phase F.1 (bd-kw93.14): cross-page navigation handler + // ────────────────────────────────────────────────────────────── + + it('forwards projectFilePaths into Q2PreviewIframe so the link handler can recognise artifact-rooted .html', async () => { + runtimeMockState.files = [ + { path: 'index.qmd', docId: 'automerge:i' }, + { path: 'about.qmd', docId: 'automerge:a' }, + { path: 'posts/first.qmd', docId: 'automerge:p1' }, + ]; + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.projectFilePaths).toEqual([ + 'index.qmd', + 'about.qmd', + 'posts/first.qmd', + ]); + }); + + it('handleNavigate updates activeFile and pushes a fresh history entry', async () => { + runtimeMockState.files = [ + { path: 'index.qmd', docId: 'automerge:i' }, + { path: 'about.qmd', docId: 'automerge:a' }, + ]; + const pushSpy = vi.spyOn(window.history, 'pushState'); + try { + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const initial = capturedIframeProps[capturedIframeProps.length - 1]; + expect(initial.currentFilePath).toBe('index.qmd'); + + // Simulate the iframe posting a NAVIGATE_TO_DOCUMENT — extract + // the latest onNavigateToDocument and call it directly. + const onNavigate = initial.onNavigateToDocument as + | ((path: string, anchor: string | null) => void) + | undefined; + expect(typeof onNavigate).toBe('function'); + onNavigate!('about.qmd', null); + + await waitFor(() => { + const latest = capturedIframeProps[capturedIframeProps.length - 1]; + return latest.currentFilePath === 'about.qmd'; + }); + + // history.pushState was called with a URL whose `?page=` is the + // new active file. + expect(pushSpy).toHaveBeenCalled(); + const lastPush = pushSpy.mock.calls[pushSpy.mock.calls.length - 1]; + expect(lastPush[2]).toContain('page=about.qmd'); + } finally { + pushSpy.mockRestore(); + } + }); + + it('handleNavigate with anchor bumps pendingAnchorEpoch on each call', async () => { + runtimeMockState.files = [ + { path: 'index.qmd', docId: 'automerge:i' }, + { path: 'about.qmd', docId: 'automerge:a' }, + ]; + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + const initialEpoch = ( + capturedIframeProps[capturedIframeProps.length - 1].pendingAnchorEpoch as number | undefined + ) ?? 0; + + const onNavigate = capturedIframeProps[capturedIframeProps.length - 1] + .onNavigateToDocument as (path: string, anchor: string | null) => void; + onNavigate('about.qmd', 'intro'); + + await waitFor(() => { + const latest = capturedIframeProps[capturedIframeProps.length - 1]; + expect(latest.pendingAnchor).toBe('intro'); + expect(latest.pendingAnchorEpoch).toBe(initialEpoch + 1); + }); + + // Click the same anchor again — epoch must advance again so the + // iframe re-scrolls (deliberate "take me back there" gesture). + onNavigate('about.qmd', 'intro'); + await waitFor(() => { + const latest = capturedIframeProps[capturedIframeProps.length - 1]; + expect(latest.pendingAnchorEpoch).toBe(initialEpoch + 2); + }); + }); + + it('popstate restores activeFile + pendingAnchor from the URL', async () => { + runtimeMockState.files = [ + { path: 'index.qmd', docId: 'automerge:i' }, + { path: 'about.qmd', docId: 'automerge:a' }, + ]; + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + // Simulate the user clicking back: location is restored to a + // previous entry, then popstate fires. We mutate location's + // search/hash via Object.defineProperty (jsdom doesn't have a + // real history stack), then dispatch the event. + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, search: '?page=about.qmd', hash: '#section-2' }, + }); + + try { + window.dispatchEvent(new PopStateEvent('popstate')); + await waitFor(() => { + const latest = capturedIframeProps[capturedIframeProps.length - 1]; + expect(latest.currentFilePath).toBe('about.qmd'); + expect(latest.pendingAnchor).toBe('section-2'); + }); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); + + it('seeds pendingAnchor from a boot URL hash', async () => { + runtimeMockState.files = [ + { path: 'index.qmd', docId: 'automerge:i' }, + { path: 'about.qmd', docId: 'automerge:a' }, + ]; + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + search: '?page=about.qmd', + hash: '#install', + }, + }); + + try { + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + const props = capturedIframeProps[capturedIframeProps.length - 1]; + expect(props.currentFilePath).toBe('about.qmd'); + expect(props.pendingAnchor).toBe('install'); + // Boot hash → epoch 1 (first scroll request); without a hash + // the epoch stays at 0. + expect(props.pendingAnchorEpoch).toBe(1); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); + + it('shows engine errors via the stale-capture overlay (CaptureRef.lastError pass-through)', async () => { + // Pin the existing pass-through in StaleCaptureOverlay (Phase + // C.5) so a future refactor doesn't silently regress D.4. + runtimeMockState.files = [{ path: 'index.qmd', docId: 'automerge:i' }]; + + const runtime = await import('@quarto/preview-runtime'); + const setHandlersCallsBefore = ( + runtime.setSyncHandlers as ReturnType + ).mock.calls.length; + + render(); + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + const setHandlersCalls = ( + runtime.setSyncHandlers as ReturnType + ).mock.calls; + const handlers = setHandlersCalls[setHandlersCallsBefore][0] as { + onCapturesChange: ( + captures: Record, + ) => void; + }; + + // Inject a sidecar entry whose state === 'error' and carries a + // user-facing message. + handlers.onCapturesChange({ + 'index.qmd': { + captureDocId: 'automerge:cap', + state: 'error', + lastError: 'kernel died: ModuleNotFoundError: pandas', + }, + }); + + await waitFor(() => { + expect( + screen.queryByText(/kernel died.*pandas/i), + ).not.toBeNull(); + }); + }); + + // ─── bd-iuzmk: browser-tab title reflects the active doc's title ───── + // + // Today the SPA's is hard-coded to "Quarto Preview" in + // q2-preview.html. Once a render lands and the AST carries a + // `meta.title`, the SPA should update `document.title` so users with + // the published page and the preview open side-by-side can tell tabs + // apart. Format: `<title> (Quarto Preview)` when a title is present; + // fall back to "Quarto Preview" when it isn't. + // + // These tests assert the contract directly on `document.title` (not + // a DOM node) because that's the value the browser tab reads. + // + // ⚠️ Earlier tests in this suite (the render-error-overlay cases + // around line 415 / 460) call + // `(runtime.renderPageForPreview).mockImplementation(...)` with + // hardcoded return shapes. `vi.clearAllMocks()` in `beforeEach` + // only clears call history, not implementations, so my tests would + // inherit a stale stub that ignores `runtimeMockState.renderResult` + // and short-circuits the title-extraction path. Each of the three + // tests below re-installs the default closure-over-state mock at + // the top so they observe their own renderResult mutation. + + async function resetRenderPageForPreviewMockToDefault() { + const runtime = await import('@quarto/preview-runtime'); + (runtime.renderPageForPreview as ReturnType<typeof vi.fn>).mockImplementation( + async (_p: string, _g?: unknown, _c?: Uint8Array) => runtimeMockState.renderResult, + ); + } + + it('sets document.title to "<meta.title> (Quarto Preview)" after a render with a title', async () => { + await resetRenderPageForPreviewMockToDefault(); + // The AST shape mirrors what pampa's JSON writer emits — meta is a + // plain object at the top level, with each value tagged via + // `t/c`. Pampa wraps a YAML `title: "Hello, world"` as MetaInlines + // (the YAML scalar is parsed for inline-formatting opportunities), + // so we use that shape here. The framework's `extractMetaString` + // also accepts MetaString for completeness — covered in the + // sibling test below. + runtimeMockState.renderResult = { + success: true, + ast_json: JSON.stringify({ + blocks: [], + meta: { + title: { + t: 'MetaInlines', + c: [{ t: 'Str', c: 'Hello, world' }], + }, + }, + }), + }; + + // Baseline before render. q2-preview.html ships with this literal. + document.title = 'Quarto Preview'; + render(<PreviewApp />); + + await waitFor(() => { + expect(document.title).toBe('Hello, world (Quarto Preview)'); + }); + }); + + it('keeps document.title at "Quarto Preview" when meta.title is absent', async () => { + await resetRenderPageForPreviewMockToDefault(); + // A doc with no `title:` in its frontmatter parses to a meta + // object without that key (or with an undefined value). The SPA + // must NOT clobber the existing title with "undefined (Quarto + // Preview)" — it falls back to the bare suffix. + runtimeMockState.renderResult = { + success: true, + ast_json: JSON.stringify({ blocks: [], meta: {} }), + }; + + document.title = 'Quarto Preview'; + render(<PreviewApp />); + + // Wait for the iframe to mount so we know the render effect + // ran at least once. Title may still be the default, which is the + // assertion this test wants to lock in. + await waitFor(() => { + expect(screen.queryByTestId('q2-preview-iframe-mock')).not.toBeNull(); + }); + + expect(document.title).toBe('Quarto Preview'); + }); + + it('updates document.title when subsequent renders carry a different title (live edit)', async () => { + await resetRenderPageForPreviewMockToDefault(); + // Live-edit case: user retitles the doc in the QMD. The next + // render's AST has the new meta.title, and the tab should + // update — same render effect, same dependency. + runtimeMockState.renderResult = { + success: true, + ast_json: JSON.stringify({ + blocks: [], + meta: { + title: { t: 'MetaString', c: 'Original' }, + }, + }), + }; + document.title = 'Quarto Preview'; + render(<PreviewApp />); + + await waitFor(() => { + expect(document.title).toBe('Original (Quarto Preview)'); + }); + + // Simulate an edit: re-mock the next render with a different + // title and trigger a re-render via the force-refresh button + // (which bumps `contentTick`, the render effect's dep). Using + // the existing affordance avoids reaching into PreviewApp's + // internals to fire a fresh render manually. + runtimeMockState.renderResult = { + success: true, + ast_json: JSON.stringify({ + blocks: [], + meta: { + title: { t: 'MetaString', c: 'Renamed' }, + }, + }), + }; + const refreshBtn = screen.getByRole('button', { name: /refresh|reload|re-?render/i }); + fireEvent.click(refreshBtn); + + await waitFor(() => { + expect(document.title).toBe('Renamed (Quarto Preview)'); + }); + }); +}); diff --git a/q2-preview-spa/src/PreviewApp.tsx b/q2-preview-spa/src/PreviewApp.tsx new file mode 100644 index 000000000..680cfc747 --- /dev/null +++ b/q2-preview-spa/src/PreviewApp.tsx @@ -0,0 +1,687 @@ +/** + * Top-level component for the q2-preview SPA. + * + * Phase A scope (bd-o5wd + bd-mflk): + * - fetch `index_document_id` from the hub's `GET /health`, + * - boot the WASM module + samod sync via `@quarto/preview-runtime`, + * - pick the first .qmd in the project as the active page, + * - render that page through `<Q2PreviewIframe>`, + * - re-render when any synced file's content changes. + * + * Out of scope (Phase A): URL-driven file selection beyond the first + * .qmd, code-cell execution (Phase C), the force-refresh button + * (bd-b5hf / A.6), real error UX beyond the connection error path. + * + * Decisions worth surfacing here: + * + * - `setAst` on Q2PreviewIframe is a no-op for now. The iframe takes + * it as a required prop because Phase 2 of q2-preview anticipated a + * WYSIWYG round-trip (the iframe asks the parent to update the + * AST). The SPA doesn't have an editor to round-trip into yet, so a + * no-op is correct. + * + * - `wsUrl` is derived from `window.location` rather than read from + * a server endpoint. The CLI always opens the SPA on the same + * host:port the websocket lives on, so this is the single source + * of truth. + * + * - `indexDocId` comes from `GET /health` rather than the URL + * fragment. The Phase A plan's Q-A3 originally chose URL-fragment + * carrier (mirroring hub-client's `#/share/...` pattern), but + * threading the docId through the CLI before serve-start turned + * out to require either pre-binding the listener + extracting + * ctx.index().document_id() or extending run_server's API. The hub + * already exposes `index_document_id` on `/health` for free + * (no auth required when auth_config is None, which is preview's + * default), so we use that. Net: simpler architecture, one extra + * round-trip on boot, no new server-side patterns introduced. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { + initWasm, + connect, + setSyncHandlers, + renderPageForPreview, + getBinaryDocById, +} from '@quarto/preview-runtime'; +import { Q2PreviewIframe } from '@quarto/preview-renderer/iframe/Q2PreviewIframe'; +import { PreviewErrorOverlay } from '@quarto/preview-renderer/overlays/PreviewErrorOverlay'; +import { extractMetaString } from '@quarto/preview-renderer/framework'; +import type { CaptureRef, FileEntry } from '@quarto/quarto-automerge-schema'; +import { ForceRefreshButton } from './components/ForceRefreshButton'; +import { StaleCaptureOverlay } from './components/StaleCaptureOverlay'; +import { pickInitialPage } from './pickInitialPage'; + +/** + * Suffix appended to the document's title in the browser tab so a + * `q2 preview` tab is distinguishable from the live / published page + * the same author may have open in a sibling tab. Used both with a + * doc title (`<title> (Quarto Preview)`) and as the standalone + * fallback when no `meta.title` is set (`Quarto Preview`). + */ +const PREVIEW_TITLE_SUFFIX = 'Quarto Preview'; + +/** + * Build the browser-tab title from the active doc's Pandoc-AST + * `meta.title`. Returns `"<title> (Quarto Preview)"` when a title is + * extractable, otherwise the bare `"Quarto Preview"` suffix. The + * input is the parsed AST (`{ blocks, meta, ... }`) — callers pass + * `JSON.parse(state.astJson)` and `null`/parse-error degrades to the + * fallback. + * + * `extractMetaString` (from the framework) handles `MetaString`, + * `MetaInlines`, and `MetaBlocks` — the three shapes pampa's JSON + * writer emits for a YAML `title: …` scalar. Other Meta variants + * (MetaBool, MetaList, MetaMap) coerce to `undefined` and we fall + * back. Exported for the integration tests; not part of the SPA's + * runtime public surface. + */ +export function buildPreviewTabTitle(ast: unknown): string { + if (!ast || typeof ast !== 'object') return PREVIEW_TITLE_SUFFIX; + const meta = (ast as { meta?: unknown }).meta; + if (!meta || typeof meta !== 'object') return PREVIEW_TITLE_SUFFIX; + const titleNode = (meta as Record<string, unknown>).title; + const title = extractMetaString(titleNode); + if (!title) return PREVIEW_TITLE_SUFFIX; + return `${title} (${PREVIEW_TITLE_SUFFIX})`; +} + +type BootState = 'loading' | 'ready' | 'error'; + +interface PreviewAppState { + boot: BootState; + files: FileEntry[]; + activeFile: string | null; + /** + * Phase F.1 (bd-kw93.14): anchor (no leading `#`) the iframe should + * scroll to after the next render commits. Set when the user clicks + * a cross-page link with `#frag` or when popstate restores a hash; + * forwarded to `Q2PreviewIframe` as `pendingAnchor`. + * + * Paired with `pendingAnchorEpoch` so subsequent edits to the same + * page don't re-trigger the scroll: each navigation event bumps the + * epoch, the iframe scrolls only when the epoch changes (per-tick + * tracking via ref). Without the epoch, an edit-driven re-render + * would jerk the user back to the original anchor every time. + */ + pendingAnchor: string | null; + pendingAnchorEpoch: number; + astJson: string | null; + /** + * Three-way value matching `Q2PreviewIframe`'s `themeFingerprint` + * contract (see its docstring): a string means "post this theme to + * the iframe"; `null` means "clear the theme"; `undefined` means + * "we have no opinion yet — keep the iframe's last-good theme". + * Initialized as `undefined` so the pre-first-render iframe doesn't + * receive an unintended `UPDATE_THEME { cssUrl: null }`. + */ + themeFingerprint: string | null | undefined; + /** + * Phase D.6 (bd-kw93.12): dep set for the *current* `activeFile`, + * fetched from `/api/preview/deps`. `null` means "unknown yet"; + * the filter in `onFileContent` falls back to pre-D.6 behaviour + * (every change bumps `contentTick`) when null — fail-open is + * correct because a missed re-render is the worse failure mode + * here. Paths in the set are project-relative forward-slash + * strings; `activeFile` itself is included in the set for a + * single comparison call. + */ + deps: Set<string> | null; + /** + * Boot-time failure (e.g. `/health` 5xx, samod connect throws). When + * set, the SPA replaces the UI with `<PreviewErrorOverlay>` — there's + * no previous render worth keeping. Distinct from `renderError`. + */ + error: Error | null; + /** + * Phase D.4 (bd-kw93.10): render-pipeline failure (WASM + * `renderPageForPreview` threw, or `result.success === false`). + * Render errors are *non-terminal*: the iframe keeps showing the + * last good `astJson` and `<PreviewErrorOverlay>` is overlaid on + * top so the user can see what broke without losing the prior + * render's context. A subsequent successful render clears this. + */ + renderError: Error | null; + /** Bumps on every onFileContent callback so the render effect re-fires. */ + contentTick: number; + /** + * IndexDocument V2 capture sidecar (Phase C.3) — path → CaptureRef + * mapping. Populated by the server-side eager-capture driver (Phase + * C.1) and read here by the render effect (Phase C.4) so the + * WASM-side `EngineRegistry::with_replay` can stand in for the real + * engine. + */ + captures: Record<string, CaptureRef>; +} + +const INITIAL_STATE: PreviewAppState = { + boot: 'loading', + files: [], + activeFile: null, + pendingAnchor: null, + pendingAnchorEpoch: 0, + astJson: null, + themeFingerprint: undefined, + deps: null, + error: null, + renderError: null, + contentTick: 0, + captures: {}, +}; + +/** + * Phase D.6 (bd-kw93.12): decide whether a text-file change at + * `changedPath` should trigger a re-render of the page named by + * `activeFile`, given the cached dep set `deps`. + * + * The filter is intentionally narrow: it ONLY filters `.qmd` edits. + * Non-qmd files (CSS, _quarto.yml, _metadata.yml, .tsx custom + * components, …) are project-wide signals that affect rendering + * regardless of the active page's include-shortcode set, so they + * always pass. + * + * Returns true (fail-open) when `deps` is null — the server response + * hasn't landed yet, and we'd rather over-render than miss a change. + */ +function shouldRerenderForTextChange( + changedPath: string, + activeFile: string | null, + deps: Set<string> | null, +): boolean { + if (!activeFile) return true; + // Non-qmd edits always pass: they're either config (_quarto.yml, + // _metadata.yml) or project-wide assets (CSS, custom components) + // that the include-shortcode dep extractor doesn't track. + if (!changedPath.toLowerCase().endsWith('.qmd')) return true; + if (deps === null) return true; + return deps.has(changedPath); +} + +/** + * Fetch the project's index document id from the hub's `/health` + * endpoint. Throws on network failure or if the response doesn't + * carry an `index_document_id`. + * + * The hub stores doc IDs in the bare form (e.g. `4ByAxLmG…`) and + * `/health` returns them that way. `@quarto/preview-runtime`'s + * `connect()` expects the `automerge:<id>` form (same as + * automerge-repo's `DocumentId`); see how hub-client normalizes the + * incoming `shareRoute.indexDocId` in App.tsx for the same reason. + * We normalize here so callers see a single consistent shape. + */ +async function fetchIndexDocId(loc: Location = window.location): Promise<string> { + const healthUrl = `${loc.protocol}//${loc.host}/health`; + const resp = await fetch(healthUrl); + if (!resp.ok) { + throw new Error(`GET /health returned ${resp.status} ${resp.statusText}`); + } + const body = (await resp.json()) as { index_document_id?: string }; + if (!body.index_document_id) { + throw new Error( + `/health response missing index_document_id; got: ${JSON.stringify(body)}`, + ); + } + return body.index_document_id.startsWith('automerge:') + ? body.index_document_id + : `automerge:${body.index_document_id}`; +} + +/** + * Derive the websocket URL from the page location. The CLI serves the + * SPA on the same host:port that hosts the samod ws endpoint, so we + * just swap the scheme. + */ +function deriveWsUrl(loc: Location = window.location): string { + const wsScheme = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${wsScheme}//${loc.host}/ws`; +} + +/** No-op `setAst` until WYSIWYG mode is wired (post-Phase-A). */ +const noopSetAst = () => { + /* deliberately empty */ +}; + +export default function PreviewApp() { + const [state, setState] = useState<PreviewAppState>(INITIAL_STATE); + + // Force-refresh trigger (bd-b5hf): bumping `contentTick` re-fires + // the render useEffect. Reuses the same channel `onFileContent` + // uses for sync-driven re-renders so there's one path through the + // render pipeline regardless of who asks. Stable identity via + // useCallback so the button doesn't re-mount on every state + // update. + const handleRefresh = useCallback(() => { + setState((s) => ({ ...s, contentTick: s.contentTick + 1 })); + }, []); + + // Phase F.1 (bd-kw93.14): the iframe posts NAVIGATE_TO_DOCUMENT + // when the user clicks a cross-page artifact-rooted `.html` link. + // Update activeFile + pendingAnchor and push a fresh history entry + // so the browser's back/forward walks the in-SPA navigation. + // History entries are keyed only on (page, anchor) — same-page + // same-anchor clicks are no-ops to avoid duplicate stack entries. + const handleNavigate = useCallback((path: string, anchor: string | null) => { + setState((s) => { + // No-op when same page + same anchor + no scroll requested + // (would be a duplicate history push). When the anchor is + // present, even a same-page click bumps the epoch so the + // iframe re-scrolls (a user clicking the same anchor twice + // is a deliberate "take me back to that section" signal). + if (s.activeFile === path && s.pendingAnchor === anchor && anchor === null) { + return s; + } + return { + ...s, + activeFile: path, + pendingAnchor: anchor, + pendingAnchorEpoch: anchor ? s.pendingAnchorEpoch + 1 : s.pendingAnchorEpoch, + }; + }); + // Build the URL the same way `pickInitialPage` reads it back so + // a popstate restore + a fresh-tab boot land on the same state. + const params = new URLSearchParams(); + params.set('page', path); + const newSearch = `?${params.toString()}`; + const newHash = anchor ? `#${anchor}` : ''; + if (window.location.search !== newSearch || window.location.hash !== newHash) { + const newUrl = `${window.location.pathname}${newSearch}${newHash}`; + window.history.pushState(null, '', newUrl); + } + }, []); + + // Phase F.1 (bd-kw93.14): browser back/forward fires popstate; map + // the restored URL back to (activeFile, pendingAnchor) using the + // same parsing the boot path uses. Falls back gracefully when the + // URL points at a page no longer in the project (e.g. file got + // renamed mid-session) — `pickInitialPage` reverts to firstQmd. + useEffect(() => { + const onPopState = () => { + setState((s) => { + if (s.boot !== 'ready' || s.files.length === 0) return s; + const restored = pickInitialPage(window.location.search, s.files); + const restoredAnchor = window.location.hash + ? window.location.hash.slice(1) + : null; + if (restored === s.activeFile && restoredAnchor === s.pendingAnchor && !restoredAnchor) { + return s; + } + return { + ...s, + activeFile: restored, + pendingAnchor: restoredAnchor, + // Bump on any popstate that carries an anchor so the + // iframe re-scrolls. Same logic as `handleNavigate`. + pendingAnchorEpoch: restoredAnchor + ? s.pendingAnchorEpoch + 1 + : s.pendingAnchorEpoch, + }; + }); + }; + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + + // Boot once: WASM init + samod connect + initial file pick. + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const indexDocId = await fetchIndexDocId(); + const wsUrl = deriveWsUrl(); + + await initWasm(); + + setSyncHandlers({ + onFilesChange: (files) => { + if (cancelled) return; + setState((s) => ({ ...s, files })); + }, + onFileContent: (path: string) => { + if (cancelled) return; + // Phase D.6 filter: read `activeFile` + `deps` via the + // setState callback so the filter sees the *latest* + // values (the closure was set up at boot time and would + // otherwise capture stale state). + setState((s) => { + if (!shouldRerenderForTextChange(path, s.activeFile, s.deps)) { + return s; + } + return { ...s, contentTick: s.contentTick + 1 }; + }); + }, + // Phase D.3 (bd-kw93.9): binary docs (images, SVGs, + // anything not text-shaped) sync through samod on a + // separate channel from text. Without this handler an + // edit to e.g. `assets/logo.svg` would land in the binary + // doc but the SPA would never re-render. Bump the same + // `contentTick` as text changes so downstream effects + // pick the change up uniformly. + onBinaryContent: () => { + if (cancelled) return; + setState((s) => ({ ...s, contentTick: s.contentTick + 1 })); + }, + // Phase C.4: keep the capture sidecar in state so the render + // effect can pick up server-recorded captures (writes by + // Phase C.1) and route them into WASM replay. + onCapturesChange: (captures) => { + if (cancelled) return; + setState((s) => ({ + ...s, + captures, + // Bump contentTick so the render effect re-fires; the + // newly-recorded capture should now affect the rendered + // AST for the active page. + contentTick: s.contentTick + 1, + })); + }, + onError: (err) => { + if (cancelled) return; + setState((s) => ({ ...s, error: err, boot: 'error' })); + }, + }); + + // 5s peer wait: the q2-preview SPA always hits a fresh + // ephemeral hub with no IndexedDB cache, so the underlying + // 1ms "probe" default in quarto-sync-client would race the + // samod handshake and `findDoc(indexDocId)` would fail with + // "Document … is unavailable" on cold loads. + const initialFiles = await connect(wsUrl, indexDocId, undefined, undefined, undefined, 5000); + if (cancelled) return; + + // Phase D.2 (bd-kw93.13): seed `activeFile` from the boot + // URL's `?page=<rel>` query if the CLI carried one through. + // Falls back to firstQmd when missing/invalid — that's the + // pre-D.2 Phase A behaviour preserved verbatim. + const activeFile = pickInitialPage( + typeof window !== 'undefined' ? window.location.search : '', + initialFiles, + ); + // Phase F.1 (bd-kw93.14): if the boot URL carries a hash + // (e.g. `?page=docs/api.qmd#install`), seed pendingAnchor so + // the first render scrolls into the named section. The iframe + // does the scrolling once the AST is committed. + const initialHash = (typeof window !== 'undefined' && window.location.hash) + ? window.location.hash.slice(1) + : ''; + setState((s) => ({ + ...s, + files: initialFiles, + activeFile, + pendingAnchor: initialHash || null, + // Boot epoch is 1 only when there's actually an anchor to + // scroll to; staying at 0 means "no scroll has been + // requested," which the iframe ignores. + pendingAnchorEpoch: initialHash ? 1 : 0, + boot: 'ready', + })); + } catch (err) { + if (cancelled) return; + setState((s) => ({ + ...s, + error: err instanceof Error ? err : new Error(String(err)), + boot: 'error', + })); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + // Phase D.6 (bd-kw93.12): fetch the dep set for the active page + // from `/api/preview/deps`. Re-fetch whenever the active file + // itself changes (different page), or whenever its content was + // just edited (a new include shortcode might have been added or + // removed). The dep set is consumed by `onFileContent`'s filter + // above; null = unknown ⇒ fail-open. + useEffect(() => { + if (!state.activeFile) return; + let cancelled = false; + const activePath = state.activeFile; + void (async () => { + try { + const resp = await fetch( + `/api/preview/deps?page=${encodeURIComponent(activePath)}`, + ); + if (cancelled) return; + if (!resp.ok) { + // Fail-open: leave `deps` as null so the filter accepts + // everything (pre-D.6 behaviour). A 400 here means the + // server hasn't indexed the page yet; the next refetch + // (driven by the activeFile/contentTick deps below) will + // try again. + console.warn( + `deps fetch returned ${resp.status} for ${activePath}; filter falls open`, + ); + return; + } + const body = (await resp.json()) as { deps?: string[] }; + if (cancelled) return; + const list = body.deps ?? []; + // Include the active page itself in the set so the filter + // doesn't need a special-case for "edit my own page." + const set = new Set<string>([activePath, ...list]); + setState((s) => + s.activeFile === activePath ? { ...s, deps: set } : s, + ); + } catch (e) { + // Network errors etc. — fail-open, just log. + console.warn( + `deps fetch threw for ${activePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + })(); + return () => { + cancelled = true; + }; + }, [state.activeFile, state.contentTick]); + + // Render the active page whenever it (or its content) changes. + useEffect(() => { + if (!state.activeFile) return; + let cancelled = false; + void (async () => { + try { + // Phase C.4: look up the capture for the active page, fetch + // the binary doc, and pass the gzipped JSON bytes through to + // the WASM renderer. The renderer constructs a ReplayEngine + // from them when present; absent ⇒ default registry (markdown + // engine; code cells render as source). + const captureRef = state.captures[state.activeFile!]; + let captureGzJson: Uint8Array | undefined; + if (captureRef?.captureDocId) { + const binaryDoc = await getBinaryDocById(captureRef.captureDocId); + if (cancelled) return; + captureGzJson = binaryDoc?.content; + } + + const result = await renderPageForPreview( + state.activeFile!, + undefined, + captureGzJson, + ); + if (cancelled) return; + // Phase D.3 (bd-kw93.9) + bd-0mji: a test-only render + // counter on `window`. Lets Playwright / SPA tests assert + // "this edit reached the SPA and produced a (re-)render" + // without inferring through DOM diffs. Production builds + // include this; it's a single integer increment, no + // measurable cost. Counts completed render attempts + // (success or non-success result), not effect firings. + if (typeof window !== 'undefined') { + const w = window as unknown as { __renderTicks?: number }; + w.__renderTicks = (w.__renderTicks ?? 0) + 1; + } + if (result.success && result.ast_json !== undefined) { + // Three-way themeFingerprint (mirrors hub-client's + // `ReactPreview` mapping at line 119-125): a string means a + // compiled theme exists; field absent means render succeeded + // with no theme intended → explicit clear (`null`). The + // render-failure branch below leaves the value untouched so + // last-good styling survives transient errors. + // + // Phase D.4 (bd-kw93.10): a successful render also clears + // `renderError` so the previous overlay (if any) goes away. + setState((s) => ({ + ...s, + astJson: result.ast_json ?? null, + themeFingerprint: result.theme_fingerprint ?? null, + renderError: null, + })); + } else { + // Log the full result so we can diagnose surprises in the + // browser console without a code change. + console.error('renderPageInProject failed', { + path: state.activeFile, + result, + }); + // Phase D.4: route into the non-terminal `renderError` + // slot. We deliberately do NOT touch `boot` or `astJson` — + // the iframe keeps showing the last-good render underneath + // and the overlay surfaces the failure on top. Distinct + // from boot errors, which DO replace the UI (no good + // render exists to fall back to). + setState((s) => ({ + ...s, + renderError: new Error( + result.error ?? `renderPageInProject failed: ${JSON.stringify(result)}`, + ), + })); + } + } catch (err) { + if (cancelled) return; + // Phase D.3: bump the render counter on the catch path too + // so "an edit triggered a render attempt" stays a reliable + // signal even when the render threw. + if (typeof window !== 'undefined') { + const w = window as unknown as { __renderTicks?: number }; + w.__renderTicks = (w.__renderTicks ?? 0) + 1; + } + // Same non-terminal treatment as the non-success branch + // above. A render that throws (e.g. malformed qmd hits the + // WASM parser) overlays on top of the previous good render. + setState((s) => ({ + ...s, + renderError: err instanceof Error ? err : new Error(String(err)), + })); + } + })(); + return () => { + cancelled = true; + }; + }, [state.activeFile, state.contentTick, state.captures]); + + // bd-iuzmk: set the browser-tab title from the active AST's + // `meta.title`. Runs after every successful render so a live-edited + // title surfaces in the tab without a separate channel. Failure + // modes (parse error, missing meta) fall back to the bare "Quarto + // Preview" suffix — same as today's hard-coded title. No cleanup: + // the next document switch / edit re-fires this effect; on unmount, + // the tab is closed anyway. + useEffect(() => { + if (state.astJson === null) return; + let ast: unknown = null; + try { + ast = JSON.parse(state.astJson); + } catch { + ast = null; + } + document.title = buildPreviewTabTitle(ast); + }, [state.astJson]); + + // ── Render ──────────────────────────────────────────────────────────── + + if (state.boot === 'error' && state.error) { + return ( + <PreviewErrorOverlay + error={{ message: state.error.message }} + visible + collapsed={false} + /> + ); + } + + // Phase D.4 (bd-kw93.10): if the *first* render failed (no good + // astJson exists to fall back to), show the overlay terminal-style. + // Subsequent failures with a prior good astJson take the + // overlay-on-top branch further down. + if (state.astJson === null && state.renderError) { + return ( + <PreviewErrorOverlay + error={{ message: state.renderError.message }} + visible + collapsed={false} + /> + ); + } + + if (state.boot === 'loading' || !state.activeFile || state.astJson === null) { + return ( + <div + style={{ + padding: 24, + color: '#666', + font: '14px -apple-system, Segoe UI, sans-serif', + }} + > + Initializing q2-preview… + </div> + ); + } + + // The wrapper anchors the absolutely-positioned refresh button + // (and any future floating chrome) so it overlays the iframe + // without an extra flex/grid layer. `height: 100%` lets the + // iframe still fill the SPA root (set by index.html). + // Phase C.5: show the stale-capture overlay when the active page's + // sidecar entry says staleness=true OR an in-flight re-execute is + // running OR a previous re-execute errored. The previous capture + // still drives the rendered preview underneath; the overlay just + // surfaces the signal + the Re-execute action. + const activeCapture: CaptureRef | undefined = state.captures[state.activeFile]; + const showStaleOverlay = + activeCapture !== undefined && + (activeCapture.staleness === true || + activeCapture.state === 'running' || + activeCapture.state === 'error'); + + return ( + <div style={{ position: 'relative', width: '100%', height: '100%' }}> + <Q2PreviewIframe + astJson={state.astJson} + currentFilePath={state.activeFile} + themeFingerprint={state.themeFingerprint} + // Phase F.1 (bd-kw93.14): forward project file paths + + // pending-anchor so the iframe link handler recognises + // artifact-rooted `.html` clicks and the iframe scrolls to + // the cross-page anchor after committing the new AST. + projectFilePaths={state.files.map((f) => f.path)} + pendingAnchor={state.pendingAnchor} + pendingAnchorEpoch={state.pendingAnchorEpoch} + onNavigateToDocument={handleNavigate} + setAst={noopSetAst} + /> + {showStaleOverlay && ( + <StaleCaptureOverlay + activePath={state.activeFile} + state={activeCapture?.state} + lastError={activeCapture?.lastError} + /> + )} + {/* Phase D.4 (bd-kw93.10): non-terminal render-error overlay. + Shown collapsed so it doesn't hide the last-good render the + user is looking at; click "Error" to expand for details. */} + {state.renderError && ( + <PreviewErrorOverlay + error={{ message: state.renderError.message }} + visible + collapsed + /> + )} + <ForceRefreshButton onRefresh={handleRefresh} /> + </div> + ); +} diff --git a/q2-preview-spa/src/components/ForceRefreshButton.tsx b/q2-preview-spa/src/components/ForceRefreshButton.tsx new file mode 100644 index 000000000..87d6991bb --- /dev/null +++ b/q2-preview-spa/src/components/ForceRefreshButton.tsx @@ -0,0 +1,58 @@ +/** + * Always-visible "refresh preview" affordance. + * + * Phase A.6 (bd-b5hf) — the epic's resolution #4 (force-refresh + * invariant): the dep-graph that drives auto-rerenders won't always + * know that a cross-document edit affects the active page (and in + * Phase A the project doesn't yet *have* a dep graph), so the SPA + * always exposes a manual escape hatch. Click → caller bumps its + * render trigger; in PreviewApp that's the `contentTick` counter + * already used by the sync handler's `onFileContent`. + * + * Positioning is `position: absolute` against a `position: relative` + * parent. The component does not own a wrapper — it expects its + * caller to render it as a sibling of whatever fills the pane (the + * `<Q2PreviewIframe>` in PreviewApp's ready state). That keeps the + * iframe at full size and the button floating at the corner without + * an extra flex/grid layer. + */ + +interface ForceRefreshButtonProps { + onRefresh: () => void; +} + +export function ForceRefreshButton({ onRefresh }: ForceRefreshButtonProps) { + return ( + <button + type="button" + aria-label="Refresh preview" + title="Refresh preview" + onClick={onRefresh} + style={{ + position: 'absolute', + top: '0.75rem', + right: '0.75rem', + zIndex: 10, + width: '2rem', + height: '2rem', + padding: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: '1px solid rgba(0, 0, 0, 0.15)', + borderRadius: '50%', + background: 'rgba(255, 255, 255, 0.85)', + color: 'rgba(0, 0, 0, 0.7)', + cursor: 'pointer', + fontSize: '1rem', + lineHeight: 1, + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.08)', + }} + > + {/* U+21BB (clockwise open circle arrow) — the standard + refresh glyph, supported by every system font, no asset + dep. The aria-label above carries the meaning for AT. */} + <span aria-hidden="true">↻</span> + </button> + ); +} diff --git a/q2-preview-spa/src/components/StaleCaptureOverlay.integration.test.tsx b/q2-preview-spa/src/components/StaleCaptureOverlay.integration.test.tsx new file mode 100644 index 000000000..8c92d4e5c --- /dev/null +++ b/q2-preview-spa/src/components/StaleCaptureOverlay.integration.test.tsx @@ -0,0 +1,101 @@ +/** + * Unit tests for StaleCaptureOverlay (Phase C.5, bd-kw93.5). + * + * Renders the component in isolation, asserting the UX seam: + * - The button label reflects the sidecar `state` field. + * - Clicking POSTs to /api/preview/re-execute with the active path. + * - 409 surfaces a "already in flight" message; 4xx/5xx show the + * server body. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { StaleCaptureOverlay } from './StaleCaptureOverlay'; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 202 }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('StaleCaptureOverlay', () => { + it('renders a Re-execute button by default', () => { + render(<StaleCaptureOverlay activePath="doc.qmd" />); + const button = screen.getByRole('button', { name: /re-execute/i }); + expect(button).not.toBeNull(); + expect((button as HTMLButtonElement).disabled).toBe(false); + }); + + it('disables the button and shows "Executing…" when state is running', () => { + render(<StaleCaptureOverlay activePath="doc.qmd" state="running" />); + const button = screen.getByRole('button', { name: /re-execute/i }); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByText(/Executing…/i)).not.toBeNull(); + }); + + it('POSTs to /api/preview/re-execute with the active path on click', async () => { + // Typed parameters so `mock.calls[0]` has the right tuple shape. + // Without these explicit `(_url, _init)` params vitest infers + // the mock as `() => Promise<Response>` and `mock.calls[0]` is + // `[]`, which then refuses the `[string, RequestInit]` cast. + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => + new Response('', { status: 202 }), + ); + vi.stubGlobal('fetch', fetchMock); + + render(<StaleCaptureOverlay activePath="posts/post1.qmd" />); + fireEvent.click(screen.getByRole('button', { name: /re-execute/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/preview/re-execute'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ path: 'posts/post1.qmd' }); + }); + + it('surfaces a 409 response as an "already in flight" message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('busy', { status: 409 })), + ); + + render(<StaleCaptureOverlay activePath="doc.qmd" />); + fireEvent.click(screen.getByRole('button', { name: /re-execute/i })); + + await waitFor(() => { + expect(screen.queryByText(/already in flight/i)).not.toBeNull(); + }); + }); + + it('surfaces a non-202 / non-409 body inline', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('bad path', { status: 400 })), + ); + + render(<StaleCaptureOverlay activePath="doc.qmd" />); + fireEvent.click(screen.getByRole('button', { name: /re-execute/i })); + + await waitFor(() => { + expect(screen.queryByText(/Re-execute failed \(400\): bad path/i)).not.toBeNull(); + }); + }); + + it('shows the recorded lastError when no POST has been made yet', () => { + render( + <StaleCaptureOverlay + activePath="doc.qmd" + state="error" + lastError="engine timed out" + />, + ); + expect(screen.queryByText(/engine timed out/i)).not.toBeNull(); + }); +}); diff --git a/q2-preview-spa/src/components/StaleCaptureOverlay.tsx b/q2-preview-spa/src/components/StaleCaptureOverlay.tsx new file mode 100644 index 000000000..c511f09d3 --- /dev/null +++ b/q2-preview-spa/src/components/StaleCaptureOverlay.tsx @@ -0,0 +1,135 @@ +/** + * Stale-capture overlay (Phase C.5, bd-kw93.5). + * + * Shown when the active page's IndexDocument sidecar entry has + * `staleness === true`. The previous capture continues to drive the + * preview render (preview stays responsive); this overlay surfaces + * the staleness signal and lets the user opt into re-executing. + * + * Behaviour: + * - Click → POST `/api/preview/re-execute` with the active path. + * - 202 Accepted → button disables, label switches to "Executing…" + * until the sidecar's `state` transitions out of `running`. The + * SPA's render effect re-fires off the new `captureDocId` via + * the existing `onCapturesChange` channel; no extra polling. + * - 409 Conflict → another tab already kicked off a re-execute; + * leave the overlay in place and let the in-flight run complete. + * - 4xx / 5xx → show the error inline; clicking again retries. + * + * Positioning matches `ForceRefreshButton`: absolute, top-left + * corner of the preview pane (so it doesn't collide with the + * top-right refresh button). + */ + +import { useState } from 'react'; + +interface StaleCaptureOverlayProps { + /** Project-relative path of the active page. */ + activePath: string; + /** + * `state` from the sidecar's `CaptureRef`. When `'running'`, the + * server is already re-executing — disable the button and show a + * spinner-style label. + */ + state?: 'idle' | 'running' | 'error'; + /** Latest error from the sidecar, if any. Surfaced inline. */ + lastError?: string; +} + +export function StaleCaptureOverlay({ + activePath, + state, + lastError, +}: StaleCaptureOverlayProps) { + const [postError, setPostError] = useState<string | null>(null); + const [isPosting, setIsPosting] = useState(false); + + const disabled = isPosting || state === 'running'; + const label = + state === 'running' + ? 'Executing…' + : isPosting + ? 'Submitting…' + : 'Re-execute'; + + const handleClick = async () => { + setPostError(null); + setIsPosting(true); + try { + const resp = await fetch('/api/preview/re-execute', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: activePath }), + }); + if (resp.status === 202) { + // Accepted; sidecar will flip via samod sync. + return; + } + if (resp.status === 409) { + setPostError('Another re-execute is already in flight.'); + return; + } + const text = await resp.text(); + setPostError(`Re-execute failed (${resp.status}): ${text}`); + } catch (e) { + setPostError(`Network error: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setIsPosting(false); + } + }; + + return ( + <div + role="status" + aria-live="polite" + style={{ + position: 'absolute', + top: '0.75rem', + left: '0.75rem', + zIndex: 10, + maxWidth: 'calc(100% - 5rem)', + padding: '0.5rem 0.75rem', + display: 'inline-flex', + alignItems: 'center', + gap: '0.75rem', + border: '1px solid rgba(0, 0, 0, 0.15)', + borderRadius: '0.375rem', + background: 'rgba(255, 248, 220, 0.95)', + color: 'rgba(0, 0, 0, 0.8)', + fontSize: '0.875rem', + lineHeight: 1.4, + boxShadow: '0 1px 4px rgba(0, 0, 0, 0.08)', + }} + > + <span>Code has changed since the last capture.</span> + <button + type="button" + onClick={handleClick} + disabled={disabled} + aria-label="Re-execute code cells" + style={{ + padding: '0.25rem 0.625rem', + border: '1px solid rgba(0, 0, 0, 0.2)', + borderRadius: '0.25rem', + background: disabled ? 'rgba(0, 0, 0, 0.05)' : '#fff', + color: disabled ? 'rgba(0, 0, 0, 0.45)' : 'inherit', + cursor: disabled ? 'default' : 'pointer', + fontSize: '0.825rem', + }} + > + {label} + </button> + {(postError || lastError) && ( + <span + role="alert" + style={{ + color: 'rgba(180, 30, 30, 0.95)', + fontSize: '0.825rem', + }} + > + {postError ?? lastError} + </span> + )} + </div> + ); +} diff --git a/q2-preview-spa/src/main.tsx b/q2-preview-spa/src/main.tsx new file mode 100644 index 000000000..b82dd38e7 --- /dev/null +++ b/q2-preview-spa/src/main.tsx @@ -0,0 +1,16 @@ +/** + * Entry point for the q2-preview SPA. + * + * Phase A.3 (bd-o5wd) — replaces the bd-hfjj Phase 6 placeholder with + * a real boot through PreviewApp. + */ + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import PreviewApp from './PreviewApp'; + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <PreviewApp /> + </StrictMode>, +); diff --git a/q2-preview-spa/src/pickInitialPage.test.ts b/q2-preview-spa/src/pickInitialPage.test.ts new file mode 100644 index 000000000..883d556cd --- /dev/null +++ b/q2-preview-spa/src/pickInitialPage.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { pickInitialPage, type FileLike } from './pickInitialPage'; + +const files: FileLike[] = [ + { path: '_quarto.yml' }, + { path: 'posts/intro.qmd' }, + { path: 'about.qmd' }, + { path: 'index.qmd' }, +]; + +describe('pickInitialPage', () => { + it('returns the queried page when it is in the file index', () => { + expect(pickInitialPage('?page=posts/intro.qmd', files)).toBe( + 'posts/intro.qmd', + ); + }); + + it('decodes percent-encoded path segments', () => { + const withSpace: FileLike[] = [{ path: 'a b.qmd' }]; + // URLSearchParams takes care of decoding %20 -> space. + expect(pickInitialPage('?page=a%20b.qmd', withSpace)).toBe('a b.qmd'); + }); + + it('falls through to the first .qmd when query is missing', () => { + expect(pickInitialPage('', files)).toBe('posts/intro.qmd'); + expect(pickInitialPage('?', files)).toBe('posts/intro.qmd'); + }); + + it('falls through when the queried page is not in the index', () => { + // `nonexistent.qmd` not in files → silent fallback to first .qmd. + expect(pickInitialPage('?page=nonexistent.qmd', files)).toBe( + 'posts/intro.qmd', + ); + }); + + it('falls through when query is unrelated', () => { + expect(pickInitialPage('?other=value', files)).toBe('posts/intro.qmd'); + }); + + it('rejects path-traversal attempts', () => { + // A hand-crafted URL with `..` segments must not be honored even + // if (somehow) it matches a file path. We never want to chase a + // user-controlled path that could read outside the project. + expect(pickInitialPage('?page=../../etc/passwd', files)).toBe( + 'posts/intro.qmd', + ); + expect(pickInitialPage('?page=posts/../about.qmd', files)).toBe( + 'posts/intro.qmd', + ); + }); + + it('returns null when no .qmd files are in the index', () => { + expect(pickInitialPage('', [{ path: '_quarto.yml' }])).toBeNull(); + }); + + it('treats `?page=` (empty value) as missing', () => { + expect(pickInitialPage('?page=', files)).toBe('posts/intro.qmd'); + expect(pickInitialPage('?page= ', files)).toBe('posts/intro.qmd'); + }); +}); diff --git a/q2-preview-spa/src/pickInitialPage.ts b/q2-preview-spa/src/pickInitialPage.ts new file mode 100644 index 000000000..41702b9f1 --- /dev/null +++ b/q2-preview-spa/src/pickInitialPage.ts @@ -0,0 +1,59 @@ +/** + * Phase D.2 (bd-kw93.13): pick the page to show when the SPA first + * mounts. + * + * The CLI carries the user's intended page (e.g. `q2 preview + * posts/intro.qmd`) into the boot URL as `?page=<rel-path>`. This + * helper: + * + * 1. Reads the `page` query parameter. + * 2. Validates that the requested path is in `files`. + * 3. Returns it on hit. + * 4. Falls through to the first `.qmd` file in the index on miss / + * missing query / unrecognised path. + * + * Keeping the helper pure (string + files in, string|null out) makes + * it unit-testable without spinning up a samod / WebSocket harness. + */ +export interface FileLike { + path: string; +} + +export function pickInitialPage( + search: string, + files: readonly FileLike[], +): string | null { + const requested = parsePageQuery(search); + if (requested && files.some((f) => f.path === requested)) { + return requested; + } + // Fall-through: first .qmd in the discovered index, as the SPA + // has done since Phase A. This is also the path taken when the + // CLI didn't pass a `?page=` hint at all. + const firstQmd = files.find((f) => f.path.endsWith('.qmd')); + return firstQmd ? firstQmd.path : null; +} + +function parsePageQuery(search: string): string | null { + // `URLSearchParams` accepts `?...` directly so we don't have to + // strip the leading `?` ourselves. + if (!search) return null; + let params: URLSearchParams; + try { + params = new URLSearchParams(search); + } catch { + return null; + } + const raw = params.get('page'); + if (!raw) return null; + // Reject empty strings (could happen with `?page=`) and reject any + // attempt to escape the project via `..` segments. The CLI never + // emits paths with `..`; defensive checks here keep a hand-crafted + // URL from confusing the activeFile lookup. + const trimmed = raw.trim(); + if (!trimmed) return null; + if (trimmed.split('/').some((seg) => seg === '..' || seg === '.')) { + return null; + } + return trimmed; +} diff --git a/q2-preview-spa/src/q2-preview-entry.tsx b/q2-preview-spa/src/q2-preview-entry.tsx new file mode 100644 index 000000000..1b5f02112 --- /dev/null +++ b/q2-preview-spa/src/q2-preview-entry.tsx @@ -0,0 +1,6 @@ +// q2-preview iframe entry — re-imports the shared entry from +// `@quarto/preview-renderer`. The script tag in `q2-preview.html` +// points here; the actual postMessage protocol lives in the shared +// package (used by both hub-client and the q2-preview SPA so the +// iframe surface stays identical). +import '@quarto/preview-renderer/q2-preview/entry'; diff --git a/q2-preview-spa/src/test-utils/setup.ts b/q2-preview-spa/src/test-utils/setup.ts new file mode 100644 index 000000000..5b688dc0a --- /dev/null +++ b/q2-preview-spa/src/test-utils/setup.ts @@ -0,0 +1,19 @@ +import '@testing-library/jest-dom'; +import 'fake-indexeddb/auto'; +import { vi } from 'vitest'; + +if (!globalThis.crypto?.randomUUID) { + const cryptoPolyfill = { + ...globalThis.crypto, + randomUUID: () => 'test-uuid-' + Math.random().toString(36).substring(2, 11), + } as Crypto; + Object.defineProperty(globalThis, 'crypto', { value: cryptoPolyfill }); +} + +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); +} diff --git a/q2-preview-spa/tsconfig.app.json b/q2-preview-spa/tsconfig.app.json new file mode 100644 index 000000000..c328724d3 --- /dev/null +++ b/q2-preview-spa/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/q2-preview-spa/tsconfig.json b/q2-preview-spa/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/q2-preview-spa/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/q2-preview-spa/tsconfig.node.json b/q2-preview-spa/tsconfig.node.json new file mode 100644 index 000000000..a96b3e59e --- /dev/null +++ b/q2-preview-spa/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/q2-preview-spa/vite.config.ts b/q2-preview-spa/vite.config.ts new file mode 100644 index 000000000..1b15d6803 --- /dev/null +++ b/q2-preview-spa/vite.config.ts @@ -0,0 +1,93 @@ +import { defineConfig } from 'vite'; +import type { Plugin } from 'vite'; +import react from '@vitejs/plugin-react'; +import wasm from 'vite-plugin-wasm'; +import path from 'path'; +import { readFileSync } from 'fs'; + +/** + * `virtual:quarto-attribution-viewer-css` — mirrors the plugin in + * `hub-client/vite.config.ts` and `ts-packages/preview-renderer/vitest.config.ts`. + * Required because `@quarto/preview-renderer` is resolved via the + * `source` condition (raw .tsx), so this build pulls in + * `framework/attribution.tsx` which imports the virtual module. + */ +function attributionViewerCssPlugin(): Plugin { + const VIRTUAL_ID = 'virtual:quarto-attribution-viewer-css'; + const RESOLVED_ID = '\0' + VIRTUAL_ID; + const sourcePath = path.resolve(__dirname, '../resources/attribution/viewer.css'); + return { + name: 'quarto-attribution-viewer-css', + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID; + }, + load(id) { + if (id === RESOLVED_ID) { + const css = readFileSync(sourcePath, 'utf-8'); + return `export default ${JSON.stringify(css)};`; + } + }, + }; +} + +// The q2-preview SPA is the future host of `quarto preview` (bd-kw93). +// Today it's a placeholder that just confirms the @quarto/preview-renderer +// boundary is wired up. Phase A of bd-kw93 will fill it in with the real +// samod / WASM / preview-pane plumbing. +export default defineConfig({ + base: './', + plugins: [react(), wasm(), attributionViewerCssPlugin()], + resolve: { + // Prefer the `source` exports condition so workspace packages resolve + // straight to .ts/.tsx without requiring a pre-built dist. Mirrors + // hub-client/vite.config.ts. + conditions: ['source', 'import', 'module', 'browser', 'default'], + alias: { + // Mirror hub-client's wasm-quarto-hub-client alias. The SPA imports + // the same WASM module through the same `wasm-quarto-hub-client` + // bare specifier; pointing it at hub-client's symlink avoids + // duplicating the symlink for now. (When `quarto preview` formalizes + // the SPA build it can either keep this alias or copy the bridge + // dir to a SPA-local location.) + 'wasm-quarto-hub-client': path.resolve( + __dirname, + '../hub-client/wasm-quarto-hub-client/wasm_quarto_hub_client.js', + ), + // The Rust WASM module references `/src/wasm-js-bridge/sass.js` + // via `wasm-bindgen raw_module`. Bridge files live in + // `@quarto/wasm-js-bridge`; aliasing the Vite-root path keeps + // the WASM module unchanged. Needed by Phase A.3 once the SPA + // initialises WASM; landing the alias now is cheap. + '/src/wasm-js-bridge': path.resolve( + __dirname, + '../ts-packages/wasm-js-bridge/src', + ), + }, + }, + optimizeDeps: { + exclude: ['wasm-quarto-hub-client', '@automerge/automerge'], + }, + build: { + target: 'esnext', + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + // Multi-entry build: `index.html` hosts the outer PreviewApp; + // `q2-preview.html` hosts the inner sandboxed iframe that + // Q2PreviewIframe loads (same pattern as hub-client). Vite + // emits a dist/q2-preview.html with its own bundled chunk so + // both files live inside the embedded SPA bundle the + // quarto-preview Rust crate serves. + input: { + main: path.resolve(__dirname, 'index.html'), + 'q2-preview': path.resolve(__dirname, 'q2-preview.html'), + }, + }, + }, + server: { + port: 5175, + fs: { + allow: ['..'], + }, + }, +}); diff --git a/q2-preview-spa/vitest.config.ts b/q2-preview-spa/vitest.config.ts new file mode 100644 index 000000000..ed87aac8e --- /dev/null +++ b/q2-preview-spa/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import viteConfig from './vite.config'; +import path from 'path'; + +// q2-preview-spa unit-test config. Inherits `/src/wasm-js-bridge` + +// `wasm-quarto-hub-client` aliases from vite.config.ts via mergeConfig +// (those need to work at vite-build time too, so they live there). +// +// The workspace-package aliases below are *test-only*: vitest doesn't +// honor the `source` exports condition on fresh clones the way Vite's +// prod build does, so we point at source explicitly. Same pattern as +// hub-client and preview-renderer. +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + '@quarto/preview-renderer': path.resolve(__dirname, '../ts-packages/preview-renderer/src'), + '@quarto/preview-runtime': path.resolve(__dirname, '../ts-packages/preview-runtime/src'), + '@quarto/quarto-automerge-schema': path.resolve(__dirname, '../ts-packages/quarto-automerge-schema/src/index.ts'), + '@quarto/quarto-sync-client': path.resolve(__dirname, '../ts-packages/quarto-sync-client/src/index.ts'), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: [ + 'src/**/*.integration.test.ts', + 'src/**/*.integration.test.tsx', + ], + passWithNoTests: true, + }, + }), +); diff --git a/q2-preview-spa/vitest.integration.config.ts b/q2-preview-spa/vitest.integration.config.ts new file mode 100644 index 000000000..a28ce10c5 --- /dev/null +++ b/q2-preview-spa/vitest.integration.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import viteConfig from './vite.config'; +import path from 'path'; + +// Integration tests: jsdom + jest-dom + fake-indexeddb. Workspace +// aliases below as in vitest.config.ts. +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + '@quarto/preview-renderer': path.resolve(__dirname, '../ts-packages/preview-renderer/src'), + '@quarto/preview-runtime': path.resolve(__dirname, '../ts-packages/preview-runtime/src'), + '@quarto/quarto-automerge-schema': path.resolve(__dirname, '../ts-packages/quarto-automerge-schema/src/index.ts'), + '@quarto/quarto-sync-client': path.resolve(__dirname, '../ts-packages/quarto-sync-client/src/index.ts'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: [ + 'src/**/*.integration.test.ts', + 'src/**/*.integration.test.tsx', + ], + setupFiles: ['./src/test-utils/setup.ts'], + passWithNoTests: true, + }, + }), +); diff --git a/ts-packages/preview-renderer/package.json b/ts-packages/preview-renderer/package.json new file mode 100644 index 000000000..58922469a --- /dev/null +++ b/ts-packages/preview-renderer/package.json @@ -0,0 +1,82 @@ +{ + "name": "@quarto/preview-renderer", + "version": "0.0.1", + "private": true, + "description": "Pure-React components that render Quarto's q2-preview format. Shared by hub-client and the q2-preview SPA.", + "license": "MIT", + "author": { + "name": "Posit PBC" + }, + "type": "module", + "main": "dist/index.js", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "import": "./dist/index.js" + }, + "./framework": { + "types": "./src/framework/index.ts", + "source": "./src/framework/index.ts", + "import": "./dist/framework/index.js" + }, + "./q2-preview": { + "types": "./src/q2-preview/index.ts", + "source": "./src/q2-preview/index.ts", + "import": "./dist/q2-preview/index.js" + }, + "./q2-preview/entry": { + "types": "./src/q2-preview/entry.tsx", + "source": "./src/q2-preview/entry.tsx", + "import": "./dist/q2-preview/entry.js" + }, + "./iframe/*": { + "types": "./src/iframe/*.tsx", + "source": "./src/iframe/*.tsx", + "import": "./dist/iframe/*.js" + }, + "./overlays/*": { + "types": "./src/overlays/*.tsx", + "source": "./src/overlays/*.tsx", + "import": "./dist/overlays/*.js" + }, + "./types/*": { + "types": "./src/types/*.ts", + "source": "./src/types/*.ts", + "import": "./dist/types/*.js" + }, + "./utils/*": { + "types": "./src/utils/*.ts", + "source": "./src/utils/*.ts", + "import": "./dist/utils/*.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:watch": "vitest" + }, + "dependencies": { + "@quarto/preview-runtime": "*", + "@quarto/quarto-automerge-schema": "*", + "morphdom": "^2.7.8", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "jsdom": "^26.0.0", + "typescript": "~5.9.3", + "vitest": "^4.0.17" + } +} diff --git a/hub-client/src/components/render/framework/Ast.tsx b/ts-packages/preview-renderer/src/framework/Ast.tsx similarity index 98% rename from hub-client/src/components/render/framework/Ast.tsx rename to ts-packages/preview-renderer/src/framework/Ast.tsx index 8fd4894c5..bfc321602 100644 --- a/hub-client/src/components/render/framework/Ast.tsx +++ b/ts-packages/preview-renderer/src/framework/Ast.tsx @@ -6,7 +6,7 @@ import { } from './AttributionLookupContext'; import { unwrapCustomNodes } from './customNode'; import type { PandocAST } from './types'; -import type { SourceInfoPool } from '../../../types/sourceInfo'; +import type { SourceInfoPool } from '../types/sourceInfo'; interface AstPropsCommon { /** Current file path for resolving relative image paths */ diff --git a/hub-client/src/components/render/framework/AttributionLookupContext.tsx b/ts-packages/preview-renderer/src/framework/AttributionLookupContext.tsx similarity index 100% rename from hub-client/src/components/render/framework/AttributionLookupContext.tsx rename to ts-packages/preview-renderer/src/framework/AttributionLookupContext.tsx diff --git a/hub-client/src/components/render/framework/RegistryContext.tsx b/ts-packages/preview-renderer/src/framework/RegistryContext.tsx similarity index 93% rename from hub-client/src/components/render/framework/RegistryContext.tsx rename to ts-packages/preview-renderer/src/framework/RegistryContext.tsx index ba35be8ea..6c9bb7d6d 100644 --- a/hub-client/src/components/render/framework/RegistryContext.tsx +++ b/ts-packages/preview-renderer/src/framework/RegistryContext.tsx @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import type { SourceInfoPool } from '../../../types/sourceInfo'; +import type { SourceInfoPool } from '../types/sourceInfo'; /** * Context that carries the active format's registry to the dispatchers, diff --git a/hub-client/src/components/render/framework/attribution.styles.test.ts b/ts-packages/preview-renderer/src/framework/attribution.styles.test.ts similarity index 100% rename from hub-client/src/components/render/framework/attribution.styles.test.ts rename to ts-packages/preview-renderer/src/framework/attribution.styles.test.ts diff --git a/hub-client/src/components/render/framework/attribution.test.ts b/ts-packages/preview-renderer/src/framework/attribution.test.ts similarity index 100% rename from hub-client/src/components/render/framework/attribution.test.ts rename to ts-packages/preview-renderer/src/framework/attribution.test.ts diff --git a/hub-client/src/components/render/framework/attribution.test.tsx b/ts-packages/preview-renderer/src/framework/attribution.test.tsx similarity index 100% rename from hub-client/src/components/render/framework/attribution.test.tsx rename to ts-packages/preview-renderer/src/framework/attribution.test.tsx diff --git a/hub-client/src/components/render/framework/attribution.tsx b/ts-packages/preview-renderer/src/framework/attribution.tsx similarity index 99% rename from hub-client/src/components/render/framework/attribution.tsx rename to ts-packages/preview-renderer/src/framework/attribution.tsx index 254bc9d4e..4ef780186 100644 --- a/hub-client/src/components/render/framework/attribution.tsx +++ b/ts-packages/preview-renderer/src/framework/attribution.tsx @@ -1,3 +1,4 @@ +/// <reference path="../global.d.ts" /> import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { AttributionLookupContext, diff --git a/hub-client/src/components/render/framework/customNode.test.ts b/ts-packages/preview-renderer/src/framework/customNode.test.ts similarity index 100% rename from hub-client/src/components/render/framework/customNode.test.ts rename to ts-packages/preview-renderer/src/framework/customNode.test.ts diff --git a/hub-client/src/components/render/framework/customNode.ts b/ts-packages/preview-renderer/src/framework/customNode.ts similarity index 100% rename from hub-client/src/components/render/framework/customNode.ts rename to ts-packages/preview-renderer/src/framework/customNode.ts diff --git a/hub-client/src/components/render/framework/dispatch.tsx b/ts-packages/preview-renderer/src/framework/dispatch.tsx similarity index 99% rename from hub-client/src/components/render/framework/dispatch.tsx rename to ts-packages/preview-renderer/src/framework/dispatch.tsx index 08fbde469..e4640bdc5 100644 --- a/hub-client/src/components/render/framework/dispatch.tsx +++ b/ts-packages/preview-renderer/src/framework/dispatch.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { RegistryContext } from './RegistryContext'; -import { isAtomicSourceInfo, ATOMIC_SYNTHETIC_KINDS } from '../../../utils/sourceInfo'; -import { isAtomicCustomNode } from '../../../utils/atomicCustomNodes'; +import { isAtomicSourceInfo, ATOMIC_SYNTHETIC_KINDS } from '../utils/sourceInfo'; +import { isAtomicCustomNode } from '../utils/atomicCustomNodes'; import type { BlockNode, InlineNode, diff --git a/hub-client/src/components/render/framework/index.ts b/ts-packages/preview-renderer/src/framework/index.ts similarity index 100% rename from hub-client/src/components/render/framework/index.ts rename to ts-packages/preview-renderer/src/framework/index.ts diff --git a/hub-client/src/components/render/framework/meta.test.ts b/ts-packages/preview-renderer/src/framework/meta.test.ts similarity index 61% rename from hub-client/src/components/render/framework/meta.test.ts rename to ts-packages/preview-renderer/src/framework/meta.test.ts index fc883a181..69b9212d1 100644 --- a/hub-client/src/components/render/framework/meta.test.ts +++ b/ts-packages/preview-renderer/src/framework/meta.test.ts @@ -7,6 +7,7 @@ import { extractMetaString, extractMetaBool, extractMetaStringList, + getMetaPath, } from './meta'; describe('extractMetaString', () => { @@ -184,3 +185,101 @@ describe('extractMetaStringList', () => { expect(extractMetaStringList({ t: 'MetaBool', c: true })).toEqual([]); }); }); + +describe('getMetaPath', () => { + // Top-level meta is a plain object; each subsequent step traverses + // a `MetaMap` whose `.c` is `[{key, key_source, value}, ...]`. + // Mirrors the JSON shape emitted by `crates/pampa/src/writers/json.rs`. + const navbarHtml = '<nav class="navbar">…</nav>'; + const fixture = { + title: { t: 'MetaString', c: 'My Doc' }, + rendered: { + t: 'MetaMap', + c: [ + { + key: 'navigation', + key_source: null, + value: { + t: 'MetaMap', + c: [ + { + key: 'navbar', + key_source: null, + value: { t: 'MetaString', c: navbarHtml }, + }, + { + key: 'body-classes', + key_source: null, + value: { t: 'MetaString', c: 'nav-sidebar floating' }, + }, + ], + }, + }, + { + key: 'includes', + key_source: null, + value: { + t: 'MetaMap', + c: [ + { + key: 'header', + key_source: null, + value: { + t: 'MetaList', + c: [ + { t: 'MetaString', c: '<link rel="icon" href="favicon.ico">' }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + + it('returns the leaf at a top-level path', () => { + const leaf = getMetaPath(fixture, ['title']); + expect(extractMetaString(leaf)).toBe('My Doc'); + }); + + it('walks into nested MetaMaps to retrieve a leaf string', () => { + const leaf = getMetaPath(fixture, ['rendered', 'navigation', 'navbar']); + expect(extractMetaString(leaf)).toBe(navbarHtml); + }); + + it('walks into nested MetaMaps to retrieve a hyphenated key', () => { + const leaf = getMetaPath(fixture, ['rendered', 'navigation', 'body-classes']); + expect(extractMetaString(leaf)).toBe('nav-sidebar floating'); + }); + + it('walks into nested MetaMaps to retrieve a list', () => { + const leaf = getMetaPath(fixture, ['rendered', 'includes', 'header']); + expect(extractMetaStringList(leaf)).toEqual([ + '<link rel="icon" href="favicon.ico">', + ]); + }); + + it('returns undefined when any path segment is missing', () => { + expect( + getMetaPath(fixture, ['rendered', 'navigation', 'sidebar']), + ).toBeUndefined(); + expect(getMetaPath(fixture, ['nonexistent'])).toBeUndefined(); + expect(getMetaPath(fixture, ['rendered', 'missing', 'x'])).toBeUndefined(); + }); + + it('returns the input meta for an empty path', () => { + expect(getMetaPath(fixture, [])).toBe(fixture); + }); + + it('returns undefined when meta is null / undefined / non-object', () => { + expect(getMetaPath(undefined, ['x'])).toBeUndefined(); + expect(getMetaPath(null, ['x'])).toBeUndefined(); + expect(getMetaPath('hello', ['x'])).toBeUndefined(); + }); + + it('returns undefined when an intermediate is not a MetaMap', () => { + // `title` is a MetaString — can't walk into it. + expect(getMetaPath(fixture, ['title', 'whatever'])).toBeUndefined(); + }); +}); diff --git a/hub-client/src/components/render/framework/meta.ts b/ts-packages/preview-renderer/src/framework/meta.ts similarity index 63% rename from hub-client/src/components/render/framework/meta.ts rename to ts-packages/preview-renderer/src/framework/meta.ts index 132fddcc0..935d67236 100644 --- a/hub-client/src/components/render/framework/meta.ts +++ b/ts-packages/preview-renderer/src/framework/meta.ts @@ -53,6 +53,48 @@ export function extractMetaBool(meta: unknown): boolean | undefined { return undefined; } +/** + * Walk a dotted path through a nested Pandoc Meta value and return + * the leaf node. `meta` is the *top-level* meta object (a plain + * `Record<string, MetaValue>`); subsequent steps drop into + * `MetaMap.c` arrays (`{key, key_source, value}` entries — see + * `crates/pampa/src/writers/json.rs::write_config_value`). + * + * Returns `undefined` when any segment is missing or the shape + * doesn't match. The caller is expected to coerce the leaf via + * [`extractMetaString`] / [`extractMetaStringList`] etc. + * + * Example: `getMetaPath(meta, ['rendered', 'navigation', 'navbar'])` + * walks `meta.rendered` (MetaMap) → `navigation` (MetaMap) → + * `navbar` (MetaString). + */ +export function getMetaPath( + meta: unknown, + path: readonly string[], +): unknown { + if (path.length === 0) return meta; + let cursor: unknown = meta; + for (let i = 0; i < path.length; i++) { + if (!cursor || typeof cursor !== 'object') return undefined; + const segment = path[i]; + if (i === 0) { + // First step: top-level meta is a plain object. + cursor = (cursor as Record<string, unknown>)[segment]; + } else { + // Subsequent steps: cursor is a MetaMap whose `c` is the + // entries array. (`MetaMap` is the only nested-object + // variant emitted by the JSON writer.) + const m = cursor as { t?: string; c?: unknown }; + if (m.t !== 'MetaMap' || !Array.isArray(m.c)) return undefined; + const entries = m.c as Array<{ key: string; value: unknown }>; + const found = entries.find((e) => e.key === segment); + if (!found) return undefined; + cursor = found.value; + } + } + return cursor; +} + /** * Extract a string list from a Pandoc MetaList. Each list entry is * coerced via the same MetaString / MetaInlines / MetaBlocks logic diff --git a/hub-client/src/components/render/framework/plainText.test.ts b/ts-packages/preview-renderer/src/framework/plainText.test.ts similarity index 100% rename from hub-client/src/components/render/framework/plainText.test.ts rename to ts-packages/preview-renderer/src/framework/plainText.test.ts diff --git a/hub-client/src/components/render/framework/plainText.ts b/ts-packages/preview-renderer/src/framework/plainText.ts similarity index 100% rename from hub-client/src/components/render/framework/plainText.ts rename to ts-packages/preview-renderer/src/framework/plainText.ts diff --git a/hub-client/src/components/render/framework/types.ts b/ts-packages/preview-renderer/src/framework/types.ts similarity index 100% rename from hub-client/src/components/render/framework/types.ts rename to ts-packages/preview-renderer/src/framework/types.ts diff --git a/ts-packages/preview-renderer/src/global.d.ts b/ts-packages/preview-renderer/src/global.d.ts new file mode 100644 index 000000000..ce42c16b2 --- /dev/null +++ b/ts-packages/preview-renderer/src/global.d.ts @@ -0,0 +1,33 @@ +/** + * Module shims for Vite's special import suffixes used by the + * q2-preview iframe entry. The preview-renderer package isn't always + * compiled under Vite (`tsc --noEmit` runs standalone for the + * library's typecheck script), so we declare these locally rather + * than rely on `/// <reference types="vite/client" />`. + * + * `?raw` returns the file's contents as a string at build time. Used + * by `q2-preview/entry.tsx` to inline-inject Bootstrap's bundled JS + * into the sandboxed iframe (Phase F.1, bd-kw93.14). + */ + +declare module '*?raw' { + const content: string; + export default content; +} + +/** + * Vite virtual module exposing the attribution viewer CSS as a string. + * Resolved at build time by `attributionViewerCssPlugin` in + * `hub-client/vite.config.ts`; shared with the CLI's + * `AttributionViewerTransform` via `include_str!` so the two surfaces + * stay in lockstep. See `resources/attribution/README.md`. + * + * Declared here (mirror of `hub-client/src/vite-env.d.ts`) so the + * preview-renderer package's standalone `tsc --noEmit` typecheck sees + * the module — the package is referenced from hub-client via project + * references, but its own typecheck runs without Vite. + */ +declare module 'virtual:quarto-attribution-viewer-css' { + const content: string; + export default content; +} diff --git a/hub-client/src/components/render/DoubleBufferedIframe.tsx b/ts-packages/preview-renderer/src/iframe/DoubleBufferedIframe.tsx similarity index 99% rename from hub-client/src/components/render/DoubleBufferedIframe.tsx rename to ts-packages/preview-renderer/src/iframe/DoubleBufferedIframe.tsx index 092811877..605d7363a 100644 --- a/hub-client/src/components/render/DoubleBufferedIframe.tsx +++ b/ts-packages/preview-renderer/src/iframe/DoubleBufferedIframe.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, useCallback, useImperativeHandle } from 'react'; import type { Ref } from 'react'; -import { postProcessIframe } from '../../utils/iframePostProcessor'; +import { postProcessIframe } from '../utils/iframePostProcessor'; // Methods exposed via ref export interface DoubleBufferedIframeHandle { diff --git a/hub-client/src/components/render/MorphIframe.tsx b/ts-packages/preview-renderer/src/iframe/MorphIframe.tsx similarity index 99% rename from hub-client/src/components/render/MorphIframe.tsx rename to ts-packages/preview-renderer/src/iframe/MorphIframe.tsx index 2861e22ff..1b9fbbe76 100644 --- a/hub-client/src/components/render/MorphIframe.tsx +++ b/ts-packages/preview-renderer/src/iframe/MorphIframe.tsx @@ -1,7 +1,7 @@ import { useRef, useEffect, useCallback, useImperativeHandle } from 'react'; import type { Ref } from 'react'; import morphdom from 'morphdom'; -import { postProcessIframe } from '../../utils/iframePostProcessor'; +import { postProcessIframe } from '../utils/iframePostProcessor'; // Methods exposed via ref export interface MorphIframeHandle { diff --git a/hub-client/src/components/render/q2-preview/Q2PreviewIframe.integration.test.tsx b/ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.integration.test.tsx similarity index 99% rename from hub-client/src/components/render/q2-preview/Q2PreviewIframe.integration.test.tsx rename to ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.integration.test.tsx index a4b5094ae..a1e02dcca 100644 --- a/hub-client/src/components/render/q2-preview/Q2PreviewIframe.integration.test.tsx +++ b/ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.integration.test.tsx @@ -18,7 +18,7 @@ import { act } from 'react'; // map so the asset-manifest test can vary which paths return which bytes. let mockBytes: string | null = null; const mockBinaryByPath: Map<string, string | null> = new Map(); -vi.mock('../../../services/wasmRenderer', () => ({ +vi.mock('@quarto/preview-runtime', () => ({ vfsReadFile: vi.fn(() => { if (mockBytes === null) return { success: false }; return { success: true, content: mockBytes }; diff --git a/hub-client/src/components/render/q2-preview/Q2PreviewIframe.tsx b/ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.tsx similarity index 80% rename from hub-client/src/components/render/q2-preview/Q2PreviewIframe.tsx rename to ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.tsx index 86e60c5f1..54ba692fa 100644 --- a/hub-client/src/components/render/q2-preview/Q2PreviewIframe.tsx +++ b/ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { vfsReadFile } from '../../../services/wasmRenderer'; -import { DEFAULT_CSS_ARTIFACT_PATH } from '../../../types/artifactPaths'; -import { buildAssetManifest, type ManifestCacheEntry } from './assetWalker'; +import { vfsReadFile } from '@quarto/preview-runtime'; +import { DEFAULT_CSS_ARTIFACT_PATH } from '../types/artifactPaths'; +import { buildAssetManifest, type ManifestCacheEntry } from '../q2-preview/assetWalker'; interface Q2PreviewIframeProps { astJson: string; @@ -22,6 +22,27 @@ interface Q2PreviewIframeProps { * user's view. */ themeFingerprint?: string | null; + /** + * Phase F.1 (bd-kw93.14): list of project file paths (no leading + * slash) forwarded to the iframe's link handler so artifact-rooted + * `.html` clicks can be reverse-mapped to the source `.qmd` for + * the SPA-style navigation flow. + */ + projectFilePaths?: readonly string[]; + /** + * Phase F.1 (bd-kw93.14): anchor (without `#`) to scroll to AFTER + * the iframe commits the current AST. Set by `PreviewApp` when the + * user clicks a cross-page link with `#frag`, or when popstate + * restores a hash. + * + * Paired with `pendingAnchorEpoch`: the iframe scrolls only when + * the epoch changes since the last scroll, so subsequent edits + * (which re-post UPDATE_AST with the same anchor + same epoch) + * don't jerk the user back. Each user-driven nav event bumps the + * epoch in `PreviewApp`. + */ + pendingAnchor?: string | null; + pendingAnchorEpoch?: number; } /** @@ -49,6 +70,9 @@ export function Q2PreviewIframe({ setAst, customComponentsCode, themeFingerprint, + projectFilePaths, + pendingAnchor, + pendingAnchorEpoch, }: Q2PreviewIframeProps) { const iframeRef = useRef<HTMLIFrameElement>(null); const [iframeReady, setIframeReady] = useState(false); @@ -146,11 +170,28 @@ export function Q2PreviewIframe({ astJson, currentFilePath, assetManifest, + // Phase F.1 (bd-kw93.14): the iframe link handler reads + // `projectFilePaths` from this payload to recognize + // artifact-rooted `.html` hrefs as project documents. + projectFilePaths, + // Phase F.1: scroll target after React commits the new AST. + // Cross-page nav posts `{ ..., pendingAnchor: 'sec' }`; the + // iframe scrolls in a useEffect once the new DOM exists. + pendingAnchor, + pendingAnchorEpoch, }, }, '*', ); - }, [iframeReady, astJson, currentFilePath, assetManifest]); + }, [ + iframeReady, + astJson, + currentFilePath, + assetManifest, + projectFilePaths, + pendingAnchor, + pendingAnchorEpoch, + ]); // Send theme CSS when iframe is ready and fingerprint is known. // Three-way semantics: diff --git a/ts-packages/preview-renderer/src/index.ts b/ts-packages/preview-renderer/src/index.ts new file mode 100644 index 000000000..07825dbde --- /dev/null +++ b/ts-packages/preview-renderer/src/index.ts @@ -0,0 +1,26 @@ +// Public API for @quarto/preview-renderer. +// +// Two surfaces: +// - Top-level re-exports below — for SPA consumers who want a single +// import path for the common preview-pane components. +// - Sub-path exports (see package.json) — for deeper imports of types/ +// utils/ framework / q2-preview internals where the barrel would +// produce name collisions (`Diagnostic` exists in both +// `types/diagnostic` and `types/intelligence`). + +// Iframe wrappers — host the rendered q2-preview format inside a +// sandboxed iframe and morph DOM on edits. +export { Q2PreviewIframe } from './iframe/Q2PreviewIframe'; +export { default as MorphIframe, type MorphIframeHandle } from './iframe/MorphIframe'; +export { default as DoubleBufferedIframe, type DoubleBufferedIframeHandle } from './iframe/DoubleBufferedIframe'; + +// Overlays — diagnostics and static error / fallback / placeholder +// views layered over the iframe. +export { PreviewErrorOverlay } from './overlays/PreviewErrorOverlay'; +export { ErrorView, FallbackView, NonQmdPlaceholderView } from './overlays/PreviewStaticInfoViews'; + +// q2-preview format — the React components that render Quarto's +// q2-preview AST. The full surface (Block, Inline, PreviewDocument, +// previewRegistry, PreviewContext, AssetManifestContext) is in the +// sub-barrel at `@quarto/preview-renderer/q2-preview`. +export * from './q2-preview'; diff --git a/hub-client/src/components/render/PreviewErrorOverlay.integration.test.tsx b/ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.integration.test.tsx similarity index 86% rename from hub-client/src/components/render/PreviewErrorOverlay.integration.test.tsx rename to ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.integration.test.tsx index 806960bbd..f4a997f59 100644 --- a/hub-client/src/components/render/PreviewErrorOverlay.integration.test.tsx +++ b/ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.integration.test.tsx @@ -8,25 +8,16 @@ * active page' string) is gone — the overlay now renders the * line/column/title from the structured diagnostic. * - * `usePreference` is mocked because the preference plumbing - * isn't under test here, and vitest 4's jsdom + the project's - * vitest config produces a non-functional `localStorage` (see - * the `--localstorage-file` warning) that the real - * `usePreference` would otherwise touch. + * After the preview-renderer decomposition (bd-hfjj Phase 4), + * `PreviewErrorOverlay` no longer reads `usePreference` itself. + * The hosting component owns persistence and passes `collapsed` + * + `onToggleCollapsed` as props (controlled), or omits both to + * use the overlay's uncontrolled fallback (defaults to collapsed). */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; -import type { Diagnostic } from '../../types/diagnostic'; - -let collapsedState = true; -const setCollapsedMock = vi.fn((v: boolean) => { - collapsedState = v; -}); - -vi.mock('../../hooks/usePreference', () => ({ - usePreference: (_key: string) => [collapsedState, setCollapsedMock], -})); +import type { Diagnostic } from '../types/diagnostic'; import { PreviewErrorOverlay } from './PreviewErrorOverlay'; @@ -45,26 +36,24 @@ const parseDiagnostic: Diagnostic = { describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { it('renders nothing when not visible', () => { - collapsedState = false; const { container } = render( <PreviewErrorOverlay error={{ message: 'boom', diagnostics: [parseDiagnostic] }} visible={false} + collapsed={false} />, ); expect(container.firstChild).toBeNull(); }); it('renders nothing when there is no error', () => { - collapsedState = false; const { container } = render( - <PreviewErrorOverlay error={null} visible={true} />, + <PreviewErrorOverlay error={null} visible={true} collapsed={false} />, ); expect(container.firstChild).toBeNull(); }); it('renders the parse diagnostic with line + title + problem when expanded', () => { - collapsedState = false; render( <PreviewErrorOverlay error={{ @@ -74,6 +63,7 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { diagnostics: [parseDiagnostic], }} visible={true} + collapsed={false} />, ); @@ -89,11 +79,11 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { }); it('falls back to message-only when no diagnostics are attached', () => { - collapsedState = false; render( <PreviewErrorOverlay error={{ message: 'something exploded' }} visible={true} + collapsed={false} />, ); @@ -102,7 +92,6 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { }); it('renders sibling pass-1 failures with source-file attribution (bd-rqba)', () => { - collapsedState = false; render( <PreviewErrorOverlay error={{ @@ -116,6 +105,7 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { ], }} visible={true} + collapsed={false} />, ); @@ -134,7 +124,6 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { }); it('renders multiple pass-1 failures, each attributed to its source', () => { - collapsedState = false; render( <PreviewErrorOverlay error={{ @@ -153,6 +142,7 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { ], }} visible={true} + collapsed={false} />, ); @@ -168,7 +158,6 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { }); it('shows collapsed indicator (no diagnostic list) when collapsed', () => { - collapsedState = true; render( <PreviewErrorOverlay error={{ @@ -176,6 +165,7 @@ describe('PreviewErrorOverlay (bd-mwtf parse-error display)', () => { diagnostics: [parseDiagnostic], }} visible={true} + collapsed={true} />, ); diff --git a/hub-client/src/components/render/PreviewErrorOverlay.tsx b/ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.tsx similarity index 75% rename from hub-client/src/components/render/PreviewErrorOverlay.tsx rename to ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.tsx index 663c8112f..0d02921f6 100644 --- a/hub-client/src/components/render/PreviewErrorOverlay.tsx +++ b/ts-packages/preview-renderer/src/overlays/PreviewErrorOverlay.tsx @@ -1,7 +1,6 @@ -import type { Diagnostic } from '../../types/diagnostic'; -import type { Pass1Failure } from '../../services/wasmRenderer'; -import { stripAnsi } from '../../utils/stripAnsi'; -import { usePreference } from '../../hooks/usePreference'; +import { useState } from 'react'; +import type { Diagnostic, Pass1Failure } from '../types/diagnostic'; +import { stripAnsi } from '../utils/stripAnsi'; interface PreviewErrorOverlayProps { error: { @@ -17,11 +16,31 @@ interface PreviewErrorOverlayProps { pass1Failures?: Pass1Failure[]; } | null; visible: boolean; + /** + * Whether the overlay is rendered in its collapsed (minimal-indicator) + * state. When omitted, the overlay falls back to internal state. The + * controlled form lets the hosting surface persist collapsedness in + * its own preference store: hub-client wraps with `usePreference` + * (localStorage); the q2-preview SPA can wire it to session state or + * leave it uncontrolled. + */ + collapsed?: boolean; + /** Toggle the collapsed state. Required only when `collapsed` is supplied. */ + onToggleCollapsed?: (next: boolean) => void; } -export function PreviewErrorOverlay({ error, visible }: PreviewErrorOverlayProps) { - // Collapsed state persisted in localStorage (defaults to collapsed) - const [collapsed, setCollapsed] = usePreference('errorOverlayCollapsed'); +export function PreviewErrorOverlay({ + error, + visible, + collapsed: controlledCollapsed, + onToggleCollapsed, +}: PreviewErrorOverlayProps) { + const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(true); + const collapsed = controlledCollapsed ?? uncontrolledCollapsed; + const setCollapsed = (next: boolean) => { + if (onToggleCollapsed) onToggleCollapsed(next); + if (controlledCollapsed === undefined) setUncontrolledCollapsed(next); + }; if (!visible || !error) return null; diff --git a/hub-client/src/components/render/PreviewStaticInfoViews.tsx b/ts-packages/preview-renderer/src/overlays/PreviewStaticInfoViews.tsx similarity index 95% rename from hub-client/src/components/render/PreviewStaticInfoViews.tsx rename to ts-packages/preview-renderer/src/overlays/PreviewStaticInfoViews.tsx index 9ae7784e4..74df1ccc8 100644 --- a/hub-client/src/components/render/PreviewStaticInfoViews.tsx +++ b/ts-packages/preview-renderer/src/overlays/PreviewStaticInfoViews.tsx @@ -1,5 +1,5 @@ -import type { Diagnostic } from '../../types/diagnostic'; -import { stripAnsi } from '../../utils/stripAnsi'; +import type { Diagnostic } from '../types/diagnostic'; +import { stripAnsi } from '../utils/stripAnsi'; // Fallback component for when WASM isn't ready yet export function FallbackView({ content, message }: { content: string; message: string }) { diff --git a/ts-packages/preview-renderer/src/placeholder.test.ts b/ts-packages/preview-renderer/src/placeholder.test.ts new file mode 100644 index 000000000..6f807cb15 --- /dev/null +++ b/ts-packages/preview-renderer/src/placeholder.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('@quarto/preview-renderer placeholder', () => { + it('package is wired up', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/hub-client/src/components/render/q2-preview/AssetManifestContext.tsx b/ts-packages/preview-renderer/src/q2-preview/AssetManifestContext.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/AssetManifestContext.tsx rename to ts-packages/preview-renderer/src/q2-preview/AssetManifestContext.tsx diff --git a/hub-client/src/components/render/q2-preview/NoteNumberingContext.tsx b/ts-packages/preview-renderer/src/q2-preview/NoteNumberingContext.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/NoteNumberingContext.tsx rename to ts-packages/preview-renderer/src/q2-preview/NoteNumberingContext.tsx diff --git a/hub-client/src/components/render/q2-preview/PreviewContext.tsx b/ts-packages/preview-renderer/src/q2-preview/PreviewContext.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/PreviewContext.tsx rename to ts-packages/preview-renderer/src/q2-preview/PreviewContext.tsx diff --git a/ts-packages/preview-renderer/src/q2-preview/PreviewDocument.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/PreviewDocument.integration.test.tsx new file mode 100644 index 000000000..3a4ca6471 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/PreviewDocument.integration.test.tsx @@ -0,0 +1,439 @@ +/** + * Vitest tests for `PreviewDocument` body container + iframe `<title>` + * (Plan 2D Phase 6.3). + * + * Mounts via `<Ast>` with `previewRegistry` so the registry's `Ast` + * entry resolves to `PreviewDocument`. The wrapper structure and + * body-class / document.title side effects are asserted against the + * Rust template's structure at `template.rs:185-247`. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { Ast } from '../framework'; +import type { PandocAST } from '../framework'; +import { previewRegistry } from './registry'; + +function astJson(meta: Record<string, unknown>, blocks: any[] = []): string { + const ast: PandocAST = { + 'pandoc-api-version': [1, 23, 0], + meta, + blocks: blocks as any, + }; + return JSON.stringify(ast); +} + +function mount(meta: Record<string, unknown>, blocks: any[] = []) { + return render( + <Ast + astJson={astJson(meta, blocks)} + currentFilePath="/project/test.qmd" + onNavigateToDocument={() => {}} + setAst={() => {}} + registry={previewRegistry} + />, + ); +} + +const STR = (c: string) => ({ t: 'Str', c }); +const PARA = (...inlines: any[]) => ({ t: 'Para', c: inlines }); +const HEADER = (level: number, text: string) => ({ + t: 'Header', + c: [level, ['', [], []], [STR(text)]], +}); +const ms = (c: string) => ({ t: 'MetaString', c }); +const mb = (c: boolean) => ({ t: 'MetaBool', c }); + +// Snapshot body.className so other tests in the suite aren't observed +// in a polluted state. Vitest happy-dom resets the DOM per file by +// default but we still want a deterministic clean slate per test. +let priorBodyClass: string; +let priorTitle: string; +beforeEach(() => { + priorBodyClass = document.body.className; + priorTitle = document.title; + document.body.className = ''; + document.title = '__test-sentinel__'; +}); +afterEach(() => { + document.body.className = priorBodyClass; + document.title = priorTitle; +}); + +describe('PreviewDocument body container', () => { + it('default render: page-layout-article + main.content#quarto-document-content', () => { + const { container } = mount({}, [PARA(STR('hello'))]); + const wrapper = container.querySelector('div#quarto-content'); + expect(wrapper).not.toBeNull(); + expect(wrapper!.className).toBe( + 'quarto-container page-columns page-rows-contents page-layout-article', + ); + const main = wrapper!.querySelector( + 'main.content#quarto-document-content', + ); + expect(main).not.toBeNull(); + // The paragraph is rendered inside <main>. + expect(main!.querySelector('p')!.textContent).toBe('hello'); + }); + + it('page-layout: full → div.page-layout-full', () => { + const { container } = mount({ 'page-layout': ms('full') }); + expect( + container.querySelector('div#quarto-content.page-layout-full'), + ).not.toBeNull(); + }); + + it('page-layout: custom value flows verbatim (no enum validation)', () => { + const { container } = mount({ 'page-layout': ms('custom') }); + expect( + container.querySelector('div#quarto-content.page-layout-custom'), + ).not.toBeNull(); + }); + + it('body-classes: custom-cls → document.body.className === "custom-cls" (no fullcontent)', () => { + mount({ 'body-classes': ms('custom-cls') }); + expect(document.body.className).toBe('custom-cls'); + }); + + it('body-classes: "" (empty string) opts out — body has no classes', () => { + // Pandoc-falsy parity: $body-classes$ template substitution + // emits the empty string verbatim, so empty string opts out + // of the literal `fullcontent` default. Only undefined + // (missing key) triggers the fallback. + mount({ 'body-classes': ms('') }); + expect(document.body.className).toBe(''); + }); + + it('default → document.body.className === "fullcontent"', () => { + mount({}); + expect(document.body.className).toBe('fullcontent'); + }); + + it('cleanup: unmount restores the pre-mount body.className', () => { + document.body.className = 'pre-existing'; + const { unmount } = mount({ 'body-classes': ms('mid') }); + expect(document.body.className).toBe('mid'); + unmount(); + expect(document.body.className).toBe('pre-existing'); + }); +}); + +describe('PreviewDocument minimal mode (no wrapper)', () => { + it('minimal: true → no #quarto-content wrapper, no <main>', () => { + const { container } = mount({ minimal: mb(true) }, [PARA(STR('x'))]); + expect(container.querySelector('div#quarto-content')).toBeNull(); + expect(container.querySelector('main.content')).toBeNull(); + // Paragraph still rendered. + expect(container.querySelector('p')!.textContent).toBe('x'); + }); + + it('theme: none → no wrapper', () => { + const { container } = mount({ theme: ms('none') }); + expect(container.querySelector('div#quarto-content')).toBeNull(); + }); + + it('theme: pandoc → no wrapper', () => { + const { container } = mount({ theme: ms('pandoc') }); + expect(container.querySelector('div#quarto-content')).toBeNull(); + }); + + it('minimal: true + title + no level-1 Header → synthetic <h1> before body', () => { + const { container } = mount( + { minimal: mb(true), title: ms('Doc Title') }, + [PARA(STR('body'))], + ); + expect(container.querySelector('div#quarto-content')).toBeNull(); + const h1 = container.querySelector('h1'); + expect(h1).not.toBeNull(); + expect(h1!.textContent).toBe('Doc Title'); + // Synthetic h1 precedes the paragraph in document order. + const all = Array.from(container.querySelectorAll('h1, p')); + expect(all[0].tagName).toBe('H1'); + expect(all[1].tagName).toBe('P'); + }); + + it('minimal: true + title + user-authored level-1 Header → no synthetic <h1>', () => { + const { container } = mount( + { minimal: mb(true), title: ms('Doc Title') }, + [HEADER(1, 'User Heading'), PARA(STR('body'))], + ); + const h1s = container.querySelectorAll('h1'); + expect(h1s.length).toBe(1); + expect(h1s[0].textContent).toBe('User Heading'); + }); + + it('minimal: true + no title → no synthetic <h1>', () => { + const { container } = mount({ minimal: mb(true) }, [PARA(STR('x'))]); + expect(container.querySelector('h1')).toBeNull(); + }); +}); + +describe('PreviewDocument iframe document.title wiring', () => { + it('writes document.title from meta.title', () => { + mount({ title: ms('My Doc') }); + expect(document.title).toBe('My Doc'); + }); + + it('meta.pagetitle wins over meta.title', () => { + mount({ pagetitle: ms('Page'), title: ms('Doc') }); + expect(document.title).toBe('Page'); + }); + + it('no title and no pagetitle → document.title is unchanged (sentinel preserved)', () => { + document.title = '__test-sentinel__'; + mount({}); + expect(document.title).toBe('__test-sentinel__'); + }); + + it('empty-string title is Pandoc-falsy — no write, sentinel preserved', () => { + document.title = '__test-sentinel__'; + mount({ title: ms('') }); + expect(document.title).toBe('__test-sentinel__'); + }); + + it('cleanup: unmount restores pre-mount document.title', () => { + document.title = '__before-mount__'; + const { unmount } = mount({ title: ms('Mounted Title') }); + expect(document.title).toBe('Mounted Title'); + unmount(); + expect(document.title).toBe('__before-mount__'); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Phase F.2 (bd-kw93.15): chrome HTML-injection slots +// ────────────────────────────────────────────────────────────────── + +/** Build a `MetaMap` value carrying `entries`. Mirrors the JSON + * shape from `crates/pampa/src/writers/json.rs::write_config_value` + * (with `key_source: null`). */ +function metaMap(entries: Array<{ key: string; value: unknown }>): unknown { + return { + t: 'MetaMap', + c: entries.map((e) => ({ key: e.key, key_source: null, value: e.value })), + }; +} + +/** Convenience: shape the `meta.rendered.navigation.<key>: html` + * injection. Returns the top-level `meta.rendered` value. */ +function renderedNavigation(map: Record<string, string>): unknown { + return metaMap([ + { + key: 'navigation', + value: metaMap( + Object.entries(map).map(([k, v]) => ({ + key: k, + value: ms(v), + })), + ), + }, + ]); +} + +describe('PreviewDocument chrome injection (Phase F.2)', () => { + it('renders navbar HTML BEFORE quarto-content via dangerouslySetInnerHTML', () => { + const navbarHtml = + '<nav class="navbar navbar-expand-lg" data-test="nv">My Site</nav>'; + const { container } = mount({ + rendered: renderedNavigation({ navbar: navbarHtml }), + }); + // The injected navbar is a sibling of #quarto-content, ordered before it. + const quartoContent = container.querySelector('#quarto-content'); + expect(quartoContent).not.toBeNull(); + const nav = container.querySelector('nav.navbar[data-test="nv"]'); + expect(nav).not.toBeNull(); + // Order: nav comes before #quarto-content. + expect( + nav!.compareDocumentPosition(quartoContent!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('renders sidebar HTML INSIDE quarto-content, before <main>', () => { + const sidebarHtml = + '<nav id="quarto-sidebar" class="sidebar" data-test="sb">Items</nav>'; + const { container } = mount({ + rendered: renderedNavigation({ sidebar: sidebarHtml }), + }); + const quartoContent = container.querySelector('#quarto-content'); + expect(quartoContent).not.toBeNull(); + // Sidebar lives inside quarto-content. + const sidebar = quartoContent!.querySelector('nav#quarto-sidebar'); + expect(sidebar).not.toBeNull(); + // Order: sidebar before <main>. + const main = quartoContent!.querySelector('main'); + expect( + sidebar!.compareDocumentPosition(main!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('wraps TOC HTML in #quarto-margin-sidebar > nav#TOC > <h2>', () => { + const tocInnerUl = + '<ul><li data-test="toc-li"><a href="#sec">Section</a></li></ul>'; + const { container } = mount({ + navigation: metaMap([ + { + key: 'toc', + value: metaMap([{ key: 'title', value: ms('Contents') }]), + }, + ]), + rendered: renderedNavigation({ toc: tocInnerUl }), + }); + // Wrapper structure mirrors template.rs:189-200. + const margin = container.querySelector( + 'div#quarto-margin-sidebar.sidebar.margin-sidebar', + ); + expect(margin).not.toBeNull(); + const tocNav = margin!.querySelector( + 'nav#TOC[role="doc-toc"].toc-active', + ); + expect(tocNav).not.toBeNull(); + const h2 = tocNav!.querySelector('h2#toc-title'); + expect(h2).not.toBeNull(); + expect(h2!.textContent).toBe('Contents'); + // The injected <ul> is in the dangerouslySetInnerHTML wrapper div. + expect(tocNav!.querySelector('li[data-test="toc-li"]')).not.toBeNull(); + }); + + it('TOC: missing navigation.toc.title omits the <h2>', () => { + const tocInnerUl = '<ul><li>Sec</li></ul>'; + const { container } = mount({ + // No `navigation.toc.title` → tocTitle is empty → no <h2>. + rendered: renderedNavigation({ toc: tocInnerUl }), + }); + const tocNav = container.querySelector('#quarto-margin-sidebar nav#TOC'); + expect(tocNav).not.toBeNull(); + expect(tocNav!.querySelector('h2#toc-title')).toBeNull(); + }); + + it('renders page-navigation INSIDE main, after children', () => { + const pageNavHtml = + '<nav class="page-navigation" data-test="pn">prev | next</nav>'; + const { container } = mount( + { + rendered: renderedNavigation({ page_navigation: pageNavHtml }), + }, + [PARA(STR('Body content here.'))], + ); + const main = container.querySelector('main#quarto-document-content'); + expect(main).not.toBeNull(); + const pageNav = main!.querySelector('nav.page-navigation'); + expect(pageNav).not.toBeNull(); + // Order: paragraph before page-nav inside main. + const para = main!.querySelector('p'); + expect( + para!.compareDocumentPosition(pageNav!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('renders footer AFTER quarto-content', () => { + const footerHtml = + '<footer class="footer" data-test="ft">my footer</footer>'; + const { container } = mount({ + rendered: renderedNavigation({ footer: footerHtml }), + }); + const quartoContent = container.querySelector('#quarto-content'); + const footer = container.querySelector('footer.footer'); + expect(footer).not.toBeNull(); + expect( + quartoContent!.compareDocumentPosition(footer!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('absent meta.rendered.navigation.* keys → no chrome elements rendered', () => { + const { container } = mount({}, [PARA(STR('plain doc'))]); + // None of the chrome wrappers should exist. + expect(container.querySelector('#quarto-margin-sidebar')).toBeNull(); + expect(container.querySelector('nav#quarto-sidebar')).toBeNull(); + expect(container.querySelector('nav.page-navigation')).toBeNull(); + expect(container.querySelector('footer.footer')).toBeNull(); + expect(container.querySelector('nav.navbar')).toBeNull(); + }); + + it('sidebar-render body-classes hoists onto document.body', () => { + // Phase F.2: when the user did NOT set top-level `body-classes`, + // `meta.rendered.navigation.body-classes` (from sidebar-render) + // is the source — same as Rust template.rs:419-428. + mount({ + rendered: metaMap([ + { + key: 'navigation', + value: metaMap([ + { key: 'body-classes', value: ms('nav-sidebar floating') }, + ]), + }, + ]), + }); + expect(document.body.className).toBe('nav-sidebar floating'); + }); + + it('user-set top-level body-classes still wins over sidebar-render', () => { + // Mirror Rust template.rs:419 — user override always wins. + mount({ + 'body-classes': ms('user-bcs'), + rendered: metaMap([ + { + key: 'navigation', + value: metaMap([ + { key: 'body-classes', value: ms('nav-sidebar floating') }, + ]), + }, + ]), + }); + expect(document.body.className).toBe('user-bcs'); + }); + + it('header-includes (favicon link) lands in document.head with cleanup marker', () => { + // The favicon transform appends a `<link rel="icon">` HTML + // string to `meta.rendered.includes.header`. The + // `HeaderIncludesEffect` hook parses it and inserts the + // `<link>` into `document.head` with a `data-q2-header-include` + // marker for the cleanup pass. + const linkHtml = + '<link rel="icon" href="/.quarto/project-artifacts/favicon.ico" type="image/x-icon" data-test="fv">'; + const { unmount } = mount({ + rendered: metaMap([ + { + key: 'includes', + value: metaMap([ + { + key: 'header', + value: { t: 'MetaList', c: [ms(linkHtml)] }, + }, + ]), + }, + ]), + }); + + const link = document.head.querySelector( + 'link[data-test="fv"][data-q2-header-include]', + ); + expect(link).not.toBeNull(); + expect(link!.getAttribute('rel')).toBe('icon'); + + // Unmount cleanup must remove the inserted node. + unmount(); + expect( + document.head.querySelector('link[data-test="fv"]'), + ).toBeNull(); + }); + + it('minimal mode skips chrome injection (matches Rust minimal template)', () => { + // Minimal mode is the rust template that has no chrome + // substitutions. PreviewDocument's minimal branch returns a + // <Fragment> with just title + children. + const { container } = mount({ + minimal: mb(true), + rendered: renderedNavigation({ + navbar: '<nav class="navbar" data-test="nv">x</nav>', + footer: '<footer class="footer" data-test="ft">y</footer>', + }), + }); + // No chrome elements injected in minimal mode. + expect(container.querySelector('nav.navbar')).toBeNull(); + expect(container.querySelector('footer.footer')).toBeNull(); + }); +}); diff --git a/hub-client/src/components/render/q2-preview/PreviewDocument.tsx b/ts-packages/preview-renderer/src/q2-preview/PreviewDocument.tsx similarity index 57% rename from hub-client/src/components/render/q2-preview/PreviewDocument.tsx rename to ts-packages/preview-renderer/src/q2-preview/PreviewDocument.tsx index 482b4bc0f..b03ff6056 100644 --- a/hub-client/src/components/render/q2-preview/PreviewDocument.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/PreviewDocument.tsx @@ -3,11 +3,21 @@ import { renderChildren, extractMetaString, extractMetaBool, + extractMetaStringList, + getMetaPath, RegistryContext, useAttributionHover, } from '../framework'; import type { BlockNode, PandocAST } from '../framework'; import * as Custom from './custom'; +import { + NavbarSlot, + SidebarSlot, + PageNavSlot, + FooterSlot, + TocSlot, + HeaderIncludesEffect, +} from './chromeSlots'; /** * q2-preview's document-root wrapper. Registered into `registry.ts` @@ -46,13 +56,21 @@ export const PreviewDocument = ({ // Mirror Rust template.rs:415-417: page-layout defaults to "article". const pageLayout = extractMetaString(meta['page-layout']) ?? 'article'; - // Mirror Rust template.rs:177 body-class default. The - // SidebarRenderTransform-computed value isn't in q2-preview's - // pipeline (Q2_PREVIEW_TRANSFORM_EXCLUDED), so the precedence here - // is user override → literal default. Empty string is the user's - // opt-out (matches Rust's $body-classes$ substitution); only - // `undefined` triggers the fallback. - const bodyClassesValue = extractMetaString(meta['body-classes']); + // Mirror Rust template.rs:177 body-class default + the hoist + // logic at template.rs:419-428. Precedence: + // 1. User-set top-level `body-classes` (always wins). + // 2. Phase F.2: `rendered.navigation.body-classes` populated + // by `SidebarRenderTransform` (e.g. `nav-sidebar floating`, + // `nav-sidebar docked`) — required for Bootstrap's + // sidebar-aware grid layout to take effect. + // 3. Literal default `fullcontent`. + // Empty string is the user's opt-out; only `undefined` triggers + // the fallback chain. + const bodyClassesValue = + extractMetaString(meta['body-classes']) ?? + extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'body-classes']), + ); const bodyClasses = bodyClassesValue ?? 'fullcontent'; // Mirror Rust is_minimal_html() (format.rs:306-318). @@ -95,12 +113,49 @@ export const PreviewDocument = ({ }; }, [meta]); + // Phase F.2 (bd-kw93.15): chrome HTML strings populated by the + // `*-render` transforms now in the q2-preview pipeline. Each + // slot is React.memo'd so an identical re-post (edit to body + // content) doesn't tear down the chrome DOM. + const navbarHtml = extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'navbar']), + ); + const sidebarHtml = extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'sidebar']), + ); + const pageNavHtml = extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'page_navigation']), + ); + const tocHtml = extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'toc']), + ); + const footerHtml = extractMetaString( + getMetaPath(meta, ['rendered', 'navigation', 'footer']), + ); + // TocGenerateTransform always sets `navigation.toc.title` + // (default "Table of Contents" — toc_generate.rs:113-119), but + // a user can still override or set it to empty. Surface it + // verbatim; the slot omits the `<h2>` when the title is empty. + const tocTitle = + extractMetaString(getMetaPath(meta, ['navigation', 'toc', 'title'])) ?? + ''; + // `meta.rendered.includes.header` collects favicon / RSS links / + // any user includes-in-header (already a list; q2 render's + // template wires `$header-includes$` into `<head>`). + const headerIncludes = extractMetaStringList( + getMetaPath(meta, ['rendered', 'includes', 'header']), + ); + // Attribution wiring (Phase 3 of // `2026-05-13-q2-preview-attribution.md`): delegated to // `useAttributionHover`, which returns inert wiring when - // `AttributionLookupContext` is unpopulated — off-path DOM stays - // byte-identical to pre-attribution. Same hook is consumed by - // q2-debug's `AstRenderer`. + // `AttributionLookupContext` is unpopulated. The q2-preview + // render path is currently in that state — `render_page_for_preview` + // does not yet install a `PreBuiltAttributionProvider`, so + // `astContext.attribution*` arrives empty, the context is empty, + // and every interpolation below is a no-op (DOM byte-identical + // to pre-attribution). When the producer-side gap is closed the + // consumer wiring lights up automatically with no React changes. const attr = useAttributionHover(); const children = renderChildren({ @@ -146,12 +201,31 @@ export const PreviewDocument = ({ return ( <> + {/* Attribution stylesheet — inert (renders nothing) when + attribution context is empty. Lives next to header- + includes since both are document-head-adjacent. */} {attr.stylesheet} + + {/* Phase F.2: header-includes (favicon, RSS links, user + includes) appended imperatively to `document.head`. */} + <HeaderIncludesEffect items={headerIncludes} /> + + {/* Navbar lives BEFORE quarto-content (template.rs:178-180). */} + {navbarHtml ? <NavbarSlot html={navbarHtml} /> : null} + <div id="quarto-content" className={`quarto-container page-columns page-rows-contents page-layout-${pageLayout}`} {...attr.hostProps} > + {/* Sidebar — INSIDE quarto-content, before TOC + main + (template.rs:186-188). */} + {sidebarHtml ? <SidebarSlot html={sidebarHtml} /> : null} + + {/* TOC — INSIDE quarto-content, before main + (template.rs:189-200). */} + {tocHtml ? <TocSlot html={tocHtml} title={tocTitle} /> : null} + <main className="content" id="quarto-document-content"> <TitleBlock ast={ast} @@ -159,8 +233,18 @@ export const PreviewDocument = ({ onNavigateToDocument={onNavigateToDocument} /> {children} + + {/* Page-nav (prev/next) — INSIDE main, after body + content (template.rs:244-246). */} + {pageNavHtml ? <PageNavSlot html={pageNavHtml} /> : null} </main> </div> + + {/* Page-footer lives AFTER quarto-content + (template.rs:252-254). */} + {footerHtml ? <FooterSlot html={footerHtml} /> : null} + + {/* Attribution overlay — inert when off-path. */} {attr.overlay} </> ); diff --git a/hub-client/src/components/render/q2-preview/assetWalker.test.ts b/ts-packages/preview-renderer/src/q2-preview/assetWalker.test.ts similarity index 99% rename from hub-client/src/components/render/q2-preview/assetWalker.test.ts rename to ts-packages/preview-renderer/src/q2-preview/assetWalker.test.ts index 211a091b6..3a9d36f3d 100644 --- a/hub-client/src/components/render/q2-preview/assetWalker.test.ts +++ b/ts-packages/preview-renderer/src/q2-preview/assetWalker.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; const vfsMock = vi.fn(); -vi.mock('../../../services/wasmRenderer', () => ({ +vi.mock('@quarto/preview-runtime', () => ({ vfsReadBinaryFile: (path: string) => vfsMock(path), })); diff --git a/hub-client/src/components/render/q2-preview/assetWalker.ts b/ts-packages/preview-renderer/src/q2-preview/assetWalker.ts similarity index 97% rename from hub-client/src/components/render/q2-preview/assetWalker.ts rename to ts-packages/preview-renderer/src/q2-preview/assetWalker.ts index fbab65eca..94a023e16 100644 --- a/hub-client/src/components/render/q2-preview/assetWalker.ts +++ b/ts-packages/preview-renderer/src/q2-preview/assetWalker.ts @@ -18,8 +18,8 @@ * stable image content keeps the same blob URL across renders. */ -import { vfsReadBinaryFile } from '../../../services/wasmRenderer'; -import { resolveRelativePath, guessMimeType } from '../../../utils/vfsPaths'; +import { vfsReadBinaryFile } from '@quarto/preview-runtime'; +import { resolveRelativePath, guessMimeType } from '../utils/vfsPaths'; export interface ManifestCacheEntry { url: string; diff --git a/hub-client/src/components/render/q2-preview/blocks/BlockQuote.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/BlockQuote.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/BlockQuote.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/BlockQuote.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/BulletList.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/BulletList.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx diff --git a/ts-packages/preview-renderer/src/q2-preview/blocks/CodeBlock.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/CodeBlock.tsx new file mode 100644 index 000000000..cbcc9b7de --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/blocks/CodeBlock.tsx @@ -0,0 +1,211 @@ +import { Fragment, type ReactNode } from 'react'; +import type { CodeBlock as CodeBlockType, NodeArgs } from '../../framework'; + +/** + * Attribute key the Rust `CodeHighlightStage` writes into a code + * node's kvs map. Wire format: JSON `[[start_byte, end_byte, + * capture_name], ...]` — the same triple-array shape decoded by the + * native HTML writer in `crates/pampa/src/writers/html.rs`. Keep this + * literal aligned with `crates/quarto-highlight-encoding`'s + * `SPANS_ATTR_KEY`. (bd-nxslt) + */ +const HL_SPANS_KEY = 'data-hl-spans'; + +/** One decoded highlight span — a half-open `[start, end)` byte range + * in the code text plus the tree-sitter capture name. */ +interface HighlightSpan { + start: number; + end: number; + capture: string; +} + +/** + * Parse the JSON triple-array stored in `data-hl-spans`. Tolerates + * malformed input — any decode error returns `null`, which the + * caller treats the same as a missing attribute (renders plain + * `<code>`). Trailing positional elements in each triple are ignored + * (forward-compat with future extras per the encoding spec). + */ +function decodeHighlightSpans(raw: string | undefined): HighlightSpan[] | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!Array.isArray(parsed) || parsed.length === 0) return null; + const spans: HighlightSpan[] = []; + for (const entry of parsed) { + if (!Array.isArray(entry) || entry.length < 3) continue; + const start = typeof entry[0] === 'number' ? entry[0] : -1; + const end = typeof entry[1] === 'number' ? entry[1] : -1; + const capture = typeof entry[2] === 'string' ? entry[2] : ''; + if (start < 0 || end < start) continue; + spans.push({ start, end, capture }); + } + return spans.length > 0 ? spans : null; +} + +/** + * Map a tree-sitter capture name (e.g. `function.builtin`, + * `string.escape`) to the CSS class suffix the HTML writer uses on + * `<span>` elements (`function-builtin`, `string-escape`). Done at + * render time, not encode time, so the wire form keeps the grammar- + * native capture names. Mirrors + * `crates/pampa/src/writers/html.rs::capture_to_class`. + */ +function captureToClass(capture: string): string { + return capture.replace(/\./g, '-'); +} + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder(); + +/** + * Render the body of a code block as nested `<span class="hl-...">` + * elements driven by the highlight spans. Walks the byte stream of + * `text` (utf-8) in start-order, emitting plain text up to each span + * boundary and wrapping the in-range bytes in a `<span>`. + * + * The walk mirrors `write_highlighted_body` in + * `crates/pampa/src/writers/html.rs`, with the same simultaneous- + * open / simultaneous-close tie-breakers — outer spans open first, + * inner spans close first — so nested grammar captures (e.g. a + * `function` enclosing a `function.builtin`) render as + * `<span class="hl-function"><span class="hl-function-builtin">… + * </span></span>`. + * + * Byte offsets index into the utf-8 representation. Sliced bytes are + * decoded back to a string via `TextDecoder`; tree-sitter only emits + * offsets on valid utf-8 boundaries, so the decode is always clean. + */ +function renderHighlighted(text: string, spans: HighlightSpan[]): ReactNode { + const bytes = utf8Encoder.encode(text); + + type Event = + | { kind: 'open'; offset: number; capture: string; tie: number } + | { kind: 'close'; offset: number; tie: number }; + + const events: Event[] = []; + for (let i = 0; i < spans.length; i++) { + const s = spans[i]; + const span_len = Math.max(0, s.end - s.start); + // Outer-first opens (larger ranges sort earlier): negative len. + events.push({ kind: 'open', offset: s.start, capture: s.capture, tie: -span_len }); + // Inner-first closes (smaller ranges sort earlier): positive + // len + i so close-end-of-A vs close-end-of-B-at-same-offset is + // stable. + events.push({ kind: 'close', offset: s.end, tie: span_len + i }); + } + events.sort((a, b) => { + if (a.offset !== b.offset) return a.offset - b.offset; + // Closes come before opens at the same offset so an adjacent + // close-then-open renders close first, then open (natural + // reading order). + if (a.kind !== b.kind) return a.kind === 'close' ? -1 : 1; + return a.tie - b.tie; + }); + + const children: ReactNode[] = []; + let cursor = 0; + // The output is a flat stream of React nodes wrapped at each open + // event into the corresponding close. We track an open-spans stack + // with their accumulated children buffers, and on close pop the + // stack and emit a `<span>` carrying those children. + type Frame = { capture: string; children: ReactNode[] }; + const stack: Frame[] = []; + let key = 0; + + function pushText(slice: Uint8Array) { + if (slice.length === 0) return; + const piece = utf8Decoder.decode(slice); + const target = stack.length > 0 ? stack[stack.length - 1].children : children; + target.push(piece); + } + + for (const ev of events) { + const offset = Math.min(ev.offset, bytes.length); + if (offset > cursor) { + pushText(bytes.subarray(cursor, offset)); + cursor = offset; + } + if (ev.kind === 'open') { + stack.push({ capture: ev.capture, children: [] }); + } else { + // close: pop and emit the span + const frame = stack.pop(); + if (!frame) continue; // mismatched close — tolerated + const className = `hl-${captureToClass(frame.capture)}`; + const target = stack.length > 0 ? stack[stack.length - 1].children : children; + target.push( + <span key={key++} className={className}>{frame.children}</span>, + ); + } + } + // Any trailing bytes after the last event. + if (cursor < bytes.length) { + pushText(bytes.subarray(cursor)); + } + // Any spans still open at end-of-text — emit them as if closed at + // the boundary so we don't lose the text or the class. (Should not + // happen for well-formed inputs.) + while (stack.length > 0) { + const frame = stack.pop()!; + const className = `hl-${captureToClass(frame.capture)}`; + const target = stack.length > 0 ? stack[stack.length - 1].children : children; + target.push( + <span key={key++} className={className}>{frame.children}</span>, + ); + } + return <Fragment>{children}</Fragment>; +} + +export const CodeBlock = ({ node }: NodeArgs<CodeBlockType>) => { + const [[id, classes, kvs], code] = node.c; + // bd-y1fs3: mirror the native HTML writer + // (`crates/pampa/src/writers/html.rs::Block::CodeBlock` + + // `write_code_container_attr`): the `<pre>` container carries + // the `Attr` (id, classes, and the non-`data-hl-spans` kvs); the + // inner `<code>` is bare. Quarto's theme CSS keys off + // `pre.sourceCode` / `pre.sourceCode > code`, so any divergence + // here visibly drifts the rendered styling between `q2 render` + // and `q2 preview`. + const preProps: Record<string, string> = {}; + if (id) preProps.id = id; + + // bd-nxslt: `data-hl-spans` is consumed by the renderer (we emit + // its content as nested `<span>` markup), so it must NOT be + // forwarded as a raw DOM attribute. Other `data-*` keys (e.g. + // `data-loc`) still pass through to the `<pre>` for downstream + // consumers (source-mapping, plugin attribute echo). + let hlSpansRaw: string | undefined; + for (const [k, v] of kvs) { + if (k === HL_SPANS_KEY) { + hlSpansRaw = v; + continue; + } + if (k.startsWith('data-')) preProps[k] = v; + } + const spans = decodeHighlightSpans(hlSpansRaw); + + // bd-y1fs3: when highlight spans are present, prepend + // `sourceCode` to the class list — matches the native writer + // (`write_code_container_attr` at + // `crates/pampa/src/writers/html.rs:487-495`). If the author's + // own class list already contains `sourceCode`, don't double it. + if (classes.length || spans !== null) { + const combined: string[] = []; + if (spans !== null && !classes.includes('sourceCode')) { + combined.push('sourceCode'); + } + for (const c of classes) combined.push(c); + if (combined.length) preProps.className = combined.join(' '); + } + + return ( + <pre {...preProps}> + <code>{spans === null ? code : renderHighlighted(code, spans)}</code> + </pre> + ); +}; diff --git a/hub-client/src/components/render/q2-preview/blocks/DefinitionList.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/DefinitionList.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/DefinitionList.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/DefinitionList.tsx diff --git a/ts-packages/preview-renderer/src/q2-preview/blocks/Div.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Div.tsx new file mode 100644 index 000000000..87e527ff9 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/blocks/Div.tsx @@ -0,0 +1,25 @@ +import { renderChildren } from '../../framework'; +import type { DivBlock, NodeArgs } from '../../framework'; +import { SECTION } from '../quartoClasses'; + +export const Div = (args: NodeArgs<DivBlock>) => { + const [[id, classes, kvs]] = args.node.c; + const props: Record<string, string> = {}; + if (id) props.id = id; + if (classes.length) props.className = classes.join(' '); + for (const [k, v] of kvs) { + if (k.startsWith('data-') || k === 'role') props[k] = v; + } + // bd-coffj: mirror the native HTML writer + // (`crates/pampa/src/writers/html.rs::Block::Div`) — a Pandoc Div + // whose class list contains "section" (output of the sectionize + // transform) renders as `<section>`, not `<div>`. Quarto theme + // CSS keys off the `<section>` tag (e.g. + // `main.content > p:has(+ section) { margin-bottom: 2rem }`), so + // emitting `<div>` here causes visible spacing drift between + // `q2 render` and `q2 preview`. + if (classes.includes(SECTION)) { + return <section {...props}>{renderChildren(args)}</section>; + } + return <div {...props}>{renderChildren(args)}</div>; +}; diff --git a/hub-client/src/components/render/q2-preview/blocks/Figure.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Figure.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/Figure.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/Figure.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/Header.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Header.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/Header.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/Header.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/HorizontalRule.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/HorizontalRule.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/HorizontalRule.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/HorizontalRule.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/LineBlock.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/LineBlock.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/LineBlock.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/LineBlock.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/OrderedList.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/OrderedList.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/OrderedList.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/OrderedList.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/Para.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Para.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/Para.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/Para.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/Plain.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Plain.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/Plain.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/Plain.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/RawBlock.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/RawBlock.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/RawBlock.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/RawBlock.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/Table.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/Table.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/Table.tsx rename to ts-packages/preview-renderer/src/q2-preview/blocks/Table.tsx diff --git a/hub-client/src/components/render/q2-preview/blocks/index.ts b/ts-packages/preview-renderer/src/q2-preview/blocks/index.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/blocks/index.ts rename to ts-packages/preview-renderer/src/q2-preview/blocks/index.ts diff --git a/ts-packages/preview-renderer/src/q2-preview/chromeSlots.tsx b/ts-packages/preview-renderer/src/q2-preview/chromeSlots.tsx new file mode 100644 index 000000000..ef69cbfd6 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/chromeSlots.tsx @@ -0,0 +1,149 @@ +/** + * Chrome HTML-injection slots for q2-preview's `PreviewDocument` + * (Phase F.2, bd-kw93.15). + * + * Each slot reads an HTML string from `meta.rendered.navigation.*` + * (populated by the corresponding `*-render` transform on the Rust + * side) and injects it via `dangerouslySetInnerHTML`. The slots + * are wrapped in `React.memo` keyed on the HTML string, so an + * identical re-post (e.g. an edit to body content that doesn't + * change the chrome) doesn't tear down the chrome DOM. + * + * The DOM-stability compromise of `dangerouslySetInnerHTML` is the + * known trade-off F.2 accepts: when the chrome HTML *does* change + * (a `_quarto.yml` edit, a page switch that updates the active + * sidebar entry), Bootstrap's open dropdowns / collapse states get + * reset because the inner DOM is replaced wholesale. bd-d8fo + * tracks the React-components rewrite that fixes this. + * + * Wrapper structure mirrors `crates/quarto-core/src/template.rs` + * lines 178-254 byte-for-byte for what's *outside* the injected + * HTML; the injected HTML itself is what each `*-render` transform + * produces (so it stays byte-identical with `q2 render`). + * + * The favicon and other `<head>` includes don't have a slot here — + * they're handled by [`HeaderIncludesEffect`] below, which + * imperatively manages `<link>` / `<meta>` elements in + * `document.head` (no React-level rendering for elements that + * don't live in the body). + */ + +import { memo, useEffect } from 'react'; + +interface SlotProps { + html: string; +} + +/** + * Navbar HTML — emitted by `NavbarRenderTransform` into + * `meta.rendered.navigation.navbar`. Renders BEFORE + * `<div id="quarto-content">` (per template.rs:178-180). + */ +export const NavbarSlot = memo(({ html }: SlotProps) => ( + <div dangerouslySetInnerHTML={{ __html: html }} /> +)); +NavbarSlot.displayName = 'NavbarSlot'; + +/** + * Sidebar HTML — emitted by `SidebarRenderTransform` into + * `meta.rendered.navigation.sidebar`. Renders INSIDE + * `<div id="quarto-content">`, BEFORE the TOC and `<main>` + * (per template.rs:186-188). + */ +export const SidebarSlot = memo(({ html }: SlotProps) => ( + <div dangerouslySetInnerHTML={{ __html: html }} /> +)); +SidebarSlot.displayName = 'SidebarSlot'; + +/** + * Page-navigation (prev/next) HTML — emitted by + * `PageNavRenderTransform` into + * `meta.rendered.navigation.page_navigation`. Renders INSIDE + * `<main>`, AFTER the body content (per template.rs:244-246). + */ +export const PageNavSlot = memo(({ html }: SlotProps) => ( + <div dangerouslySetInnerHTML={{ __html: html }} /> +)); +PageNavSlot.displayName = 'PageNavSlot'; + +/** + * Page-footer HTML — emitted by `FooterRenderTransform` into + * `meta.rendered.navigation.footer`. Renders AFTER + * `<div id="quarto-content">` (per template.rs:252-254). + */ +export const FooterSlot = memo(({ html }: SlotProps) => ( + <div dangerouslySetInnerHTML={{ __html: html }} /> +)); +FooterSlot.displayName = 'FooterSlot'; + +/** + * TOC HTML (the inner `<ul>`) — emitted by `TocRenderTransform` + * into `meta.rendered.navigation.toc`. The wrapping + * `<div id="quarto-margin-sidebar"><nav id="TOC"><h2>...</h2>` + * is supplied by the template (template.rs:189-200), so this slot + * only fills the inner `<ul>`. Memo keyed on (html, title) so a + * `toc-title:` edit re-renders correctly without tearing the DOM + * for content edits. + */ +export const TocSlot = memo(({ html, title }: SlotProps & { title: string }) => ( + <div + id="quarto-margin-sidebar" + className="sidebar margin-sidebar" + > + <nav id="TOC" role="doc-toc" className="toc-active"> + {title && <h2 id="toc-title">{title}</h2>} + <div dangerouslySetInnerHTML={{ __html: html }} /> + </nav> + </div> +)); +TocSlot.displayName = 'TocSlot'; + +interface HeaderIncludesProps { + /** + * Raw HTML strings to append to `document.head`. Each entry is + * parsed and its top-level children are inserted with a + * `data-q2-header-include` marker for the cleanup pass to find. + * Identity comparison via the joined string in the dep array; + * pass already-derived list shape (callers use + * `extractMetaStringList` upstream). + */ + items: readonly string[]; +} + +/** + * Imperative `<head>` injector for `meta.rendered.includes.header` + * (favicon `<link>`, listing-feed `<link rel="alternate">`, etc.). + * Not a render-time slot because elements like `<meta>` and `<link>` + * don't belong in `<body>` — they have to land in `document.head`, + * which React doesn't own. + * + * Cleanup on unmount removes the previously-inserted nodes so + * test re-mounts (vitest, Playwright) don't accumulate state. + * + * Idempotent on re-mount: each effect run replaces the previous set + * via cleanup, so re-rendering the same items does not duplicate. + */ +export function HeaderIncludesEffect({ items }: HeaderIncludesProps) { + // Joined string is the dep so the effect only re-runs when the + // bag of include strings actually changes. Per-item identity + // doesn't matter; the markers + cleanup handle the diff. + const key = items.join('\0'); + useEffect(() => { + if (items.length === 0) return; + const wrapper = document.createElement('div'); + wrapper.innerHTML = items.join('\n'); + const inserted: Element[] = []; + for (const el of Array.from(wrapper.children)) { + el.setAttribute('data-q2-header-include', '1'); + document.head.appendChild(el); + inserted.push(el); + } + return () => { + for (const el of inserted) { + el.remove(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + return null; +} diff --git a/hub-client/src/components/render/q2-preview/custom-components.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/custom-components.integration.test.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom-components.integration.test.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom-components.integration.test.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/Callout.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Callout.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/Callout.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/Callout.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/CrossrefResolvedRef.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/CrossrefResolvedRef.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/CrossrefResolvedRef.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/CrossrefResolvedRef.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/Equation.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/Equation.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/Fallback.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Fallback.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/Fallback.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/Fallback.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/FloatRefTarget.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/FloatRefTarget.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/FloatRefTarget.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/FloatRefTarget.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/PreviewTitleBlock.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/PreviewTitleBlock.integration.test.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/PreviewTitleBlock.integration.test.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/PreviewTitleBlock.integration.test.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/PreviewTitleBlock.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/PreviewTitleBlock.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/PreviewTitleBlock.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/PreviewTitleBlock.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/Proof.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Proof.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/Proof.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/Proof.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/Theorem.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Theorem.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/Theorem.tsx rename to ts-packages/preview-renderer/src/q2-preview/custom/Theorem.tsx diff --git a/hub-client/src/components/render/q2-preview/custom/index.ts b/ts-packages/preview-renderer/src/q2-preview/custom/index.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/custom/index.ts rename to ts-packages/preview-renderer/src/q2-preview/custom/index.ts diff --git a/hub-client/src/components/render/q2-preview/dispatchers.tsx b/ts-packages/preview-renderer/src/q2-preview/dispatchers.tsx similarity index 96% rename from hub-client/src/components/render/q2-preview/dispatchers.tsx rename to ts-packages/preview-renderer/src/q2-preview/dispatchers.tsx index 5d7d7db9b..cfe50aa67 100644 --- a/hub-client/src/components/render/q2-preview/dispatchers.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/dispatchers.tsx @@ -1,13 +1,12 @@ import { useContext } from 'react'; -import { RegistryContext } from '../framework/RegistryContext'; -import { AttributionWrap, renderChildren } from '../framework'; +import { RegistryContext, AttributionWrap, renderChildren } from '../framework'; import type { BlockNode, CustomBlockNode, CustomInlineNode, InlineNode, NodeArgs, -} from '../framework/types'; +} from '../framework'; const placeholderStyle: React.CSSProperties = { color: '#888', diff --git a/hub-client/src/components/render/q2-preview/entry.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/entry.integration.test.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/entry.integration.test.tsx rename to ts-packages/preview-renderer/src/q2-preview/entry.integration.test.tsx diff --git a/ts-packages/preview-renderer/src/q2-preview/entry.tsx b/ts-packages/preview-renderer/src/q2-preview/entry.tsx new file mode 100644 index 000000000..bf463028c --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/entry.tsx @@ -0,0 +1,483 @@ +/** + * Entry point for the q2-preview renderer iframe. + * + * Loaded by `/q2-preview.html` and handles the postMessage protocol + * with the parent (`Q2PreviewIframe`). Mounts the framework's `<Ast>` + * with `previewRegistry` as the format-side defaults, layered with any + * user-TSX overrides loaded via `LOAD_CUSTOM_COMPONENTS`. + * + * Two structural differences from `q2-debug/entry.tsx`: + * + * 1. `UPDATE_THEME` is handled at module top (not inside any React + * component). The handler imperatively manages a single + * `<link rel="stylesheet" data-q2-theme>` element in `document.head` + * — pure DOM, no React state. This avoids a race: if the listener + * lived in `PreviewRoot`'s `useEffect`, it would only attach after + * React commits the mount triggered by the first `UPDATE_AST`. The + * parent posts theme + AST from sibling `useEffect`s on the same + * `iframeReady` transition; if theme posts first the message would + * be dropped. + * + * 2. `__Q2_PREVIEW_RENDERER__` is set at module top (not inside + * `loadCustomComponents`). This makes the renderer surface + * importable in tests without firing `LOAD_CUSTOM_COMPONENTS` + * setup messages — the framework-primitive parity test (Plan 2A + * item 14) relies on this. + */ + +import { createRoot } from 'react-dom/client'; +import React, { useEffect, useMemo, useRef } from 'react'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; +// Phase F.1 (bd-kw93.14): Bootstrap 5 bundled JS, vendored at the +// repo's `resources/js/bootstrap/`. We embed it as raw text and +// inject as an inline `<script>` at module top so Bootstrap's +// data-API click delegates are attached before any chrome HTML +// arrives. The vendored copy is paired-versioned with the SCSS at +// `resources/scss/bootstrap/` (5.3.1 today). See the Phase F plan +// for the rationale: `BootstrapJsStage` could ride the WASM +// pipeline (math_js.rs precedent), but q2-preview also excludes +// `ApplyTemplateStage` so the JS would never get a `<script>` tag. +// Static iframe injection is the cleaner separation — chrome JS is +// iframe-template responsibility, not document-render responsibility. +// `?raw` is typed via `src/global.d.ts` (Vite's `?raw` suffix +// returns a string at build time). +import bootstrapJsSrc from '../../../../resources/js/bootstrap/bootstrap.bundle.min.js?raw'; + +(() => { + const existing = document.head.querySelector('script[data-q2-bootstrap]'); + if (existing) return; + const tag = document.createElement('script'); + tag.setAttribute('data-q2-bootstrap', '1'); + tag.textContent = bootstrapJsSrc; + document.head.appendChild(tag); +})(); + +import { + Ast, + Node, + renderChildren, + renderNode, + rewrapCustomNodes, + extractMetaString, + extractMetaBool, + extractMetaStringList, + inlinesToPlainText, + blocksToPlainText, +} from '../framework'; +import type { FormatRegistry, NoteInline, PandocAST } from '../framework'; +import { Block, Inline, previewRegistry, PreviewContext } from '.'; +import { AssetManifestContext } from './AssetManifestContext'; +import { NoteNumberingContext } from './NoteNumberingContext'; +import { renderSlot } from './utils'; +import { PreviewTitleBlock } from './custom/PreviewTitleBlock'; +import { + buildCustomRegistry, + type ComponentExports, +} from '../utils/customRegistry'; +import { installLinkHandlers } from '../utils/iframeLinkHandlers'; + +// Set the renderer-surface global at module top. Importing this module +// is sufficient to populate `window.__Q2_PREVIEW_RENDERER__`. The +// explicit-object form (rather than `{ ...framework, ...preview }` +// spread) keeps framework internals (`renderChildrenRegistry`, +// `RegistryContext`) off the global and locks the public surface. +// +// Plan 2C exposes `renderSlot` so user TSX overrides of CustomNode +// components can recurse into named slots (Callout's title/content, +// FloatRefTarget's caption_long/caption_short, ...) without +// reimplementing the per-slot setLocalAst plumbing. +// +// Plan 2D (6.0c.1) exposes the framework-tier meta and plain-text +// helpers so a user TSX override of `__title_block__` can coerce +// `ast.meta` values without re-implementing the Pandoc-AST walks. +// Plan 2D (7.3.1) exposes `PreviewTitleBlock` so a user override +// can compose the built-in chrome (e.g. wrap it and add a DOI line) +// instead of re-implementing it from scratch. +(window as any).__Q2_PREVIEW_RENDERER__ = { + renderChildren, + renderNode, + renderSlot, + Node, + Block, + Inline, + previewRegistry, + extractMetaString, + extractMetaBool, + extractMetaStringList, + inlinesToPlainText, + blocksToPlainText, + PreviewTitleBlock, +}; + +let root: ReturnType<typeof createRoot> | null = null; +let customRegistry: Record<string, React.ComponentType<any>> = {}; +let componentsLoading = false; + +interface UpdateAstPayload { + astJson: string; + currentFilePath: string; + /** + * Manifest of `{ origPath → blobUrl }` produced by the parent's + * `assetWalker.ts`. Forwarded into `AssetManifestContext` so + * `<Image>` can resolve project-relative URLs to blob URLs without + * any VFS access in the iframe. External URLs (`https?:`, `data:`, + * `//`) are not in the manifest — `lookupAssetUrl` passes them + * through. + */ + assetManifest?: Record<string, string>; + /** + * Phase F.1 (bd-kw93.14): project file paths (no leading slash) + * forwarded into the iframe link handler so artifact-rooted + * `.html` clicks can be reverse-mapped to source `.qmd`. Used + * for documentation today — `installLinkHandlers` always + * intercepts artifact-rooted hrefs. + */ + projectFilePaths?: readonly string[]; + /** + * Phase F.1 (bd-kw93.14): anchor (without `#`) to scroll into + * view after React commits this AST. Set on cross-page nav and + * back/forward; null/undefined means "no pending scroll". + * + * Paired with `pendingAnchorEpoch`: the iframe scrolls only when + * the epoch ticks past what it last saw. Re-renders from edits + * carry the same epoch so they don't trigger a re-scroll. + */ + pendingAnchor?: string | null; + pendingAnchorEpoch?: number; +} + +// Module-top message handler. Registered before `IFRAME_READY` is +// posted so the parent's `UPDATE_THEME` (which can fire immediately +// after `IFRAME_READY` from a sibling `useEffect`) is never dropped. +window.addEventListener('message', async (event) => { + if (event.data.type === 'LOAD_CUSTOM_COMPONENTS') { + componentsLoading = true; + await loadCustomComponents(event.data.componentsCode); + componentsLoading = false; + } else if (event.data.type === 'UPDATE_AST') { + if (componentsLoading) { + await new Promise((resolve) => { + const check = setInterval(() => { + if (!componentsLoading) { + clearInterval(check); + resolve(undefined); + } + }, 50); + }); + } + updateAst(event.data.payload); + } else if (event.data.type === 'UPDATE_THEME') { + applyTheme(event.data.cssUrl); + } +}); + +/** + * Imperatively manage a single `<link data-q2-theme>` in + * `document.head`. The `data-q2-theme` attribute is the idempotency + * selector — repeated `applyTheme` calls with the same URL just + * `setAttribute('href', sameUrl)` in place, no element duplication. + * + * `cssUrl === null` removes the element (explicit clear). The pre- + * first-message state has no `<link data-q2-theme>` element at all + * and is distinct from a received `cssUrl: null` (which removes any + * prior element). + */ +function applyTheme(cssUrl: string | null): void { + let link = document.head.querySelector<HTMLLinkElement>( + 'link[data-q2-theme]', + ); + if (cssUrl === null) { + if (link) link.remove(); + return; + } + if (!link) { + link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('data-q2-theme', '1'); + document.head.appendChild(link); + } + link.setAttribute('href', cssUrl); +} + +/** + * Phase F.1 (bd-kw93.14): scroll the iframe document to the element + * with the given id. No-op when the element doesn't exist (a stale + * back/forward to a section that's been removed shouldn't blow up). + */ +/** + * Scroll the iframe document to the element with the given id. + * Returns true when the element was found and scrolled, false when + * it wasn't yet in the DOM (the caller uses this to decide whether + * the epoch has been "consumed" — see the anchor-scroll useEffect). + */ +function scrollToAnchorInDocument(anchor: string): boolean { + const el = document.getElementById(anchor); + if (!el) return false; + el.scrollIntoView({ behavior: 'instant', block: 'start' }); + return true; +} + +async function loadCustomComponents(componentsCode: Record<string, string>) { + // Tied to dynamic user-TSX imports — materialize React and katex + // when LOAD_CUSTOM_COMPONENTS arrives. q2-preview does NOT set + // `window.RevealReact` (q2-debug-only — slide-demo template). + (window as any).React = React; + (window as any).katex = katex; + + const loadedModules: ComponentExports[] = []; + for (const [componentName, code] of Object.entries(componentsCode)) { + try { + const blob = new Blob([code], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + try { + const module = await import(/* @vite-ignore */ url); + loadedModules.push(module as ComponentExports); + console.log( + `[Q2PreviewIframe] Loaded custom component: ${componentName}`, + ); + } finally { + URL.revokeObjectURL(url); + } + } catch (err) { + console.error( + `[Q2PreviewIframe] Failed to load custom component ${componentName}:`, + err, + ); + } + } + + customRegistry = buildCustomRegistry(loadedModules); +} + +interface PreviewRootProps { + astJson: string; + currentFilePath: string; + /** Forwarded from `UPDATE_AST` payload; default is empty manifest. */ + assetManifest: Record<string, string>; + /** Phase F.1: forwarded into `installLinkHandlers`. */ + projectFilePaths?: readonly string[]; + /** Phase F.1: post-render scroll target (no leading `#`). */ + pendingAnchor?: string | null; + /** Phase F.1: monotonic epoch — scroll fires when this advances. */ + pendingAnchorEpoch?: number; + onNavigateToDocument?: (path: string, anchor: string | null) => void; + setAst: (newAst: PandocAST) => void; +} + +/** + * Walk the parsed AST for `Note` inlines and assign each a sequential + * number by document order. Lookup keyed by object identity — the + * framework's walker-purity contract preserves Note references through + * `unwrapCustomNodes`, so the WeakMap built here works on both pre- + * and post-unwrap AST shapes. + * + * Walks pre-unwrap (over the JSON.parse output) so the same parsed + * object can be handed to <Ast> via the discriminated input — avoids + * a double-parse. + * + * Descends both `c` fields and CustomNode wrapper slot children + * (`c[1][i].c[1]`) so notes nested inside callout / theorem bodies + * are reached. + */ +function walkForNoteNumbers(ast: PandocAST): WeakMap<NoteInline, number> { + const map = new WeakMap<NoteInline, number>(); + let counter = 0; + function visit(value: unknown) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const v of value) visit(v); + return; + } + const obj = value as { t?: unknown; c?: unknown }; + if (obj.t === 'Note') { + counter += 1; + map.set(value as NoteInline, counter); + } + if ('c' in obj) visit(obj.c); + } + visit(ast.blocks); + return map; +} + +function PreviewRoot(props: PreviewRootProps) { + // Refs so the link-handler closure (installed once at mount) + // sees the *latest* currentFilePath / projectFilePaths instead + // of the values captured at first install. Without these, every + // cross-page nav would still resolve relative links against the + // boot-time activeFile and the same-doc fast-path would never + // recognize the user's current page. + const currentFilePathRef = useRef(props.currentFilePath); + currentFilePathRef.current = props.currentFilePath; + const onNavigateRef = useRef(props.onNavigateToDocument); + onNavigateRef.current = props.onNavigateToDocument; + + // Install link handlers once per mount. The iframe remounts on + // every document switch (q2-debug's existing behavior — see + // `ReactPreview.tsx` previewState reset), so closures captured + // here cannot go stale within a single mount. + useEffect(() => { + installLinkHandlers(document, { + currentFilePath: props.currentFilePath, + projectFilePaths: props.projectFilePaths, + onQmdLinkClick: (arg) => { + if ('path' in arg) { + // Phase F.1 (bd-kw93.14): same-doc cross-page + // navigation (path === currentFilePath) is just + // an anchor scroll. Handle locally to avoid a + // pointless round-trip + re-render. Use refs so + // we read the *latest* activeFile / callback, + // not the boot-time values. + if (arg.path === currentFilePathRef.current) { + if (arg.anchor) { + scrollToAnchorInDocument(arg.anchor); + } + } else { + onNavigateRef.current?.(arg.path, arg.anchor); + } + } else { + // Pure `#frag` click — same-doc anchor scroll. + scrollToAnchorInDocument(arg.anchor); + } + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Phase F.1 (bd-kw93.14): scroll to the cross-page anchor after + // React commits the new AST. Triggered when the epoch advances — + // each user-driven nav event in `PreviewApp` bumps the epoch, + // edits don't. + // + // The race we have to guard against: the parent posts UPDATE_AST + // with the new pendingAnchor *before* the new astJson is ready + // (the React render effect runs async after activeFile changes). + // So this effect can fire once with the new anchor + the OLD + // astJson — `getElementById('intro')` returns null because that + // doc doesn't have it yet. Only mark the epoch consumed when the + // element was actually found, so the next pass (with the new + // astJson) tries again. requestAnimationFrame is defensive + // against a Firefox layout-thrash race where scrollIntoView + // fires before the post-commit layout pass. + const lastScrolledEpochRef = useRef<number>(0); + useEffect(() => { + const epoch = props.pendingAnchorEpoch ?? 0; + if (!props.pendingAnchor || epoch === 0) return; + if (epoch === lastScrolledEpochRef.current) return; + const anchor = props.pendingAnchor; + const raf = requestAnimationFrame(() => { + if (scrollToAnchorInDocument(anchor)) { + lastScrolledEpochRef.current = epoch; + } + }); + return () => cancelAnimationFrame(raf); + }, [props.pendingAnchor, props.pendingAnchorEpoch, props.astJson]); + + // Single merge site: built-in preview leaves + CustomNode + // components (under their type_name keys) + the user's TSX + // overrides. Pandoc tags and CustomNode type_names share one + // namespace by project policy (registry.test.ts locks it), so the + // same `customRegistry` spread covers overrides of either kind. + const mergedPreviewRegistry: FormatRegistry = { + ...previewRegistry, + ...customRegistry, + } as FormatRegistry; + + // Pre-parse the AST and run the Note-numbering walk in one + // useMemo. The parsed AST is then passed to <Ast> via the + // discriminated input — no second JSON.parse. On parse failure, + // hand <Ast> the original astJson so its existing error pane + // surfaces the message. + const { parsed, noteNumbers } = useMemo(() => { + try { + const p = JSON.parse(props.astJson) as PandocAST; + return { parsed: p, noteNumbers: walkForNoteNumbers(p) }; + } catch { + return { parsed: null, noteNumbers: new WeakMap<NoteInline, number>() }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.astJson]); + + const astProps = parsed + ? { ast: parsed } + : { astJson: props.astJson }; + + return ( + <PreviewContext.Provider + value={{ currentFilePath: props.currentFilePath }} + > + <AssetManifestContext.Provider value={props.assetManifest}> + <NoteNumberingContext.Provider value={noteNumbers}> + <Ast + {...astProps} + currentFilePath={props.currentFilePath} + onNavigateToDocument={props.onNavigateToDocument} + setAst={props.setAst} + registry={mergedPreviewRegistry} + /> + </NoteNumberingContext.Provider> + </AssetManifestContext.Provider> + </PreviewContext.Provider> + ); +} + +function updateAst(payload: UpdateAstPayload) { + const { + astJson, + currentFilePath, + assetManifest, + projectFilePaths, + pendingAnchor, + pendingAnchorEpoch, + } = payload; + const rootElement = document.getElementById('root'); + if (!rootElement) { + console.error('Root element not found'); + return; + } + + try { + if (!root) { + root = createRoot(rootElement); + } + root.render( + <PreviewRoot + astJson={astJson} + currentFilePath={currentFilePath} + assetManifest={assetManifest ?? {}} + projectFilePaths={projectFilePaths} + pendingAnchor={pendingAnchor} + pendingAnchorEpoch={pendingAnchorEpoch} + onNavigateToDocument={(path, anchor) => { + window.parent.postMessage( + { type: 'NAVIGATE_TO_DOCUMENT', path, anchor }, + '*', + ); + }} + setAst={(newAst) => { + // Rewrap JS-native CustomNodes back to wire-format + // Div/Span before posting. Keeps the parent-side + // (and any downstream consumer reading `data-custom-*` + // attributes) on the wire shape it expects. + window.parent.postMessage( + { type: 'SET_AST', ast: rewrapCustomNodes(newAst) }, + '*', + ); + }} + />, + ); + } catch (err) { + console.error('Failed to render AST:', err); + rootElement.innerHTML = ` + <div style="padding: 20px; color: red;"> + <strong>Render Error:</strong> + <pre>${err instanceof Error ? err.message : String(err)}</pre> + </div> + `; + } +} + +// Signal that the iframe is ready to receive messages. Posted AFTER +// the module-top message listener is registered so no UPDATE_THEME +// or UPDATE_AST is dropped. +window.parent.postMessage({ type: 'IFRAME_READY' }, '*'); diff --git a/hub-client/src/components/render/q2-preview/index.ts b/ts-packages/preview-renderer/src/q2-preview/index.ts similarity index 51% rename from hub-client/src/components/render/q2-preview/index.ts rename to ts-packages/preview-renderer/src/q2-preview/index.ts index f6575c046..6061b2a25 100644 --- a/hub-client/src/components/render/q2-preview/index.ts +++ b/ts-packages/preview-renderer/src/q2-preview/index.ts @@ -2,5 +2,8 @@ export { Block, Inline } from './dispatchers'; export { PreviewDocument } from './PreviewDocument'; export { previewRegistry } from './registry'; export { PreviewContext, type PreviewContextValue } from './PreviewContext'; -export { Q2PreviewIframe } from './Q2PreviewIframe'; +// Q2PreviewIframe moved to ../iframe/ in Phase 4. Re-exporting here for +// callers that imported it via the q2-preview barrel. +export { Q2PreviewIframe } from '../iframe/Q2PreviewIframe'; export { AssetManifestContext } from './AssetManifestContext'; +export { buildAssetManifest, type ManifestCacheEntry } from './assetWalker'; diff --git a/hub-client/src/components/render/q2-preview/inlines/Cite.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Cite.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Cite.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Cite.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Code.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Code.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Code.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Code.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Emph.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Emph.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Emph.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Emph.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Image.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Image.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Image.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Image.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/LineBreak.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/LineBreak.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/LineBreak.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/LineBreak.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Link.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Link.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Link.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Link.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Math.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Math.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Math.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Math.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Note.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Note.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Note.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Note.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Quoted.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Quoted.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Quoted.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Quoted.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/RawInline.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/RawInline.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/RawInline.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/RawInline.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/SmallCaps.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/SmallCaps.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/SmallCaps.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/SmallCaps.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/SoftBreak.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/SoftBreak.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/SoftBreak.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/SoftBreak.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Space.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Space.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Space.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Space.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Span.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Span.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Span.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Span.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Str.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Str.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Str.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Str.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Strikeout.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Strikeout.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Strikeout.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Strikeout.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Strong.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Strong.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Strong.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Strong.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Subscript.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Subscript.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Subscript.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Subscript.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Superscript.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Superscript.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Superscript.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Superscript.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/Underline.tsx b/ts-packages/preview-renderer/src/q2-preview/inlines/Underline.tsx similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/Underline.tsx rename to ts-packages/preview-renderer/src/q2-preview/inlines/Underline.tsx diff --git a/hub-client/src/components/render/q2-preview/inlines/index.ts b/ts-packages/preview-renderer/src/q2-preview/inlines/index.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/inlines/index.ts rename to ts-packages/preview-renderer/src/q2-preview/inlines/index.ts diff --git a/hub-client/src/components/render/q2-preview/q2-preview.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/q2-preview.integration.test.tsx similarity index 70% rename from hub-client/src/components/render/q2-preview/q2-preview.integration.test.tsx rename to ts-packages/preview-renderer/src/q2-preview/q2-preview.integration.test.tsx index df271bc70..12e19423b 100644 --- a/hub-client/src/components/render/q2-preview/q2-preview.integration.test.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/q2-preview.integration.test.tsx @@ -260,18 +260,213 @@ describe('q2-preview Pandoc base-type gap-fill components', () => { expect(ol!.getAttribute('start')).toBe('3'); }); - it('CodeBlock renders <pre><code class="lang"> with language class', () => { + it('CodeBlock puts the language class on <pre>, leaving <code> bare (matches q2 render)', () => { + // bd-y1fs3: q2 render's HTML writer (see + // `crates/pampa/src/writers/html.rs::Block::CodeBlock`) + // writes `<pre class="…"><code>…</code></pre>` — classes on + // the outer container, bare <code>. The React renderer must + // match so Quarto theme rules (e.g. `pre.sourceCode > code`) + // resolve identically across the two pipelines. const ast = [{ t: 'CodeBlock', c: [['', ['python'], []], 'print("hi")'], }]; const { container } = mount(ast); - const code = container.querySelector('pre > code'); + const pre = container.querySelector('pre'); + const code = pre?.querySelector('code'); + expect(pre).not.toBeNull(); expect(code).not.toBeNull(); - expect(code!.className).toBe('python'); + expect(pre!.className).toBe('python'); + expect(code!.className).toBe(''); expect(code!.textContent).toBe('print("hi")'); }); + // ─── bd-nxslt: code-cell syntax highlighting in q2 preview ────────── + // + // Mirrors the HTML writer's `write_highlighted_body` in + // `crates/pampa/src/writers/html.rs`. CodeHighlightStage (Rust + // side) annotates `CodeBlock.attr.kvs` with `data-hl-spans`, + // whose value is the JSON triple-array format defined in + // `crates/quarto-highlight-encoding`: `[[start_byte, end_byte, + // capture_name], ...]`. The React component must read that + // attribute and render nested `<span class="hl-CAP">…</span>` + // with `.` replaced by `-` in the capture (`function.builtin` + // → `hl-function-builtin`). Plain text falls through unchanged + // when the attribute is absent or empty. + + it('CodeBlock renders highlight spans when data-hl-spans is present', () => { + // `cat("hi")` — 9 bytes. Spans: cat=function (0,3), + // "(" and ")" = punctuation.bracket (3,4) (8,9), "hi" inside + // the string = string (4,8). Mirrors what the R grammar + // would emit. + const text = 'cat("hi")'; + const spans = [ + [0, 3, 'function'], + [3, 4, 'punctuation.bracket'], + [4, 8, 'string'], + [8, 9, 'punctuation.bracket'], + ]; + const ast = [{ + t: 'CodeBlock', + c: [ + ['', ['r'], [['data-hl-spans', JSON.stringify(spans)]]], + text, + ], + }]; + const { container } = mount(ast); + const pre = container.querySelector('pre'); + const code = pre?.querySelector('code'); + expect(pre).not.toBeNull(); + expect(code).not.toBeNull(); + + // bd-y1fs3: the native HTML writer prepends `sourceCode` to + // <pre>'s class list whenever highlight spans are emitted + // (`write_code_container_attr` in + // `crates/pampa/src/writers/html.rs:487-495`). Quarto theme + // CSS keys off `pre.sourceCode` and `pre.sourceCode > code`, + // so the React side must reproduce the prefix or the styles + // drift. + expect(pre!.className.split(/\s+/)).toContain('sourceCode'); + expect(pre!.className.split(/\s+/)).toContain('r'); + // <code> is bare under the native writer; the React side + // must match so Quarto's `pre.sourceCode > code` CSS rules + // resolve identically. + expect(code!.className).toBe(''); + + // Span structure: cat in hl-function, parens in + // hl-punctuation-bracket, inner literal in hl-string. + const highlightSpans = code!.querySelectorAll('span[class^="hl-"]'); + expect(highlightSpans.length).toBe(4); + expect(highlightSpans[0].className).toBe('hl-function'); + expect(highlightSpans[0].textContent).toBe('cat'); + expect(highlightSpans[1].className).toBe('hl-punctuation-bracket'); + expect(highlightSpans[1].textContent).toBe('('); + expect(highlightSpans[2].className).toBe('hl-string'); + expect(highlightSpans[2].textContent).toBe('"hi"'); + expect(highlightSpans[3].className).toBe('hl-punctuation-bracket'); + expect(highlightSpans[3].textContent).toBe(')'); + + // Whole-text round-trip: every character is preserved. + expect(code!.textContent).toBe('cat("hi")'); + + // Raw `data-hl-spans` attribute must NOT leak through as a + // DOM attribute. Matches the Rust HTML writer's behavior + // (`!output.html().contains("data-hl-spans=")` in + // crates/quarto-core/tests/render_to_html_user_grammars.rs). + expect(pre!.hasAttribute('data-hl-spans')).toBe(false); + expect(code!.hasAttribute('data-hl-spans')).toBe(false); + }); + + it('CodeBlock falls back to plain text when data-hl-spans is absent', () => { + // Behaviorally identical to the existing "renders <pre><code + // class=lang>" test, but explicit about the no-highlight + // path so a regression that incorrectly fires the highlighter + // (e.g. on an empty array) gets caught here. + const ast = [{ + t: 'CodeBlock', + c: [['', ['r'], []], 'cat("hi")'], + }]; + const { container } = mount(ast); + const code = container.querySelector('pre > code')!; + expect(code.querySelectorAll('span[class^="hl-"]').length).toBe(0); + expect(code.textContent).toBe('cat("hi")'); + }); + + it('CodeBlock falls back to plain text when data-hl-spans is empty array', () => { + // Defensive: the encoder may emit `[]` for a code cell whose + // grammar lookup succeeded but produced no captures (e.g. + // a single-character cell with no matchable tokens). Treat + // an empty array the same as missing attribute — no spans + // emitted, plain text rendered. + const ast = [{ + t: 'CodeBlock', + c: [ + ['', ['r'], [['data-hl-spans', '[]']]], + 'x', + ], + }]; + const { container } = mount(ast); + const code = container.querySelector('pre > code')!; + expect(code.querySelectorAll('span[class^="hl-"]').length).toBe(0); + expect(code.textContent).toBe('x'); + }); + + it('CodeBlock highlight survives non-ASCII source (utf-8 byte offsets)', () => { + // `data-hl-spans` byte offsets index into the utf-8 + // representation, not utf-16 / char counts. A grammar + // matching `α` (a 2-byte char) at offset 0 should still + // produce a span containing exactly that character. + // Mirrors how the Rust writer slices `&text[cursor..end]` + // by byte index — `&str` slicing must hit utf-8 boundaries + // and we expect the same here. + const text = 'α'; // 2 bytes in utf-8, 1 utf-16 unit in JS string + const spans = [[0, 2, 'identifier']]; + const ast = [{ + t: 'CodeBlock', + c: [ + ['', ['r'], [['data-hl-spans', JSON.stringify(spans)]]], + text, + ], + }]; + const { container } = mount(ast); + const code = container.querySelector('pre > code')!; + const span = code.querySelector('span.hl-identifier')!; + expect(span).not.toBeNull(); + expect(span.textContent).toBe('α'); + expect(code.textContent).toBe('α'); + }); + + // ─── bd-coffj: Div with class="section" → <section> tag ────────────── + // + // The native HTML writer + // (`crates/pampa/src/writers/html.rs::Block::Div`, lines 1129-1142) + // emits `<section>...</section>` for a Pandoc `Div` whose class + // list contains `"section"` — this is the sectionize transform's + // output. The React `Div` component must match: Quarto theme CSS + // keys off the `<section>` tag (e.g. + // `main.content > p:has(+ section) { margin-bottom: 2rem }`), + // and `<div class="section">` doesn't trigger those rules. The + // visible symptom is a paragraph-before-section bottom-margin of + // 17px in preview vs 34px in render against the fixture website. + + it('Div with class="section" renders as <section> (sectionize transform)', () => { + const ast = [{ + t: 'Div', + c: [ + ['a-section', ['section', 'level3'], []], + [{ t: 'Header', c: [3, ['', [], []], [STR('A section')]] }], + ], + }]; + const { container } = mount(ast); + // The container itself must be a <section>, not a <div>. + const section = container.querySelector('section.section.level3'); + expect(section).not.toBeNull(); + expect(section!.id).toBe('a-section'); + expect(section!.className).toBe('section level3'); + // Negative check: there must NOT be a <div> with the section + // classes for this Pandoc Div. (Other unrelated <div>s in + // the rendered tree are fine.) + const divWithSection = container.querySelector('div.section.level3'); + expect(divWithSection).toBeNull(); + }); + + it('Div without "section" class still renders as <div>', () => { + const ast = [{ + t: 'Div', + c: [ + ['my-callout', ['callout-note'], []], + [PARA(STR('callout body'))], + ], + }]; + const { container } = mount(ast); + // Regression guard: only `section` triggers the elevation; + // other Quarto-extension classes (callouts, columns, etc.) + // keep <div>. + const div = container.querySelector('div.callout-note'); + expect(div).not.toBeNull(); + expect(container.querySelector('section.callout-note')).toBeNull(); + }); + it('Image — manifest hit returns the blob URL', () => { const ast = [PARA({ t: 'Image', diff --git a/hub-client/src/components/render/q2-preview/quartoClasses.ts b/ts-packages/preview-renderer/src/q2-preview/quartoClasses.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/quartoClasses.ts rename to ts-packages/preview-renderer/src/q2-preview/quartoClasses.ts diff --git a/hub-client/src/components/render/q2-preview/registry.test.ts b/ts-packages/preview-renderer/src/q2-preview/registry.test.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/registry.test.ts rename to ts-packages/preview-renderer/src/q2-preview/registry.test.ts diff --git a/hub-client/src/components/render/q2-preview/registry.ts b/ts-packages/preview-renderer/src/q2-preview/registry.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/registry.ts rename to ts-packages/preview-renderer/src/q2-preview/registry.ts diff --git a/hub-client/src/components/render/q2-preview/theoremEnvs.ts b/ts-packages/preview-renderer/src/q2-preview/theoremEnvs.ts similarity index 100% rename from hub-client/src/components/render/q2-preview/theoremEnvs.ts rename to ts-packages/preview-renderer/src/q2-preview/theoremEnvs.ts diff --git a/hub-client/src/components/render/q2-preview/utils.tsx b/ts-packages/preview-renderer/src/q2-preview/utils.tsx similarity index 99% rename from hub-client/src/components/render/q2-preview/utils.tsx rename to ts-packages/preview-renderer/src/q2-preview/utils.tsx index 8ce4cb102..b81a91363 100644 --- a/hub-client/src/components/render/q2-preview/utils.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/utils.tsx @@ -30,7 +30,7 @@ import type { InlineNode, Slot, } from '../framework'; -import type { Attr } from '../framework/types'; +import type { Attr } from '../framework'; import { Node } from '../framework'; // --- asset URL lookup ------------------------------------------------ diff --git a/ts-packages/preview-renderer/src/test-utils/setup.ts b/ts-packages/preview-renderer/src/test-utils/setup.ts new file mode 100644 index 000000000..89c32c62f --- /dev/null +++ b/ts-packages/preview-renderer/src/test-utils/setup.ts @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +if (!globalThis.crypto?.randomUUID) { + const cryptoPolyfill = { + ...globalThis.crypto, + randomUUID: () => 'test-uuid-' + Math.random().toString(36).substring(2, 11), + } as Crypto; + Object.defineProperty(globalThis, 'crypto', { value: cryptoPolyfill }); +} + +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); +} + +if (!globalThis.IntersectionObserver) { + globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + root: null, + rootMargin: '', + thresholds: [], + takeRecords: () => [], + })) as unknown as typeof IntersectionObserver; +} diff --git a/hub-client/src/types/artifactPaths.ts b/ts-packages/preview-renderer/src/types/artifactPaths.ts similarity index 100% rename from hub-client/src/types/artifactPaths.ts rename to ts-packages/preview-renderer/src/types/artifactPaths.ts diff --git a/hub-client/src/types/diagnostic.ts b/ts-packages/preview-renderer/src/types/diagnostic.ts similarity index 100% rename from hub-client/src/types/diagnostic.ts rename to ts-packages/preview-renderer/src/types/diagnostic.ts diff --git a/hub-client/src/types/intelligence.ts b/ts-packages/preview-renderer/src/types/intelligence.ts similarity index 100% rename from hub-client/src/types/intelligence.ts rename to ts-packages/preview-renderer/src/types/intelligence.ts diff --git a/hub-client/src/types/project.test.ts b/ts-packages/preview-renderer/src/types/project.test.ts similarity index 100% rename from hub-client/src/types/project.test.ts rename to ts-packages/preview-renderer/src/types/project.test.ts diff --git a/hub-client/src/types/project.ts b/ts-packages/preview-renderer/src/types/project.ts similarity index 100% rename from hub-client/src/types/project.ts rename to ts-packages/preview-renderer/src/types/project.ts diff --git a/hub-client/src/types/sourceInfo.ts b/ts-packages/preview-renderer/src/types/sourceInfo.ts similarity index 100% rename from hub-client/src/types/sourceInfo.ts rename to ts-packages/preview-renderer/src/types/sourceInfo.ts diff --git a/hub-client/src/utils/atomicCustomNodes.ts b/ts-packages/preview-renderer/src/utils/atomicCustomNodes.ts similarity index 100% rename from hub-client/src/utils/atomicCustomNodes.ts rename to ts-packages/preview-renderer/src/utils/atomicCustomNodes.ts diff --git a/hub-client/src/utils/componentPath.test.ts b/ts-packages/preview-renderer/src/utils/componentPath.test.ts similarity index 100% rename from hub-client/src/utils/componentPath.test.ts rename to ts-packages/preview-renderer/src/utils/componentPath.test.ts diff --git a/hub-client/src/utils/componentPath.ts b/ts-packages/preview-renderer/src/utils/componentPath.ts similarity index 100% rename from hub-client/src/utils/componentPath.ts rename to ts-packages/preview-renderer/src/utils/componentPath.ts diff --git a/hub-client/src/utils/customRegistry.test.ts b/ts-packages/preview-renderer/src/utils/customRegistry.test.ts similarity index 100% rename from hub-client/src/utils/customRegistry.test.ts rename to ts-packages/preview-renderer/src/utils/customRegistry.test.ts diff --git a/hub-client/src/utils/customRegistry.ts b/ts-packages/preview-renderer/src/utils/customRegistry.ts similarity index 100% rename from hub-client/src/utils/customRegistry.ts rename to ts-packages/preview-renderer/src/utils/customRegistry.ts diff --git a/hub-client/src/utils/iframeLinkHandlers.integration.test.ts b/ts-packages/preview-renderer/src/utils/iframeLinkHandlers.integration.test.ts similarity index 56% rename from hub-client/src/utils/iframeLinkHandlers.integration.test.ts rename to ts-packages/preview-renderer/src/utils/iframeLinkHandlers.integration.test.ts index 72c8ba9b0..41ec3efe8 100644 --- a/hub-client/src/utils/iframeLinkHandlers.integration.test.ts +++ b/ts-packages/preview-renderer/src/utils/iframeLinkHandlers.integration.test.ts @@ -177,4 +177,123 @@ describe('installLinkHandlers', () => { anchor: null, }); }); + + // ─── Phase F.1 (bd-kw93.14): artifact-rooted .html links ──────── + // + // After link-rewrite is included in the q2-preview pipeline, body + // hrefs like `[A](about.qmd)` become artifact-rooted .html URLs + // (`/.quarto/project-artifacts/about.html`). The link handler must + // intercept these and route them through `onQmdLinkClick` with the + // source-side `.qmd` path so the SPA can swap activeFile. + + test('artifact-rooted .html link maps back to its .qmd via projectFilePaths', () => { + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'about.qmd', 'posts/first.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('/.quarto/project-artifacts/about.html'); + const continued = clickFromBody(a); + + expect(onQmdLinkClick).toHaveBeenCalledWith({ + path: 'about.qmd', + anchor: null, + }); + expect(continued).toBe(false); + }); + + test('artifact-rooted .html#anchor preserves the anchor', () => { + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'about.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('/.quarto/project-artifacts/about.html#intro'); + clickFromBody(a); + + expect(onQmdLinkClick).toHaveBeenCalledWith({ + path: 'about.qmd', + anchor: 'intro', + }); + }); + + test('artifact-rooted nested page maps to its .qmd', () => { + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'posts/first.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('/.quarto/project-artifacts/posts/first.html'); + clickFromBody(a); + + expect(onQmdLinkClick).toHaveBeenCalledWith({ + path: 'posts/first.qmd', + anchor: null, + }); + }); + + test('external https://...html link is NOT hijacked by artifact-root logic', () => { + // Risk 2 from Phase F plan: an external link that happens to + // end in .html (e.g. `https://example.org/index.html`) must + // open in a new tab via the existing external-link handler, + // never route through onQmdLinkClick. + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'about.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('https://example.org/about.html'); + clickFromBody(a); + + expect(openSpy).toHaveBeenCalledWith( + 'https://example.org/about.html', + '_blank', + 'noopener,noreferrer', + ); + expect(onQmdLinkClick).not.toHaveBeenCalled(); + }); + + test('artifact-rooted link to a missing page still intercepts (overlay shows on failed render)', () => { + // Plan §F.1 acceptance: missing-page link should surface the + // D.4 error overlay rather than blanking the iframe with a + // 404 navigation. So the handler intercepts even when the + // reverse-map's .qmd candidate isn't in projectFilePaths; + // PreviewApp's render attempt fails and the overlay appears. + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'about.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('/.quarto/project-artifacts/missing.html'); + const continued = clickFromBody(a); + + expect(onQmdLinkClick).toHaveBeenCalledWith({ + path: 'missing.qmd', + anchor: null, + }); + expect(continued).toBe(false); + }); + + test('non-artifact-rooted absolute path is left alone', () => { + // A user-authored absolute href that isn't artifact-rooted + // (e.g. someone hand-wrote `<a href="/about.html">`) should + // not be intercepted — preserves the existing pass-through + // behaviour for non-`.qmd`, non-`.html` links to user content. + installLinkHandlers(doc, { + currentFilePath: 'index.qmd', + projectFilePaths: ['index.qmd', 'about.qmd'], + onQmdLinkClick, + }); + + const a = appendAnchor('/about.html'); + const continued = clickFromBody(a); + + expect(onQmdLinkClick).not.toHaveBeenCalled(); + expect(continued).toBe(true); + }); }); diff --git a/hub-client/src/utils/iframeLinkHandlers.ts b/ts-packages/preview-renderer/src/utils/iframeLinkHandlers.ts similarity index 54% rename from hub-client/src/utils/iframeLinkHandlers.ts rename to ts-packages/preview-renderer/src/utils/iframeLinkHandlers.ts index 4ccd77dea..8ddbf0406 100644 --- a/hub-client/src/utils/iframeLinkHandlers.ts +++ b/ts-packages/preview-renderer/src/utils/iframeLinkHandlers.ts @@ -9,6 +9,15 @@ * default. Relative paths are resolved against `currentFilePath`. * - Click on an `<a href="#sec">` (same-document anchor) — calls * `onQmdLinkClick({ anchor: 'sec' })` and prevents default. + * - Click on an `<a href="/.quarto/project-artifacts/foo.html">` + * (Phase F.1, bd-kw93.14) — reverse-maps the artifact-rooted + * `.html` URL to its source `.qmd`, calls + * `onQmdLinkClick({ path: 'foo.qmd', anchor })`. We always + * intercept artifact-rooted hrefs (even when the candidate `.qmd` + * is not in `projectFilePaths`) so a missing-page click surfaces + * the D.4 render-error overlay rather than navigating the iframe + * to a 404. External `https://example.com/foo.html` links fall + * into the new-tab branch above and are never hijacked. * - `Cmd+S` / `Ctrl+S` keydown — posts `{ type: 'hub-client-save' }` * to `window.parent` and prevents default. * @@ -23,18 +32,33 @@ * (single DOM walk, single attach), not a continuously-rendered React * tree. The contrast is "one-shot HTML walk vs continuously-re-rendered * React DOM," not q2-debug vs q2-preview. - * - * The `/.quarto/...` artifact-rooted reverse-mapping branch from - * `iframePostProcessor.ts:253-272` has no analog here — q2-preview's - * pipeline excludes `LinkRewriteTransform`, so artifact-rooted hrefs - * never appear in the q2-preview AST. */ import { resolveRelativePath } from './vfsPaths'; +/** + * VFS root the q2-preview WASM renderer uses for artifact paths. Body + * `.qmd` hrefs go through `LinkRewriteTransform` and come out rooted + * here as `.html` URLs (`/.quarto/project-artifacts/about.html`). + * + * Duplicated from `iframePostProcessor.ts`. bd-msp0 tracks hoisting + * this constant once the service-worker resource resolution lands. + */ +const ARTIFACT_ROOT = '/.quarto/project-artifacts/'; + export interface InstallLinkHandlersOptions { /** Current file path; used to resolve relative `.qmd` link targets. */ currentFilePath: string; + /** + * Phase F.1 (bd-kw93.14): project file paths used to reverse-map + * artifact-rooted `.html` clicks back to their source `.qmd`. + * Optional and currently unused except for type-shape consistency + * with `iframePostProcessor.ts` — the q2-preview link handler + * intercepts every artifact-rooted href regardless of whether the + * mapped `.qmd` is in this list, so the field is a documentation + * hook for the future (e.g. a "did you mean ..." overlay). + */ + projectFilePaths?: readonly string[]; /** * Click callback for `.qmd` links and same-document anchor clicks. * - With a `.qmd` link → `{ path: <resolved>, anchor: <fragment | null> }`. @@ -75,6 +99,17 @@ export function installLinkHandlers( return; } + // Phase F.1: artifact-rooted .html hrefs land here after + // LinkRewriteTransform runs. Always intercept; route the + // .qmd candidate through onQmdLinkClick (PreviewApp's render + // attempt is what surfaces a missing-page error). + const artifact = parseArtifactHref(href); + if (artifact && opts.onQmdLinkClick) { + ev.preventDefault(); + opts.onQmdLinkClick({ path: artifact.qmdCandidate, anchor: artifact.anchor }); + return; + } + const parsed = parseLink(href); if (parsed.path && parsed.path.endsWith('.qmd') && opts.onQmdLinkClick) { ev.preventDefault(); @@ -106,6 +141,35 @@ function parseLink(href: string): ParsedLink { return { path, anchor: anchor || null }; } +/** + * Parse an artifact-rooted `.html` href into its `.qmd` source-path + * candidate + optional anchor. Returns null for hrefs that don't + * start with the artifact root or don't carry a `.html` stem. + * + * Examples: + * /.quarto/project-artifacts/about.html → { qmdCandidate: 'about.qmd', anchor: null } + * /.quarto/project-artifacts/about.html#sec → { qmdCandidate: 'about.qmd', anchor: 'sec' } + * /.quarto/project-artifacts/posts/x.html → { qmdCandidate: 'posts/x.qmd', anchor: null } + * /.quarto/project-artifacts/styles.css → null (not .html) + * ./about.qmd → null (not artifact-rooted) + * + * Unlike `iframePostProcessor.ts::reverseMapArtifactHref`, this helper + * does not consult `projectFilePaths` — see the docstring on + * `installLinkHandlers` for the rationale (missing-page UX). + */ +function parseArtifactHref(href: string): { qmdCandidate: string; anchor: string | null } | null { + if (!href.startsWith(ARTIFACT_ROOT)) return null; + const stripped = href.slice(ARTIFACT_ROOT.length); + const hashIdx = stripped.indexOf('#'); + const stem = hashIdx === -1 ? stripped : stripped.slice(0, hashIdx); + const anchor = hashIdx === -1 ? null : stripped.slice(hashIdx + 1) || null; + if (!stem.endsWith('.html')) return null; + return { + qmdCandidate: stem.slice(0, -'.html'.length) + '.qmd', + anchor, + }; +} + function findAnchorAncestor(start: Element | null): HTMLAnchorElement | null { let node: Element | null = start; while (node) { diff --git a/hub-client/src/utils/iframePostProcessor.integration.test.ts b/ts-packages/preview-renderer/src/utils/iframePostProcessor.integration.test.ts similarity index 100% rename from hub-client/src/utils/iframePostProcessor.integration.test.ts rename to ts-packages/preview-renderer/src/utils/iframePostProcessor.integration.test.ts diff --git a/hub-client/src/utils/iframePostProcessor.test.ts b/ts-packages/preview-renderer/src/utils/iframePostProcessor.test.ts similarity index 100% rename from hub-client/src/utils/iframePostProcessor.test.ts rename to ts-packages/preview-renderer/src/utils/iframePostProcessor.test.ts diff --git a/hub-client/src/utils/iframePostProcessor.ts b/ts-packages/preview-renderer/src/utils/iframePostProcessor.ts similarity index 99% rename from hub-client/src/utils/iframePostProcessor.ts rename to ts-packages/preview-renderer/src/utils/iframePostProcessor.ts index d07adad5b..98595b796 100644 --- a/hub-client/src/utils/iframePostProcessor.ts +++ b/ts-packages/preview-renderer/src/utils/iframePostProcessor.ts @@ -9,7 +9,7 @@ * switch the active editor file (bd-lnd3). */ -import { vfsReadFile, vfsReadBinaryFile } from '../services/wasmRenderer'; +import { vfsReadFile, vfsReadBinaryFile } from '@quarto/preview-runtime'; import { resolveRelativePath, guessMimeType } from './vfsPaths'; /** diff --git a/hub-client/src/utils/sourceInfo.test.ts b/ts-packages/preview-renderer/src/utils/sourceInfo.test.ts similarity index 100% rename from hub-client/src/utils/sourceInfo.test.ts rename to ts-packages/preview-renderer/src/utils/sourceInfo.test.ts diff --git a/hub-client/src/utils/sourceInfo.ts b/ts-packages/preview-renderer/src/utils/sourceInfo.ts similarity index 100% rename from hub-client/src/utils/sourceInfo.ts rename to ts-packages/preview-renderer/src/utils/sourceInfo.ts diff --git a/hub-client/src/utils/stripAnsi.test.ts b/ts-packages/preview-renderer/src/utils/stripAnsi.test.ts similarity index 100% rename from hub-client/src/utils/stripAnsi.test.ts rename to ts-packages/preview-renderer/src/utils/stripAnsi.test.ts diff --git a/hub-client/src/utils/stripAnsi.ts b/ts-packages/preview-renderer/src/utils/stripAnsi.ts similarity index 100% rename from hub-client/src/utils/stripAnsi.ts rename to ts-packages/preview-renderer/src/utils/stripAnsi.ts diff --git a/hub-client/src/utils/vfsPaths.test.ts b/ts-packages/preview-renderer/src/utils/vfsPaths.test.ts similarity index 100% rename from hub-client/src/utils/vfsPaths.test.ts rename to ts-packages/preview-renderer/src/utils/vfsPaths.test.ts diff --git a/hub-client/src/utils/vfsPaths.ts b/ts-packages/preview-renderer/src/utils/vfsPaths.ts similarity index 100% rename from hub-client/src/utils/vfsPaths.ts rename to ts-packages/preview-renderer/src/utils/vfsPaths.ts diff --git a/ts-packages/preview-renderer/tsconfig.json b/ts-packages/preview-renderer/tsconfig.json new file mode 100644 index 000000000..6bf0cb41d --- /dev/null +++ b/ts-packages/preview-renderer/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.integration.test.ts", + "src/**/*.integration.test.tsx", + "src/test-utils/**" + ] +} diff --git a/ts-packages/preview-renderer/vitest.config.ts b/ts-packages/preview-renderer/vitest.config.ts new file mode 100644 index 000000000..bb7384a06 --- /dev/null +++ b/ts-packages/preview-renderer/vitest.config.ts @@ -0,0 +1,74 @@ +import { defineConfig } from 'vitest/config'; +import type { Plugin } from 'vite'; +import path from 'path'; +import { readFileSync } from 'fs'; + +/** + * Expose `virtual:quarto-attribution-viewer-css` as a module whose + * default export is the contents of `resources/attribution/viewer.css`. + * + * `resources/attribution/viewer.css` is the single source of truth + * shared with the CLI's `AttributionViewerTransform` (via + * `include_str!`). Vite / Vitest silently return empty for `?raw` + * imports of files outside the project root even with + * `server.fs.allow: ['..']`, so the virtual-module indirection is the + * supported way to embed an out-of-tree asset's contents at + * build/test time. + * + * Mirrors `attributionViewerCssPlugin` in `hub-client/vite.config.ts` + * — the framework moved into this package but the resource still + * lives at the repo root. Both consumers (hub-client app build, + * preview-renderer standalone vitest) need their own copy of the + * plugin. + */ +function attributionViewerCssPlugin(): Plugin { + const VIRTUAL_ID = 'virtual:quarto-attribution-viewer-css'; + const RESOLVED_ID = '\0' + VIRTUAL_ID; + // preview-renderer lives at `ts-packages/preview-renderer/`, so the + // repo-root resource path is two levels up. + const sourcePath = path.resolve(__dirname, '../../resources/attribution/viewer.css'); + return { + name: 'quarto-attribution-viewer-css', + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID; + }, + load(id) { + if (id === RESOLVED_ID) { + const css = readFileSync(sourcePath, 'utf-8'); + return `export default ${JSON.stringify(css)};`; + } + }, + }; +} + +export default defineConfig({ + plugins: [attributionViewerCssPlugin()], + resolve: { + conditions: ['source', 'import', 'module', 'browser', 'default'], + alias: { + // Resolve workspace packages to source so tests work on a fresh + // clone (no built dist required). Mirrors hub-client/vitest.config.ts. + '@quarto/quarto-automerge-schema': path.resolve( + __dirname, + '../quarto-automerge-schema/src/index.ts', + ), + '@quarto/quarto-sync-client': path.resolve( + __dirname, + '../quarto-sync-client/src/index.ts', + ), + '@quarto/preview-runtime': path.resolve( + __dirname, + '../preview-runtime/src', + ), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: [ + 'src/**/*.integration.test.ts', + 'src/**/*.integration.test.tsx', + ], + passWithNoTests: true, + }, +}); diff --git a/ts-packages/preview-renderer/vitest.integration.config.ts b/ts-packages/preview-renderer/vitest.integration.config.ts new file mode 100644 index 000000000..7f9c6a630 --- /dev/null +++ b/ts-packages/preview-renderer/vitest.integration.config.ts @@ -0,0 +1,81 @@ +import { defineConfig } from 'vitest/config'; +import type { Plugin } from 'vite'; +import path from 'path'; +import { readFileSync } from 'fs'; + +/** + * `virtual:quarto-attribution-viewer-css` virtual module — mirrors + * the plugin in `vitest.config.ts` and `hub-client/vite.config.ts`. + * See the comment on `attributionViewerCssPlugin` in `vitest.config.ts` + * for the contract. + */ +function attributionViewerCssPlugin(): Plugin { + const VIRTUAL_ID = 'virtual:quarto-attribution-viewer-css'; + const RESOLVED_ID = '\0' + VIRTUAL_ID; + const sourcePath = path.resolve(__dirname, '../../resources/attribution/viewer.css'); + return { + name: 'quarto-attribution-viewer-css', + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID; + }, + load(id) { + if (id === RESOLVED_ID) { + const css = readFileSync(sourcePath, 'utf-8'); + return `export default ${JSON.stringify(css)};`; + } + }, + }; +} + +export default defineConfig({ + plugins: [attributionViewerCssPlugin()], + resolve: { + conditions: ['source', 'import', 'module', 'browser', 'default'], + alias: { + '@quarto/quarto-automerge-schema': path.resolve( + __dirname, + '../quarto-automerge-schema/src/index.ts', + ), + '@quarto/quarto-sync-client': path.resolve( + __dirname, + '../quarto-sync-client/src/index.ts', + ), + '@quarto/preview-runtime': path.resolve( + __dirname, + '../preview-runtime/src', + ), + // Integration tests pull in preview-runtime through + // iframePostProcessor / assetWalker, which transitively imports + // the wasm-quarto-hub-client glue. The actual WASM module isn't + // needed for these tests (the dispatch functions are mocked or + // never called), but the import must still resolve. Point at the + // hub-client symlink so the JS shim loads. + 'wasm-quarto-hub-client': path.resolve( + __dirname, + '../../hub-client/wasm-quarto-hub-client/wasm_quarto_hub_client.js', + ), + // wasmRenderer.ts dynamically imports the sass bridge via the + // Vite-root path `/src/wasm-js-bridge/sass.js`. In test runs the + // bridge isn't invoked (no `initWasm()`), but Vite still resolves + // the import statement at transform time, so the path must + // resolve to *something*. As of A.0 the bridge lives in + // `@quarto/wasm-js-bridge`; alias the sub-tree (NOT plain + // `/src`, which would also intercept test files' own absolute + // paths). + '/src/wasm-js-bridge': path.resolve( + __dirname, + '../wasm-js-bridge/src', + ), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: [ + 'src/**/*.integration.test.ts', + 'src/**/*.integration.test.tsx', + ], + setupFiles: ['./src/test-utils/setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/ts-packages/preview-runtime/package.json b/ts-packages/preview-runtime/package.json new file mode 100644 index 000000000..6f1a59912 --- /dev/null +++ b/ts-packages/preview-runtime/package.json @@ -0,0 +1,56 @@ +{ + "name": "@quarto/preview-runtime", + "version": "0.0.1", + "private": true, + "description": "WASM + automerge glue for Quarto's q2-preview renderer. Shared by hub-client and the q2-preview SPA.", + "license": "MIT", + "author": { + "name": "Posit PBC" + }, + "type": "module", + "main": "dist/index.js", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "import": "./dist/index.js" + }, + "./test-utils/*": { + "types": "./src/test-utils/*.ts", + "source": "./src/test-utils/*.ts", + "import": "./dist/test-utils/*.js" + }, + "./userGrammar/*": { + "types": "./src/userGrammar/*.ts", + "source": "./src/userGrammar/*.ts", + "import": "./dist/userGrammar/*.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:wasm": "vitest run --config vitest.wasm.config.ts", + "test:watch": "vitest" + }, + "dependencies": { + "@automerge/automerge": "^2.2.9", + "@automerge/automerge-repo": "^2.5.1", + "@quarto/pandoc-types": "*", + "@quarto/preview-renderer": "*", + "@quarto/quarto-automerge-schema": "*", + "@quarto/quarto-sync-client": "*", + "web-tree-sitter": "^0.26.8" + }, + "devDependencies": { + "jsdom": "^26.0.0", + "typescript": "~5.9.3", + "vitest": "^4.0.17" + } +} diff --git a/hub-client/src/services/automergeSync.test.ts b/ts-packages/preview-runtime/src/automergeSync.test.ts similarity index 99% rename from hub-client/src/services/automergeSync.test.ts rename to ts-packages/preview-runtime/src/automergeSync.test.ts index e494e043f..2fd6c701e 100644 --- a/hub-client/src/services/automergeSync.test.ts +++ b/ts-packages/preview-runtime/src/automergeSync.test.ts @@ -19,7 +19,7 @@ import { _setClientForTesting, _getCallbacksForTesting, } from './automergeSync'; -import { createMockSyncClient, type MockSyncClient } from '../test-utils/mockSyncClient'; +import { createMockSyncClient, type MockSyncClient } from './test-utils/mockSyncClient'; // Mock the wasmRenderer module to avoid WASM initialization vi.mock('./wasmRenderer', () => ({ diff --git a/hub-client/src/services/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts similarity index 86% rename from hub-client/src/services/automergeSync.ts rename to ts-packages/preview-runtime/src/automergeSync.ts index 9a1ca8fcc..a1f89f9b7 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -14,6 +14,7 @@ import { type EditorContentChange, type FileEntry, type ActorIdentity, + type CaptureRef, type CreateBinaryFileResult, type CreateProjectOptions, type CreateProjectResult, @@ -23,11 +24,12 @@ import { import { vfsAddFile, vfsAddBinaryFile, vfsRemoveFile, vfsClear, initWasm } from './wasmRenderer'; // Re-export types for use in other components -export type { Patch, EditorContentChange, FileEntry, ActorIdentity, CreateBinaryFileResult, CreateProjectOptions, CreateProjectResult }; +export type { Patch, EditorContentChange, FileEntry, ActorIdentity, CaptureRef, CreateBinaryFileResult, CreateProjectOptions, CreateProjectResult }; // Event handlers for state changes type FilesChangeHandler = (files: FileEntry[]) => void; type IdentitiesChangeHandler = (identities: Record<string, ActorIdentity>) => void; +type CapturesChangeHandler = (captures: Record<string, CaptureRef>) => void; type FileContentHandler = (path: string, content: string, patches: Patch[]) => void; type BinaryContentHandler = (path: string, content: Uint8Array, mimeType: string) => void; type ConnectionHandler = (connected: boolean) => void; @@ -35,6 +37,7 @@ type ErrorHandler = (error: Error) => void; let onFilesChange: FilesChangeHandler | null = null; let onIdentitiesChange: IdentitiesChangeHandler | null = null; +let onCapturesChange: CapturesChangeHandler | null = null; let onFileContent: FileContentHandler | null = null; let onBinaryContent: BinaryContentHandler | null = null; let onConnectionChange: ConnectionHandler | null = null; @@ -60,6 +63,7 @@ let client: SyncClient | null = null; export function setSyncHandlers(handlers: { onFilesChange?: FilesChangeHandler; onIdentitiesChange?: IdentitiesChangeHandler; + onCapturesChange?: CapturesChangeHandler; onFileContent?: FileContentHandler; onBinaryContent?: BinaryContentHandler; onConnectionChange?: ConnectionHandler; @@ -67,6 +71,7 @@ export function setSyncHandlers(handlers: { }) { if (handlers.onFilesChange) onFilesChange = handlers.onFilesChange; if (handlers.onIdentitiesChange) onIdentitiesChange = handlers.onIdentitiesChange; + if (handlers.onCapturesChange) onCapturesChange = handlers.onCapturesChange; if (handlers.onFileContent) onFileContent = handlers.onFileContent; if (handlers.onBinaryContent) onBinaryContent = handlers.onBinaryContent; if (handlers.onConnectionChange) onConnectionChange = handlers.onConnectionChange; @@ -106,6 +111,9 @@ function createInternalCallbacks(): SyncClientCallbacks { onIdentitiesChange: (identities: Record<string, ActorIdentity>) => { onIdentitiesChange?.(identities); }, + onCapturesChange: (captures: Record<string, CaptureRef>) => { + onCapturesChange?.(captures); + }, onConnectionChange: (connected: boolean) => { onConnectionChange?.(connected); }, @@ -130,12 +138,20 @@ function ensureClient(): SyncClient { * * Auth is handled via HttpOnly cookies, sent automatically by the * browser on same-origin WebSocket upgrades. + * + * `peerTimeoutMs` controls how long to wait for the samod handshake + * before falling through to offline-from-IndexedDB mode. The + * underlying default (1 ms) is appropriate for hub-client where + * IndexedDB usually has cached docs from a prior session. The + * q2-preview SPA hits a fresh ephemeral hub with no IndexedDB + * cache, so it passes a longer timeout to avoid an `findDoc` + * "unavailable" race on cold start. */ -export async function connect(syncServerUrl: string, indexDocId: string, actorId?: string, screenName?: string, color?: string): Promise<FileEntry[]> { +export async function connect(syncServerUrl: string, indexDocId: string, actorId?: string, screenName?: string, color?: string, peerTimeoutMs?: number): Promise<FileEntry[]> { await initWasm(); vfsClear(); - return ensureClient().connect(syncServerUrl, indexDocId, actorId, screenName, color); + return ensureClient().connect(syncServerUrl, indexDocId, actorId, screenName, color, peerTimeoutMs); } /** @@ -169,6 +185,19 @@ export function getBinaryFileContent(path: string): { content: Uint8Array; mimeT return ensureClient().getBinaryFileContent(path); } +/** + * Fetch a binary document by samod document ID (not by path). + * + * Used by Phase C.4 (bd-kw93.3) to resolve capture binary docs + * referenced from the IndexDocument's V2 capture sidecar. + * Returns `null` for unknown ids or non-binary documents. + */ +export async function getBinaryDocById( + docId: string, +): Promise<{ content: Uint8Array; mimeType: string } | null> { + return ensureClient().getBinaryDocById(docId); +} + /** * Update the content of a file using incremental text updates. */ diff --git a/ts-packages/preview-runtime/src/index.ts b/ts-packages/preview-runtime/src/index.ts new file mode 100644 index 000000000..c75156a18 --- /dev/null +++ b/ts-packages/preview-runtime/src/index.ts @@ -0,0 +1,16 @@ +/// <reference path="./vite-shims.d.ts" /> +/// <reference path="./wasm-quarto-hub-client.d.ts" /> + +// Public API for @quarto/preview-runtime. +// +// Re-exports the WASM-renderer + automerge-sync + user-grammar surface +// so consumers can write `import { vfsReadFile, ... } from '@quarto/preview-runtime'`. +// Internal sub-modules are reachable via sub-path exports (see package.json) +// when callers need finer-grained imports (e.g. testing helpers). +// +// The triple-slash reference above pulls in ambient module declarations +// (sass JS bridge, `*.wasm?url` asset URLs) so consumers like hub-client, +// which transitively typechecks our `.ts` sources, see them in scope. + +export * from './wasmRenderer'; +export * from './automergeSync'; diff --git a/ts-packages/preview-runtime/src/placeholder.test.ts b/ts-packages/preview-runtime/src/placeholder.test.ts new file mode 100644 index 000000000..50093ca45 --- /dev/null +++ b/ts-packages/preview-runtime/src/placeholder.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('@quarto/preview-runtime placeholder', () => { + it('package is wired up', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/hub-client/src/test-utils/mockSyncClient.ts b/ts-packages/preview-runtime/src/test-utils/mockSyncClient.ts similarity index 100% rename from hub-client/src/test-utils/mockSyncClient.ts rename to ts-packages/preview-runtime/src/test-utils/mockSyncClient.ts diff --git a/hub-client/src/test-utils/mockWasm.ts b/ts-packages/preview-runtime/src/test-utils/mockWasm.ts similarity index 98% rename from hub-client/src/test-utils/mockWasm.ts rename to ts-packages/preview-runtime/src/test-utils/mockWasm.ts index f533f83de..763d97046 100644 --- a/hub-client/src/test-utils/mockWasm.ts +++ b/ts-packages/preview-runtime/src/test-utils/mockWasm.ts @@ -9,7 +9,7 @@ * - Configurable responses and error injection */ -import type { Diagnostic, RenderResponse } from '../types/diagnostic'; +import type { Diagnostic, RenderResponse } from '@quarto/preview-renderer/types/diagnostic'; /** * VFS response type matching the real wasmRenderer.ts diff --git a/ts-packages/preview-runtime/src/test-utils/setup.ts b/ts-packages/preview-runtime/src/test-utils/setup.ts new file mode 100644 index 000000000..b05d756df --- /dev/null +++ b/ts-packages/preview-runtime/src/test-utils/setup.ts @@ -0,0 +1,18 @@ +import 'fake-indexeddb/auto'; +import { vi } from 'vitest'; + +if (!globalThis.crypto?.randomUUID) { + const cryptoPolyfill = { + ...globalThis.crypto, + randomUUID: () => 'test-uuid-' + Math.random().toString(36).substring(2, 11), + } as Crypto; + Object.defineProperty(globalThis, 'crypto', { value: cryptoPolyfill }); +} + +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); +} diff --git a/hub-client/src/services/userGrammarCache.test.ts b/ts-packages/preview-runtime/src/userGrammar/Cache.test.ts similarity index 98% rename from hub-client/src/services/userGrammarCache.test.ts rename to ts-packages/preview-runtime/src/userGrammar/Cache.test.ts index c7ac8bbd3..6dbcafeaf 100644 --- a/hub-client/src/services/userGrammarCache.test.ts +++ b/ts-packages/preview-runtime/src/userGrammar/Cache.test.ts @@ -10,12 +10,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserGrammarCache } from './userGrammarCache'; -import type { GrammarDescriptor } from './userGrammarDiscovery'; +import { UserGrammarCache } from './Cache'; +import type { GrammarDescriptor } from './Discovery'; import type { UserGrammarHighlighter, LoadUserGrammarArgs, -} from './userGrammarHighlight'; +} from './Highlight'; interface FakeBinary { bytes: Uint8Array; diff --git a/hub-client/src/services/userGrammarCache.ts b/ts-packages/preview-runtime/src/userGrammar/Cache.ts similarity index 98% rename from hub-client/src/services/userGrammarCache.ts rename to ts-packages/preview-runtime/src/userGrammar/Cache.ts index f49d5e5e1..9b744a286 100644 --- a/hub-client/src/services/userGrammarCache.ts +++ b/ts-packages/preview-runtime/src/userGrammar/Cache.ts @@ -18,11 +18,11 @@ * `loadUserGrammar` with an in-memory factory. */ -import type { GrammarDescriptor } from './userGrammarDiscovery'; +import type { GrammarDescriptor } from './Discovery'; import type { LoadUserGrammarArgs, UserGrammarHighlighter, -} from './userGrammarHighlight'; +} from './Highlight'; /** * Minimal surface that the cache needs from a `JsUserGrammars` diff --git a/hub-client/src/services/userGrammarDiscovery.test.ts b/ts-packages/preview-runtime/src/userGrammar/Discovery.test.ts similarity index 98% rename from hub-client/src/services/userGrammarDiscovery.test.ts rename to ts-packages/preview-runtime/src/userGrammar/Discovery.test.ts index fc84d7eda..277427637 100644 --- a/hub-client/src/services/userGrammarDiscovery.test.ts +++ b/ts-packages/preview-runtime/src/userGrammar/Discovery.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'; -import { discoverUserGrammars } from './userGrammarDiscovery'; +import { discoverUserGrammars } from './Discovery'; describe('discoverUserGrammars', () => { it('returns [] for a project with no grammars directory', () => { diff --git a/hub-client/src/services/userGrammarDiscovery.ts b/ts-packages/preview-runtime/src/userGrammar/Discovery.ts similarity index 100% rename from hub-client/src/services/userGrammarDiscovery.ts rename to ts-packages/preview-runtime/src/userGrammar/Discovery.ts diff --git a/hub-client/src/services/userGrammarHighlight.ts b/ts-packages/preview-runtime/src/userGrammar/Highlight.ts similarity index 100% rename from hub-client/src/services/userGrammarHighlight.ts rename to ts-packages/preview-runtime/src/userGrammar/Highlight.ts diff --git a/hub-client/src/services/userGrammarHighlight.wasm.test.ts b/ts-packages/preview-runtime/src/userGrammar/Highlight.wasm.test.ts similarity index 96% rename from hub-client/src/services/userGrammarHighlight.wasm.test.ts rename to ts-packages/preview-runtime/src/userGrammar/Highlight.wasm.test.ts index fa1a6d2ec..40d2d180f 100644 --- a/hub-client/src/services/userGrammarHighlight.wasm.test.ts +++ b/ts-packages/preview-runtime/src/userGrammar/Highlight.wasm.test.ts @@ -15,7 +15,7 @@ import { readFile } from 'fs/promises'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; -import { loadUserGrammar, type UserGrammarHighlighter } from './userGrammarHighlight'; +import { loadUserGrammar, type UserGrammarHighlighter } from './Highlight'; type SpanTriple = [number, number, string]; @@ -23,7 +23,8 @@ let highlighter: UserGrammarHighlighter; beforeAll(async () => { const __dirname = dirname(fileURLToPath(import.meta.url)); - const repoRoot = resolve(__dirname, '../../..'); + // ts-packages/preview-runtime/src/userGrammar/ → repo root is 4 levels up. + const repoRoot = resolve(__dirname, '../../../..'); const fixtureDir = join( repoRoot, 'crates/quarto-highlight/tests/fixtures/user-grammar-toml', diff --git a/ts-packages/preview-runtime/src/vite-shims.d.ts b/ts-packages/preview-runtime/src/vite-shims.d.ts new file mode 100644 index 000000000..036165c1e --- /dev/null +++ b/ts-packages/preview-runtime/src/vite-shims.d.ts @@ -0,0 +1,27 @@ +// Minimal type shims for bundler-specific import paths used in this +// package. Mirrors `vite/client` ambient declarations without forcing +// `vite` into preview-runtime's dependency tree. Vite (and Vitest, when +// running tests) handle the actual resolution at build/test time; this +// file just makes `tsc` happy. + +// `?url` suffix for asset imports (web-tree-sitter's WASM URL). +declare module '*.wasm?url' { + const url: string; + export default url; +} + +// The SASS JS bridge. Loaded both by the Rust WASM module (via +// `raw_module = "/src/wasm-js-bridge/sass.js"`) and by `wasmRenderer +// .ts`'s `setupSassVfsCallbacks`. The leading `/` is interpreted by +// Vite as a project-root-relative path, resolving to the consumer's +// `src/wasm-js-bridge/sass.js`. Every consumer (hub-client, the q2 +// preview SPA, …) must host these bridge files at that location. +declare module '/src/wasm-js-bridge/*.js' { + export function setVfsCallbacks( + readFn: (path: string) => string | null, + isFileFn: (path: string) => boolean, + listFn: () => string[], + ): void; + export function jsSassAvailable(): boolean; + export function jsSassCompilerName(): string; +} diff --git a/ts-packages/preview-runtime/src/wasm-quarto-hub-client.d.ts b/ts-packages/preview-runtime/src/wasm-quarto-hub-client.d.ts new file mode 100644 index 000000000..8a256a755 --- /dev/null +++ b/ts-packages/preview-runtime/src/wasm-quarto-hub-client.d.ts @@ -0,0 +1,160 @@ +/** + * Type declarations for wasm-quarto-hub-client + */ +declare module 'wasm-quarto-hub-client' { + export function init(): void; + export function vfs_add_file(path: string, content: string): string; + export function vfs_add_binary_file(path: string, content: Uint8Array): string; + export function vfs_remove_file(path: string): string; + export function vfs_list_files(): string; + export function vfs_clear(): string; + export function vfs_set_runtime_metadata(yaml: string): string; + export function vfs_get_runtime_metadata(): string; + export function vfs_read_file(path: string): string; + export function vfs_read_binary_file(path: string): string; + /** + * JS-interop user-grammar provider — hand-in to `render_qmd` / + * `render_qmd_content` so the render pipeline consults + * `web-tree-sitter`-backed grammars before built-ins. Construct via + * `new JsUserGrammars()`, populate via `register(class, fn)`, then + * pass the handle (or `undefined`). The handle is consumed by the + * render call; construct a fresh one per call. + */ + export class JsUserGrammars { + constructor(); + register( + language_class: string, + highlight_fn: (class_: string, source: string) => string | null | undefined, + ): void; + free(): void; + } + + export function render_qmd( + path: string, + user_grammars?: JsUserGrammars, + ): Promise<string>; + export function render_qmd_content( + content: string, + template_bundle: string, + user_grammars?: JsUserGrammars, + ): Promise<string>; + export function render_page_in_project( + path: string, + user_grammars?: JsUserGrammars, + ): Promise<string>; + export function render_page_for_preview( + path: string, + user_grammars?: JsUserGrammars, + capture_gz_json?: Uint8Array, + ): Promise<string>; + + /** Test-only: calls the user-grammar bridge directly. Phase 4.3 of syntax-highlighting. */ + export function quarto_highlight_with_user_for_test( + language_class: string, + source: string, + user: JsUserGrammars, + ): string | undefined; + export function get_builtin_template(name: string): string; + + // JavaScript execution test functions (interstitial validation) + export function test_js_available(): boolean; + export function test_js_simple_template(template: string, data_json: string): Promise<string>; + export function test_js_ejs(template: string, data_json: string): Promise<string>; + + // Project creation functions + export function get_project_choices(): string; + export function create_project(choice_id: string, title: string): Promise<string>; + + // LSP intelligence functions + export function lsp_analyze_document(path: string): string; + export function lsp_get_symbols(path: string): string; + export function lsp_get_folding_ranges(path: string): string; + export function lsp_get_diagnostics(path: string): string; + + // QMD parsing and AST conversion functions + export function parse_qmd_content(content: string): string; + export function ast_to_qmd(ast_json: string): string; + /** Incrementally write a modified AST back to QMD, preserving unchanged source text. */ + export function incremental_write_qmd(original_qmd: string, new_ast_json: string): string; + + // Response type for parse/write operations + export interface AstResponse { + success: boolean; + /** JSON-serialized Pandoc AST (on successful parse) */ + ast?: string; + /** QMD source text (on successful AST-to-QMD conversion) */ + qmd?: string; + error?: string; + diagnostics?: AstDiagnostic[]; + } + + export interface AstDiagnostic { + kind: string; + title: string; + code?: string; + problem?: string; + hints: string[]; + start_line?: number; + start_column?: number; + end_line?: number; + end_column?: number; + details: { kind: string; content: string; start_line?: number; start_column?: number; end_line?: number; end_column?: number }[]; + } + + // SASS compilation functions + export function sass_available(): boolean; + export function sass_compiler_name(): string | undefined; + export function compile_scss(scss: string, minified: boolean, load_paths_json: string): Promise<string>; + export function compile_scss_with_bootstrap(scss: string, minified: boolean): Promise<string>; + export function compile_theme_css_by_name(theme_name: string, minified: boolean): Promise<string>; + export function compile_default_bootstrap_css(minified: boolean): Promise<string>; + + // Response types for project creation (for documentation/reference) + export interface ProjectChoice { + id: string; + name: string; + description: string; + } + + export interface ProjectChoicesResponse { + success: boolean; + choices: ProjectChoice[]; + } + + export interface ProjectFile { + path: string; + content_type: 'text' | 'binary'; + content: string; + mime_type?: string; + } + + export interface CreateProjectResponse { + success: boolean; + error?: string; + files?: ProjectFile[]; + } + + // Template processing functions + /** Process a template file: extract template-name and produce stripped content. */ + export function prepare_template(content: string): string; + + /** Response type for prepare_template */ + export type PrepareTemplateResponse = + | { + success: true; + /** The template-name metadata value, or null if not present */ + template_name: string | null; + /** The template content with template-name removed from frontmatter */ + stripped_content: string; + } + | { + success: false; + error: string; + }; + + export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + + export default function __wbg_init( + module_or_path?: InitInput | Promise<InitInput> + ): Promise<void>; +} diff --git a/hub-client/src/services/wasmRenderer.test.ts b/ts-packages/preview-runtime/src/wasmRenderer.test.ts similarity index 100% rename from hub-client/src/services/wasmRenderer.test.ts rename to ts-packages/preview-runtime/src/wasmRenderer.test.ts diff --git a/hub-client/src/services/wasmRenderer.ts b/ts-packages/preview-runtime/src/wasmRenderer.ts similarity index 93% rename from hub-client/src/services/wasmRenderer.ts rename to ts-packages/preview-runtime/src/wasmRenderer.ts index b38e15613..fc94d9835 100644 --- a/hub-client/src/services/wasmRenderer.ts +++ b/ts-packages/preview-runtime/src/wasmRenderer.ts @@ -5,12 +5,14 @@ * VFS operations, QMD rendering, and SASS compilation. */ -import type { Diagnostic, RenderResponse } from '../types/diagnostic'; +/// <reference path="./vite-shims.d.ts" /> + +import type { Diagnostic, RenderResponse } from '@quarto/preview-renderer/types/diagnostic'; import type { RustQmdJson } from '@quarto/pandoc-types' import type { AstResponse } from 'wasm-quarto-hub-client' -import { discoverUserGrammars } from './userGrammarDiscovery'; -import { UserGrammarCache } from './userGrammarCache'; -import { loadUserGrammar } from './userGrammarHighlight'; +import { discoverUserGrammars } from './userGrammar/Discovery'; +import { UserGrammarCache } from './userGrammar/Cache'; +import { loadUserGrammar } from './userGrammar/Highlight'; // Response types from WASM module interface VfsResponse { @@ -21,7 +23,7 @@ interface VfsResponse { } // Re-export Diagnostic type for convenience -export type { Diagnostic } from '../types/diagnostic'; +export type { Diagnostic } from '@quarto/preview-renderer/types/diagnostic'; // Extended WASM module type with SASS compilation functions interface WasmModuleExtended { @@ -53,6 +55,15 @@ interface WasmModuleExtended { path: string, user_grammars?: unknown, ) => Promise<string>; + // Same as `render_page_in_project` but maps the default-when-absent + // `html` format to `q2-preview`, so a bare-markdown file rendered + // under `quarto preview` flows through the AST-iframe pipeline and + // returns `ast_json`. Explicit non-html formats pass through. + render_page_for_preview: ( + path: string, + user_grammars?: unknown, + capture_gz_json?: Uint8Array, + ) => Promise<string>; // Attribution-aware sibling. When `attribution_json` is `undefined`, // behaviour is byte-identical to `render_page_in_project` (the // former is a one-line wrapper that forwards `None`). When @@ -168,8 +179,15 @@ export async function initWasm(): Promise<void> { */ async function setupSassVfsCallbacks(): Promise<void> { try { - // Import the sass bridge module - const sassModule = await import('../wasm-js-bridge/sass.js'); + // Import the sass bridge module. The same module is loaded by the + // Rust WASM at init-time via `wasm-bindgen`'s `raw_module = "/src/ + // wasm-js-bridge/sass.js"` annotation; using the same Vite-root + // absolute path here lets both consumers (Rust + TS) land on the + // same module instance regardless of which workspace package this + // file is bundled into. The consumer (hub-client today, the q2 + // preview SPA later) is required to host these bridge files at + // `src/wasm-js-bridge/`. + const sassModule = await import('/src/wasm-js-bridge/sass.js'); // Create VFS read callback const readFn = (path: string): string | null => { @@ -456,6 +474,36 @@ export async function renderPageInProjectWithAttribution( ); } +/** + * Same as [`renderPageInProject`] but applies `quarto preview`'s + * default-format substitution: documents whose detected format is + * `html` (the default when no `format:` is set in the YAML) render + * through the q2-preview pipeline and return `ast_json` instead of + * `html`. Explicit non-html formats pass through unchanged. + * + * Use this from the q2-preview SPA. hub-client keeps using + * `renderPageInProject` so its own format dispatch is unchanged. + * + * Phase C.4 (bd-kw93.3): when `captureGzJson` is provided — gzipped + * JSON bytes of a server-recorded `EngineCapture` (the same wire + * format Phase C.1 writes to the capture binary doc) — the WASM + * substitutes a `ReplayEngine` for the captured engine name so code + * cell output appears in the rendered AST without a second engine + * invocation in the browser. Pass `undefined` (or omit) to render + * with the default registry (markdown engine; code cells render as + * source). + */ +export async function renderPageForPreview( + path: string, + userGrammars?: unknown, + captureGzJson?: Uint8Array, +): Promise<RenderResponse> { + const wasm = getWasm(); + return JSON.parse( + await wasm.render_page_for_preview(path, userGrammars, captureGzJson), + ); +} + /** * Render QMD content directly (without VFS). See [`renderQmd`] for * the `userGrammars` parameter's semantics. diff --git a/ts-packages/preview-runtime/tsconfig.json b/ts-packages/preview-runtime/tsconfig.json new file mode 100644 index 000000000..eb5224288 --- /dev/null +++ b/ts-packages/preview-runtime/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.integration.test.ts", + "src/test-utils/**" + ] +} diff --git a/ts-packages/preview-runtime/vitest.config.ts b/ts-packages/preview-runtime/vitest.config.ts new file mode 100644 index 000000000..d8d9d4d8f --- /dev/null +++ b/ts-packages/preview-runtime/vitest.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + conditions: ['source', 'import', 'module', 'browser', 'default'], + alias: { + // Mirror hub-client/vite.config.ts. The wasm-quarto-hub-client symlink + // lives in hub-client/ for now; preview-runtime points at it through + // the workspace. When the SPA is added in Phase 6 it does the same. + 'wasm-quarto-hub-client': path.resolve( + __dirname, + '../../hub-client/wasm-quarto-hub-client/wasm_quarto_hub_client.js', + ), + // Workspace-package aliases to source — mirrors the pattern in + // hub-client/vitest.config.ts. Vitest doesn't honor the `source` + // export condition on fresh clones, so workspace deps need + // explicit aliases when no `dist/` has been built. + '@quarto/quarto-automerge-schema': path.resolve( + __dirname, + '../quarto-automerge-schema/src/index.ts', + ), + '@quarto/quarto-sync-client': path.resolve( + __dirname, + '../quarto-sync-client/src/index.ts', + ), + '@quarto/preview-renderer': path.resolve( + __dirname, + '../preview-renderer/src', + ), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + exclude: ['src/**/*.integration.test.ts'], + passWithNoTests: true, + }, +}); diff --git a/ts-packages/preview-runtime/vitest.integration.config.ts b/ts-packages/preview-runtime/vitest.integration.config.ts new file mode 100644 index 000000000..a4ace14c6 --- /dev/null +++ b/ts-packages/preview-runtime/vitest.integration.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + conditions: ['source', 'import', 'module', 'browser', 'default'], + alias: { + 'wasm-quarto-hub-client': path.resolve( + __dirname, + '../../hub-client/wasm-quarto-hub-client/wasm_quarto_hub_client.js', + ), + '@quarto/quarto-automerge-schema': path.resolve( + __dirname, + '../quarto-automerge-schema/src/index.ts', + ), + '@quarto/quarto-sync-client': path.resolve( + __dirname, + '../quarto-sync-client/src/index.ts', + ), + '@quarto/preview-renderer': path.resolve( + __dirname, + '../preview-renderer/src', + ), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: ['src/**/*.integration.test.ts'], + setupFiles: ['./src/test-utils/setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts b/ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts index 10ea151ba..e9f0c1b1d 100644 --- a/ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts +++ b/ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts @@ -18,7 +18,9 @@ describe('migrateIndexDocument', () => { expect(doc.files).toEqual({ 'index.qmd': 'doc1' }); }); - it('is a no-op on a V1 doc', () => { + it('migrates a V1 doc forward to the current version', () => { + // V1 was the schema before the capture sidecar was introduced. + // Migration must bump it to current without dropping anything. const doc: IndexDocument = { files: { 'index.qmd': 'doc1' }, version: 1, @@ -26,8 +28,8 @@ describe('migrateIndexDocument', () => { }; const changed = migrateIndexDocument(doc); - expect(changed).toBe(false); - expect(doc.version).toBe(1); + expect(changed).toBe(true); + expect(doc.version).toBe(CURRENT_SCHEMA_VERSION); expect(doc.identities).toEqual({ actor1: { name: 'Alice', color: '#E91E63' } }); }); @@ -46,6 +48,88 @@ describe('migrateIndexDocument', () => { }); }); +describe('migrateIndexDocument — v2 (capture sidecar)', () => { + it('bumps a V0 doc straight to V2', () => { + const doc: IndexDocument = { files: { 'index.qmd': 'doc1' } }; + const changed = migrateIndexDocument(doc); + + expect(changed).toBe(true); + expect(doc.version).toBe(2); + expect(doc.version).toBe(CURRENT_SCHEMA_VERSION); + // captures is optional and absent until a capture is recorded + expect(doc.captures).toBeUndefined(); + }); + + it('migrates a V1 doc to V2 without touching files or identities', () => { + const doc: IndexDocument = { + files: { 'index.qmd': 'doc1' }, + version: 1, + identities: { actor1: { name: 'Alice', color: '#E91E63' } }, + }; + const changed = migrateIndexDocument(doc); + + expect(changed).toBe(true); + expect(doc.version).toBe(2); + expect(doc.files).toEqual({ 'index.qmd': 'doc1' }); + expect(doc.identities).toEqual({ actor1: { name: 'Alice', color: '#E91E63' } }); + expect(doc.captures).toBeUndefined(); + }); + + it('is a no-op on a V2 doc', () => { + const doc: IndexDocument = { + files: { 'index.qmd': 'doc1' }, + version: 2, + identities: {}, + captures: { + 'index.qmd': { + captureDocId: 'capture-doc-1', + state: 'idle', + }, + }, + }; + const changed = migrateIndexDocument(doc); + + expect(changed).toBe(false); + expect(doc.version).toBe(2); + expect(doc.captures).toEqual({ + 'index.qmd': { captureDocId: 'capture-doc-1', state: 'idle' }, + }); + }); + + it('preserves an existing captures sidecar through migration from V1', () => { + // V1 docs cannot legally have captures, but if a future-V2-written doc + // is mis-tagged as V1, migration must not drop the sidecar. + const doc: IndexDocument = { + files: { 'index.qmd': 'doc1' }, + version: 1, + captures: { 'index.qmd': { captureDocId: 'cap-1' } }, + }; + const changed = migrateIndexDocument(doc); + + expect(changed).toBe(true); + expect(doc.version).toBe(2); + expect(doc.captures).toEqual({ 'index.qmd': { captureDocId: 'cap-1' } }); + }); + + it('accepts a CaptureRef with all optional fields populated', () => { + // Type-level test: the shape compiles and roundtrips. + const doc: IndexDocument = { + files: { 'posts/p.qmd': 'doc-p' }, + version: 2, + captures: { + 'posts/p.qmd': { + captureDocId: 'cap-p', + staleness: true, + state: 'error', + lastError: 'engine timed out', + }, + }, + }; + expect(doc.captures!['posts/p.qmd'].lastError).toBe('engine timed out'); + expect(doc.captures!['posts/p.qmd'].state).toBe('error'); + }); +}); + describe('setIdentity', () => { it('adds a new identity', () => { const doc: IndexDocument = { files: {}, version: 1, identities: {} }; diff --git a/ts-packages/quarto-automerge-schema/src/index.ts b/ts-packages/quarto-automerge-schema/src/index.ts index 4db6fae04..b037dd6f8 100644 --- a/ts-packages/quarto-automerge-schema/src/index.ts +++ b/ts-packages/quarto-automerge-schema/src/index.ts @@ -11,7 +11,7 @@ // ============================================================================ /** Current schema version for IndexDocument. */ -export const CURRENT_SCHEMA_VERSION = 1; +export const CURRENT_SCHEMA_VERSION = 2; /** Current schema version for ProjectSetDocument. */ export const CURRENT_PROJECT_SET_SCHEMA_VERSION = 1; @@ -26,32 +26,74 @@ export interface ActorIdentity { color: string; // hex color from the palette, e.g. "#E91E63" } +/** + * Reference to a recorded engine capture for a file in the index. + * + * Stored in the `captures` sidecar map (V2+) keyed by the same path used + * in `files`. `captureDocId` points to a separate Automerge document + * holding the serialized `EngineCapture`. The other fields are surface + * state consumed by the q2 preview SPA. + * + * `state` is reserved as a string enum (rather than a boolean `executing`) + * so future error/idle states can grow without another schema bump. + */ +export interface CaptureRef { + captureDocId: string; + staleness?: boolean; + state?: 'idle' | 'running' | 'error'; + lastError?: string; +} + /** * Root document that maps file paths to Automerge document IDs. * This is the entry point for a Quarto project in Automerge. * * `version` and `identities` are optional because V0 documents * (created before schema versioning) will not have them. + * + * `captures` is a sidecar map (V2+) keyed by the same paths used in + * `files`. Entries are absent until an engine capture is recorded for + * the path; the q2 preview server writes them and the SPA reads them. */ export interface IndexDocument { files: Record<string, string>; // path -> docId mapping - version?: number; // schema version (1 = current) + version?: number; // schema version (2 = current) identities?: Record<string, ActorIdentity>; // actorId -> identity + captures?: Record<string, CaptureRef>; // path -> capture sidecar entry (V2+) } /** * Migrate an IndexDocument to the current schema version. * Must be called inside an Automerge `change()` callback. * + * Steps (idempotent): + * V0 → V1: initialize `identities`, set `version = 1`. + * V1 → V2: bump `version = 2`. No structural change — `captures` is + * absent until a capture is recorded. + * + * Any preexisting `captures` sidecar (e.g. on a doc mis-tagged as V1) + * is preserved through the bump. + * * @returns true if the document was modified (migration applied) */ export function migrateIndexDocument(doc: IndexDocument): boolean { - if (doc.version !== undefined) return false; - doc.version = CURRENT_SCHEMA_VERSION; - if (!doc.identities) { - doc.identities = {}; + if (doc.version === CURRENT_SCHEMA_VERSION) return false; + + let changed = false; + if (doc.version === undefined) { + // V0 → V1 + if (!doc.identities) { + doc.identities = {}; + } + doc.version = 1; + changed = true; } - return true; + if (doc.version === 1) { + // V1 → V2 (sidecar capture map; no structural change needed) + doc.version = 2; + changed = true; + } + return changed; } /** diff --git a/ts-packages/quarto-sync-client/src/client.test.ts b/ts-packages/quarto-sync-client/src/client.test.ts index 19bfc993f..3c5484e8c 100644 --- a/ts-packages/quarto-sync-client/src/client.test.ts +++ b/ts-packages/quarto-sync-client/src/client.test.ts @@ -93,6 +93,7 @@ function noopCallbacks(): SyncClientCallbacks { onFileRemoved: vi.fn(), onFilesChange: vi.fn(), onIdentitiesChange: vi.fn(), + onCapturesChange: vi.fn(), onConnectionChange: vi.fn(), onError: vi.fn(), }; @@ -203,3 +204,173 @@ describe('createSyncClient identity', () => { }); }); }); + +describe('createSyncClient captures (Phase C.3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('connect fires onCapturesChange with the empty sidecar when the index doc has no captures', async () => { + const indexDoc: IndexDocument = { files: {}, version: 2, identities: {} }; + const { handle } = createMockHandle(indexDoc); + + installMockRepo(handle, handle); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + + await client.connect('ws://localhost:9999', 'mock-doc-id', 'actor-1', 'Alice', '#FF0000'); + + expect(cbs.onCapturesChange).toHaveBeenCalledWith({}); + }); + + it('connect fires onCapturesChange with the populated sidecar when the index doc carries captures', async () => { + const indexDoc: IndexDocument = { + files: { 'index.qmd': 'doc1' }, + version: 2, + identities: {}, + captures: { + 'index.qmd': { captureDocId: 'cap-1', state: 'idle' }, + }, + }; + const { handle } = createMockHandle(indexDoc); + + installMockRepo(handle, handle); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + + await client.connect('ws://localhost:9999', 'mock-doc-id', 'actor-1', 'Alice', '#FF0000'); + + expect(cbs.onCapturesChange).toHaveBeenCalledWith({ + 'index.qmd': { captureDocId: 'cap-1', state: 'idle' }, + }); + }); +}); + +// ─── bd-4uvv: getBinaryDocById prefix normalization ────────────────── +// +// samod's TS `repo.find()` rejects bare docIds with +// `Error: Invalid AutomergeUrl: <id>`. The IndexDocument's capture +// sidecar (Phase C.3) stores bare docIds — same as `files` — so the +// binary-doc-by-id call path must prepend the `automerge:` scheme just +// like the text-doc loader (`loadFileDocuments`, lines 305-307 in +// client.ts). Without the prefix, every project-mode q2 preview render +// falls back to the default registry because the capture binary doc +// never arrives, and code cells render as inert source even when the +// server's eager capture driver wrote a perfectly valid capture. + +describe('createSyncClient.getBinaryDocById URL normalization (bd-4uvv)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * Spy-wrapper around `installMockRepo`. Captures the actual `find()` + * arguments so the test can assert the prefix-normalization rule + * without invoking samod's real URL parser. + */ + function installSpyRepo<T>( + binaryHandle: ReturnType<typeof createMockHandle<T>>['handle'], + ): { findCalls: unknown[] } { + const findCalls: unknown[] = []; + const mockNetworkSubsystem = { on: vi.fn(), off: vi.fn() }; + vi.mocked(Repo).mockImplementation(function (this: unknown) { + Object.assign(this as Record<string, unknown>, { + find: vi.fn((id: unknown) => { + findCalls.push(id); + return Promise.resolve(binaryHandle); + }), + import: vi.fn().mockReturnValue(binaryHandle), + create: vi.fn().mockReturnValue(binaryHandle), + networkSubsystem: mockNetworkSubsystem, + }); + return this as Repo; + } as unknown as typeof Repo); + return { findCalls }; + } + + it('prepends the automerge: scheme when callers pass a bare docId', async () => { + // Index doc handle just so `connect` succeeds. The real assertion + // is on the *second* find() call (the one inside getBinaryDocById). + const indexDoc: IndexDocument = { files: {}, version: 2, identities: {} }; + const { handle: indexHandle } = createMockHandle(indexDoc); + // Binary doc handle returned for the capture lookup. Shape mirrors + // what `quarto_hub::resource::create_binary_document` writes. + const binaryContent = new Uint8Array([1, 2, 3]); + const { handle: binaryHandle } = createMockHandle({ + content: binaryContent, + mimeType: 'application/x-engine-capture+gzip', + }); + + // First `find` call resolves the index doc; subsequent calls land + // on `binaryHandle`. The single-handle installSpyRepo collapses + // both; for this test we only care about the bare-id round trip. + const { findCalls } = installSpyRepo(binaryHandle); + // Override: the very first find (during `connect`) needs the + // index-doc handle, not the binary handle. + vi.mocked(Repo).mockImplementationOnce(function (this: unknown) { + Object.assign(this as Record<string, unknown>, { + find: vi.fn((id: unknown) => { + findCalls.push(id); + // Return indexHandle for the connect path, binaryHandle for + // subsequent calls. Driven by call order, not id-shape. + return Promise.resolve(findCalls.length === 1 ? indexHandle : binaryHandle); + }), + import: vi.fn().mockReturnValue(indexHandle), + create: vi.fn().mockReturnValue(indexHandle), + networkSubsystem: { on: vi.fn(), off: vi.fn() }, + }); + return this as Repo; + } as unknown as typeof Repo); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + await client.connect('ws://localhost:9999', 'mock-index-id', 'actor-1', 'Alice', '#FF0000'); + + const result = await client.getBinaryDocById('bare-capture-id'); + + // Second find() call (after connect's index-doc lookup) is the + // bd-4uvv assertion: bare id must be prefixed. + expect(findCalls.length).toBeGreaterThanOrEqual(2); + expect(findCalls[findCalls.length - 1]).toBe('automerge:bare-capture-id'); + // And the returned bytes must round-trip back to the caller. + // `toStrictEqual` because the mock handle stores a structuredClone, + // breaking the original Uint8Array's object identity. + expect(result).not.toBeNull(); + expect(result?.content).toStrictEqual(binaryContent); + expect(result?.mimeType).toBe('application/x-engine-capture+gzip'); + }); + + it('does not double-prefix when the docId already has the scheme', async () => { + const indexDoc: IndexDocument = { files: {}, version: 2, identities: {} }; + const { handle: indexHandle } = createMockHandle(indexDoc); + const { handle: binaryHandle } = createMockHandle({ + content: new Uint8Array([0]), + mimeType: 'application/x-engine-capture+gzip', + }); + + const findCalls: unknown[] = []; + vi.mocked(Repo).mockImplementation(function (this: unknown) { + Object.assign(this as Record<string, unknown>, { + find: vi.fn((id: unknown) => { + findCalls.push(id); + return Promise.resolve(findCalls.length === 1 ? indexHandle : binaryHandle); + }), + import: vi.fn().mockReturnValue(indexHandle), + create: vi.fn().mockReturnValue(indexHandle), + networkSubsystem: { on: vi.fn(), off: vi.fn() }, + }); + return this as Repo; + } as unknown as typeof Repo); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + await client.connect('ws://localhost:9999', 'mock-index-id', 'actor-1', 'Alice', '#FF0000'); + + await client.getBinaryDocById('automerge:already-prefixed'); + + // Idempotent: no `automerge:automerge:...` double-prefix. + expect(findCalls[findCalls.length - 1]).toBe('automerge:already-prefixed'); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 77aa2cd3e..d8f2865a2 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -18,8 +18,10 @@ import type { BinaryDocumentContent, FileEntry, ActorIdentity, + CaptureRef, } from '@quarto/quarto-automerge-schema'; import { + CURRENT_SCHEMA_VERSION, isTextDocument, isBinaryDocument, getDocumentType, @@ -128,9 +130,17 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return doc.identities ? { ...doc.identities } : {}; } + // Helper: get captures sidecar from index document (V2+; absent on V1) + function getCapturesFromIndex(doc: IndexDocument): Record<string, CaptureRef> { + return doc.captures ? { ...doc.captures } : {}; + } + // Track last-seen identities for diffing let lastIdentities: Record<string, ActorIdentity> = {}; + // Track last-seen captures for diffing + let lastCaptures: Record<string, CaptureRef> = {}; + // Helper: fire onIdentitiesChange if identities differ from last seen function notifyIdentitiesIfChanged(doc: IndexDocument): void { const current = getIdentitiesFromIndex(doc); @@ -140,6 +150,15 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS } } + // Helper: fire onCapturesChange if the sidecar differs from last seen + function notifyCapturesIfChanged(doc: IndexDocument): void { + const current = getCapturesFromIndex(doc); + if (JSON.stringify(current) !== JSON.stringify(lastCaptures)) { + lastCaptures = current; + callbacks.onCapturesChange?.(current); + } + } + // Helper: wait for peer connection function waitForPeer(repo: Repo, timeoutMs: number = 30000): Promise<void> { return new Promise((resolve, reject) => { @@ -327,8 +346,17 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS * * Supports offline mode: if the peer connection fails or times out, * the function will continue and load documents from local IndexedDB. + * + * `peerTimeoutMs` controls how long to wait for the samod handshake's + * `peer` event before falling through to offline mode. The default of + * 1 ms preserves hub-client's "probe-then-use-cached-IndexedDB" + * behavior. Callers without an IndexedDB cache (e.g. the q2-preview + * SPA, which runs against ephemeral hubs) should pass a longer + * value so `findDoc()` runs after at least one peer is known — + * otherwise automerge-repo's synchronizer marks the doc unavailable + * because `#peers` is empty when `handle.request()` fires. */ - async function connect(syncServerUrl: string, indexDocId: string, actorId?: string, screenName?: string, color?: string): Promise<FileEntry[]> { + async function connect(syncServerUrl: string, indexDocId: string, actorId?: string, screenName?: string, color?: string, peerTimeoutMs: number = 1): Promise<FileEntry[]> { // Disconnect from any existing connection await disconnect(); @@ -344,7 +372,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS let isOnline = false; try { console.log('Waiting for peer connection...'); - await waitForPeer(state.repo, 1); // Quick check - auto-reconnects in background + await waitForPeer(state.repo, peerTimeoutMs); console.log('Peer connected - online mode'); isOnline = true; } catch (peerError) { @@ -381,6 +409,10 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS lastIdentities = getIdentitiesFromIndex(currentDoc); callbacks.onIdentitiesChange?.(lastIdentities); + // Fire initial captures (may be empty on V1 docs or freshly-created projects) + lastCaptures = getCapturesFromIndex(currentDoc); + callbacks.onCapturesChange?.(lastCaptures); + // Subscribe to index changes const indexChangeHandler = () => { const changedDoc = indexHandle.doc(); @@ -389,6 +421,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS syncWithFiles(newFiles); callbacks.onFilesChange?.(newFiles); notifyIdentitiesIfChanged(changedDoc); + notifyCapturesIfChanged(changedDoc); } }; indexHandle.on('change', indexChangeHandler); @@ -457,6 +490,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS state.indexHandle = null; state.actorId = null; lastIdentities = {}; + lastCaptures = {}; callbacks.onConnectionChange?.(false); } @@ -494,6 +528,46 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return { content: doc.content, mimeType: doc.mimeType }; } + /** + * Fetch a binary document by its samod document ID, regardless of + * whether it's tracked in the project's `files` index. + * + * Phase C.4 (bd-kw93.3) uses this to resolve capture binary docs + * referenced from the IndexDocument's V2 capture sidecar: those + * docs aren't files (no path), so the path-keyed APIs above don't + * see them. Returns `null` if the document doesn't exist or isn't a + * binary document. + */ + async function getBinaryDocById( + docId: string, + ): Promise<{ content: Uint8Array; mimeType: string } | null> { + if (!state.repo) return null; + // bd-4uvv: samod's TS `repo.find()` requires the `automerge:<id>` + // URL scheme; the bare docId we get from the IndexDocument capture + // sidecar throws `Invalid AutomergeUrl`. The text-doc loader + // (`loadFileDocuments`) normalizes the same way — keep both call + // sites consistent. + // + // `String(docId)` coerces the value before `.startsWith` because + // automerge's read proxy can return string-valued fields as + // `RawString` (no `.startsWith` method) depending on how the doc + // was constructed. `loadFileDocuments` reads from the same shape; + // bare-id callers that synthesize an `automerge:` URL must + // coerce first. + const docIdStr = String(docId); + const normalized = docIdStr.startsWith('automerge:') + ? docIdStr + : `automerge:${docIdStr}`; + try { + const handle = await findDoc<BinaryDocumentContent>(normalized as DocumentId); + const doc = handle.doc(); + if (!doc || !isBinaryDocument(doc)) return null; + return { content: doc.content, mimeType: doc.mimeType }; + } catch { + return null; + } + } + /** * Update text file content using incremental updates. */ @@ -752,7 +826,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS // pre-generated ID so the first change uses the correct actor. console.log(`[createNewProject] Creating index document with ID ${indexDocId}`); const indexHandle = createDoc<IndexDocument>( - { files: {}, version: 1, identities: {} }, + { files: {}, version: CURRENT_SCHEMA_VERSION, identities: {} }, indexDocId, ); state.indexHandle = indexHandle; @@ -769,6 +843,10 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS lastIdentities = getIdentitiesFromIndex(indexHandle.doc()!); callbacks.onIdentitiesChange?.(lastIdentities); + // Fire initial captures (always empty for a fresh project) + lastCaptures = getCapturesFromIndex(indexHandle.doc()!); + callbacks.onCapturesChange?.(lastCaptures); + // Phase 3: Create file documents (now using the correct actor). const createdFiles: FileEntry[] = []; @@ -907,6 +985,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS isFileBinary, getFileContent, getBinaryFileContent, + getBinaryDocById, updateFileContent, applyEditorOperations, updateFileAst, diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index d09e1211d..d8fbbc7c6 100644 --- a/ts-packages/quarto-sync-client/src/index.ts +++ b/ts-packages/quarto-sync-client/src/index.ts @@ -35,6 +35,7 @@ export type { FilePayload, SyncClientCallbacks, ASTOptions, + CaptureRef, CreateBinaryFileResult, CreateProjectOptions, CreateProjectResult, diff --git a/ts-packages/quarto-sync-client/src/types.ts b/ts-packages/quarto-sync-client/src/types.ts index b6f1265fa..7e5cab7fd 100644 --- a/ts-packages/quarto-sync-client/src/types.ts +++ b/ts-packages/quarto-sync-client/src/types.ts @@ -3,7 +3,10 @@ */ import type { Patch } from '@automerge/automerge-repo'; -import type { FileEntry, ActorIdentity } from '@quarto/quarto-automerge-schema'; +import type { FileEntry, ActorIdentity, CaptureRef } from '@quarto/quarto-automerge-schema'; + +// Re-export so consumers don't need a second import for the capture sidecar shape +export type { CaptureRef }; // Re-export Patch for consumers export type { Patch }; @@ -98,6 +101,17 @@ export interface SyncClientCallbacks { */ onIdentitiesChange?: (identities: Record<string, ActorIdentity>) => void; + /** + * Called when the capture sidecar map changes (optional). + * Provides the full path -> CaptureRef mapping from the IndexDocument. + * Fires on initial sync and on every index doc change where the + * sidecar differs (by JSON-equality) from the last-fired snapshot. + * + * Wired in Phase C.3; consumed by Phase C.4 (browser-side replay) to + * pick up captures the server has eagerly recorded. + */ + onCapturesChange?: (captures: Record<string, CaptureRef>) => void; + /** * Called when connection state changes (optional). */ diff --git a/ts-packages/wasm-js-bridge/package.json b/ts-packages/wasm-js-bridge/package.json new file mode 100644 index 000000000..d9a024698 --- /dev/null +++ b/ts-packages/wasm-js-bridge/package.json @@ -0,0 +1,22 @@ +{ + "name": "@quarto/wasm-js-bridge", + "version": "0.0.1", + "private": true, + "description": "JS glue loaded by wasm-quarto-hub-client via wasm-bindgen `raw_module`. Hosts each consumer's `src/wasm-js-bridge/*` path (sass, cache, fetch, template). One copy in the workspace; consumers alias `/src/wasm-js-bridge` to this package's src dir.", + "license": "MIT", + "author": { + "name": "Posit PBC" + }, + "type": "module", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "ejs": "^3.1.10", + "sass": "^1.77.0" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vitest": "^4.0.17" + } +} diff --git a/hub-client/src/wasm-js-bridge/cache.d.ts b/ts-packages/wasm-js-bridge/src/cache.d.ts similarity index 100% rename from hub-client/src/wasm-js-bridge/cache.d.ts rename to ts-packages/wasm-js-bridge/src/cache.d.ts diff --git a/hub-client/src/wasm-js-bridge/cache.js b/ts-packages/wasm-js-bridge/src/cache.js similarity index 100% rename from hub-client/src/wasm-js-bridge/cache.js rename to ts-packages/wasm-js-bridge/src/cache.js diff --git a/hub-client/src/wasm-js-bridge/cache.test.ts b/ts-packages/wasm-js-bridge/src/cache.test.ts similarity index 100% rename from hub-client/src/wasm-js-bridge/cache.test.ts rename to ts-packages/wasm-js-bridge/src/cache.test.ts diff --git a/hub-client/src/wasm-js-bridge/fetch.js b/ts-packages/wasm-js-bridge/src/fetch.js similarity index 100% rename from hub-client/src/wasm-js-bridge/fetch.js rename to ts-packages/wasm-js-bridge/src/fetch.js diff --git a/hub-client/src/wasm-js-bridge/sass.d.ts b/ts-packages/wasm-js-bridge/src/sass.d.ts similarity index 100% rename from hub-client/src/wasm-js-bridge/sass.d.ts rename to ts-packages/wasm-js-bridge/src/sass.d.ts diff --git a/hub-client/src/wasm-js-bridge/sass.js b/ts-packages/wasm-js-bridge/src/sass.js similarity index 100% rename from hub-client/src/wasm-js-bridge/sass.js rename to ts-packages/wasm-js-bridge/src/sass.js diff --git a/ts-packages/wasm-js-bridge/src/sass.test.ts b/ts-packages/wasm-js-bridge/src/sass.test.ts new file mode 100644 index 000000000..521b7a071 --- /dev/null +++ b/ts-packages/wasm-js-bridge/src/sass.test.ts @@ -0,0 +1,29 @@ +/** + * Public-API guard for the SASS bridge. + * + * The Rust WASM module loads `sass.js` via `wasm-bindgen`'s + * `raw_module = "/src/wasm-js-bridge/sass.js"`, and `wasmRenderer.ts` + * (in `@quarto/preview-runtime`) dynamically imports it through the + * same Vite-root path. Both depend on these specific function names + * — renaming any of them silently breaks the Rust ↔ JS bridge at + * runtime, which the type system can't catch (the WASM module is + * generated, not consumer code). + * + * This test pins the names so a rename has to land here too. + */ + +import { describe, it, expect } from 'vitest'; +// @ts-expect-error sass.js is a JS file with a .d.ts companion — TS +// resolution under bundler mode picks up the .d.ts cleanly, but the +// resolver doesn't always like the bare `./sass` form. Use the full +// .js path which works at runtime AND points at the d.ts at compile. +import * as sass from './sass.js'; + +describe('@quarto/wasm-js-bridge sass surface', () => { + it('exports the four functions the Rust WASM module + wasmRenderer.ts expect', () => { + expect(typeof sass.setVfsCallbacks).toBe('function'); + expect(typeof sass.jsSassAvailable).toBe('function'); + expect(typeof sass.jsSassCompilerName).toBe('function'); + expect(typeof sass.jsCompileSass).toBe('function'); + }); +}); diff --git a/hub-client/src/wasm-js-bridge/template.js b/ts-packages/wasm-js-bridge/src/template.js similarity index 100% rename from hub-client/src/wasm-js-bridge/template.js rename to ts-packages/wasm-js-bridge/src/template.js