From 6783ecfd03bb754510c950ebc473f5aa12341c01 Mon Sep 17 00:00:00 2001
From: Parker Lougheed
Date: Tue, 30 Sep 2025 15:48:05 +0200
Subject: [PATCH 1/2] [markdown] Preserve metadata passed to fenced code blocks
---
pkgs/markdown/CHANGELOG.md | 2 +
.../fenced_code_block_syntax.dart | 53 ++++++++++++++-----
.../test/common_mark/fenced_code_blocks.unit | 4 +-
.../markdown/test/gfm/fenced_code_blocks.unit | 4 +-
.../test/original/fenced_code_block.unit | 24 +++++++++
5 files changed, 71 insertions(+), 16 deletions(-)
diff --git a/pkgs/markdown/CHANGELOG.md b/pkgs/markdown/CHANGELOG.md
index 60a2508877..49b9e358ec 100644
--- a/pkgs/markdown/CHANGELOG.md
+++ b/pkgs/markdown/CHANGELOG.md
@@ -1,5 +1,7 @@
## 7.3.1-wip
+* Preserve metadata passed to fenced code blocks as
+ `data-metadata` on the created `pre` element.
* Update the README link to the markdown playground
(https://dart-lang.github.io/tools).
* Update `package:web` API references in the example.
diff --git a/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart b/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
index cea110665f..ed0864e2ef 100644
--- a/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
+++ b/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
@@ -21,9 +21,9 @@ class FencedCodeBlockSyntax extends BlockSyntax {
@override
Node parse(BlockParser parser) {
- final openingFence = _FenceMatch.fromMatch(pattern.firstMatch(
- escapePunctuation(parser.current.content),
- )!);
+ final openingFence = _FenceMatch.fromMatch(
+ pattern.firstMatch(escapePunctuation(parser.current.content))!,
+ );
var text = parseChildLines(
parser,
@@ -38,16 +38,27 @@ class FencedCodeBlockSyntax extends BlockSyntax {
text = '$text\n';
}
+ final (languageString, metadataString) = openingFence.languageAndMetadata;
+
final code = Element.text('code', text);
- if (openingFence.hasLanguage) {
- var language = decodeHtmlCharacters(openingFence.language);
+ if (languageString != null) {
+ var language = decodeHtmlCharacters(languageString);
if (parser.document.encodeHtml) {
language = escapeHtmlAttribute(language);
}
code.attributes['class'] = 'language-$language';
}
- return Element('pre', [code]);
+ final pre = Element('pre', [code]);
+ if (metadataString != null) {
+ var metadata = decodeHtmlCharacters(metadataString);
+ if (parser.document.encodeHtml) {
+ metadata = escapeHtmlAttribute(metadata);
+ }
+ pre.attributes['data-metadata'] = metadata;
+ }
+
+ return pre;
}
String _removeIndentation(String content, int length) {
@@ -130,12 +141,30 @@ class _FenceMatch {
// https://spec.commonmark.org/0.30/#info-string.
final String info;
- // The first word of the info string is typically used to specify the language
- // of the code sample,
- // https://spec.commonmark.org/0.30/#example-143.
- String get language => info.split(' ').first;
+ /// Returns the language and remaining metadata from the [info] string.
+ ///
+ /// The language is the first word of the info string,
+ /// to match the (unspecified, but typical) behavior of CommonMark parsers,
+ /// as suggested in https://spec.commonmark.org/0.30/#example-143.
+ ///
+ /// The metadata is any remaining part of the info string after the language.
+ (String? language, String? metadata) get languageAndMetadata {
+ if (info.isEmpty) {
+ return (null, null);
+ }
- bool get hasInfo => info.isNotEmpty;
+ // We assume the info string is trimmed already.
+ final firstSpaceIndex = info.indexOf(' ');
+ if (firstSpaceIndex == -1) {
+ // If there is no space, the whole string is the language.
+ return (info, null);
+ }
+
+ return (
+ info.substring(0, firstSpaceIndex),
+ info.substring(firstSpaceIndex + 1),
+ );
+ }
- bool get hasLanguage => language.isNotEmpty;
+ bool get hasInfo => info.isNotEmpty;
}
diff --git a/pkgs/markdown/test/common_mark/fenced_code_blocks.unit b/pkgs/markdown/test/common_mark/fenced_code_blocks.unit
index 06dec5865c..5854bc370f 100644
--- a/pkgs/markdown/test/common_mark/fenced_code_blocks.unit
+++ b/pkgs/markdown/test/common_mark/fenced_code_blocks.unit
@@ -214,7 +214,7 @@ def foo(x)
end
~~~~~~~
<<<
-def foo(x)
+def foo(x)
return 3
end
@@ -234,7 +234,7 @@ foo
foo
~~~
<<<
-foo
+foo
>>> Fenced code blocks - 147
```
diff --git a/pkgs/markdown/test/gfm/fenced_code_blocks.unit b/pkgs/markdown/test/gfm/fenced_code_blocks.unit
index f6fa8432a7..52f217ae18 100644
--- a/pkgs/markdown/test/gfm/fenced_code_blocks.unit
+++ b/pkgs/markdown/test/gfm/fenced_code_blocks.unit
@@ -214,7 +214,7 @@ def foo(x)
end
~~~~~~~
<<<
-def foo(x)
+def foo(x)
return 3
end
@@ -234,7 +234,7 @@ foo
foo
~~~
<<<
-foo
+foo
>>> Fenced code blocks - 117
```
diff --git a/pkgs/markdown/test/original/fenced_code_block.unit b/pkgs/markdown/test/original/fenced_code_block.unit
index 779e747c3c..3492dfca6a 100644
--- a/pkgs/markdown/test/original/fenced_code_block.unit
+++ b/pkgs/markdown/test/original/fenced_code_block.unit
@@ -5,3 +5,27 @@
<<<
'foo'
+>>> with basic metadata string
+```dart meta
+code
+```
+
+<<<
+code
+
+>>> with characters to escape
+```dart title="main.dart"
+code
+```
+
+<<<
+code
+
+>>> with HTML character reference
+```dart |
+code
+```
+
+<<<
+code
+
\ No newline at end of file
From df5f03f68266a2ca25e8b7bec39c2aaad845fab7 Mon Sep 17 00:00:00 2001
From: Parker Lougheed
Date: Tue, 30 Sep 2025 16:28:23 +0200
Subject: [PATCH 2/2] Extract shared logic as suggested by Gemini
---
.../fenced_code_block_syntax.dart | 24 +++++++++++--------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart b/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
index ed0864e2ef..158aba9873 100644
--- a/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
+++ b/pkgs/markdown/lib/src/block_syntaxes/fenced_code_block_syntax.dart
@@ -42,25 +42,29 @@ class FencedCodeBlockSyntax extends BlockSyntax {
final code = Element.text('code', text);
if (languageString != null) {
- var language = decodeHtmlCharacters(languageString);
- if (parser.document.encodeHtml) {
- language = escapeHtmlAttribute(language);
- }
- code.attributes['class'] = 'language-$language';
+ final processedLanguage = _processAttribute(languageString,
+ encodeHtml: parser.document.encodeHtml);
+ code.attributes['class'] = 'language-$processedLanguage';
}
final pre = Element('pre', [code]);
if (metadataString != null) {
- var metadata = decodeHtmlCharacters(metadataString);
- if (parser.document.encodeHtml) {
- metadata = escapeHtmlAttribute(metadata);
- }
- pre.attributes['data-metadata'] = metadata;
+ final processedMetadata = _processAttribute(metadataString,
+ encodeHtml: parser.document.encodeHtml);
+ pre.attributes['data-metadata'] = processedMetadata;
}
return pre;
}
+ static String _processAttribute(String value, {bool encodeHtml = false}) {
+ final decodedValue = decodeHtmlCharacters(value);
+ if (encodeHtml) {
+ return escapeHtmlAttribute(decodedValue);
+ }
+ return decodedValue;
+ }
+
String _removeIndentation(String content, int length) {
final text = content.replaceFirst(RegExp('^\\s{0,$length}'), '');
return content.substring(content.length - text.length);