Skip to content

Commit c08d044

Browse files
committed
feat: Added support for code fence titles
1 parent f000f43 commit c08d044

3 files changed

Lines changed: 118 additions & 4 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,38 @@ Parsley is available via Swift Package Manager and runs on macOS and Linux.
3636
Parsley can be used as a reader in the static site generator [Saga](https://github.com/loopwerk/Saga), using [SagaParsleyMarkdownReader](https://github.com/loopwerk/SagaParsleyMarkdownReader).
3737

3838

39+
## Code block titles
40+
Parsley supports adding a title (typically a filename) to fenced code blocks using the `title="..."` syntax:
41+
42+
~~~markdown
43+
```python title="views.py"
44+
def hello():
45+
print("Hello, World!")
46+
```
47+
~~~
48+
49+
This generates HTML with a `data-title` attribute on the `<pre>` element:
50+
51+
```html
52+
<pre data-title="views.py"><code class="language-python">def hello():
53+
print("Hello, World!")
54+
</code></pre>
55+
```
56+
57+
You can then use CSS to display the title, for example:
58+
59+
```css
60+
pre[data-title]::before {
61+
content: attr(data-title);
62+
display: block;
63+
background: #1a1a1a;
64+
padding: 0.5em 1em;
65+
font-size: 0.85em;
66+
border-bottom: 1px solid #333;
67+
}
68+
```
69+
70+
3971
## Modifying the generated HTML
4072
Parsley doesn't come with a plugin system, it relies purely on `cmark-gfm` under the hood to render Markdown to HTML. If you want to modify the generated HTML, for example if you want to add `target="blank"` to all external links, [SwiftSoup](https://github.com/scinfu/SwiftSoup) is a great way to achieve this.
4173

Sources/Parsley/Parsley.swift

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,19 @@ public struct Parsley {
2222
cmark_parser_attach_syntax_extension(parser, syntaxExtension.createPointer())
2323
}
2424

25+
// Pre-process markdown to extract title from code fence info
26+
let processedContent = preprocessCodeBlockTitles(content)
27+
2528
// Parse into an ast
26-
content.withCString {
29+
processedContent.withCString {
2730
let stringLength = Int(strlen($0))
2831
cmark_parser_feed(parser, $0, stringLength)
2932
}
30-
33+
3134
guard let ast = cmark_parser_finish(parser) else {
3235
throw MarkdownError.conversionFailed
3336
}
34-
37+
3538
// Render the ast into an html string
3639
guard let htmlCString = cmark_render_html(&ast.pointee, options.rawValue, parser.pointee.syntax_extensions) else {
3740
throw MarkdownError.conversionFailed
@@ -51,7 +54,8 @@ public struct Parsley {
5154
throw MarkdownError.conversionFailed
5255
}
5356

54-
return html
57+
// Post-process HTML to convert code-title comments to data-title attributes
58+
return processCodeTitleComments(html)
5559
}
5660

5761
/// This parses a String into a Document, which contains parsed Metadata and the document title.
@@ -74,6 +78,32 @@ public struct Parsley {
7478
}
7579

7680
private extension Parsley {
81+
/// Pre-processes markdown to extract title from code fence info.
82+
/// Transforms: ```python title="views.py" → ```python
83+
/// <!--code-title:views.py-->
84+
static func preprocessCodeBlockTitles(_ markdown: String) -> String {
85+
let pattern = #"```(\S+)\s+title="([^"]+)"\n"#
86+
guard let regex = try? NSRegularExpression(pattern: pattern) else { return markdown }
87+
return regex.stringByReplacingMatches(
88+
in: markdown,
89+
range: NSRange(markdown.startIndex..., in: markdown),
90+
withTemplate: "```$1\n<!--code-title:$2-->\n"
91+
)
92+
}
93+
94+
/// Converts code-title comment markers in HTML to data-title attributes on pre tags.
95+
/// Transforms: <pre><code class="language-xxx">&lt;!--code-title:filename--&gt;\n
96+
/// Into: <pre data-title="filename"><code class="language-xxx">
97+
static func processCodeTitleComments(_ html: String) -> String {
98+
let pattern = #"<pre><code([^>]*)>&lt;!--code-title:([^-]+)--&gt;\n"#
99+
guard let regex = try? NSRegularExpression(pattern: pattern) else { return html }
100+
return regex.stringByReplacingMatches(
101+
in: html,
102+
range: NSRange(html.startIndex..., in: html),
103+
withTemplate: #"<pre data-title="$2"><code$1>"#
104+
)
105+
}
106+
77107
/// Turns a string like `author: Kevin\ntags: Swift` into a dictionary:
78108
/// ["author": "Kevin", "tags": "Swift"]
79109
static func metadata(from content: String?) -> [String: String] {

Tests/ParsleyTests/ParsleyTests.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,55 @@ let markdown = try Parsley.parse(input)
149149
XCTAssertEqual(markdown.metadata, [:])
150150
}
151151

152+
func testFencedCodeBlockWithTitle() throws {
153+
let input = """
154+
```python title="views.py"
155+
def test():
156+
pass
157+
```
158+
"""
159+
let expectedOutput = """
160+
<pre data-title="views.py"><code class="language-python">def test():
161+
pass
162+
</code></pre>
163+
"""
164+
165+
let markdown = try Parsley.parse(input)
166+
XCTAssertEqual(markdown.body, expectedOutput)
167+
}
168+
169+
func testFencedCodeBlockWithTitleAndPath() throws {
170+
let input = """
171+
```ts title="lib/store.js"
172+
function test() {}
173+
```
174+
"""
175+
let expectedOutput = """
176+
<pre data-title="lib/store.js"><code class="language-ts">function test() {}
177+
</code></pre>
178+
"""
179+
180+
let markdown = try Parsley.parse(input)
181+
XCTAssertEqual(markdown.body, expectedOutput)
182+
}
183+
184+
func testFencedCodeBlockWithoutTitle() throws {
185+
let input = """
186+
```python
187+
def test():
188+
pass
189+
```
190+
"""
191+
let expectedOutput = """
192+
<pre><code class="language-python">def test():
193+
pass
194+
</code></pre>
195+
"""
196+
197+
let markdown = try Parsley.parse(input)
198+
XCTAssertEqual(markdown.body, expectedOutput)
199+
}
200+
152201
func testSmartQuotesOff() throws {
153202
let input = """
154203
"test"
@@ -203,6 +252,9 @@ let markdown = try Parsley.parse(input)
203252
("testTitleAndMetadataNewline", testTitleAndMetadataNewline),
204253
("testOtherFeatures", testOtherFeatures),
205254
("testFencedCodeBlock", testFencedCodeBlock),
255+
("testFencedCodeBlockWithTitle", testFencedCodeBlockWithTitle),
256+
("testFencedCodeBlockWithTitleAndPath", testFencedCodeBlockWithTitleAndPath),
257+
("testFencedCodeBlockWithoutTitle", testFencedCodeBlockWithoutTitle),
206258
("testSmartQuotesOff", testSmartQuotesOff),
207259
("testSmartQuotesOn", testSmartQuotesOn),
208260
("testSafe", testSafe),

0 commit comments

Comments
 (0)