Skip to content

Commit f6336c7

Browse files
authored
Merge pull request #161 from posit-dev/feat-mock-code
feat: mock code cells
2 parents 385314e + a5c075a commit f6336c7

9 files changed

Lines changed: 1237 additions & 0 deletions

File tree

great_docs/_mock_code.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from pathlib import Path
5+
6+
# Matches the opening fence of an executable code block:
7+
# ```{python} or ```{python} (with trailing whitespace)
8+
_EXEC_FENCE_RE = re.compile(r"^```\{python\}\s*$")
9+
10+
# Matches the closing fence:
11+
# ```
12+
_CLOSE_FENCE_RE = re.compile(r"^```\s*$")
13+
14+
# Matches a hash-pipe option line:
15+
# #| key: value
16+
_HASHPIPE_RE = re.compile(r"^#\|\s*(\S+?):\s*(.*)$")
17+
18+
# The delimiter that separates display code from eval code:
19+
_DELIMITER = "# ---"
20+
21+
22+
def expand_mock_cells(text: str) -> str:
23+
"""Rewrite `source-code: mock` cells in *text* into two-cell pairs.
24+
25+
Parameters
26+
----------
27+
text
28+
The full content of a `.qmd` file.
29+
30+
Returns
31+
-------
32+
str
33+
The rewritten content. Unchanged if no mock cells are found.
34+
"""
35+
lines = text.split("\n")
36+
out: list[str] = []
37+
i = 0
38+
39+
while i < len(lines):
40+
line = lines[i]
41+
42+
# Look for an opening executable fence
43+
if not _EXEC_FENCE_RE.match(line):
44+
out.append(line)
45+
i += 1
46+
continue
47+
48+
# Collect the entire cell (fence-to-fence)
49+
cell_start = i
50+
i += 1
51+
cell_body: list[str] = []
52+
found_close = False
53+
while i < len(lines):
54+
if _CLOSE_FENCE_RE.match(lines[i]):
55+
found_close = True
56+
i += 1
57+
break
58+
cell_body.append(lines[i])
59+
i += 1
60+
61+
if not found_close:
62+
# Malformed cell — emit as-is
63+
out.append(lines[cell_start])
64+
out.extend(cell_body)
65+
continue
66+
67+
# Parse hash-pipe options from the top of the cell body
68+
options: dict[str, str] = {}
69+
option_lines: list[str] = []
70+
body_start = 0
71+
for j, bline in enumerate(cell_body):
72+
m = _HASHPIPE_RE.match(bline)
73+
if m:
74+
options[m.group(1)] = m.group(2).strip()
75+
option_lines.append(bline)
76+
body_start = j + 1
77+
else:
78+
break
79+
80+
# Not a mock cell — emit unchanged
81+
if options.get("source-code") != "mock":
82+
out.append(lines[cell_start])
83+
out.extend(cell_body)
84+
out.append("```")
85+
continue
86+
87+
# Split the remaining body at the delimiter
88+
raw_body = cell_body[body_start:]
89+
display_lines: list[str] = []
90+
eval_lines: list[str] = []
91+
found_delim = False
92+
for bline in raw_body:
93+
if not found_delim and bline.strip() == _DELIMITER:
94+
found_delim = True
95+
continue
96+
if found_delim:
97+
eval_lines.append(bline)
98+
else:
99+
display_lines.append(bline)
100+
101+
# Collect options to forward (everything except source-code)
102+
# output-title and output-frame are forwarded to the eval cell only
103+
output_title = options.pop("output-title", None)
104+
output_frame = options.pop("output-frame", None)
105+
# Remove source-code from forwarded options
106+
options.pop("source-code", None)
107+
108+
# Build forwarded option lines for both cells
109+
forwarded = []
110+
for key, val in options.items():
111+
forwarded.append(f"#| {key}: {val}")
112+
113+
# --- Emit the display cell (eval: false) ---
114+
out.append("```{python}")
115+
out.append("#| eval: false")
116+
for fwd in forwarded:
117+
# Don't forward echo/output overrides to display cell
118+
if not fwd.startswith("#| echo:") and not fwd.startswith("#| output:"):
119+
out.append(fwd)
120+
out.extend(display_lines)
121+
out.append("```")
122+
123+
# --- Emit the eval cell (echo: false) ---
124+
if found_delim and eval_lines:
125+
out.append("")
126+
out.append("```{python}")
127+
out.append("#| echo: false")
128+
if output_title:
129+
out.append(f"#| output-title: {output_title}")
130+
if output_frame:
131+
out.append(f"#| output-frame: {output_frame}")
132+
for fwd in forwarded:
133+
# Don't forward eval overrides to eval cell
134+
if not fwd.startswith("#| eval:"):
135+
out.append(fwd)
136+
out.extend(eval_lines)
137+
out.append("```")
138+
elif not found_delim:
139+
# No delimiter found — the entire body is display-only (eval: false).
140+
# This is a valid use case (equivalent to just eval: false).
141+
pass
142+
143+
return "\n".join(out)
144+
145+
146+
def process_qmd_file(path: Path) -> bool:
147+
"""Process a single `.qmd` file, rewriting mock cells in place.
148+
149+
Parameters
150+
----------
151+
path
152+
Path to the `.qmd` file.
153+
154+
Returns
155+
-------
156+
bool
157+
`True` if the file was modified, `False` otherwise.
158+
"""
159+
content = path.read_text(encoding="utf-8")
160+
if "#| source-code: mock" not in content:
161+
return False
162+
163+
rewritten = expand_mock_cells(content)
164+
if rewritten == content:
165+
return False
166+
167+
path.write_text(rewritten, encoding="utf-8")
168+
return True
169+
170+
171+
def process_directory(directory: Path) -> list[str]:
172+
"""Process all `.qmd` files under *directory*.
173+
174+
Parameters
175+
----------
176+
directory
177+
Root directory to scan recursively.
178+
179+
Returns
180+
-------
181+
list[str]
182+
Relative paths of files that were modified.
183+
"""
184+
modified: list[str] = []
185+
for qmd in sorted(directory.rglob("*.qmd")):
186+
if process_qmd_file(qmd):
187+
try:
188+
rel = str(qmd.relative_to(directory))
189+
except ValueError:
190+
rel = str(qmd)
191+
modified.append(rel)
192+
return modified
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
title: Output Title
2+
author: Great Docs
3+
version: 1.0.0
4+
quarto-required: ">=1.3.0"
5+
contributes:
6+
filters:
7+
- output-title.lua
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
-- output-title.lua — Quarto filter for labelling and framing code cell outputs
2+
--
3+
-- Usage in .qmd files:
4+
--
5+
-- ```{python}
6+
-- #| output-title: "Response"
7+
-- chat.chat("Hello!")
8+
-- ```
9+
--
10+
-- ```{python}
11+
-- #| output-frame: true
12+
-- print("framed output, no title")
13+
-- ```
14+
--
15+
-- Wraps the cell's output in a styled container with an optional title.
16+
-- Works with any executable code cell, and composes with source-code: mock.
17+
18+
--- Extract the output-title value from a cell Div's attributes.
19+
--- Quarto passes unrecognised hash-pipe options as attributes on the
20+
--- enclosing div.cell element.
21+
---@param div pandoc.Div
22+
---@return string|nil The title text (unquoted), or nil if absent.
23+
local function get_output_title(div)
24+
-- Quarto passes custom cell options as attributes on the div
25+
local title = div.attributes["output-title"]
26+
if title == nil then return nil end
27+
28+
-- Strip surrounding quotes if present
29+
title = title:match('^"(.*)"$') or title:match("^'(.*)'$") or title
30+
if title == "" then return nil end
31+
return title
32+
end
33+
34+
--- Check whether output-frame is set to a truthy value.
35+
---@param div pandoc.Div
36+
---@return boolean
37+
local function get_output_frame(div)
38+
local val = div.attributes["output-frame"]
39+
if val == nil then return false end
40+
val = val:lower()
41+
return val == "true" or val == "yes" or val == "1"
42+
end
43+
44+
--- Check whether a block element is a cell-output container.
45+
---@param el pandoc.Block
46+
---@return boolean
47+
local function is_cell_output(el)
48+
if el.t ~= "Div" then return false end
49+
for _, cls in ipairs(el.classes) do
50+
if cls:match("^cell%-output") then return true end
51+
end
52+
return false
53+
end
54+
55+
function Div(div)
56+
-- Only operate on executable code cells
57+
if not div.classes:includes("cell") then return nil end
58+
59+
local title = get_output_title(div)
60+
local frame = get_output_frame(div)
61+
62+
-- Need either a title or an explicit frame request
63+
if title == nil and not frame then return nil end
64+
65+
-- Remove attributes so they don't leak into the HTML
66+
div.attributes["output-title"] = nil
67+
div.attributes["output-frame"] = nil
68+
69+
-- Collect output blocks and wrap them
70+
local new_content = pandoc.List()
71+
local output_blocks = pandoc.List()
72+
73+
for _, el in ipairs(div.content) do
74+
if is_cell_output(el) then
75+
output_blocks:insert(el)
76+
else
77+
new_content:insert(el)
78+
end
79+
end
80+
81+
if #output_blocks == 0 then return nil end
82+
83+
-- Build the container HTML
84+
local title_html = '<div class="gd-output-title-container">\n'
85+
if title then
86+
title_html = title_html
87+
.. '<div class="gd-output-title-header">' .. title .. '</div>\n'
88+
end
89+
title_html = title_html .. '<div class="gd-output-title-body">\n'
90+
local close_html = '</div>\n</div>'
91+
92+
-- Wrap: open tag, output blocks, close tag
93+
new_content:insert(pandoc.RawBlock("html", title_html))
94+
for _, ob in ipairs(output_blocks) do
95+
new_content:insert(ob)
96+
end
97+
new_content:insert(pandoc.RawBlock("html", close_html))
98+
99+
div.content = new_content
100+
return div
101+
end

great_docs/assets/great-docs.scss

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7809,6 +7809,77 @@ html[data-bs-theme="dark"] {
78097809
}
78107810

78117811

7812+
/* ── Output Title Container ─────────────────────────────────────────
7813+
Wraps code cell output with a labelled header.
7814+
Used by #| output-title: "..." hash-pipe option.
7815+
*/
7816+
.gd-output-title-container {
7817+
border: 1px solid rgba(0, 0, 0, 0.1);
7818+
border-radius: 0.375rem;
7819+
margin-top: 0.5rem;
7820+
margin-bottom: 1rem;
7821+
overflow: hidden;
7822+
}
7823+
7824+
.gd-output-title-header {
7825+
background: rgba(0, 0, 0, 0.03);
7826+
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
7827+
padding: 0.35rem 0.75rem;
7828+
font-size: 0.8rem;
7829+
font-weight: 600;
7830+
color: rgba(0, 0, 0, 0.55);
7831+
letter-spacing: 0.02em;
7832+
}
7833+
7834+
.gd-output-title-body {
7835+
padding: 0.5rem 0.75rem;
7836+
}
7837+
7838+
.gd-output-title-body .cell-output {
7839+
margin: 0;
7840+
padding: 0;
7841+
}
7842+
7843+
.gd-output-title-body .cell-output pre {
7844+
margin: 0;
7845+
border: none;
7846+
background: transparent;
7847+
padding: 0;
7848+
}
7849+
7850+
/* Frameless variant for rich HTML outputs (GT tables, styled DataFrames, etc.)
7851+
These objects carry their own visual structure; a surrounding frame creates
7852+
an ugly double-border. We keep the title label but drop the container
7853+
border and body padding so the HTML output breathes.
7854+
We target display outputs that contain a child div (rich HTML wrapper)
7855+
rather than just a <pre> block (which is a plain return value). */
7856+
.gd-output-title-container:has(.gd-output-title-body .cell-output-display > div) {
7857+
border: none;
7858+
}
7859+
7860+
.gd-output-title-container:has(.gd-output-title-body .cell-output-display > div)
7861+
.gd-output-title-header {
7862+
background: transparent;
7863+
border-bottom: none;
7864+
padding-left: 0;
7865+
}
7866+
7867+
.gd-output-title-container:has(.gd-output-title-body .cell-output-display > div)
7868+
.gd-output-title-body {
7869+
padding: 0;
7870+
}
7871+
7872+
body.quarto-dark .gd-output-title-container {
7873+
border-color: rgba(255, 255, 255, 0.12);
7874+
}
7875+
7876+
body.quarto-dark .gd-output-title-header {
7877+
background: rgba(255, 255, 255, 0.04);
7878+
border-bottom-color: rgba(255, 255, 255, 0.12);
7879+
color: rgba(255, 255, 255, 0.55);
7880+
}
7881+
7882+
78127883
/*-- scss:functions --*/
78137884

78147885
/*-- scss:uses --*/

0 commit comments

Comments
 (0)