Skip to content

Commit 3f4a653

Browse files
authored
feat: add code callouts (#649)
- [x] I have read the [contributing guidelines](https://github.com/iamgio/quarkdown/blob/main/CONTRIBUTING.md). - [x] I have tested the changes locally. - [ ] An issue for this change exists, and it was discussed with maintainers. This is required for new features and non-trivial changes. If present, append `Closes #ISSUE_NUMBER` at the end of this PR description. - [x] (Optional) I have added necessary documentation to [`docs`](https://github.com/iamgio/quarkdown/tree/main/docs) and [`CHANGELOG.md`](https://github.com/iamgio/quarkdown/blob/main/CHANGELOG.md) <img width="728" height="438" alt="image" src="https://github.com/user-attachments/assets/7d7720d5-7033-45cc-b0b3-b0e07875c1e1" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## New Features - Added code block callouts with numbered markers linked to explanatory descriptions. - Callouts support line numbers, focused lines, captions, and code loaded from files. - Added validation and safe escaping for callout content. - Added open-ended focused-line ranges for highlighting from a line to the beginning or end. ## Documentation - Updated code block documentation, changelog, and examples to explain callouts and focus ranges. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 7d4cac3 + 889c68e commit 3f4a653

30 files changed

Lines changed: 834 additions & 143 deletions

File tree

.run/CLI_ Mock.run.xml

Lines changed: 0 additions & 17 deletions
This file was deleted.

.run/CLI_Mock.run.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<configuration default="false" name="CLI: Mock" type="JetRunConfigurationType">
33
<option name="MAIN_CLASS_NAME" value="com.quarkdown.cli.QuarkdownCliKt" />
44
<module name="quarkdown.quarkdown-cli.main" />
5-
<option name="PROGRAM_PARAMETERS" value="c main.qd --libs ../quarkdown-libs/src/main/resources" />
5+
<option name="PROGRAM_PARAMETERS" value="c main.qd" />
66
<shortenClasspath name="NONE" />
77
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/mock" />
88
<method v="2">

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
#### [Code block callouts](https://quarkdown.com/wiki/code#callouts)
8+
9+
The new `callouts` argument of the `.code` function attaches numbered markers to specific lines of a code block, each paired with a description displayed right below the block.
10+
11+
```markdown
12+
.code callouts:{
13+
- 3: Defines the horizontal coordinate.
14+
- 5: Takes the two coordinates.
15+
- 6: Assigns the horizontal coordinate.
16+
}
17+
.read {assets/point.ts}
18+
```
19+
20+
<img width="660" alt="Callout" src="https://github.com/user-attachments/assets/b2f183ab-0e11-4213-bdd4-7913dd8622c1" />
21+
522
### Changed
623

724
#### Reflectionless function calls

docs/code.qd

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,16 @@ The `.code` function specifies the language through the optional `lang` argument
3838
.code lang:{typescript}
3939
.read {assets/point.ts}
4040

41-
### Line numbers
41+
### Callouts
4242

43-
Standard code blocks always show line numbers by default. The `.code` function lets you toggle line numbers using the optional `linenumbers` [`Boolean`](boolean.qd) argument, which defaults to `yes` (equivalent to `true`).
43+
The `.code` function can attach numbered markers to specific lines through the optional `callouts` argument, a [`Dictionary`](dictionary.qd) that pairs line numbers with a description.
4444

4545
.examplemirror
46-
.code linenumbers:{no}
46+
.code callouts:{
47+
- 3: Defines the horizontal coordinate.
48+
- 5: Takes the two coordinates.
49+
- 6: Assigns the horizontal coordinate.
50+
}
4751
.read {assets/point.ts}
4852

4953
### Focused lines
@@ -54,6 +58,16 @@ The `.code` function allows you to focus on a [`Range`](range.qd) of lines, star
5458
.code focus:{5..8}
5559
.read {assets/point.ts}
5660

61+
### Line numbers
62+
63+
Standard code blocks always show line numbers by default. The `.code` function lets you toggle line numbers using the optional `linenumbers` [`Boolean`](boolean.qd) argument, which defaults to `yes` (equivalent to `true`).
64+
65+
.examplemirror
66+
.code linenumbers:{no}
67+
.read {assets/point.ts}
68+
69+
Note that line numbers are required for callouts and focused lines to work.
70+
5771
### Extending
5872

5973
`.code` is a [primitive](primitives.qd), so [extending it](element-styling.qd) affects every code block in the document at once, including fenced and indented blocks.

mock/code.qd

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@
1212

1313
<<<
1414

15+
#### With callouts
16+
17+
.code lang:{java} callouts:{
18+
- 4: The constructor.
19+
- 8: Getter for the wrapped value.
20+
}
21+
.read {code/Wrapper.java}
22+
1523
#### Focused
1624

1725
.code lang:{java} focus:{4..6}
1826
.read {code/Wrapper.java}
1927

28+
<<<
29+
2030
#### Without line numbers
2131

2232
.code lang:{java} linenumbers:{no}

quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Code.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.quarkdown.core.ast.quarkdown.CaptionableNode
99
import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode
1010
import com.quarkdown.core.function.dsl.functionCallArguments
1111
import com.quarkdown.core.function.value.data.Range
12+
import com.quarkdown.core.function.value.wrappedAsValue
1213
import com.quarkdown.core.visitor.node.NodeVisitor
1314

1415
/**
@@ -17,7 +18,8 @@ import com.quarkdown.core.visitor.node.NodeVisitor
1718
* @param language optional syntax language
1819
* @param showLineNumbers whether to show line numbers
1920
* @param highlight whether to apply syntax highlighting
20-
* @param focusedLines range of lines to focus on. No lines are focused if `null`
21+
* @param focusedLines range of lines to focus on (1-based). No lines are focused if `null`
22+
* @param callouts callout contents associated with line numbers (1-based)
2123
* @param caption optional caption
2224
* @param referenceId optional ID for cross-referencing via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference]
2325
*/
@@ -27,6 +29,7 @@ class Code(
2729
val showLineNumbers: Boolean = true,
2830
val highlight: Boolean = true,
2931
val focusedLines: Range? = null,
32+
val callouts: Map<Int, String> = emptyMap(),
3033
override val caption: InlineContent? = null,
3134
override val referenceId: String? = null,
3235
) : LocationTrackableNode,
@@ -46,6 +49,14 @@ class Code(
4649
arg("caption", inline(caption))
4750
arg("linenumbers", boolean(showLineNumbers))
4851
arg("focus", obj(focusedLines))
52+
arg(
53+
"callouts",
54+
dictionary(
55+
callouts
56+
.map { it.key.toString() to it.value.wrappedAsValue() }
57+
.toMap(),
58+
),
59+
)
4960
arg("ref", string(referenceId))
5061
arg("code", evaluable(content))
5162
}

quarkdown-core/src/main/kotlin/com/quarkdown/core/function/dsl/FunctionCallArgumentsBuilder.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import com.quarkdown.core.ast.attributes.primitive.PrimitiveFunctionBackedNode
88
import com.quarkdown.core.function.call.FunctionCallArgument
99
import com.quarkdown.core.function.expression.Expression
1010
import com.quarkdown.core.function.value.BooleanValue
11+
import com.quarkdown.core.function.value.DictionaryValue
1112
import com.quarkdown.core.function.value.NoneValue
1213
import com.quarkdown.core.function.value.NumberValue
1314
import com.quarkdown.core.function.value.ObjectValue
15+
import com.quarkdown.core.function.value.OutputValue
1416
import com.quarkdown.core.function.value.StringValue
1517
import com.quarkdown.core.function.value.data.EvaluableString
1618
import com.quarkdown.core.function.value.wrappedAsValue
@@ -76,6 +78,11 @@ class FunctionCallArgumentsBuilder internal constructor() {
7678
*/
7779
fun boolean(value: Boolean?): Expression = value?.let(::BooleanValue) ?: NoneValue
7880

81+
/**
82+
* @return [value] wrapped as a dictionary expression, or [NoneValue] if `null`
83+
*/
84+
fun <V : OutputValue<*>> dictionary(value: Map<String, V>?): Expression = value?.let { DictionaryValue(it.toMutableMap()) } ?: NoneValue
85+
7986
/**
8087
* @return [value] wrapped as an evaluable string, expanded (including nested function calls)
8188
* when the resulting argument is evaluated, or [NoneValue] if `null`

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/BaseHtmlNodeRenderer.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,14 @@ open class BaseHtmlNodeRenderer(
156156
"focus-lines".takeIf { node.focusedLines != null },
157157
)
158158

159+
// Lines marked with a callout, e.g. `data-callouts="1,3,5"`.
160+
optionalAttribute(
161+
"data-callouts",
162+
node.callouts.keys
163+
.takeIf { it.isNotEmpty() }
164+
?.joinToString(","),
165+
)
166+
159167
// Focus range.
160168
optionalAttribute("data-focus-start", node.focusedLines?.start)
161169
optionalAttribute("data-focus-end", node.focusedLines?.end)

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/QuarkdownHtmlNodeRenderer.kt

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,11 @@ class QuarkdownHtmlNodeRenderer(
152152

153153
// The reference ID or label is set as the ID of the element, allowing cross-references to link to it.
154154
val label = node.getLocationLabel(context)
155-
val id = (node as? CrossReferenceableNode)?.linkableReferenceId?.let(::sanitizeId) ?: label?.let { "$idPrefix-$it" }
155+
val id =
156+
(node as? CrossReferenceableNode)
157+
?.linkableReferenceId
158+
?.let(::sanitizeId)
159+
?: label?.let { "$idPrefix-$it" }
156160
id?.let { optionalAttribute("id", it) }
157161

158162
if (node.caption == null && label == null) {
@@ -688,16 +692,51 @@ class QuarkdownHtmlNodeRenderer(
688692
)
689693
}.build()
690694

695+
/**
696+
* Whether a rendered [Code] block must be wrapped in a `<figure>` tag hosting its caption:
697+
* that is, when the block is numbered, captioned, or referenceable via a cross-reference.
698+
*/
699+
private val Code.requiresFigure: Boolean
700+
get() = caption != null || getLocationLabel(context) != null || linkableReferenceId != null
701+
702+
/**
703+
* Builds the list of callout descriptions displayed below a code block,
704+
* with each item numbered to match the marker attached to its corresponding line of code.
705+
* @param callouts callout contents, in marker order
706+
* @return the `ul.code-callouts` tag
707+
*/
708+
private fun calloutList(callouts: Collection<String>) =
709+
buildTag("ul") {
710+
className("code-callouts")
711+
callouts.forEachIndexed { index, content ->
712+
tag("li") {
713+
className("code-callout")
714+
tag("span") {
715+
className("code-callout-marker")
716+
+(index + 1).toString()
717+
}
718+
+escapeCriticalContent(content)
719+
}
720+
}
721+
}
722+
691723
override fun visit(node: Code): String {
692724
val block = super.visit(node)
725+
val code =
726+
if (node.requiresFigure) {
727+
buildTag("figure") {
728+
+block
729+
numberedCaption(node, positionProvider = { codeBlocks })
730+
}
731+
} else {
732+
block
733+
}
693734

694-
// If the code is numbered, has a caption, or has a reference ID, it is wrapped in a figure.
695-
if (node.caption == null && node.getLocationLabel(context) == null && node.linkableReferenceId == null) {
696-
return block
697-
}
698-
return buildTag("figure") {
699-
+block
700-
numberedCaption(node, positionProvider = { codeBlocks })
735+
if (node.callouts.isEmpty()) return code
736+
737+
return buildMultiTag {
738+
+code
739+
+calloutList(node.callouts.values)
701740
}
702741
}
703742

quarkdown-html/src/main/scss/components/_code.scss

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,36 @@
7070
color: white;
7171
}
7272

73+
// Code block callouts
74+
75+
.code-callout-marker {
76+
@include font.global-font-family;
77+
78+
background-color: var(--qd-background-color);
79+
font-variant-numeric: tabular-nums;
80+
color: var(--qd-main-color);
81+
border: 1px solid var(--qd-main-color);
82+
border-radius: 100%;
83+
padding: 0 var(--qd-digit-circle-padding);
84+
}
85+
86+
pre .code-callout-marker {
87+
margin-left: 0.5em;
88+
}
89+
90+
.code-callouts {
91+
list-style: none;
92+
padding-left: 0;
93+
94+
.code-callout-marker {
95+
margin-right: 0.5em;
96+
}
97+
}
98+
99+
:is(pre, figure):has(code[data-callouts]) {
100+
margin-bottom: 0;
101+
}
102+
73103
// Code block copy button
74104

75105
.hljs-copy-wrapper {

0 commit comments

Comments
 (0)