The Quarto Review Extension uses deterministic correspondence mapping to create stable, bidirectional links between:
.qmd files)This document explains how the mapping works, how to verify it, and how to extend it for translations.
The system generates structure-based IDs that are:
[prefix].[section-path].[element-type]-[counter]
review.para-1 # First paragraph (no section)
review.sec-intro.para-1 # First paragraph in Introduction
review.sec-intro.header-1 # Introduction heading
review.sec-intro.methods.para-2 # Second paragraph in Methods subsection
review.sec-intro.methods.results.codeblock-1 # First code block in Results sub-subsection
review (configurable via _extension.yml)sec-intro.methods)para-1, header-2, bulletlist-1, etc.When Quarto renders the document, the Lua filter (_extensions/review/review.lua) processes each element:
-- Generates ID based on current context
function generate_id(element_type, level)
local parts = {config.id_prefix}
-- Add section hierarchy (e.g., ["sec-intro", "methods"])
for _, section in ipairs(context.section_stack) do
table.insert(parts, section)
end
-- Add element type and counter
local key = element_type
if level then
key = key .. "-" .. level
end
context.element_counters[key] = (context.element_counters[key] or 0) + 1
local counter = context.element_counters[key]
table.insert(parts, element_type:lower() .. "-" .. counter)
return table.concat(parts, config.id_separator)
end
Each editable element is wrapped with data attributes, including the original markdown:
<!-- Paragraph with formatting -->
<div class="review-editable"
data-review-id="review.sec-intro.para-1"
data-review-type="Para"
data-review-origin="source"
data-review-markdown="This is **bold** and *italic* text with a [link](https://example.com).">
<p>This is <strong>bold</strong> and <em>italic</em> text with a <a href="https://example.com">link</a>.</p>
</div>
<!-- Header -->
<div class="review-editable"
data-review-id="review.sec-intro.methods.header-1"
data-review-type="Header"
data-review-level="2"
data-review-origin="source"
data-review-markdown="## Methods">
<h2>Methods</h2>
</div>
<!-- Code Block with language -->
<div class="review-editable"
data-review-id="review.sec-intro.codeblock-1"
data-review-type="CodeBlock"
data-review-origin="source"
data-review-markdown="```javascript console.log("hello"); ```">
<pre><code class="language-javascript">console.log("hello");</code></pre>
</div>
Key Point: The data-review-markdown attribute contains the original markdown source (HTML-escaped), enabling:
.qmd needed for reviewing)When the page loads, ChangesModule.initializeFromDOM() parses the HTML:
public initializeFromDOM(): void {
const editableElements = document.querySelectorAll('.review-editable');
this.originalElements = Array.from(editableElements).map((elem) => {
const id = elem.getAttribute('data-review-id') || '';
const type = elem.getAttribute('data-review-type') || 'Para';
const level = elem.getAttribute('data-review-level');
const element: Element = {
id,
content: this.extractMarkdownContent(elem, metadata),
metadata: {
type: type as Element['metadata']['type'],
level: level ? parseInt(level, 10) : undefined,
// ...
},
};
return element;
});
}
This creates the correspondence:
HTML Element (data-review-id) ←→ JavaScript Element Object ←→ Markdown Source
One of the key features of this extension is the ability to distribute only the HTML file for review, without requiring access to the .qmd source files or a Quarto installation.
quarto render document.qmd
document.html → Reviewer's browser
.qmd source needed.json file with operations.json operations file.qmdThe embedded markdown in data-review-markdown attributes means:
Since the HTML embeds the original markdown, be aware that:
Render a document and inspect the HTML for correct attributes:
cd example
quarto render document.qmd
Look for elements like:
<div class="review-editable"
data-review-id="review.sec-intro.para-1"
data-review-type="Para"
data-review-origin="source">
Render the document twice:
quarto render document.qmd
cp _output/document.html test1.html
quarto render document.qmd
cp _output/document.html test2.html
diff test1.html test2.html
The data-review-id attributes should be identical between renders.
Make a minor edit (add a word to a paragraph), then re-render:
# Edit document.qmd - change one paragraph
quarto render document.qmd
Expected: IDs for unchanged elements remain the same; only edited section IDs may shift.
Open browser console on rendered page:
// Check that elements are initialized
document.querySelectorAll('.review-editable').length
// Inspect a specific element's data
const elem = document.querySelector('[data-review-id="review.sec-intro.para-1"]');
console.log({
id: elem.getAttribute('data-review-id'),
type: elem.getAttribute('data-review-type'),
content: elem.textContent
});
Issue #1: Headers wrapped in <section> tags instead of <div>
Quarto post-processes headers into semantic <section> tags:
<!-- Generated by Lua filter -->
<div class="review-editable" data-review-id="review.sec-intro.header-1" ...>
<h1>Introduction</h1>
</div>
<!-- Post-processed by Quarto -->
<section id="sec-intro" class="level1"
data-review-type="Header"
data-review-id="review.sec-intro.header-1" ...>
<h1>Introduction</h1>
</section>
Result: Headers are missing the class="review-editable" attribute, so they cannot be clicked to edit.
Impact: Headers are not editable in the UI.
Potential Fix:
class="review-editable" to <section> tags post-render (JavaScript patch)keep-md option to preserve original structureMap equivalent elements across language versions:
<!-- document_en.qmd -->
# Introduction {#sec-intro}
This is an example. {#para-1}
<!-- document_fr.qmd -->
# Introduction {#sec-intro}
Ceci est un exemple. {#para-1}
The deterministic IDs ensure 1:1 correspondence:
English French
├─ review.sec-intro.header-1 ←→ review.sec-intro.header-1
├─ review.sec-intro.para-1 ←→ review.sec-intro.para-1
└─ review.sec-intro.para-2 ←→ review.sec-intro.para-2
interface Element {
id: string; // e.g., "review.sec-intro.para-1"
content: string; // Markdown content
metadata: ElementMetadata; // Type, level, attributes
sourcePosition?: { // Optional source location
line: number;
column: number;
};
}
interface ElementMetadata {
type: 'Para' | 'Header' | 'CodeBlock' | 'BulletList' | 'OrderedList' | 'BlockQuote';
level?: number; // For headers: 1-6
attributes: Record<string, string>;
classes: string[];
}
| Attribute | Type | Description | Example |
|---|---|---|---|
data-review-id |
string | Unique deterministic ID | review.sec-intro.para-1 |
data-review-type |
string | Element type | Para, Header, CodeBlock |
data-review-level |
number | Header level (1-6) | 2 for <h2> |
data-review-markdown |
string | Original markdown source (HTML-escaped) | This is **bold** text. |
data-review-origin |
string | Source indicator | source, user |
data-review-source-line |
number | Source file line (optional) | 42 |
data-review-source-column |
number | Source file column (optional) | 0 |
import { describe, it, expect } from 'vitest';
import { ChangesModule } from '@/modules/changes';
describe('Correspondence Mapping', () => {
it('should generate deterministic IDs', () => {
// Create mock DOM
document.body.innerHTML = `
<div class="review-editable"
data-review-id="review.sec-intro.para-1"
data-review-type="Para">
<p>Test content</p>
</div>
`;
const changes = new ChangesModule();
changes.initializeFromDOM();
const elements = changes.getCurrentState();
expect(elements[0].id).toBe('review.sec-intro.para-1');
expect(elements[0].metadata.type).toBe('Para');
});
});
import { test, expect } from '@playwright/test';
test('element IDs are deterministic across renders', async ({ page }) => {
// First render
await page.goto('http://localhost:8080/document.html');
const ids1 = await page.locator('[data-review-id]').evaluateAll(
elems => elems.map(e => e.getAttribute('data-review-id'))
);
// Re-render (simulated by reload)
await page.reload();
const ids2 = await page.locator('[data-review-id]').evaluateAll(
elems => elems.map(e => e.getAttribute('data-review-id'))
);
// IDs should be identical
expect(ids1).toEqual(ids2);
});
cd example/_output
grep -n "data-review-id" document.html | head -20
Look for patterns in ID structure.
grep "data-review-id.*header" document.html
Verify section nesting is correct.
# Count paragraphs
grep -c "data-review-type=\"Para\"" document.html
# Count headers
grep -c "data-review-type=\"Header\"" document.html
// Get all IDs
const ids = Array.from(document.querySelectorAll('[data-review-id]'))
.map(el => el.getAttribute('data-review-id'));
console.table(ids);
// Check for duplicates
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
console.log('Duplicates:', duplicates);
_extension.yml)review:
id-prefix: "review" # Change to your prefix
id-separator: "." # Change separator (e.g., "-", "_")
editable-elements: # Which elements to wrap
- Para
- Header
- CodeBlock
- BulletList
- OrderedList
- BlockQuote
<div data-review data-review-config='{
"autoSave": false,
"enableComments": true,
"enableTranslation": false
}'></div>
para-5 will renumber all subsequent paragraphs