diff --git a/claude-notes/designs/document-profile-contract.md b/claude-notes/designs/document-profile-contract.md index b0357dc48..150e8ef7d 100644 --- a/claude-notes/designs/document-profile-contract.md +++ b/claude-notes/designs/document-profile-contract.md @@ -2,7 +2,9 @@ **Status:** Active (Phase 0 of the website epic, `bd-0tr6` / `bd-f3jc`; extended in Phase 8 sub-phase 8.0, `bd-fegm` + `bd-r82e`). -**Version tag:** `DOCUMENT_PROFILE_VERSION = 7` +**Version tag:** `DOCUMENT_PROFILE_VERSION = 13` (see the Change log +for per-version deltas; the authoritative history is the doc comment +on the constant in `document_profile.rs`) **Type:** `quarto_core::document_profile::DocumentProfile` **Stage:** `quarto_core::stage::stages::DocumentProfileStage` (name `"document-profile"`) + `UnwrapProfileStage` (`"unwrap-profile"`), @@ -67,6 +69,7 @@ produced. | `listing_content_globs` | `Vec` expanded from the host page's `listing.*.contents:` declarations (`bd-xbnf`, listings L6). Flattened across all listings on the page. The dependency-graph builder expands these against `ProjectIndex` at graph-build time (host-relative first, project-relative fallback — matches L3's render-time rule) to add forward edges from each listing host to its content files; hosts with non-empty entries are also added to the graph's `force_render` set so Mode B (`quarto render posts/foo.qmd`) pulls in listing hosts when any of their content files is targeted. Since bd-listing-inline-contents-tyy446ze the entries also include the literal `path:` of each inline `contents:` record that names a project document, so editing that document re-renders the host. Resolution is **not** cached on the profile (the per-doc cache cannot represent dependency on the full project source set safely). Field type and `DOCUMENT_PROFILE_VERSION` unchanged. Default empty. | | `listing_item` | `ListingItemInfo` advertising per-document data for listings consumers (`bd-n8a4`). **Scoped feature surface — listings only**; non-listing consumers must use the corresponding top-level fields (`title`, `description`, `image`, …). Author-supplied values populate during `DocumentProfile::extract`; `ListingItemInfoStage` (`bd-izqh`, L1, landed) auto-fills holes pre-checkpoint for `description` (full first paragraph), `image` (first inline image's URL), `word_count` (Q1-parity tokenization, footnote text excluded), `reading_time_minutes` (`ceil(word_count / 200)`), and `date_modified` (filesystem mtime via `SystemRuntime::path_metadata` formatted as `YYYY-MM-DD` UTC). Author values always win — the stage strictly fills holes. The nested `extra: BTreeMap` is the **only** open-shape field in the profile and is forbidden to non-listing consumers — see §"Scoped feature surfaces". Default empty (`ListingItemInfo::is_empty()`). | | `engine_resolution` | `Option` (`engine-resolution.md` §9.1). `Some` only when the document's engine resolution is provably load-free at Pass-1 — the needs-no-load predicate in `engine-resolution.md` §3.3 (P1–P4) — and is then **complete**: `sequence` is the resolved engine names in run order, `ownership` is the language→engine map in insertion order. `None` means resolution fell through to Pass-2's existing (non-profiled) resolution — **not an error**; most documents may show `None` until every engine a project uses is static or tabled (`engine-resolution.md` §3.3, §12). Names only, no `ConfigValue` blobs. Default `None`. | +| `comments` | `Vec` — every editorial comment (`[>> … ]` / `[…]{.quarto-edit-comment}`) in the document body, in source order (v13, bd-0rsk07il / GH #445). Each entry: `text` (plain-text projection of the comment content), `source` (the mark's span, for jump-to-comment and joining with out-of-band authorship such as the hub attribution overlay), typed `author` / `date` from the mark's in-band `author=` / `date=` attributes (`None` when unstamped — the stamping convention is ISO 8601 UTC dates and display-name identity), and `attributes` (remaining kvs, authored order). Comments in included files count toward the including document. "Outstanding" = present: resolving deletes the mark from source. Count is `comments.len()`; there is deliberately no separate count field. Default empty. | ## Non-guarantees (explicit) @@ -534,3 +537,27 @@ Tracking: `bd-creo` (CLI strictness), `bd-mwtf` / `DocumentProfileError::VersionMismatch` and silently regenerated, identical to every prior bump. Plan: `claude-notes/plans/2026-06-29-plan6-pass1-engine-resolution.md`. + +- **2026-08-25 — v13 (bd-0rsk07il, GH #445, editorial comments).** + Adds `comments: Vec` — every editorial comment in + the document body, in source order. Each entry carries the + plain-text projection of the comment's content, the mark's source + span, in-band `author=` / `date=` attributes promoted to typed + fields, and the remaining attr kvs as an ordered passthrough list. + Comments are `[>> … ]` / `[…]{.quarto-edit-comment}` marks; the qmd + reader normalizes both to a `Span` whose classes contain + `quarto-edit-comment`, which is what the extraction walk keys on. + Comments in included files count toward the including document + (checkpoint sees the post-include AST, same as `outline`). + "Outstanding" is presence — resolving a comment deletes it from + source. + + The serialized shape is strictly additive (`#[serde(default)]`, + omitted when empty), but the version is bumped anyway: a cached v12 + profile would deserialize cleanly and silently report "no comments" + for a document that has them — precisely the semantic misread the + version check exists to prevent. v12 cache entries are rejected and + regenerated as with every prior bump. First consumer: hub-client's + comment-toggle badge (outstanding-comment count for the active + page). Plan: + `claude-notes/plans/2026-08-25-document-profile-comments.md`. diff --git a/claude-notes/plans/2026-08-25-document-profile-comments.md b/claude-notes/plans/2026-08-25-document-profile-comments.md new file mode 100644 index 000000000..7b2a28b30 --- /dev/null +++ b/claude-notes/plans/2026-08-25-document-profile-comments.md @@ -0,0 +1,344 @@ +# DocumentProfile: comment summary for downstream tooling (GH #445) + +**Strand:** bd-0rsk07il +**GH issue:** https://github.com/quarto-dev/q2/issues/445 +**Status:** reviewed 2026-08-25 (open questions resolved — see +§"Resolved decisions"); awaiting explicit go-ahead to execute. + +## Overview + +Editorial comments are `[>> comment text ]` marks in qmd source, parsed +into `Inline::EditComment` nodes (`crates/quarto-pandoc-types/src/inline.rs:325`: +`attr: Attr`, `content: Inlines`, `source_info`, `attr_source`). In the +q2-preview AST JSON they surface as `Span`s with class +`quarto-edit-comment`, which +`ts-packages/preview-renderer/src/q2-preview/custom/CommentBlock.tsx` +extracts and renders as per-block bubbles. The comment display mode +toggle (expand / show / hide) lives in +`hub-client/src/components/ReplayDrawer.tsx` (`CommentsModeToggle`). + +GH #445 asks: teach Quarto 2's Pass-1 processing (the +`DocumentProfile`) to summarize the comments present in a document, so +UI that wants "are there comments? how many?" doesn't have to process +the whole document — and so *other* documents' comment states are +knowable without rendering them (Pass-1 profiles exist for every +project file). + +**First consumer:** hub-client's comment-mode toggle gets a badge/pill +with the count of outstanding comments on the active page. + +## Facts established during investigation + +- **Representation at the checkpoint (execution finding, 2026-08-25):** + the qmd reader's postprocess step rewrites every `Inline::EditComment` + into an `Inline::Span` whose classes start with `quarto-edit-comment` + (id and kvs preserved, content trimmed — + `crates/pampa/src/pandoc/treesitter_utils/postprocess.rs`, + `.with_edit_comment`). The qmd writer maps that span form back to + `[>> …]` decorated syntax only when it has no id/kvs and exactly the + one class (`write_span`, `crates/pampa/src/writers/qmd.rs:1954`); + otherwise it writes `[…]{.quarto-edit-comment …}` — which re-parses + to the same span shape. So the **class-based span is the canonical + AST form**; the extractor keys on the class (with a defensive + `Inline::EditComment` arm since the type still exists pre-postprocess). +- The profile checkpoint (`DocumentProfileStage`, between + `MetadataMergeStage` and `PreEngineSugaringStage`) sees the AST + **after include expansion** and **before any AST mutation** — + `EditComment` nodes are parse products, so they are all present at + the checkpoint. Comments inside included files count toward the + including document (consistent with how `outline` works). +- Comments attached to code blocks are stored by the hub UI as `[>> ]` + paragraphs inside a wrapper Div (`quarto-edit-comment-container`) — + still ordinary `EditComment` inlines, so a plain AST walk finds them. +- Comments carry **no author/date today**: `CommentBlock.tsx`'s + `addComment` writes a bare span (empty attr). Author dots in the UI + come from the automerge **attribution overlay**, not from source. + `EditComment.attr` (id/classes/kvs) exists and round-trips through + the qmd writer (`write_editcomment`), so author/date *could* be + persisted later without a parser change. +- "Outstanding" = present in source. The ✓ resolve button deletes the + comment from the source; there is no resolved-but-kept state. +- The WASM render path (`render_page_in_project*` in + `crates/wasm-quarto-hub-client/src/lib.rs`) runs Pass-1 over every + project file and returns a `RenderResponse` JSON to hub-client. The + response has no profile-derived fields today. +- `LinkResolutionStage` + (`crates/quarto-core/src/stage/stages/link_resolution.rs`) already + implements the manual block/inline walk pattern (it matches + `Inline::EditComment` explicitly); the comment scan follows the same + pattern. + +## Design decisions (proposed — please review) + +### D1. Profile field: entries, not just a count + +```rust +/// One editorial comment found in the document body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProfileComment { + /// Plain-text projection of the comment's content + /// (`pampa::writers::plaintext::inlines_to_string`). + pub text: String, + /// Source span of the mark, for jump-to-comment / gutter markers. + pub source: quarto_source_map::SourceInfo, + /// In-band author, from the mark's `author=` attribute + /// (`[>> text ]{author="…"}`). `None` when unstamped — all + /// hub-authored comments today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + /// In-band timestamp, from the mark's `date=` attribute. Kept as + /// the raw string (same policy as `DocumentProfile::date`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + /// Remaining attr kvs passthrough (anything other than + /// `author`/`date`), so future conventions need no shape change. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attributes: Vec<(String, String)>, +} + +/// On DocumentProfile: +#[serde(default, skip_serializing_if = "Vec::is_empty")] +pub comments: Vec, +``` + +Count is `comments.len()` — no separate count field to keep in sync. +Carrying entries (text + span) rather than a bare count is what makes +the follow-on use cases (jump-to-comment, tooltips, MCP listing, +search) possible without another version bump. Comments are short; +profile size impact is negligible. + +Rejected alternative: `comment_count: u32` only. Cheaper, but every +listed use case beyond the badge would immediately need a second bump. + +### D2. Version bump 12 → 13 + +Strictly, a `#[serde(default)]` field is additive (cf. `order`, added +without a bump). But profiles are **cached** (Phase-8 incremental +rebuilds): a cached v12 profile would deserialize cleanly and report +"no comments" for a document that has them — a silent semantic misread, +exactly what the version-bump rule exists for. Bump, and add the v13 +entry to the version-history doc comment and the contract doc. + +### D3. Extraction in pure `DocumentProfile::extract` + +A `count_comments(blocks)`-style walk (module-private, mirroring +`extract_outline`) over the body: recurse through block/inline +containers collecting `Inline::EditComment`. No stage side-channel +needed — comments are in the AST the extractor already receives. +Walk must cover comments nested inside other inlines (Emph, Span, +etc.) and inside container blocks (Div, BlockQuote, lists, tables, +figure captions) — reuse the traversal shape of +`LinkResolutionStage`. + +Deliberately **not** included: comments in YAML front matter (not a +thing), comments in non-body metadata inlines. Document order = +source order (walk order). + +### D4. Surfacing to hub-client: active-page summary on `RenderResponse` + +Add to `RenderResponse` (wasm lib.rs): + +```rust +#[serde(skip_serializing_if = "Option::is_none")] +comments: Option, // { count, entries: [{ text, line?, ... }] } +``` + +populated on both the project-active-page branch (from the Pass-1 +profile of the active page) and the single-doc branch (the profile is +produced mid-pipeline; Phase 2 resolves the cleanest retention point — +likely stashing the extracted profile, or just its comment vec, on +`StageContext` the way other cross-stage artifacts travel). +Hub-client threads it from the render result through `ReactPreview` up +to `Editor` via a callback (same pattern as `onDiagnosticsChange`), +into `ReplayDrawer`. + +**Deferred (follow-up strand, filed at execution time):** a +project-wide `path → comment summary` map in the response, for +file-sidebar per-file badges. Pass-1 already computes everything; +this is pure surface area, but it doubles the response-plumbing scope +and the first consumer doesn't need it. + +### D5. Authorship: interpret in-band attrs now; stamp them as follow-up + +The issue mentions authors and last-comment-date. Hub-authored marks +carry neither today (authorship comes from the attribution overlay), +but **authorship should have an in-band representation in the qmd +itself** — a comment's author shouldn't be recoverable only through +automerge history. So: + +- **In scope here (read side):** the profile *interprets* `author=` + and `date=` attributes on comment marks as first-class + `ProfileComment` fields (D1). The convention + `[>> text ]{author="…" date="…"}` is thereby defined and honored by + Pass-1 from day one, and "last comment date" becomes derivable the + moment marks are stamped. +- **Follow-up strand (write side):** teach the hub add-comment path + (`CommentBlock.tsx` `addComment`) to stamp `author=`/`date=` from + the session identity when writing the span, plus how the bubbles + display in-band authors vs. attribution-derived ones (in-band should + win when present). Filed at execution time, linked to this strand — + it's a hub-client product/UX change with its own review surface + (source noise, identity naming), and the badge doesn't depend on it. +- The attribution-overlay join stays available for unstamped legacy + comments; the profile's `source` span is the join key. + +## Other use cases this unlocks (for discussion) + +- **File-sidebar badges**: per-file outstanding-comment counts across + the project, from the Pass-1 index — no rendering of inactive files. + (The deferred half of D4.) +- **CLI report**: `q2 comments` (or `q2 inspect`) listing outstanding + comments across a project with file:line — review-round tooling. +- **Publish/render gate**: warn (or `--fail-on-comments`) when + rendering/publishing a document that still has outstanding comments, + analogous in spirit to `draft`. +- **MCP surface**: a quarto-hub-mcp tool listing outstanding comments + so agents can run "address every open comment" triage loops. +- **Hub search**: comment text as a searchable facet + (`hub-client/src/services/search/` already consumes profile data + for titles). +- **Editor affordances**: gutter/scrollbar comment markers in Monaco + from the profile's source spans, without an extra parse. +- **Notifications/presence**: "N new comments since you last looked" — + client-side diff of successive profile summaries. + +None of these are in scope here; each would be its own strand once the +profile field exists. + +## Phases and work items + +### Phase 1 — Rust core (TDD) — **done 2026-08-25** + +- [x] Tests first: 5 unit tests in `document_profile.rs` (empty + + JSON omission; paragraph comment w/ span-slices-source check; + all container kinds in source order; author/date/kv attrs both + syntaxes; JSON round-trip) + 1 pipeline integration test + (`profile_sees_comments_from_included_file` in + `document_profile_pipeline.rs`). Verified failing first + (E0609 on the missing field). +- [x] `ProfileComment` + `comments` field + `CommentCollector` walk + (mirrors `LinkResolutionStage`'s traversal; comment spans are + leaves). Execution finding recorded in §Facts: at the checkpoint + comments are `Span`s with class `quarto-edit-comment` (reader + postprocess rewrites `EditComment`), so the walk keys on the + class with a defensive `EditComment` arm. +- [x] `DOCUMENT_PROFILE_VERSION` 12 → 13 (+ history comment); contract + doc updated (header version tag, guarantees row, change log). + Cache invalidation is automatic — the version is in the + cache-key hash domain (`project/cache_key.rs`). +- [x] `cargo nextest run --workspace`: 13395 passed. Clippy clean on + quarto-core. + +### Phase 2 — WASM / response surface — **done 2026-08-25** + +- [x] Retention point: `UnwrapProfileStage` now **moves** the profile + onto `StageContext.document_profile` instead of discarding it + (zero-copy; runs after `LinkResolutionStage`, so the stash is + complete). `run_pipeline` bridges it to + `RenderContext.document_profile`; both Pass-2 renderers copy it + onto `WasmPassTwoOutput.document_profile` (mirroring + `theme_fingerprint`). Verified the q2-preview pipeline keeps + both checkpoint stages (`Q2_PREVIEW_STAGE_EXCLUDED` excludes + only math-js / render-html-body / apply-template). +- [x] `RenderResponse.comments: Option>` populated in + both single-doc and project-active branches (all five + construction sites); `None` ≡ zero for consumers. +- [x] `JsonComment` transport type + `ProfileComment::to_json` live in + quarto-core (natively testable; 1-based Monaco positions, end + fallback mirroring `diagnostic_to_json`, `file` field for + include-mapped comments). TDD: 4 new tests verified failing + first (`render_qmd_to_html_bridges_document_profile_to_ctx`, + `active_page_profile_comments_on_{html,preview}_output`, + `profile_comment_to_json_positions_and_fields`). +- [x] TS types: `RenderComment` + `RenderResponse.comments` in + `ts-packages/preview-renderer/src/types/diagnostic.ts` — the + single definition preview-runtime and hub-client both import. +- [x] Workspace green (13399); `npm run build:wasm` compiles the + wasm-side changes cleanly; clippy clean. + +### Phase 3 — hub-client badge (first consumer) — **done 2026-08-25** + +- [x] Threading: `ReactPreview` reports `RenderResponse.comments` via + a new `onCommentsChange` prop after each successful + preview-pipeline render (wire-absent → `[]`; parse-only formats + and failures preserve last-good, matching the AST/fingerprint + semantics) → `PreviewRouter` passthrough → `Editor` keeps + `outstandingCommentCount` state → `ReplayDrawer.commentsCount`. +- [x] Badge: one `comments-toggle-badge` pill on the + `CommentsModeToggle` group (both drawer states), hidden at 0, + with singular/plural aria-label. TDD: + `ReplayDrawer.commentsBadge.test.tsx` (4 tests) verified + failing first. +- [x] `npm run build:all` (strict tsc -b) ✓; `npm run test` 1005 ✓; + `npm run test:ci` / `test:wasm` 133 ✓ against the rebuilt WASM. +- [x] `hub-client/changelog.md` two-commit dance: entry for + `d124b72f` committed as `58c7afe1` (changelog render gate + passed). + +### Phase 4 — verification & wrap-up — **done 2026-08-25** + +- [x] Full `cargo xtask verify`: every leg green **except** one + pre-existing failure in the ts-packages leg — + `preview-renderer custom-components.integration.test.tsx > + Equation > appends \tag{N}` (`.katex-tag` missing) fails + identically on `main` (3dd9acfd); unrelated to this branch + (equation rendering untouched). Filed as **bd-kn7ln981** (p1, + the GH #250 failure class). All legs this branch touches pass: + workspace build + 13399 tests, WASM build, hub-client + build:all + unit (1005) + wasm-tier (133). +- [x] End-to-end (real browser, `npm run local-prod`, fresh project + `comment-badge-e2e`, `format: q2-preview`), all observed via + Chrome DevTools automation: + 1. Doc with 2 `[>> … ]` marks → toggle badge shows **2** + (aria-label "2 outstanding comments"). + 2. Edit adds a third mark → badge **3**. + 3. Add a comment through the bubble UI input → source gains + `[>> added from the bubble UI]`, badge **4**. + 4. Click the bubble's ✓ resolve → mark deleted from source, + badge **3**. + Gotcha worth remembering: the PWA service worker served a + stale bundle at first — unregister + cache clear + hard + reload before concluding anything about local-prod behavior. + Note: the default-project fixture renders through the HTML + iframe path where no badge consumer exists (by design — + comment chrome is q2-preview's); the badge activates with + `format: q2-preview`. +- [x] Follow-up strands filed, linked `discovered-from:bd-0rsk07il`: + **bd-juei440d** (D5 write side: stamp author/date in hub + add-comment path), **bd-lh3lgb20** (D4 deferral: project-wide + summary map for sidebar badges), **bd-kn7ln981** (pre-existing + red test found during verify). Remaining use-case ideas (CLI + report, publish gate, MCP tool, search facet, gutter markers, + notifications) intentionally left unfiled pending user + interest. +- [x] Close bd-0rsk07il (implementation complete on branch + `braid/bd-0rsk07il-document-profile-comments`; merge + push + await user approval). +- [ ] Comment on GH #445 — **not done**: posting publicly needs + explicit approval, and the commits are unpushed. Ask the user. + +## Resolved decisions (plan review, 2026-08-25) + +1. **D1 scope — entries + spans, confirmed.** Size: cache bloat is + expected to be dominated by non-text input, not comment text. + Privacy: the profile adds no information not already present in the + document itself, so no *new* exposure; existing safeguards apply. + Verified in-tree: the native profile cache lives at + `/.quarto/cache/` + (`crates/quarto-core/src/project/profile_cache.rs`, via + `NativeRuntime::with_cache_dir`), and `q2 create`'s git scaffolding + ensures `/.quarto/` is in `.gitignore` + (`crates/quarto/src/commands/create/project.rs:151-153`). Projects + assembled by hand without that ignore entry could commit cached + profiles — but the source document carrying the same text is + committed regardless, so this changes nothing. +2. **D4 — active-page-only for the first cut, confirmed.** The + project-wide `path → summary` map stays a follow-up strand. +3. **Badge placement — single location** (one badge for the toggle + group, not one per button). Final design to be iterated on a + working version; implementation uses best judgment for the first + screenshot. +4. **D5 convention — confirmed:** `author=` / `date=` attribute + names; `date` is ISO 8601 UTC; the hub stamps the **display name** + as the identity string for now (expected to be tweaked once seen in + action). Write-side stamping is a **follow-up strand**, not Phase 3. diff --git a/crates/quarto-core/src/document_profile.rs b/crates/quarto-core/src/document_profile.rs index e7e77587b..62a89453f 100644 --- a/crates/quarto-core/src/document_profile.rs +++ b/crates/quarto-core/src/document_profile.rs @@ -111,7 +111,17 @@ use thiserror::Error; /// `resolve_engines_pass1`. `None` means the document could not be /// resolved load-free at index time — advisory, not an error; Pass-2 /// always re-resolves via the full loading resolver regardless. -pub const DOCUMENT_PROFILE_VERSION: u32 = 12; +/// - `13`: `bd-0rsk07il` (GH #445). Adds `comments: +/// Vec` — every editorial comment (`[>> … ]` / +/// `[…]{.quarto-edit-comment}`) in the document body, in source +/// order, each carrying its plain-text projection, source span, +/// in-band `author=`/`date=` attributes (typed), and remaining +/// attr kvs. Strictly additive in serialized shape +/// (`#[serde(default)]`), but bumped anyway: a cached v12 profile +/// would deserialize cleanly and silently report "no comments" for +/// a document that has them — the semantic misread the version +/// check exists to prevent. +pub const DOCUMENT_PROFILE_VERSION: u32 = 13; /// Reduced, serializable form of [`crate::engine::EngineResolution`] for the /// profile (names only — configs stay in merged metadata; Plan 6 decision 6). @@ -440,6 +450,124 @@ pub struct ProfileAuthor { pub affiliations: Vec, } +/// One editorial comment found in the document body (v13, +/// bd-0rsk07il / GH #445). +/// +/// A comment is authored as `[>> comment text ]` (optionally with an +/// attribute block) or equivalently `[…]{.quarto-edit-comment}`; the +/// qmd reader normalizes both to an `Inline::Span` whose classes +/// contain `quarto-edit-comment`. Comments in included files count +/// toward the including document (the checkpoint sees the +/// post-include AST), consistent with `outline`. +/// +/// "Outstanding" is presence: resolving a comment deletes it from +/// the source, so every entry here is an open comment. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProfileComment { + /// Plain-text projection of the comment's content + /// (`pampa::writers::plaintext::inlines_to_string`). + pub text: String, + + /// Source span of the mark, for jump-to-comment, gutter markers, + /// and joining with out-of-band authorship (e.g. the hub's + /// attribution overlay). + pub source: quarto_source_map::SourceInfo, + + /// In-band author, from the mark's `author=` attribute + /// (`[>> text ]{author="…"}`). `None` when unstamped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + + /// In-band timestamp, from the mark's `date=` attribute. Kept as + /// the raw string; the stamping convention is ISO 8601 UTC. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + + /// Remaining attr kvs (anything other than `author`/`date`), in + /// authored order, so future conventions need no shape change. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attributes: Vec<(String, String)>, +} + +/// Transport form of a [`ProfileComment`] for JSON consumers that +/// cannot resolve a [`SourceInfo`] themselves (the WASM +/// `RenderResponse`, future CLI `--json` reports). +/// +/// Line and column numbers are **1-based** to match Monaco — the same +/// convention as `quarto_error_reporting::JsonDiagnostic`. `file` is +/// the source file the mark maps back to, which for a comment inside +/// an `{{< include >}}` is the *included* file, not the host — a +/// consumer offering jump-to-comment in the host buffer must check it. +/// +/// [`SourceInfo`]: quarto_source_map::SourceInfo +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct JsonComment { + /// Plain-text comment content. + pub text: String, + /// In-band author (`author=` attribute), when stamped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + /// In-band timestamp (`date=` attribute), when stamped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + /// Remaining attr kvs, authored order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attributes: Vec<(String, String)>, + /// Path of the source file the mark's span maps to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_column: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_column: Option, +} + +impl ProfileComment { + /// Resolve this comment's span against `ctx` and produce the + /// transport form. Position fields are `None` when the span + /// cannot be mapped (synthetic sources); the textual fields are + /// always populated. + /// + /// The end-position fallback mirrors + /// `quarto_error_reporting::diagnostic_to_json`: try the span + /// length, then length − 1, then reuse the start. + pub fn to_json(&self, ctx: &quarto_source_map::SourceContext) -> JsonComment { + let start = self.source.map_offset(0, ctx); + let end = self + .source + .map_offset(self.source.length(), ctx) + .or_else(|| { + if self.source.length() > 0 { + self.source.map_offset(self.source.length() - 1, ctx) + } else { + None + } + }) + .or_else(|| start.clone()); + + let file = start + .as_ref() + .and_then(|s| ctx.get_file(s.file_id)) + .map(|f| f.path.clone()); + + JsonComment { + text: self.text.clone(), + author: self.author.clone(), + date: self.date.clone(), + attributes: self.attributes.clone(), + file, + start_line: start.as_ref().map(|s| (s.location.row + 1) as u32), + start_column: start.as_ref().map(|s| (s.location.column + 1) as u32), + end_line: end.as_ref().map(|e| (e.location.row + 1) as u32), + end_column: end.as_ref().map(|e| (e.location.column + 1) as u32), + } + } +} + /// One affiliation attached to a [`ProfileAuthor`]. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct ProfileAffiliation { @@ -749,6 +877,16 @@ pub struct DocumentProfile { /// Added v12 (Plan 6 Phase 5). #[serde(default)] pub engine_resolution: Option, + + /// Every editorial comment in the document body, in source order. + /// See [`ProfileComment`]. Consumers wanting a count use + /// `comments.len()` — there is deliberately no separate count + /// field to keep in sync. + /// + /// Default empty; serializer omits empty lists. Added v13 + /// (bd-0rsk07il, GH #445). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub comments: Vec, } /// Helper for `#[serde(skip_serializing_if = ...)]` on plain bool @@ -811,6 +949,7 @@ impl Default for DocumentProfile { listing_item: ListingItemInfo::default(), listing_content_globs: Vec::new(), engine_resolution: None, + comments: Vec::new(), } } } @@ -892,6 +1031,7 @@ impl DocumentProfile { // (needs the registry + AST, not available to this pure // metadata-only extractor) — mirrors the `includes` field. engine_resolution: None, + comments: extract_comments(&ast.blocks), } } @@ -1149,6 +1289,198 @@ fn strip_numbers(entries: &mut Vec) { } } +/// Collect every editorial comment in the document body, in source +/// (walk) order — see [`ProfileComment`]. +/// +/// At the checkpoint a comment is an `Inline::Span` whose classes +/// contain `quarto-edit-comment`: the qmd reader's postprocess +/// rewrites `Inline::EditComment` parse nodes into that span form +/// (both `[>> … ]` and `[…]{.quarto-edit-comment}` normalize to it). +/// A defensive `EditComment` arm keeps the walk correct for any AST +/// that reaches extraction pre-postprocess. +/// +/// The traversal mirrors `LinkResolutionStage`'s: every block and +/// inline container is entered (list items, table cells, captions, +/// footnotes, custom-node slots, …). A comment itself is a leaf — its +/// content is the comment text, not further-scanned document body. +fn extract_comments(blocks: &[quarto_pandoc_types::block::Block]) -> Vec { + let mut collector = CommentCollector { seen: Vec::new() }; + for block in blocks { + collector.visit_block(block); + } + collector.seen +} + +/// Marker class the qmd reader stamps on comment spans. +const EDIT_COMMENT_CLASS: &str = "quarto-edit-comment"; + +struct CommentCollector { + seen: Vec, +} + +impl CommentCollector { + fn record( + &mut self, + attr: &quarto_pandoc_types::attr::Attr, + content: &quarto_pandoc_types::Inlines, + source: &quarto_source_map::SourceInfo, + ) { + let (_, _, kvs) = attr; + let mut author = None; + let mut date = None; + let mut attributes = Vec::new(); + for (k, v) in kvs.iter() { + match k.as_str() { + "author" => author = Some(v.clone()), + "date" => date = Some(v.clone()), + _ => attributes.push((k.clone(), v.clone())), + } + } + self.seen.push(ProfileComment { + text: pampa::writers::plaintext::inlines_to_string(content).0, + source: source.clone(), + author, + date, + attributes, + }); + } + + fn visit_block(&mut self, block: &quarto_pandoc_types::block::Block) { + use quarto_pandoc_types::block::Block; + match block { + Block::Plain(p) => self.visit_inlines(&p.content), + Block::Paragraph(p) => self.visit_inlines(&p.content), + Block::LineBlock(lb) => { + for line in lb.content.iter() { + self.visit_inlines(line); + } + } + Block::BlockQuote(bq) => self.visit_blocks(&bq.content), + Block::OrderedList(ol) => { + for item in ol.content.iter() { + self.visit_blocks(item); + } + } + Block::BulletList(bl) => { + for item in bl.content.iter() { + self.visit_blocks(item); + } + } + Block::DefinitionList(dl) => { + for (term, defs) in dl.content.iter() { + self.visit_inlines(term); + for def in defs.iter() { + self.visit_blocks(def); + } + } + } + Block::Header(h) => self.visit_inlines(&h.content), + Block::Div(d) => self.visit_blocks(&d.content), + Block::Figure(f) => self.visit_blocks(&f.content), + Block::Table(t) => { + if let Some(short) = t.caption.short.as_ref() { + self.visit_inlines(short); + } + if let Some(long) = t.caption.long.as_ref() { + self.visit_blocks(long); + } + for row in t.head.rows.iter().chain(t.foot.rows.iter()) { + for cell in row.cells.iter() { + self.visit_blocks(&cell.content); + } + } + for body in t.bodies.iter() { + for row in body.body.iter() { + for cell in row.cells.iter() { + self.visit_blocks(&cell.content); + } + } + } + } + Block::CaptionBlock(cb) => self.visit_inlines(&cb.content), + Block::Custom(c) => { + for (_name, slot) in c.slots.iter() { + self.visit_slot(slot); + } + } + Block::CodeBlock(_) + | Block::RawBlock(_) + | Block::HorizontalRule(_) + | Block::BlockMetadata(_) + | Block::NoteDefinitionPara(_) + | Block::NoteDefinitionFencedBlock(_) => {} + } + } + + fn visit_blocks(&mut self, blocks: &[quarto_pandoc_types::block::Block]) { + for b in blocks { + self.visit_block(b); + } + } + + fn visit_inlines(&mut self, inlines: &quarto_pandoc_types::Inlines) { + for inline in inlines.iter() { + self.visit_inline(inline); + } + } + + fn visit_inline(&mut self, inline: &quarto_pandoc_types::inline::Inline) { + use quarto_pandoc_types::inline::Inline; + match inline { + Inline::Span(s) => { + if s.attr.1.iter().any(|c| c == EDIT_COMMENT_CLASS) { + self.record(&s.attr, &s.content, &s.source_info); + } else { + self.visit_inlines(&s.content); + } + } + // Pre-postprocess parse form; postprocess rewrites this + // into the span form above before the checkpoint. + Inline::EditComment(e) => self.record(&e.attr, &e.content, &e.source_info), + Inline::Link(l) => self.visit_inlines(&l.content), + Inline::Image(i) => self.visit_inlines(&i.content), + Inline::Emph(e) => self.visit_inlines(&e.content), + Inline::Underline(u) => self.visit_inlines(&u.content), + Inline::Strong(s) => self.visit_inlines(&s.content), + Inline::Strikeout(s) => self.visit_inlines(&s.content), + Inline::Superscript(s) => self.visit_inlines(&s.content), + Inline::Subscript(s) => self.visit_inlines(&s.content), + Inline::SmallCaps(s) => self.visit_inlines(&s.content), + Inline::Quoted(q) => self.visit_inlines(&q.content), + Inline::Insert(i) => self.visit_inlines(&i.content), + Inline::Delete(d) => self.visit_inlines(&d.content), + Inline::Highlight(h) => self.visit_inlines(&h.content), + Inline::Note(n) => self.visit_blocks(&n.content), + Inline::Custom(c) => { + for (_name, slot) in c.slots.iter() { + self.visit_slot(slot); + } + } + Inline::Str(_) + | Inline::Cite(_) + | Inline::Code(_) + | Inline::Space(_) + | Inline::SoftBreak(_) + | Inline::LineBreak(_) + | Inline::Math(_) + | Inline::RawInline(_) + | Inline::Shortcode(_) + | Inline::NoteReference(_) + | Inline::Attr(_) => {} + } + } + + fn visit_slot(&mut self, slot: &quarto_pandoc_types::custom::Slot) { + use quarto_pandoc_types::custom::Slot; + match slot { + Slot::Block(b) => self.visit_block(b), + Slot::Blocks(bs) => self.visit_blocks(bs), + Slot::Inline(i) => self.visit_inline(i), + Slot::Inlines(is) => self.visit_inlines(is), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1988,8 +2320,8 @@ Body. } #[test] - fn document_profile_version_is_12() { - assert_eq!(DOCUMENT_PROFILE_VERSION, 12); + fn document_profile_version_is_13() { + assert_eq!(DOCUMENT_PROFILE_VERSION, 13); } /// A v3 profile (the pre-listings shape) must be rejected by @@ -2349,6 +2681,152 @@ Body. ); } + // ------------------------------------------------------------------ + // Comments (bd-0rsk07il, GH #445, v13). At the checkpoint a comment + // is an `Inline::Span` whose classes contain `quarto-edit-comment` + // (the reader's postprocess rewrites `Inline::EditComment` into that + // form; both `[>> … ]` and `[…]{.quarto-edit-comment}` normalize to + // it). + // ------------------------------------------------------------------ + + fn extract_from(qmd: &str) -> DocumentProfile { + let ast = parse_qmd(qmd); + DocumentProfile::extract(&ast, Path::new("c.qmd"), "c.html", "html") + } + + #[test] + fn profile_extract_no_comments_field_empty_and_omitted() { + let profile = extract_from("---\ntitle: T\n---\n\nNo comments here.\n"); + assert!(profile.comments.is_empty()); + let json = profile.to_json().expect("serialize"); + assert!( + !json.contains("\"comments\""), + "empty comments list must be omitted from serialized profiles" + ); + } + + #[test] + fn profile_extract_comment_in_paragraph() { + let qmd = "---\ntitle: T\n---\n\nSome prose [>> fix this ] and more.\n"; + let profile = extract_from(qmd); + assert_eq!(profile.comments.len(), 1); + let c = &profile.comments[0]; + assert_eq!(c.text, "fix this"); + assert_eq!(c.author, None); + assert_eq!(c.date, None); + assert!(c.attributes.is_empty()); + // The span points back into the source at the mark. + let covered = &qmd[c.source.start_offset()..c.source.end_offset()]; + assert!( + covered.contains("fix this"), + "source span must cover the mark, got {covered:?}" + ); + } + + #[test] + fn profile_extract_comments_across_block_kinds_in_source_order() { + let qmd = "\ +--- +title: T +--- + +# Head [>> one ] + +> Quoted [>> two ] + +- item [>> three ] + +::: {.some-div} +Inside *emph [>> four ] tail*. +::: + +::: {.quarto-edit-comment-container} +```python +x = 1 +``` + +[>> five ] +::: +"; + let profile = extract_from(qmd); + let texts: Vec<&str> = profile.comments.iter().map(|c| c.text.as_str()).collect(); + assert_eq!( + texts, + vec!["one", "two", "three", "four", "five"], + "every container kind is walked, in source order" + ); + } + + #[test] + fn profile_extract_comment_author_date_attrs() { + // Decorated syntax with attrs, and the equivalent explicit + // span form — both normalize to the same shape. + let qmd = "---\ntitle: T\n---\n\n\ +[>> attributed ]{author=\"Alice Example\" date=\"2026-08-25T12:00:00Z\" priority=\"high\"}\n\n\ +[span form]{.quarto-edit-comment author=\"Bob\"}\n"; + let profile = extract_from(qmd); + assert_eq!(profile.comments.len(), 2); + + let a = &profile.comments[0]; + assert_eq!(a.text, "attributed"); + assert_eq!(a.author.as_deref(), Some("Alice Example")); + assert_eq!(a.date.as_deref(), Some("2026-08-25T12:00:00Z")); + assert_eq!( + a.attributes, + vec![("priority".to_string(), "high".to_string())], + "author/date are typed fields; other kvs pass through" + ); + + let b = &profile.comments[1]; + assert_eq!(b.text, "span form"); + assert_eq!(b.author.as_deref(), Some("Bob")); + assert_eq!(b.date, None); + assert!(b.attributes.is_empty()); + } + + #[test] + fn profile_comment_to_json_positions_and_fields() { + // Transport form for the WASM RenderResponse: 1-based + // line/column (Monaco convention, matching JsonDiagnostic) + // resolved against the render's SourceContext, plus the file + // the mark maps to (relevant when the comment came from an + // included file). + let qmd = "---\ntitle: T\n---\n\nProse [>> fix this ]{author=\"A\"} end.\n"; + let profile = extract_from(qmd); + assert_eq!(profile.comments.len(), 1); + + let mut sm_ctx = quarto_source_map::SourceContext::new(); + sm_ctx.add_file("test.qmd".to_string(), Some(qmd.to_string())); + + let json = profile.comments[0].to_json(&sm_ctx); + assert_eq!(json.text, "fix this"); + assert_eq!(json.author.as_deref(), Some("A")); + assert_eq!(json.date, None); + assert_eq!(json.file.as_deref(), Some("test.qmd")); + assert_eq!(json.start_line, Some(5), "mark sits on line 5, 1-based"); + assert!(json.start_column.is_some()); + assert_eq!(json.end_line, Some(5)); + + let wire = serde_json::to_string(&json).expect("serialize"); + assert!(wire.contains("\"start_line\":5"), "snake_case keys: {wire}"); + assert!( + !wire.contains("\"date\""), + "absent optionals are omitted: {wire}" + ); + } + + #[test] + fn profile_comments_roundtrip_json() { + let profile = + extract_from("---\ntitle: T\n---\n\nProse [>> keep me ]{author=\"A\"} here.\n"); + assert_eq!(profile.comments.len(), 1); + let json = profile.to_json().expect("serialize"); + let restored = DocumentProfile::from_json(&json).expect("deserialize"); + assert_eq!(profile, restored); + assert_eq!(restored.comments[0].text, "keep me"); + assert_eq!(restored.comments[0].author.as_deref(), Some("A")); + } + #[test] fn from_map_explicit_extra_wins_over_bare_key() { let li = cv_map(vec![ diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index 7017dc1af..41a995eeb 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -714,6 +714,10 @@ pub async fn run_pipeline( // pipeline returns. Pre-pipeline callers don't write // `ctx.format_options`, so the overwrite is safe. ctx.format_options = stage_ctx.format_options; + // Bridge the document profile stashed by `UnwrapProfileStage` + // (bd-0rsk07il) so response builders can read it after a full + // render. `None` for pipelines that stop before the unwrap stage. + ctx.document_profile = stage_ctx.document_profile; result .map_err(|e| match e { diff --git a/crates/quarto-core/src/project/pass2_renderer.rs b/crates/quarto-core/src/project/pass2_renderer.rs index c48f8fd8a..fd014b5f5 100644 --- a/crates/quarto-core/src/project/pass2_renderer.rs +++ b/crates/quarto-core/src/project/pass2_renderer.rs @@ -641,6 +641,16 @@ pub struct WasmPassTwoOutput { /// website-merge and default-project-flush paths. `None` if /// no theme artifact was produced. pub theme_fingerprint: Option, + /// The active page's Pass-1 [`DocumentProfile`], taken from the + /// per-page `RenderContext` after the render (where `run_pipeline` + /// bridged it from the `UnwrapProfileStage` stash; bd-0rsk07il). + /// The WASM response builder reads the comment summary (and any + /// other profile data) from here. `None` only if the pipeline + /// stopped before the unwrap stage — not the case for either + /// Pass-2 renderer. + /// + /// [`DocumentProfile`]: crate::document_profile::DocumentProfile + pub document_profile: Option, } impl WasmPassTwoOutput { @@ -905,6 +915,7 @@ impl Pass2Renderer for RenderToHtmlRenderer { payload: Pass2Payload::Html(render_output.html), diagnostics: render_output.diagnostics, source_context: render_output.source_context, + document_profile: ctx.document_profile, page_artifacts: ctx.artifacts, theme_fingerprint, }) @@ -1189,6 +1200,7 @@ impl Pass2Renderer for RenderToPreviewAstRenderer { payload: Pass2Payload::AstJson(preview_output.ast_json), diagnostics: preview_output.diagnostics, source_context: preview_output.source_context, + document_profile: ctx.document_profile, page_artifacts: ctx.artifacts, theme_fingerprint, }) diff --git a/crates/quarto-core/src/render.rs b/crates/quarto-core/src/render.rs index faeb860df..3c62bc450 100644 --- a/crates/quarto-core/src/render.rs +++ b/crates/quarto-core/src/render.rs @@ -406,6 +406,19 @@ pub struct RenderContext<'a> { /// by writing into `stage_ctx.registry`, mirroring how /// `project_index` and `resource_resolver` are threaded. pub engine_registry_override: Option>, + + /// The document's Pass-1 [`DocumentProfile`], bridged back from + /// `StageContext::document_profile` by `run_pipeline` after the + /// pipeline finishes (bd-0rsk07il). `None` before a render, and + /// for pipelines that stop before `UnwrapProfileStage` (which is + /// the stage that stashes it). + /// + /// Read by response builders — the WASM `RenderResponse` comment + /// summary today; any native consumer that wants the profile of a + /// document it just rendered without re-parsing. + /// + /// [`DocumentProfile`]: crate::document_profile::DocumentProfile + pub document_profile: Option, } /// Options for rendering @@ -456,6 +469,7 @@ impl<'a> RenderContext<'a> { attribution_data: None, format_options: FormatOptions::default(), engine_registry_override: None, + document_profile: None, } } diff --git a/crates/quarto-core/src/stage/context.rs b/crates/quarto-core/src/stage/context.rs index e8bd71ac4..5a2d29860 100644 --- a/crates/quarto-core/src/stage/context.rs +++ b/crates/quarto-core/src/stage/context.rs @@ -185,6 +185,21 @@ pub struct StageContext { /// re-running the resolver. pub engine_resolution: Option, + /// This document's Pass-1 [`DocumentProfile`], stashed by + /// `UnwrapProfileStage` as it hands the AST back to downstream + /// stages (bd-0rsk07il). `None` until that stage runs — and for + /// Pass-1 head pipelines, which stop at the `AtProfile` bundle + /// and never reach the unwrap (the orchestrator holds the profile + /// itself there). + /// + /// `run_pipeline` bridges it back to + /// `RenderContext::document_profile`, where response builders + /// (the WASM `RenderResponse` comment summary; future native + /// consumers) read it without re-parsing the document. + /// + /// [`DocumentProfile`]: crate::document_profile::DocumentProfile + pub document_profile: Option, + /// Engine registry for this render — carried from `project.registry`. /// Shared via Arc so clones across pipeline stages are cheap. /// @@ -330,6 +345,7 @@ impl StageContext { resource_copies: Vec::new(), project_index: None, engine_resolution: None, + document_profile: None, registry, claimed_engine_name: None, resource_resolver: None, diff --git a/crates/quarto-core/src/stage/stages/unwrap_profile.rs b/crates/quarto-core/src/stage/stages/unwrap_profile.rs index 9c42b449a..ea682088d 100644 --- a/crates/quarto-core/src/stage/stages/unwrap_profile.rs +++ b/crates/quarto-core/src/stage/stages/unwrap_profile.rs @@ -32,22 +32,30 @@ use async_trait::async_trait; use crate::stage::{PipelineData, PipelineDataKind, PipelineError, PipelineStage, StageContext}; -/// Pipeline stage that drops the [`DocumentProfile`] from an +/// Pipeline stage that takes the [`DocumentProfile`] out of an /// [`DocumentAtProfile`] bundle and re-emits its `DocumentAst`. /// -/// The discarded profile is still useful in Phase 0 in two ways: +/// Since bd-0rsk07il the profile is not discarded: it is **moved onto +/// [`StageContext::document_profile`]**, so callers that drive the +/// full pipeline (single-doc renders — where no orchestrator ever +/// sees the `AtProfile` bundle) can read the document's profile after +/// the run. `run_pipeline` bridges it back to +/// `RenderContext::document_profile`. This stage runs after +/// `LinkResolutionStage`, so the stashed profile carries +/// `body_link_targets` too. /// -/// 1. Its existence on the pipeline-data type proves the checkpoint -/// semantics are honored. -/// 2. Pipeline integration tests (see `crates/quarto-core/tests/ -/// document_profile_pipeline.rs`) tap the `AtProfile` variant -/// before this stage runs and verify clone-and-resume produces -/// byte-identical output. +/// The checkpoint semantics are unchanged: /// -/// Phase 1 replaces this stage with a real project-orchestration -/// consumer that reads the profile *and* resumes the pipeline. +/// 1. The `AtProfile` pipeline-data variant still proves the +/// checkpoint is honored in the type. +/// 2. Pipeline integration tests (see `crates/quarto-core/tests/ +/// integration/document_profile_pipeline.rs`) tap the `AtProfile` +/// variant before this stage runs and verify clone-and-resume +/// produces byte-identical output. The Pass-1 orchestrator +/// short-circuits before this stage and keeps the bundle. /// /// [`DocumentProfile`]: crate::document_profile::DocumentProfile +/// [`StageContext::document_profile`]: crate::stage::StageContext pub struct UnwrapProfileStage; impl UnwrapProfileStage { @@ -79,7 +87,7 @@ impl PipelineStage for UnwrapProfileStage { async fn run( &self, input: PipelineData, - _ctx: &mut StageContext, + ctx: &mut StageContext, ) -> Result { let PipelineData::AtProfile(bundle) = input else { return Err(PipelineError::unexpected_input( @@ -88,6 +96,9 @@ impl PipelineStage for UnwrapProfileStage { input.kind(), )); }; + // Stash rather than discard: single-doc callers read this via + // the `run_pipeline` → `RenderContext` bridge (bd-0rsk07il). + ctx.document_profile = Some(bundle.profile); Ok(PipelineData::DocumentAst(bundle.ast)) } } diff --git a/crates/quarto-core/tests/integration/document_profile_pipeline.rs b/crates/quarto-core/tests/integration/document_profile_pipeline.rs index f8b39cffe..3f93963ad 100644 --- a/crates/quarto-core/tests/integration/document_profile_pipeline.rs +++ b/crates/quarto-core/tests/integration/document_profile_pipeline.rs @@ -428,6 +428,50 @@ async fn profile_outline_ids_deduped_across_includes() { ); } +#[tokio::test] +async fn profile_sees_comments_from_included_file() { + // bd-0rsk07il (GH #445): comments in an included file count toward + // the including document — IncludeExpansion runs before the + // DocumentProfile checkpoint, so the comment scan sees the + // post-include AST. Also pins source order across the splice + // boundary (parent comment before the include, child comment, + // parent comment after). + let temp = tempfile::TempDir::new().expect("tempdir"); + let project_dir = temp + .path() + .canonicalize() + .unwrap_or_else(|_| temp.path().to_path_buf()); + + let child_path = project_dir.join("child.qmd"); + std::fs::write(&child_path, "Child body [>> child note ] here.\n").expect("write child"); + + let parent_path = project_dir.join("parent.qmd"); + let parent_content: &[u8] = b"---\ntitle: Parent\n---\n\n\ + Before [>> parent first ] text.\n\n\ + {{< include child.qmd >}}\n\n\ + After [>> parent last ]{author=\"Alice\"} text.\n"; + + let bundle = run_head_pipeline_in_dir(&project_dir, &parent_path, parent_content).await; + + let texts: Vec<&str> = bundle + .profile + .comments + .iter() + .map(|c| c.text.as_str()) + .collect(); + assert_eq!( + texts, + vec!["parent first", "child note", "parent last"], + "comments from included files count toward the host, in source \ + order (bd-0rsk07il); got: {texts:?}" + ); + assert_eq!( + bundle.profile.comments[2].author.as_deref(), + Some("Alice"), + "in-band author attribute survives the pipeline path" + ); +} + #[tokio::test] async fn profile_records_direct_include_in_includes_field() { // bd-r82e: a parent that pulls in `{{< include child.qmd >}}` @@ -657,3 +701,55 @@ async fn pipeline_clone_and_resume_listing_item_visible_in_profile() { assert_eq!(li.word_count, Some(9)); assert_eq!(li.reading_time_minutes, Some(1)); } + +/// bd-0rsk07il Phase 2: the profile extracted at the checkpoint is +/// stashed on `StageContext` by `DocumentProfileStage` and bridged +/// back to the caller's `RenderContext` by `run_pipeline`, so +/// response builders (the WASM `RenderResponse`, future native +/// consumers) can read it after a full render without re-parsing. +#[tokio::test] +async fn render_qmd_to_html_bridges_document_profile_to_ctx() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let dir = temp + .path() + .canonicalize() + .unwrap_or_else(|_| temp.path().to_path_buf()); + let qmd = b"---\ntitle: Bridged\n---\n\nProse [>> needs work ] here.\n"; + let qmd_path = dir.join("doc.qmd"); + std::fs::write(&qmd_path, qmd).expect("write fixture"); + + let project = ProjectContext { + dir: dir.clone(), + config: ProjectConfig::default(), + is_single_file: true, + files: vec![DocumentInfo::from_path(qmd_path.clone())], + output_dir: dir.clone(), + + ..Default::default() + }; + let doc = DocumentInfo::from_path(qmd_path.clone()); + let format = Format::html(); + let binaries = quarto_core::render::BinaryDependencies::new(); + let mut ctx = RenderContext::new(&project, &doc, &format, &binaries); + let runtime: Arc = + Arc::new(quarto_system_runtime::NativeRuntime::new()); + + let output = render_qmd_to_html( + qmd, + &qmd_path.to_string_lossy(), + &mut ctx, + &HtmlRenderConfig::default(), + runtime, + ) + .await + .expect("render succeeds"); + assert!(!output.html.is_empty()); + + let profile = ctx + .document_profile + .as_ref() + .expect("RenderContext must carry the bridged document profile"); + assert_eq!(profile.title.as_deref(), Some("Bridged")); + let texts: Vec<&str> = profile.comments.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, vec!["needs work"]); +} diff --git a/crates/quarto-core/tests/integration/render_page_in_project.rs b/crates/quarto-core/tests/integration/render_page_in_project.rs index aeb01ba19..b367e3efc 100644 --- a/crates/quarto-core/tests/integration/render_page_in_project.rs +++ b/crates/quarto-core/tests/integration/render_page_in_project.rs @@ -1042,3 +1042,64 @@ fn render_to_preview_ast_renderer_with_attribution_surfaces_keys() { snippet(json), ); } + +/// bd-0rsk07il Phase 2: the active page's Pass-2 output carries its +/// `DocumentProfile`, so the WASM response builder can surface the +/// comment summary (and other profile data) without re-parsing. +/// HTML-renderer flavor. +#[test] +fn active_page_profile_comments_on_html_output() { + let temp = TempDir::new().unwrap(); + let project_dir = canonical(temp.path()); + + write( + &project_dir.join("_quarto.yml"), + "project:\n type: website\nwebsite:\n title: Test\n", + ); + write( + &project_dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nProse [>> one ] here.\n\nMore [>> two ]{author=\"Alice\"} text.\n", + ); + + let active = canonical(&project_dir.join("index.qmd")); + let output = render_active_page(&project_dir, &active); + + let profile = output + .document_profile + .as_ref() + .expect("active-page output must carry the document profile"); + let texts: Vec<&str> = profile.comments.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, vec!["one", "two"]); + assert_eq!(profile.comments[1].author.as_deref(), Some("Alice")); + assert_eq!(profile.title.as_deref(), Some("Home")); +} + +/// Same as [`active_page_profile_comments_on_html_output`] but through +/// `RenderToPreviewAstRenderer` — the hub-client q2-preview path the +/// comment badge actually consumes. +#[test] +fn active_page_profile_comments_on_preview_output() { + let temp = TempDir::new().unwrap(); + let project_dir = canonical(temp.path()); + + write( + &project_dir.join("_quarto.yml"), + "project:\n type: website\nwebsite:\n title: Test\n", + ); + write( + &project_dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nProse [>> one ] here.\n\nMore [>> two ]{author=\"Alice\"} text.\n", + ); + + let active = canonical(&project_dir.join("index.qmd")); + let output = render_active_page_preview(&project_dir, &active); + assert!(output.payload.as_ast_json().is_some()); + + let profile = output + .document_profile + .as_ref() + .expect("q2-preview active-page output must carry the document profile"); + let texts: Vec<&str> = profile.comments.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, vec!["one", "two"]); + assert_eq!(profile.comments[1].author.as_deref(), Some("Alice")); +} diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock index 6cb8a3947..c9bc21405 100644 --- a/crates/wasm-quarto-hub-client/Cargo.lock +++ b/crates/wasm-quarto-hub-client/Cargo.lock @@ -2302,6 +2302,7 @@ dependencies = [ "quarto-config", "quarto-pandoc-types", "quarto-source-map", + "quarto-util", "yaml-rust2", ] diff --git a/crates/wasm-quarto-hub-client/src/lib.rs b/crates/wasm-quarto-hub-client/src/lib.rs index ce4bbdf72..e8a823886 100644 --- a/crates/wasm-quarto-hub-client/src/lib.rs +++ b/crates/wasm-quarto-hub-client/src/lib.rs @@ -638,6 +638,31 @@ struct RenderResponse { /// errors, single-doc renders without a `theme:` YAML key). #[serde(skip_serializing_if = "Option::is_none")] theme_fingerprint: Option, + /// Editorial-comment summary for the active page (bd-0rsk07il, + /// GH #445): every outstanding comment from the page's Pass-1 + /// `DocumentProfile`, in source order, with 1-based Monaco + /// positions (see `quarto_core::document_profile::JsonComment`). + /// The hub-client comment-toggle badge reads `comments.length`. + /// `None` when the document has no comments and on error + /// responses. + #[serde(skip_serializing_if = "Option::is_none")] + comments: Option>, +} + +/// Build the `RenderResponse.comments` summary from a document +/// profile: the transport form of each comment, or `None` when the +/// profile is absent (pipeline stopped early) or has no comments +/// (keeps the wire payload compact — consumers treat absent as +/// zero). +fn comments_to_json( + profile: Option<&quarto_core::document_profile::DocumentProfile>, + ctx: &SourceContext, +) -> Option> { + let profile = profile?; + if profile.comments.is_empty() { + return None; + } + Some(profile.comments.iter().map(|c| c.to_json(ctx)).collect()) } /// Create a minimal project context for WASM rendering. @@ -1554,6 +1579,9 @@ async fn render_single_doc_to_response( let warnings = diagnostics_to_json(&diagnostics, &source_context); let theme_fingerprint = extract_theme_fingerprint(&ctx.artifacts); + // bd-0rsk07il: the profile was bridged onto the RenderContext by + // `run_pipeline` (via the `UnwrapProfileStage` stash). + let comments = comments_to_json(ctx.document_profile.as_ref(), &source_context); serde_json::to_string(&RenderResponse { success: true, error: None, @@ -1569,6 +1597,7 @@ async fn render_single_doc_to_response( }, pass1_failures: None, theme_fingerprint, + comments, }) .unwrap() } @@ -1795,6 +1824,14 @@ async fn render_project_active_page_to_response( // and default-project-flush paths. let theme_fingerprint_from_output = active_output.theme_fingerprint.clone(); + // bd-0rsk07il: the active page's profile travels on the Pass-2 + // output (captured from the per-page RenderContext by the + // renderer); its comment summary rides the response. + let comments = comments_to_json( + active_output.document_profile.as_ref(), + &active_output.source_context, + ); + // Pass-1 failures for non-active-page files (bd-rqba). The // active page's own Pass-1 failure shortcuts above via // `pass_failure_response`, so anything reaching this branch @@ -1856,6 +1893,7 @@ async fn render_project_active_page_to_response( Some(pass1_failures) }, theme_fingerprint: theme_fingerprint_from_output, + comments, }) .unwrap() } @@ -1898,6 +1936,7 @@ fn error_response(msg: impl Into) -> String { warnings: None, pass1_failures: None, theme_fingerprint: None, + comments: None, }) .unwrap() } @@ -1923,6 +1962,7 @@ fn render_error_response(e: QuartoError) -> String { warnings: None, pass1_failures: None, theme_fingerprint: None, + comments: None, }) .unwrap() } @@ -1961,6 +2001,7 @@ fn pass_failure_response( warnings: None, pass1_failures: None, theme_fingerprint: None, + comments: None, }) .unwrap() } diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 90253ad60..2cfbeeae4 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-08-25 + +- [`d124b72f`](https://github.com/quarto-dev/q2/commits/d124b72f): The comment-display toggle in the bottom bar now shows a count badge with the number of outstanding comments on the current document, so you can see at a glance whether a page has open comments without scanning it. + ### 2026-08-22 - [`0de96cd7a`](https://github.com/quarto-dev/q2/commits/0de96cd7a): Clicking in either preview pane now lines the source editor up with what you clicked: the first line of that block's source sits at the same height on screen as the block itself, so the two panes read side by side. Previously the q2-preview did nothing at all when you clicked a block, and the HTML preview scrolled your line to the middle of the editor. Selecting text that came from an included file also no longer moves your cursor to an unrelated line of the file you are editing. diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index f1d882b5e..8b89b774d 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -18,7 +18,7 @@ import { import { vfsAddFile, isWasmReady, clearCapture } from '@quarto/preview-runtime'; import { PreviewStatusBar } from './render/PreviewStatusBar'; import { hasExecutableCells } from '../services/executableCells'; -import type { Diagnostic } from '@quarto/preview-renderer/types/diagnostic'; +import type { Diagnostic, RenderComment } from '@quarto/preview-renderer/types/diagnostic'; import { useIntelligenceProviders } from '../hooks/useIntelligenceProviders'; import { registerQmdLanguage } from './quartoTheme'; import { processFileForUpload } from '../services/resourceService'; @@ -279,6 +279,11 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // three-way toggle in the replay bar, threaded into the q2-preview // iframe where CommentBlock consumes it. const [commentsMode, setCommentsMode] = useState<'expand' | 'show' | 'hide'>('show'); + // Outstanding editorial comments on the active page (bd-0rsk07il, + // GH #445), reported by ReactPreview from the render pipeline's + // DocumentProfile summary. Drives the count badge on the + // comments-mode toggle in the replay bar. + const [outstandingCommentCount, setOutstandingCommentCount] = useState(0); // `useAttribution` (inside ReactPreview) reports whether it's // mid-build via `onAttributionGeneratingChange`; the flag drives // the rotating-gradient border on the Attribution pill so a slow @@ -396,6 +401,13 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC setDiagnostics(newDiagnostics); }, []); + // Callback for when a preview render reports the active page's + // outstanding comments (bd-0rsk07il). Only the count is kept — + // the badge is the sole consumer today. + const handleCommentsChange = useCallback((comments: RenderComment[]) => { + setOutstandingCommentCount(comments.length); + }, []); + // Callback for when preview WASM status changes const handleWasmStatusChange = useCallback((status: 'loading' | 'ready' | 'error', error: string | null) => { setWasmStatus(status); @@ -1149,6 +1161,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC onFileChange={handlePreviewFileChange} onOpenNewFileDialog={handlePreviewOpenNewFileDialog} onDiagnosticsChange={handleDiagnosticsChange} + onCommentsChange={handleCommentsChange} onWasmStatusChange={handleWasmStatusChange} onRegisterScrollToLine={handleRegisterScrollToLine} onRegisterSetScrollRatio={handleRegisterSetScrollRatio} @@ -1178,6 +1191,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC onAttributionChange={setAttributionOn} commentsMode={commentsMode} onCommentsModeChange={setCommentsMode} + commentsCount={outstandingCommentCount} attributionGenerating={attributionGenerating} attributionDisabled={ currentFormat !== 'q2-debug' && currentFormat !== 'q2-preview' diff --git a/hub-client/src/components/ReplayDrawer.commentsBadge.test.tsx b/hub-client/src/components/ReplayDrawer.commentsBadge.test.tsx new file mode 100644 index 000000000..8084d3eb7 --- /dev/null +++ b/hub-client/src/components/ReplayDrawer.commentsBadge.test.tsx @@ -0,0 +1,83 @@ +/** + * Tests for the outstanding-comments count badge on the comments-mode + * toggle (bd-0rsk07il, GH #445). + * + * The count arrives from the render pipeline's `DocumentProfile` + * comment summary (RenderResponse.comments) via Editor state. One + * badge for the whole toggle group (resolved decision: single + * location), hidden at zero. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import ReplayDrawer from './ReplayDrawer'; +import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; + +afterEach(cleanup); + +const inactiveState: ReplayState = { + isActive: false, + historyLength: 0, + currentIndex: 0, + isPlaying: false, + playbackSpeed: 1, + currentContent: '', + timestamp: null, + actor: null, + chunkActors: [], +}; + +const noopControls: ReplayControls = { + enter: () => {}, + exit: () => {}, + apply: () => {}, + seekTo: () => {}, + seekToStart: () => {}, + seekToEnd: () => {}, + play: () => {}, + pause: () => {}, + stepForward: () => {}, + stepBackward: () => {}, + cycleSpeed: () => {}, + getTimestampAtIndex: () => null, +}; + +function renderDrawer(commentsCount?: number) { + return render( + {}} + commentsCount={commentsCount} + />, + ); +} + +describe('comments-mode toggle badge', () => { + it('shows the outstanding-comment count when positive', () => { + renderDrawer(3); + const badge = screen.getByLabelText('3 outstanding comments'); + expect(badge.textContent).toBe('3'); + }); + + it('renders no badge at zero', () => { + renderDrawer(0); + expect(screen.queryByLabelText(/outstanding comment/)).toBeNull(); + // The toggle group itself still renders. + expect(screen.getByRole('group', { name: 'Comment display mode' })).toBeTruthy(); + }); + + it('renders no badge when the count is not provided', () => { + renderDrawer(undefined); + expect(screen.queryByLabelText(/outstanding comment/)).toBeNull(); + }); + + it('uses singular phrasing for one comment', () => { + renderDrawer(1); + const badge = screen.getByLabelText('1 outstanding comment'); + expect(badge.textContent).toBe('1'); + }); +}); diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index ce475658d..c126b8722 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -422,3 +422,23 @@ animation: none; } } + +/* Outstanding-comments count badge on the comments-mode toggle + (bd-0rsk07il, GH #445). One badge for the whole group; hidden at + zero (the element is simply not rendered). Reuses the view-toggle + palette so it reads as part of the control. */ +.comments-toggle-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + height: 14px; + padding: 0 4px; + margin-left: 4px; + border-radius: 7px; + font-size: 10px; + font-weight: 600; + line-height: 1; + background: var(--view-toggle-active-bg); + color: var(--view-toggle-active-text); +} diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 2741e5ebf..ccbda1e3f 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -49,6 +49,13 @@ interface Props { */ commentsMode?: CommentsMode; onCommentsModeChange?: (next: CommentsMode) => void; + /** + * Number of outstanding editorial comments on the active page + * (bd-0rsk07il, GH #445), from the render pipeline's + * `DocumentProfile` comment summary. Renders one count badge on + * the comments toggle group; hidden when 0 or absent. + */ + commentsCount?: number; } type CommentsMode = 'expand' | 'show' | 'hide'; @@ -61,9 +68,12 @@ type CommentsMode = 'expand' | 'show' | 'hide'; function CommentsModeToggle({ mode, onChange, + count, }: { mode: CommentsMode; onChange: (next: CommentsMode) => void; + /** Outstanding-comment count; badge hidden when 0 or absent. */ + count?: number; }) { // Speech-bubble outline shared by the show/hide icons; the expand // icon is the same bubble with a taller body. @@ -118,6 +128,15 @@ function CommentsModeToggle({ + {count !== undefined && count > 0 && ( + + {count} + + )} ); } @@ -203,6 +222,7 @@ export default function ReplayDrawer({ attributionDisabled, commentsMode, onCommentsModeChange, + commentsCount, }: Props) { const showAttributionToggle = attributionOn !== undefined && onAttributionChange !== undefined; @@ -320,7 +340,7 @@ export default function ReplayDrawer({ /> )} {showCommentsToggle && ( - + )} ); @@ -386,7 +406,7 @@ export default function ReplayDrawer({ /> )} {showCommentsToggle && ( - + )} diff --git a/hub-client/src/components/render/PreviewRouter.tsx b/hub-client/src/components/render/PreviewRouter.tsx index 3887a138b..28ce6214f 100644 --- a/hub-client/src/components/render/PreviewRouter.tsx +++ b/hub-client/src/components/render/PreviewRouter.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import type * as Monaco from 'monaco-editor'; import type { FileEntry } from '@quarto/preview-renderer/types/project'; import { isSourceFile } from '@quarto/preview-renderer/types/project'; -import type { Diagnostic } from '@quarto/preview-renderer/types/diagnostic'; +import type { Diagnostic, RenderComment } from '@quarto/preview-renderer/types/diagnostic'; import type { ActorIdentity, CaptureRef } from '@quarto/preview-runtime'; import { parseQmdToAst, isWasmReady, initWasm } from '@quarto/preview-runtime'; import Preview from './Preview'; @@ -57,6 +57,13 @@ interface PreviewRouterProps { * `Preview` branch has no comment chrome). */ commentsMode?: 'expand' | 'show' | 'hide'; + /** + * Reports the active page's outstanding editorial comments up to + * `Editor.tsx` for the comments-toggle badge (bd-0rsk07il, GH + * #445). Only fires from the ReactPreview branch — the non-React + * `Preview` branch has no comment chrome. + */ + onCommentsChange?: (comments: RenderComment[]) => void; /** * Reports `useAttribution`'s in-flight state up to `Editor.tsx` so * the Attribution pill can animate its border while attribution @@ -151,7 +158,7 @@ export default function PreviewRouter(props: PreviewRouterProps) { // Render the appropriate preview component with shared WASM error banner. // `identities` and `attributionOn` are for ReactPreview only — Preview // doesn't know about either. - const { onRegisterScrollToLine, onRegisterSetScrollRatio, onRegisterReplayScroll, onFormatChange, onContentRewrite, fileContents, identities, captures, attributionOn, commentsMode, onAttributionGeneratingChange, ...commonProps } = props; + const { onRegisterScrollToLine, onRegisterSetScrollRatio, onRegisterReplayScroll, onFormatChange, onContentRewrite, fileContents, identities, captures, attributionOn, commentsMode, onCommentsChange, onAttributionGeneratingChange, ...commonProps } = props; return (
@@ -161,7 +168,7 @@ export default function PreviewRouter(props: PreviewRouterProps) { )}
{reactFormat ? ( - + ) : ( // Phase 9 Decision 6: pass `fileContents` so any sibling // edit (including `_quarto.yml`) triggers a re-render via diff --git a/hub-client/src/components/render/ReactPreview.tsx b/hub-client/src/components/render/ReactPreview.tsx index c5117860c..81c2df305 100644 --- a/hub-client/src/components/render/ReactPreview.tsx +++ b/hub-client/src/components/render/ReactPreview.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import type { CSSProperties } from 'react'; import type * as Monaco from 'monaco-editor'; import type { FileEntry } from '@quarto/preview-renderer/types/project'; -import type { Diagnostic, PreviewNodeEditPayload } from '@quarto/preview-renderer/types/diagnostic'; +import type { Diagnostic, PreviewNodeEditPayload, RenderComment } from '@quarto/preview-renderer/types/diagnostic'; import type { ActorIdentity, CaptureRef } from '@quarto/preview-runtime'; import { parseQmdToAstWithAttribution, @@ -91,6 +91,15 @@ interface PreviewProps { onFileChange: (file: FileEntry, anchor?: string) => void; onOpenNewFileDialog: (initialFilename: string) => void; onDiagnosticsChange: (diagnostics: Diagnostic[]) => void; + /** + * Reports the active page's outstanding editorial comments + * (bd-0rsk07il, GH #445) after each successful preview-pipeline + * render, from `RenderResponse.comments` (absent on the wire means + * zero — an empty array is reported). Not called for parse-only + * formats (q2-debug), which never compute the summary, nor on + * render failures (last-good count is preserved, matching the AST). + */ + onCommentsChange?: (comments: RenderComment[]) => void; onAstChange?: (astJson: string | null) => void; currentSlideIndex?: number; onSlideChange?: (slideIndex: number) => void; @@ -163,6 +172,14 @@ type RenderResult = { * transient errors (see `setThemeFingerprint` call site). */ themeFingerprint?: string | null; + /** + * Outstanding editorial comments on the active page from the + * document's Pass-1 profile (bd-0rsk07il). Present exactly when the + * render went through the preview pipeline (RenderResponse carries + * the summary; wire-absent means zero, mapped to `[]` here). Omitted + * for parse-only formats, where the count is unknown. + */ + comments?: RenderComment[]; } | { success: false; error: string; @@ -293,6 +310,7 @@ async function doRender( untransformedAstJson: result.untransformed_ast_json, diagnostics: allDiagnostics, themeFingerprint, + comments: result.comments ?? [], }; } else { const errorMsg = @@ -459,6 +477,7 @@ export default function ReactPreview({ onFileChange, onOpenNewFileDialog, onDiagnosticsChange, + onCommentsChange, onAstChange, currentSlideIndex, onSlideChange, @@ -718,6 +737,12 @@ export default function ReactPreview({ if (result.themeFingerprint !== undefined) { setThemeFingerprint(result.themeFingerprint); } + // Outstanding-comment count for the toolbar badge. Only + // preview-pipeline renders carry the field; failures and + // parse-only formats leave the last-good count in place. + if (result.comments !== undefined) { + onCommentsChange?.(result.comments); + } // Notify parent of AST change onAstChange?.(result.astJson); } else { @@ -734,7 +759,7 @@ export default function ReactPreview({ setPreviewState('ERROR_FROM_GOOD'); } } - }, [scrollSyncEnabled, onDiagnosticsChange, onAstChange, format, attributionPayload, captureBytes]); + }, [scrollSyncEnabled, onDiagnosticsChange, onCommentsChange, onAstChange, format, attributionPayload, captureBytes]); // Immediate render update (no debounce) const updatePreview = useCallback((newContent: string, documentPath?: string) => { diff --git a/ts-packages/preview-renderer/src/types/diagnostic.ts b/ts-packages/preview-renderer/src/types/diagnostic.ts index c169ac8d2..444dc5264 100644 --- a/ts-packages/preview-renderer/src/types/diagnostic.ts +++ b/ts-packages/preview-renderer/src/types/diagnostic.ts @@ -61,6 +61,31 @@ export interface Pass1Failure { diagnostics: Diagnostic[]; } +/** + * One outstanding editorial comment on the rendered page, from the + * document's Pass-1 `DocumentProfile` (bd-0rsk07il, GH #445). + * Matches `quarto_core::document_profile::JsonComment`: positions are + * 1-based (Monaco convention); `file` is the source file the mark + * maps to, which for a comment inside an `{{< include >}}` is the + * included file rather than the active document. + */ +export interface RenderComment { + /** Plain-text comment content. */ + text: string; + /** In-band author (`author=` attribute), when stamped. */ + author?: string; + /** In-band ISO 8601 timestamp (`date=` attribute), when stamped. */ + date?: string; + /** Remaining attr key-value pairs, authored order. */ + attributes?: [string, string][]; + /** Path of the source file the mark's span maps to. */ + file?: string; + start_line?: number; + start_column?: number; + end_line?: number; + end_column?: number; +} + /** * Render response from WASM with structured diagnostics. */ @@ -99,6 +124,13 @@ export interface RenderResponse { * (errors, q2-debug, themeless single-doc). */ theme_fingerprint?: string; + /** + * Outstanding editorial comments on the active page, in source + * order (bd-0rsk07il, GH #445). Absent when the document has none + * (consumers treat absent as zero) and on error responses. The + * comment-toggle badge reads `comments.length`. + */ + comments?: RenderComment[]; } /**