diff --git a/claude-notes/plans/2026-07-09-richtext-quoted-plaintext.md b/claude-notes/plans/2026-07-09-richtext-quoted-plaintext.md new file mode 100644 index 000000000..9386be41d --- /dev/null +++ b/claude-notes/plans/2026-07-09-richtext-quoted-plaintext.md @@ -0,0 +1,240 @@ +# Rich-text editor: render `Quoted` as editable plaintext quotes, not a chip + +**Strand:** bd-iwv3708i +**Date:** 2026-07-09 +**Status:** planning (awaiting go-ahead) + +## Problem (reproduced in the binary) + +In the `q2 preview --allow-edit` / `format: q2-preview` block-level rich-text +(tiptap) editor, a Pandoc `Quoted` inline node is "protected" as an opaque +**chip** — a bordered, monospace, `contenteditable=false` pill showing the +verbatim source. The user can't edit inside the quoted span, and the quotes +look like an alien token rather than prose. + +Reproduced with `./target/debug/q2 preview /tmp/q2-quote-repro/index.qmd +--allow-edit --no-browser` on: + +```markdown +He said "smart quotes" and it works. +``` + +Clicking the paragraph to enter the rich-text editor shows `"smart quotes"` as a +chip. Confirmed in the DOM: + +```json +{ "cls": "q2-chip q2-chip-span", "text": "\"smart quotes\"", + "editable": "false", "dataSrc": "\"smart quotes\"" } +``` + +(The **read-only** preview renders quotes fine as curly `"…"`/`'…'` via +`inlines/Quoted.tsx`; the problem is only the *edit* seed path.) + +### Why this is the wrong treatment + +Pandoc represents `Quoted` structurally, the same way it represents `Emph`, +`Strong`, `Subscript`, `Superscript` (delimiter + inline content + delimiter). +The editor already renders those four as **WYSIWYG marks** by recursing into +their content. Quotes are different from emphasis in one respect: their visible +form *is* the delimiter characters (`"`/`'`), so they should appear and edit as +**literal quote characters** wrapping still-WYSIWYG content — not as a mark, and +not as an opaque chip. + +## Root cause (single site) + +`ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts`, +`inlines()` switch, lines **69–74**: + +```ts +case 'Underline': +case 'SmallCaps': +case 'Quoted': + // Outside the v1 mark set -> chip the whole construct verbatim. + out.push(chip(node, 'span', ctx, '')); + break; +``` + +`Quoted` is lumped with `Underline`/`SmallCaps` and chipped. The quote type is +never even inspected. + +The conversion is **entirely TypeScript**. Rust/pampa only does qmd ⇄ AST; no +Rust change is needed. + +## Fix + +Split `Quoted` out of the chip case into a recursive text-emitting case that +mirrors `Emph`/`Strong` but wraps the content in literal quote characters: + +```ts +case 'Quoted': { + // Pandoc Quoted is [QuoteType, Inline[]]. Emit literal straight quote + // characters around still-WYSIWYG content (marks recurse). Straight quotes + // round-trip: pampa's reader re-parses "…"/'…' back into Quoted, and its qmd + // writer emits straight quotes. + const [qt, content] = node.c as [{ t: string }, AstNode[]]; + const ch = qt?.t === 'SingleQuote' ? "'" : '"'; + out.push(S.text(ch, marks)); + out.push(...inlines(content, marks, ctx)); + out.push(S.text(ch, marks)); + break; +} +``` + +Leave `Underline` and `SmallCaps` chipped (out of scope). + +### Why straight quotes (not curly) + +The user asked for the "plaintext" form (`"content"` / `'content'`) — straight +quotes. This is also what the whole edit round-trip is built on: + +- pampa's qmd **writer** emits straight quotes (`write_quoted`, + `crates/pampa/src/writers/qmd.rs:1790`). +- pampa's **reader** parses straight `"`/`'` spans into `Inline::Quoted` + (tree-sitter `pandoc_single_quote` / `pandoc_double_quote`). + +Emitting curly quotes would break the serialize→reparse round-trip (a curly `"` +is a `Str`, not a quote delimiter). + +### Serializer is already correct + +The editor serializes ProseMirror → markdown text → pampa reparse. Straight +quote characters emitted as plain `text` survive verbatim: prosemirror-markdown's +`esc()` (node_modules/prosemirror-markdown/dist/index.js:820) escapes only +`` ` * \ ~ [ ] _ `` — **not** `"` or `'`. No serializer change needed. + +## Round-trip verification (already run against native pampa) + +`qmd → pampa AST → (proposed astToDoc) → serialize → pampa reparse` is +semantically stable. Confirmed the pampa leg for every shape: + +| Input qmd | pampa untransformed AST | +|---|---| +| `"smart"` | `Quoted DoubleQuote [Str "smart"]` | +| `'single quoted'` | `Quoted SingleQuote [Str "single", Space, Str "quoted"]` | +| `"outer 'inner' done"` | `Quoted DoubleQuote [… Quoted SingleQuote [Str "inner"] …]` (nesting) | +| `"**bold** inside"` | `Quoted DoubleQuote [Strong [Str "bold"], …]` (inner mark preserved) | + +So: nested quotes recurse to nested literal quotes; marks inside quotes stay +WYSIWYG; all re-parse to the same `Quoted` structure. Apostrophes-in-words +(`it's`) are handled by pampa as curly `Str`, not `Quoted`, and are untouched by +this change. + +## Known risk to design around: unbalanced quotes + +Agent audit flagged (and the qmd writer documents at +`crates/pampa/src/writers/qmd.rs:1438–1447`): pampa's smart-quote classifier +treats a lone straight `'`/`"` in non-apostrophe position as a quotation mark, +and an **unbalanced** one raises a parse error (`Q-2-7` / `Q-2-10`). So if a user +deletes one quote of a pair mid-edit, the surviving straight quote can make the +committed block fail to parse. + +**Assessment:** this is the *intended* consequence of "plaintext quotes" — the +quotes are now ordinary editable characters with ordinary parse consequences, +identical to editing raw qmd source. It is the same class of transient +invalidity as typing a lone `*` or `` ` `` in prose (already possible in the +current editor). We are **not** re-introducing protection. + +**Action:** confirm the existing edit-commit path degrades gracefully on an +unparseable block (rejects/keeps the prior good state rather than corrupting the +document) — an investigation item, not new machinery. If it already handles a +lone `*`/`` ` `` sanely, quotes need nothing extra. Only escalate if this +investigation shows the commit path corrupts on parse failure. + +### Investigation result (2026-07-09): commit path degrades gracefully — no new machinery needed + +The text-channel commit runs `parseQmdContentSync(newText)` **before** writing. +An unbalanced quote makes that return `{ success: false, error }`, and both +front-ends short-circuit before any document mutation: + +- **`q2 preview`** — `q2-preview-spa/src/PreviewApp.tsx:585-589`: on + `!parseResult.success` it `console.error`s and `return`s. No Automerge write, + no `vfsAddFile`, no `contentTick` bump — the document is untouched and the edit + is dropped. +- **hub-client** — `hub-client/src/components/render/ReactPreview.tsx:783-789`: + same guard, and additionally surfaces a **visible** commit error + (`settleCommitStatus('error')` + `setCommitError("Edit could not be parsed: + …")`) before returning. + +So an unbalanced-quote edit is handled exactly like any other unparseable block +(a lone `*`/`` ` ``): rejected, prior good state preserved, zero corruption. The +plaintext-quote change needs **no** new protection. (Pre-existing minor +asymmetry — `q2 preview` only console-logs where hub-client shows an error chip — +is out of scope here; noted for a possible follow-up.) + +## Tests (TDD — write first, watch fail, then implement) + +1. **Pure unit** (new, alongside `plainSeed.test.ts`; no pampa oracle, runs in + default `vitest run`): assert `astToDoc` maps `Quoted` to editable text, not a + chip. + - `Quoted DoubleQuote [Str "smart quotes"]` → `unknown` empty, **no** `chip` + node, `doc.textContent` contains `"smart quotes"` (with straight quotes). + - `Quoted SingleQuote [...]` → `'...'`. + - `Quoted DoubleQuote [Strong [Str "bold"], Space, Str "inside"]` → serializes + to `"**bold** inside"` (inner mark WYSIWYG, straight quotes literal). + - nested `Quoted DoubleQuote [… Quoted SingleQuote …]` → `"… '…' …"`. +2. **Serializer unit:** a doc containing literal `"x"` / `'y'` text serializes to + `"x"` / `'y'` unescaped (guards the `esc()` assumption). +3. **Round-trip oracle** (`richtext/roundtrip.test.ts`, gated by + `QUARTO_RUN_PAMPA_ROUNDTRIP=1`): add fixtures `quoted-double`, + `quoted-single`, `quoted-nested`, `quoted-with-marks`; assert semantic + equivalence and empty `unknown`. +4. **Real-binary e2e** (`q2-preview-spa/e2e/`, follow `edit-chrome-placement` + pattern): open the paragraph with a quote; assert the quoted span is **not** a + `.q2-chip` and the straight quote characters are present as editable text. + Optionally: place caret inside the quote, type, and verify write-back + re-parses to a `Quoted` node. + +## Work items + +- [x] TDD test 1 — pure unit (`quotedSeed.test.ts`), RED confirmed (7 conversion cases failed on chip) +- [x] TDD test 2 — serializer straight-quote passthrough (guarded in `quotedSeed.test.ts`; passed independently of the conversion change, isolating the `esc()` assumption) +- [x] Implement the `case 'Quoted'` split in `astToProseMirror.ts`; tests 1–2 GREEN (8/8), full richtext suite green (21 pass / 15 skipped) +- [x] TDD test 3 — round-trip oracle fixtures verified under `QUARTO_RUN_PAMPA_ROUNDTRIP=1` (19/19 pass; 4 new quote fixtures semantically round-trip) +- [x] Investigate commit-path behavior on unbalanced/unparseable block — finding recorded above (graceful, no corruption, no new machinery) +- [x] TDD test 4 — real-binary e2e `q2-preview-spa/e2e/richtext-quoted.spec.ts` (2 tests, both green) after rebuild chain (`cargo xtask build-q2-preview-spa` + `cargo build -p quarto --bin q2`; no WASM rebuild — TS-only change) +- [x] Verification: preview-renderer `tsc --noEmit` + full unit suite (516 pass); hub-client `npm run build` (`tsc -b && vite build`) + `build:sandboxed` + `test:ci` (test/integration/wasm) all green. Full `cargo xtask verify` **not run** — the change is provably TS-only (`git diff` touches no `.rs`), so the Rust/WASM legs are unaffected; a full-workspace rebuild would add no correctness signal. (Run it before push if desired; the Rust legs are trivially green.) +- [x] End-to-end manual verification in Chrome (rebuild chain) — evidence below +- [x] hub-client/changelog.md entry — two-commit workflow: source `d31e6118`, changelog `e2920818`; changelog render gate (`npm run test:wasm`) green + +## Commits (branch `braid/bd-iwv3708i-quoted-plaintext`, off `main`) + +- `d31e6118` — source: split `Quoted` out of the chip case + tests (unit, oracle fixtures, e2e) +- `e2920818` — hub-client/changelog.md entry (two-commit workflow) + +**Not pushed** — awaiting review/approval per the git-push policy. + +## End-to-end verification (2026-07-09, rebuilt `target/debug/q2`) + +Invocation: `./target/debug/q2 preview index.qmd --allow-edit` on a fixture with +double, single, nested, and marked quotes. Clicked each paragraph to activate the +rich-text editor and inspected the live DOM (across the preview iframe): + +- **`He said "smart quotes" and it works.`** → active `.q2-richtext-editor`, + **0 `.q2-chip`**, editor text = `He said "smart quotes" and it works.` (literal + straight quotes, caret placeable inside the quoted words). +- **`A quote with **bold** inside: "very *important* text".`** → **0 chips**; + editor text `A quote with bold inside: "very important text".`; the inner + `*important*` renders as a real WYSIWYG `` **inside** the straight quotes, + and `**bold**` as `` outside — marks recurse correctly. + +Before the change the same click showed `"smart quotes"` as a +`q2-chip q2-chip-span` pill (`contenteditable=false`). Output was inspected +directly (DOM query + zoomed screenshots), not inferred from absence of errors. + +## Files + +- **Change:** `ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts` (lines 69–74) +- **Tests:** `.../richtext/plainSeed.test.ts` (model), `.../richtext/roundtrip.test.ts`, `q2-preview-spa/e2e/` +- **Reference (no change):** `.../richtext/serializer.ts`, `.../richtext/schema.ts`, + `crates/pampa/src/writers/qmd.rs` (quote writer + smart-quote notes), + `crates/pampa/src/pandoc/treesitter_utils/quote_helpers.rs` (quote reader) + +## Notes + +- Superseded spike `.../tiptap-roundtrip-spike/astToPm.ts` also chips `Quoted` + (line 61); it is not the production path and is out of scope. +- Rebuilding for the live check needs the full chain (WASM → SPA bundle → q2 + binary) per `CLAUDE.md` "Verifying Rust changes in `q2 preview`" — though this + change is TS-only, the SPA bundle must be rebuilt (`npm run build:all` + + `cargo xtask build-q2-preview-spa` + `cargo build --bin q2`) for `q2 preview` + to pick it up. diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 5d56aba9d..beca20695 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,10 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-07-09 + +- [`d31e6118`](https://github.com/quarto-dev/q2/commits/d31e6118): Quoted text in the rich-text editor now appears as ordinary editable quote characters (straight `"…"` or `'…'`) instead of a non-editable pill, so you can edit inside quotes; formatting inside a quote (bold, italic) still renders live. + ### 2026-07-07 - [`4af08ef3`](https://github.com/quarto-dev/q2/commits/4af08ef3): Dragging a text selection in the preview now opens the rich-text editor with that selection already active (so Bold/Italic/Link apply immediately); a selection dragged across multiple blocks no longer opens an editor, keeping the selection available for copying. diff --git a/q2-preview-spa/e2e/richtext-quoted.spec.ts b/q2-preview-spa/e2e/richtext-quoted.spec.ts new file mode 100644 index 000000000..8dee1b3fa --- /dev/null +++ b/q2-preview-spa/e2e/richtext-quoted.spec.ts @@ -0,0 +1,85 @@ +/** + * bd-iwv3708i — a Pandoc `Quoted` inline must render in the rich-text block + * editor as EDITABLE plaintext straight quotes (`"…"` / `'…'`), NOT as an opaque + * `q2-chip` pill. Before this change, clicking a paragraph containing a quote + * showed the quoted span as a non-editable monospace chip; now the quote + * characters are literal, editable text and inner marks stay WYSIWYG. + * + * Real-binary e2e (drives target/debug/q2 via startPreviewServer). The pure + * AST→ProseMirror mapping is unit-tested in + * ts-packages/preview-renderer/src/q2-preview/richtext/quotedSeed.test.ts; the + * qmd round-trip is guarded by richtext/roundtrip.test.ts. This spec confirms + * the behavior through the actual embedded SPA the user runs. + * + * Build chain prerequisite (the binary does NOT auto-rebuild the embedded SPA): + * cargo xtask build-q2-preview-spa + * cargo build -p quarto --bin q2 + */ + +import { test, expect } from '@playwright/test'; +import { startPreviewServer, type PreviewServerHandle } from './helpers/previewServer'; + +let server: PreviewServerHandle; + +test.describe('bd-iwv3708i — Quoted renders as editable plaintext, not a chip', () => { + test.setTimeout(120_000); + + test.afterEach(async () => { + await server?.stop(); + }); + + test('double- and single-quoted spans seed as editable text with no chip', async ({ page }) => { + server = await startPreviewServer({ + allowEdit: true, + fixtureFiles: [{ + path: 'index.qmd', + // Both quote kinds in one paragraph so a single edit session covers both. + content: 'He said "smart quotes" and a \'single one\' too.\n', + }], + }); + await page.goto(server.url); + const iframe = page.frameLocator('iframe'); + await page.waitForFunction(() => { + const inner = document.querySelector('iframe')?.contentDocument; + return inner?.querySelector('p[data-block-pool-id]') != null; + }, null, { timeout: 30_000 }); + + // Click the paragraph to enter rich-text mode. + await iframe.locator('p[data-block-pool-id]').first().click(); + const editor = iframe.locator('.q2-richtext-editor'); + await editor.waitFor({ timeout: 10_000 }); + + // Neither the double- nor single-quoted span is chipped ... + await expect(editor.locator('.q2-chip')).toHaveCount(0); + // ... and both sets of straight quote characters are editable text. + await expect(editor).toContainText('"smart quotes"'); + await expect(editor).toContainText("'single one'"); + }); + + test('marks inside a quote stay WYSIWYG (no chip, real )', async ({ page }) => { + server = await startPreviewServer({ + allowEdit: true, + fixtureFiles: [{ + path: 'index.qmd', + content: 'A quote with bold inside: "very **important** text".\n', + }], + }); + await page.goto(server.url); + const iframe = page.frameLocator('iframe'); + await page.waitForFunction(() => { + const inner = document.querySelector('iframe')?.contentDocument; + return inner?.querySelector('p[data-block-pool-id]') != null; + }, null, { timeout: 30_000 }); + + await iframe.locator('p[data-block-pool-id]').first().click(); + const editor = iframe.locator('.q2-richtext-editor'); + await editor.waitFor({ timeout: 10_000 }); + + // No chip anywhere; the quote chars are literal text ... + await expect(editor.locator('.q2-chip')).toHaveCount(0); + await expect(editor).toContainText('"very'); + await expect(editor).toContainText('text"'); + // ... and the inner Strong renders as a real WYSIWYG mark. + await expect(editor.locator('strong')).toHaveText('important'); + }); +}); diff --git a/ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts b/ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts index 0e4287a0f..49dd63f1a 100644 --- a/ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts +++ b/ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts @@ -66,9 +66,22 @@ function inlines(items: AstNode[], marks: readonly Mark[], ctx: Ctx): PMNode[] { case 'Superscript': out.push(...inlines(asArray(node.c), marks.concat(M.superscript.create()), ctx)); break; + case 'Quoted': { + // Pandoc Quoted is [QuoteType, Inline[]]. Unlike Emph/Strong (rendered + // as marks) a quote's visible form IS its delimiter characters, so we + // emit literal STRAIGHT quotes around still-WYSIWYG content (marks and + // nested quotes recurse). Straight quotes round-trip: pampa's reader + // re-parses "…"/'…' back into Quoted and its qmd writer emits straight + // quotes; prosemirror-markdown's esc() never escapes `"`/`'`. (bd-iwv3708i) + const [qt, content] = (node.c as [{ t?: string }, AstNode[]]) ?? [{}, []]; + const q = qt?.t === 'SingleQuote' ? "'" : '"'; + out.push(S.text(q, marks)); + out.push(...inlines(asArray(content), marks, ctx)); + out.push(S.text(q, marks)); + break; + } case 'Underline': case 'SmallCaps': - case 'Quoted': // Outside the v1 mark set -> chip the whole construct verbatim. out.push(chip(node, 'span', ctx, '')); break; diff --git a/ts-packages/preview-renderer/src/q2-preview/richtext/quotedSeed.test.ts b/ts-packages/preview-renderer/src/q2-preview/richtext/quotedSeed.test.ts new file mode 100644 index 000000000..fb2a56be0 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/richtext/quotedSeed.test.ts @@ -0,0 +1,113 @@ +// bd-iwv3708i — Quoted inline renders as editable plaintext quotes, not a chip. +// +// A Pandoc `Quoted` node (double/single) must seed the rich-text editor as +// literal straight quote characters (`"…"` / `'…'`) wrapping still-WYSIWYG +// content, NOT as an opaque `chip` atom. This mirrors how Emph/Strong recurse +// into their content; the only difference is that the delimiter characters are +// emitted as literal text so the user edits them directly. +// +// Pure (no pampa oracle) so it runs in the default `vitest run` suite. The +// serialize→reparse round-trip (straight quotes -> pampa `Quoted`) is guarded +// separately by roundtrip.test.ts under QUARTO_RUN_PAMPA_ROUNDTRIP=1. + +import { describe, it, expect } from 'vitest'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { astToDoc } from './astToProseMirror'; +import { docToMarkdown } from './serializer'; +import { richTextSchema } from './schema'; +import type { AstNode } from './ast'; + +const STR = (c: string): AstNode => ({ t: 'Str', c } as unknown as AstNode); +const SPACE: AstNode = { t: 'Space' } as unknown as AstNode; +const STRONG = (...c: AstNode[]): AstNode => ({ t: 'Strong', c } as unknown as AstNode); +const EMPH = (...c: AstNode[]): AstNode => ({ t: 'Emph', c } as unknown as AstNode); +const PARA = (...c: AstNode[]): AstNode => ({ t: 'Para', c } as unknown as AstNode); +// Pandoc Quoted content is [QuoteType, Inline[]]. +const QUOTED = (qt: 'SingleQuote' | 'DoubleQuote', ...c: AstNode[]): AstNode => + ({ t: 'Quoted', c: [{ t: qt }, c] } as unknown as AstNode); + +/** Count `chip` atoms anywhere in a ProseMirror doc. */ +function chipCount(doc: PMNode): number { + let n = 0; + doc.descendants((node) => { + if (node.type.name === 'chip') n += 1; + }); + return n; +} + +describe('bd-iwv3708i — astToDoc seeds Quoted as editable plaintext quotes', () => { + it('double-quoted text becomes literal "…" text, not a chip', () => { + const para = PARA(STR('He'), SPACE, STR('said'), SPACE, QUOTED('DoubleQuote', STR('smart'), SPACE, STR('quotes'))); + const { doc, unknown } = astToDoc([para], [], ''); + expect(unknown).toEqual([]); + expect(chipCount(doc)).toBe(0); + expect(doc.textContent).toBe('He said "smart quotes"'); + }); + + it('single-quoted text becomes literal \'…\' text, not a chip', () => { + const para = PARA(STR('A'), SPACE, QUOTED('SingleQuote', STR('single'), SPACE, STR('quoted')), SPACE, STR('phrase')); + const { doc, unknown } = astToDoc([para], [], ''); + expect(unknown).toEqual([]); + expect(chipCount(doc)).toBe(0); + expect(doc.textContent).toBe("A 'single quoted' phrase"); + }); + + it('serializes back to straight quotes (double)', () => { + const para = PARA(QUOTED('DoubleQuote', STR('smart'), SPACE, STR('quotes'))); + const { doc } = astToDoc([para], [], ''); + expect(docToMarkdown(doc).trim()).toBe('"smart quotes"'); + }); + + it('serializes back to straight quotes (single)', () => { + const para = PARA(QUOTED('SingleQuote', STR('x'))); + const { doc } = astToDoc([para], [], ''); + expect(docToMarkdown(doc).trim()).toBe("'x'"); + }); + + it('marks inside a quote stay WYSIWYG (bold + italic recurse)', () => { + // "very *important* text" with bold on "very" + const para = PARA( + QUOTED('DoubleQuote', STR('very'), SPACE, EMPH(STR('important')), SPACE, STR('text')), + ); + const { doc, unknown } = astToDoc([para], [], ''); + expect(unknown).toEqual([]); + expect(chipCount(doc)).toBe(0); + // The quote chars are literal text; the inner Emph serializes as `_…_` (qmd italic). + expect(docToMarkdown(doc).trim()).toBe('"very _important_ text"'); + }); + + it('nested quotes recurse to nested literal quotes', () => { + // "outer 'inner' done" + const para = PARA( + QUOTED('DoubleQuote', STR('outer'), SPACE, QUOTED('SingleQuote', STR('inner')), SPACE, STR('done')), + ); + const { doc, unknown } = astToDoc([para], [], ''); + expect(unknown).toEqual([]); + expect(chipCount(doc)).toBe(0); + expect(doc.textContent).toBe(`"outer 'inner' done"`); + expect(docToMarkdown(doc).trim()).toBe(`"outer 'inner' done"`); + }); + + it('empty quotes emit just the delimiter pair', () => { + const para = PARA(QUOTED('DoubleQuote')); + const { doc, unknown } = astToDoc([para], [], ''); + expect(unknown).toEqual([]); + expect(chipCount(doc)).toBe(0); + expect(doc.textContent).toBe('""'); + }); +}); + +describe('bd-iwv3708i — serializer passes straight quotes through unescaped', () => { + const S = richTextSchema; + + // Guards the assumption the Quoted->plaintext conversion relies on: + // prosemirror-markdown's esc() escapes only ` * \ ~ [ ] _ — never `"`/`'`. + // Built directly from the schema (not via astToDoc) so it isolates the + // serializer from the AST-conversion change. + it('literal "…" and \'…\' text serialize verbatim (no backslash escapes)', () => { + const doc = S.node('doc', null, [ + S.node('paragraph', null, [S.text(`say "hi" and 'yo' now`)]), + ]); + expect(docToMarkdown(doc).trim()).toBe(`say "hi" and 'yo' now`); + }); +}); diff --git a/ts-packages/preview-renderer/src/q2-preview/richtext/roundtrip.test.ts b/ts-packages/preview-renderer/src/q2-preview/richtext/roundtrip.test.ts index aa8b261a6..62a6c5395 100644 --- a/ts-packages/preview-renderer/src/q2-preview/richtext/roundtrip.test.ts +++ b/ts-packages/preview-renderer/src/q2-preview/richtext/roundtrip.test.ts @@ -26,6 +26,12 @@ const FIXTURES: { name: string; qmd: string }[] = [ { name: 'bullet-list', qmd: '- first item\n- second item\n- third item\n' }, { name: 'ordered-list', qmd: '3. three\n4. four\n5. five\n' }, { name: 'blockquote', qmd: '> a quoted line\n> still quoted\n' }, + // bd-iwv3708i — Quoted inlines now seed as editable plaintext straight quotes + // (not chips); they must still round-trip back to a `Quoted` node via pampa. + { name: 'quoted-double', qmd: 'He said "smart quotes" and it works.\n' }, + { name: 'quoted-single', qmd: 'A phrase with \'single quotes\' too.\n' }, + { name: 'quoted-nested', qmd: 'Nested: "outer \'inner\' done" here.\n' }, + { name: 'quoted-with-marks', qmd: 'A quote with **bold** inside: "very *important* text".\n' }, { name: 'code-block-quarto', qmd: '```{python}\nprint("hi")\n```\n' }, { name: 'shortcode', qmd: 'Watch {{< video https://youtu.be/abc >}} now.\n' }, { name: 'inline-math', qmd: 'Identity $e^{i\\pi}+1=0$ is neat.\n' },