From 330cf215e50a72e103a979e6696d0ba30d55ed5f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 11:39:48 -0400 Subject: [PATCH 1/3] fix(core,builder): one owner for fenced-code detection Slide-split, directive tokenization, and serialize-escape each had their own fence regex and disagreed: - MarkdownParser matched backticks only, so ```dart {.hero} did not open a fence and tilde fences never matched at all. - TagTokenizer matched backtick or tilde but required column 0. - SlideSerializer let a ~~~ line close a ``` fence. Add fencedCodeRanges/isInsideFencedCode in core and route all three through it. The rule follows what SuperDeck already renders via package:markdown: 3+ backticks or tildes, backtick openers reject backticks in the info string, a closer matches the opening character at >= the opening length, and an unclosed fence runs to EOF. Closers keep their info string. demo/slides.md closes a fence with ```{.code}; treating that as "not a closer" swallowed the following --- and merged two slides. Tests drive the shipped APIs (MarkdownParser, TagTokenizer, SlideSerializer) rather than a reimplemented scanner, and fence_agreement_test pins split and tokenize to the same snippets. --- .../lib/src/parsers/markdown_parser.dart | 27 ++--- .../lib/src/parsers/slide_serializer.dart | 24 ++-- .../src/parsers/fence_agreement_test.dart | 75 +++++++++++++ .../src/parsers/markdown_parser_test.dart | 96 ++++++++++++++++ .../src/parsers/slide_serializer_test.dart | 16 +++ .../lib/src/markdown/markdown_fences.dart | 104 ++++++++++++++++++ .../core/lib/src/markdown/tag_tokenizer.dart | 42 +------ packages/core/lib/superdeck_core.dart | 1 + .../src/markdown/markdown_fences_test.dart | 59 ++++++++++ .../test/src/markdown/tag_tokenizer_test.dart | 49 +++++++++ 10 files changed, 419 insertions(+), 74 deletions(-) create mode 100644 packages/builder/test/src/parsers/fence_agreement_test.dart create mode 100644 packages/core/lib/src/markdown/markdown_fences.dart create mode 100644 packages/core/test/src/markdown/markdown_fences_test.dart diff --git a/packages/builder/lib/src/parsers/markdown_parser.dart b/packages/builder/lib/src/parsers/markdown_parser.dart index 8de9efdb..12e9f199 100644 --- a/packages/builder/lib/src/parsers/markdown_parser.dart +++ b/packages/builder/lib/src/parsers/markdown_parser.dart @@ -33,9 +33,6 @@ String _uniquifyKey( class MarkdownParser { const MarkdownParser(); - // Regex to match code fence: 3+ backticks at start, optionally followed by language - static final _codeFencePattern = RegExp(r'^(`{3,})(\s*\S*)?$'); - static final _yamlKeyPattern = RegExp(r'^[A-Za-z_][\w-]*\s*:'); /// Leading characters that mark a line as markdown body (heading, directive, @@ -46,8 +43,8 @@ class MarkdownParser { /// /// A slide is bounded by `---` separator lines. A slide may begin with an /// optional YAML frontmatter block delimited by a `---` pair at its start. - /// Code blocks (fenced by ```) are respected, so `---` inside a code block - /// won't be treated as a separator. + /// Fenced code (backtick or tilde, including info strings) is decided by + /// [fencedCodeRanges], so `---` inside a fence is never a separator. static List _splitSlides(String content) { content = content.trim(); if (content.isEmpty) return []; @@ -92,24 +89,16 @@ class MarkdownParser { /// Returns the indices of `---` lines that sit outside fenced code blocks. static Set _findSeparatorLines(List lines) { + final text = lines.join('\n'); + final fences = fencedCodeRanges(text); final separators = {}; - int? codeFenceLength; + var offset = 0; for (var i = 0; i < lines.length; i++) { - final trimmed = lines[i].trim(); - final fenceMatch = _codeFencePattern.firstMatch(trimmed); - if (fenceMatch != null) { - final backticks = fenceMatch.group(1)!.length; - if (codeFenceLength == null) { - codeFenceLength = backticks; - } else if (backticks >= codeFenceLength) { - codeFenceLength = null; - } - continue; + if (!isInsideFencedCode(offset, fences) && lines[i].trim() == '---') { + separators.add(i); } - - if (codeFenceLength != null) continue; - if (trimmed == '---') separators.add(i); + offset += lines[i].length + 1; } return separators; diff --git a/packages/builder/lib/src/parsers/slide_serializer.dart b/packages/builder/lib/src/parsers/slide_serializer.dart index e53cbf5e..b8edf6aa 100644 --- a/packages/builder/lib/src/parsers/slide_serializer.dart +++ b/packages/builder/lib/src/parsers/slide_serializer.dart @@ -25,7 +25,6 @@ class SlideSerializer { static final _identifierPattern = RegExp(r'^[\w-]+$'); static final _directiveLinePattern = RegExp(r'^\s*@[\w-]+'); - static final _fencePattern = RegExp(r'^(`{3,}|~{3,})'); /// Serializes a list of [slides] into a single Markdown document. String serialize(List slides) { @@ -263,7 +262,9 @@ class SlideSerializer { /// canonical output reads `top: 12` instead of `top: 12.0`. Both re-parse to /// the same normalized value. String _numValue(num value) { - if (value is double && value.isFinite && value == value.truncateToDouble()) { + if (value is double && + value.isFinite && + value == value.truncateToDouble()) { return value.truncate().toString(); } return value.toString(); @@ -317,29 +318,20 @@ class SlideSerializer { /// `_@foo`; the parser restores it via `_updateIgnoredTags`. String _escapeContent(String content) { if (content.isEmpty) return content; + final fences = fencedCodeRanges(content); final lines = content.split('\n'); final result = []; - int? fenceLength; + var offset = 0; for (final line in lines) { - final fenceMatch = _fencePattern.firstMatch(line); - if (fenceMatch != null) { - final length = fenceMatch.group(1)!.length; - if (fenceLength == null) { - fenceLength = length; - } else if (length >= fenceLength) { - fenceLength = null; - } - result.add(line); - continue; - } - - if (fenceLength == null && _directiveLinePattern.hasMatch(line)) { + if (!isInsideFencedCode(offset, fences) && + _directiveLinePattern.hasMatch(line)) { final atIndex = line.indexOf('@'); result.add('${line.substring(0, atIndex)}_${line.substring(atIndex)}'); } else { result.add(line); } + offset += line.length + 1; } return result.join('\n'); diff --git a/packages/builder/test/src/parsers/fence_agreement_test.dart b/packages/builder/test/src/parsers/fence_agreement_test.dart new file mode 100644 index 00000000..7fef621e --- /dev/null +++ b/packages/builder/test/src/parsers/fence_agreement_test.dart @@ -0,0 +1,75 @@ +import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; +import 'package:test/test.dart'; + +/// Slide-split and directive tokenization must hide the same `---` / `@` +/// markers inside a fence. These snippets are fed to both shipped APIs. +void main() { + const cases = <_FenceCase>[ + _FenceCase( + 'backtick fence', + markdown: '# Slide\n\n```\n---\n@ignored\n```\n\n@visible\n', + slides: 1, + tokens: ['visible'], + ), + _FenceCase( + 'tilde fence', + markdown: '# Slide\n\n~~~\n---\n@ignored\n~~~\n\n@visible\n', + slides: 1, + tokens: ['visible'], + ), + _FenceCase( + 'language + {.hero} fence', + markdown: '# Slide\n\n```dart {.hero}\n---\n@override\n```\n\n@visible\n', + slides: 1, + tokens: ['visible'], + ), + _FenceCase( + 'unclosed fence', + markdown: '# Slide\n\n```\n---\n@ignored\n', + slides: 1, + tokens: [], + ), + _FenceCase( + '{.code} closer then separator', + markdown: + '### Code\n\n```dart\nvoid main() {}\n```{.code}\n\n---\n\n## Next\n\n@visible\n', + slides: 2, + tokens: ['visible'], + ), + _FenceCase( + 'separator after a closed fence', + markdown: '# One\n\n```\ncode\n```\n\n---\n\n# Two\n\n@visible\n', + slides: 2, + tokens: ['visible'], + ), + ]; + + for (final fixture in cases) { + test('${fixture.name}: split and tokenize agree', () { + final slides = const MarkdownParser().parse(fixture.markdown); + final tokens = const TagTokenizer().tokenize(fixture.markdown); + + expect(slides, hasLength(fixture.slides), reason: fixture.name); + expect( + tokens.map((token) => token.name), + fixture.tokens, + reason: fixture.name, + ); + }); + } +} + +class _FenceCase { + final String name; + final String markdown; + final int slides; + final List tokens; + + const _FenceCase( + this.name, { + required this.markdown, + required this.slides, + required this.tokens, + }); +} diff --git a/packages/builder/test/src/parsers/markdown_parser_test.dart b/packages/builder/test/src/parsers/markdown_parser_test.dart index 71e6075d..a383476e 100644 --- a/packages/builder/test/src/parsers/markdown_parser_test.dart +++ b/packages/builder/test/src/parsers/markdown_parser_test.dart @@ -377,4 +377,100 @@ Content for the second slide }, ); }); + + group('fenced code does not split slides', () { + test('--- inside a backtick fence stays on one slide', () { + const markdown = ''' +# Code + +``` +--- +not a separator +``` +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(1)); + expect(slides.single.content, contains('---')); + expect(slides.single.content, contains('not a separator')); + }); + + test('--- inside a tilde fence stays on one slide', () { + const markdown = ''' +# Code + +~~~ +--- +not a separator +~~~ +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(1)); + expect(slides.single.content, contains('---')); + }); + + test('--- inside a language + {.hero} fence stays on one slide', () { + const markdown = ''' +# Hero fence + +```dart {.hero} +--- +@override +void main() {} +``` +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(1)); + expect(slides.single.content, contains('@override')); + expect(slides.single.content, contains('---')); + }); + + test('--- inside an unclosed fence stays on one slide', () { + const markdown = ''' +# Open fence + +``` +--- +still the same slide +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(1)); + expect(slides.single.content, contains('still the same slide')); + }); + + test('```{.code} closer lets a following --- split slides', () { + const markdown = ''' +### Code Blocks + +```dart +void main() {} +```{.code} + +--- + +## Custom Widgets +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(2)); + expect(slides[0].content, contains('### Code Blocks')); + expect(slides[1].content, contains('## Custom Widgets')); + }); + + test('--- after a closed fence still splits slides', () { + const markdown = ''' +# One + +``` +code +``` + +--- + +# Two +'''; + final slides = markdownParser.parse(markdown); + expect(slides, hasLength(2)); + expect(slides[0].content, contains('# One')); + expect(slides[1].content, contains('# Two')); + }); + }); } diff --git a/packages/builder/test/src/parsers/slide_serializer_test.dart b/packages/builder/test/src/parsers/slide_serializer_test.dart index 907f08aa..d1bc5893 100644 --- a/packages/builder/test/src/parsers/slide_serializer_test.dart +++ b/packages/builder/test/src/parsers/slide_serializer_test.dart @@ -228,6 +228,22 @@ void main() { ); }); + test('tilde fenced code containing --- and @ is preserved', () { + expectRoundTrip( + parseDeck( + '# Code\n\n~~~\n@override\nvoid main() {}\n---\nnot a separator\n~~~', + ), + ); + }); + + test('hero info-string fence containing --- and @ is preserved', () { + expectRoundTrip( + parseDeck( + '# Code\n\n```dart {.hero}\n@override\n---\nvoid main() {}\n```', + ), + ); + }); + test('serialize then re-serialize is stable (idempotent)', () { final slides = parseDeck( '@section {\n flex: 2\n}\n@block {\n align: center\n}\n# Title', diff --git a/packages/core/lib/src/markdown/markdown_fences.dart b/packages/core/lib/src/markdown/markdown_fences.dart new file mode 100644 index 00000000..a426fa86 --- /dev/null +++ b/packages/core/lib/src/markdown/markdown_fences.dart @@ -0,0 +1,104 @@ +/// Parse-phase fenced code blocks. +/// +/// SuperDeck hides structural syntax (`---` slide splits and `@` directives) +/// inside fenced code. Slide-split, directive tokenization, and serialize-escape +/// must all call [fencedCodeRanges] so they cannot drift. +/// +/// A fence opens on a line that, after optional leading whitespace, starts +/// with three or more backticks or tildes. A backtick opener cannot contain +/// more backticks in its info string (so ` ```dart {.hero}` opens and +/// ` ``` `` ` does not). A closer is a later line whose marker uses the same +/// character and is at least as long — SuperDeck decks close fences with +/// ` ```{.code}` (an info string on the closer), so info does not block a +/// close. An unclosed fence extends to the end of the text. +List<({int start, int end})> fencedCodeRanges(String text) { + final ranges = <({int start, int end})>[]; + String? openChar; + var openLength = 0; + var openStart = 0; + + _forEachLine(text, (start, end, next, line) { + final fence = _readFenceLine(line); + if (openChar == null) { + if (fence != null && fence.canOpen) { + openChar = fence.character; + openLength = fence.length; + openStart = start; + } + return; + } + + if (fence != null && + fence.character == openChar && + fence.length >= openLength) { + ranges.add((start: openStart, end: next)); + openChar = null; + } + }); + + if (openChar != null) { + ranges.add((start: openStart, end: text.length)); + } + + return ranges; +} + +/// Whether [offset] sits inside one of [ranges] (`start <= offset < end`). +bool isInsideFencedCode(int offset, List<({int start, int end})> ranges) { + for (final range in ranges) { + if (offset >= range.start && offset < range.end) return true; + } + return false; +} + +void _forEachLine( + String text, + void Function(int start, int end, int next, String line) visit, +) { + var i = 0; + while (i < text.length) { + final start = i; + while (i < text.length) { + final unit = text.codeUnitAt(i); + if (unit == 0x0A || unit == 0x0D) break; + i++; + } + final end = i; + if (i < text.length) { + if (text.codeUnitAt(i) == 0x0D && + i + 1 < text.length && + text.codeUnitAt(i + 1) == 0x0A) { + i += 2; + } else { + i += 1; + } + } + visit(start, end, i, text.substring(start, end)); + } +} + +({String character, int length, bool canOpen})? _readFenceLine(String line) { + var i = 0; + while (i < line.length) { + final unit = line.codeUnitAt(i); + if (unit != 0x20 && unit != 0x09) break; + i++; + } + if (i >= line.length) return null; + + final markerUnit = line.codeUnitAt(i); + if (markerUnit != 0x60 && markerUnit != 0x7E) return null; + + var length = 0; + while (i < line.length && line.codeUnitAt(i) == markerUnit) { + length++; + i++; + } + if (length < 3) return null; + + final info = line.substring(i); + final character = String.fromCharCode(markerUnit); + final canOpen = character == '~' || !info.contains('`'); + + return (character: character, length: length, canOpen: canOpen); +} diff --git a/packages/core/lib/src/markdown/tag_tokenizer.dart b/packages/core/lib/src/markdown/tag_tokenizer.dart index 92c55a53..68e67aee 100644 --- a/packages/core/lib/src/markdown/tag_tokenizer.dart +++ b/packages/core/lib/src/markdown/tag_tokenizer.dart @@ -2,6 +2,7 @@ import 'package:yaml/yaml.dart'; import '../deck/deck_format_exception.dart'; import '../utils/yaml_utils.dart'; +import 'markdown_fences.dart'; class TagToken { final String name; @@ -27,29 +28,9 @@ class TagTokenizer { const TagTokenizer(); static final _tagPattern = RegExp(r'^\s*@([\w-]+)', multiLine: true); - static final _codeBlockPattern = RegExp( - r'^(`{3,}|~{3,}).*?^\1', - multiLine: true, - dotAll: true, - ); - static final _fenceOpenPattern = RegExp(r'^(`{3,}|~{3,})', multiLine: true); List tokenize(String text) { - // Find all closed code block ranges to exclude from tag matching. - final codeBlockRanges = <_Range>[]; - for (final match in _codeBlockPattern.allMatches(text)) { - codeBlockRanges.add(_Range(match.start, match.end)); - } - - // Also protect unclosed fences: any fence open not covered by a closed - // range extends to the end of the document. - for (final match in _fenceOpenPattern.allMatches(text)) { - if (!_isInsideCodeBlock(match.start, codeBlockRanges)) { - codeBlockRanges.add(_Range(match.start, text.length)); - // Only the first unclosed fence matters; subsequent ones are inside it. - break; - } - } + final codeBlockRanges = fencedCodeRanges(text); final tokens = []; @@ -57,8 +38,7 @@ class TagTokenizer { final tagName = match.group(1)!; final startIndex = match.start; - // Skip if this match is inside a code block - if (_isInsideCodeBlock(startIndex, codeBlockRanges)) { + if (isInsideFencedCode(startIndex, codeBlockRanges)) { continue; } @@ -185,15 +165,6 @@ class TagTokenizer { ); } } - - bool _isInsideCodeBlock(int position, List<_Range> codeBlockRanges) { - for (final range in codeBlockRanges) { - if (position >= range.start && position < range.end) { - return true; - } - } - return false; - } } class _BraceExtraction { @@ -209,10 +180,3 @@ class _BraceExtraction { int get endIndex => closeIndex + 1; } - -class _Range { - final int start; - final int end; - - const _Range(this.start, this.end); -} diff --git a/packages/core/lib/superdeck_core.dart b/packages/core/lib/superdeck_core.dart index 338b5c73..a27085f0 100644 --- a/packages/core/lib/superdeck_core.dart +++ b/packages/core/lib/superdeck_core.dart @@ -18,6 +18,7 @@ export 'src/deck/slide_contract.dart'; export 'src/deck/slide_model.dart'; // Markdown export 'src/markdown/hero_tag_helpers.dart'; +export 'src/markdown/markdown_fences.dart'; export 'src/markdown/markdown_syntaxes.dart'; export 'src/markdown/tag_tokenizer.dart'; // Plugins diff --git a/packages/core/test/src/markdown/markdown_fences_test.dart b/packages/core/test/src/markdown/markdown_fences_test.dart new file mode 100644 index 00000000..1f836bed --- /dev/null +++ b/packages/core/test/src/markdown/markdown_fences_test.dart @@ -0,0 +1,59 @@ +import 'package:superdeck_core/superdeck_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('fencedCodeRanges', () { + test('covers a backtick fence including its closer', () { + const text = '```\ncode\n```\nafter'; + final ranges = fencedCodeRanges(text); + + expect(ranges, hasLength(1)); + expect(isInsideFencedCode(0, ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('code'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('after'), ranges), isFalse); + }); + + test('covers a tilde fence', () { + const text = '~~~\ncode\n~~~\nafter'; + final ranges = fencedCodeRanges(text); + + expect(ranges, hasLength(1)); + expect(isInsideFencedCode(text.indexOf('code'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('after'), ranges), isFalse); + }); + + test('opens on a language + {.hero} info string', () { + const text = '```dart {.hero}\n---\n@override\n```\n@visible'; + final ranges = fencedCodeRanges(text); + + expect(isInsideFencedCode(text.indexOf('---'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('@override'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('@visible'), ranges), isFalse); + }); + + test('an unclosed fence extends to EOF', () { + const text = '```\n---\nstill inside'; + final ranges = fencedCodeRanges(text); + + expect(ranges, hasLength(1)); + expect(ranges.single.end, text.length); + expect(isInsideFencedCode(text.indexOf('still inside'), ranges), isTrue); + }); + + test('```{.code} closes an opening fence', () { + const text = '```dart\ncode\n```{.code}\nafter'; + final ranges = fencedCodeRanges(text); + + expect(isInsideFencedCode(text.indexOf('code'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('after'), ranges), isFalse); + }); + + test('a tilde line does not close a backtick fence', () { + const text = '```\ninside\n~~~\nstill\n```\noutside'; + final ranges = fencedCodeRanges(text); + + expect(isInsideFencedCode(text.indexOf('still'), ranges), isTrue); + expect(isInsideFencedCode(text.indexOf('outside'), ranges), isFalse); + }); + }); +} diff --git a/packages/core/test/src/markdown/tag_tokenizer_test.dart b/packages/core/test/src/markdown/tag_tokenizer_test.dart index 78ef4fe6..954209d0 100644 --- a/packages/core/test/src/markdown/tag_tokenizer_test.dart +++ b/packages/core/test/src/markdown/tag_tokenizer_test.dart @@ -389,6 +389,55 @@ not closed'''; expect(result, hasLength(1)); expect(result.first.name, 'visible'); }); + + test('ignores tags inside a language + {.hero} fence', () { + const text = ''' +```dart {.hero} +@override +void main() {} +``` +@visible'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens.single.name, 'visible'); + }); + + test('ignores tags inside an indented fence', () { + const text = ''' + ``` + @ignored + ``` +@visible'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens.single.name, 'visible'); + }); + + test('```{.code} closer unhides the following tag', () { + const text = ''' +```dart +@ignored +```{.code} +@visible'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens.map((token) => token.name), ['visible']); + }); + + test('a tilde line does not close a backtick fence', () { + const text = ''' +``` +@ignored +~~~ +@still-ignored +``` +@visible'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens.map((token) => token.name), ['visible']); + }); }); group('error handling', () { From bb43e8465823e9575f90b85fa235eee09e6084c7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 11:41:11 -0400 Subject: [PATCH 2/3] fix(builder): one owner for reserved directive names The authoring reserved set lived twice: BlockParser rejected a raw 'column' string, and SlideSerializer._reservedTags duplicated the model discriminators as string literals. Dropping 'column' from the serialize copy would emit @column, which parse then rejects. Add directive_names.dart deriving the set from SectionBlock.key, ContentBlock.key, and WidgetBlock.key plus the rejected column alias, and use it from both parse and serialize. Semantics are unchanged; only the owner of the fact moved. It lives in builder because directive grammar is a parse concern and column is not a block type in the core model. The serializer test now loops over reservedDirectiveNames, so a name added to the set cannot silently lose its @widget escaping. --- .../builder/lib/src/parsers/block_parser.dart | 6 ++- .../lib/src/parsers/directive_names.dart | 17 ++++++++ .../lib/src/parsers/slide_serializer.dart | 7 +--- .../src/parsers/directive_names_test.dart | 42 +++++++++++++++++++ .../src/parsers/slide_serializer_test.dart | 37 ++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 packages/builder/lib/src/parsers/directive_names.dart create mode 100644 packages/builder/test/src/parsers/directive_names_test.dart diff --git a/packages/builder/lib/src/parsers/block_parser.dart b/packages/builder/lib/src/parsers/block_parser.dart index a1c49275..7e5d6c4f 100644 --- a/packages/builder/lib/src/parsers/block_parser.dart +++ b/packages/builder/lib/src/parsers/block_parser.dart @@ -1,5 +1,7 @@ import 'package:superdeck_core/superdeck_core.dart'; +import 'directive_names.dart'; + class ParsedBlock { final String type; final int startIndex; @@ -44,9 +46,9 @@ class BlockParser { final tokens = const TagTokenizer().tokenize(text); return tokens.map((token) { - if (token.name == 'column') { + if (token.name == deprecatedColumnDirective) { throw DeckFormatException( - 'Unsupported @column directive. Use @block instead.', + 'Unsupported @$deprecatedColumnDirective directive. Use @block instead.', text, token.startIndex, ); diff --git a/packages/builder/lib/src/parsers/directive_names.dart b/packages/builder/lib/src/parsers/directive_names.dart new file mode 100644 index 00000000..1470f628 --- /dev/null +++ b/packages/builder/lib/src/parsers/directive_names.dart @@ -0,0 +1,17 @@ +import 'package:superdeck_core/superdeck_core.dart'; + +/// Legacy `@column` tag. Parse rejects it; serialize must not emit it as +/// widget shorthand or reparse would throw. +const deprecatedColumnDirective = 'column'; + +/// Authoring tags that are not widget shorthand. +/// +/// `section`, `block`, and `widget` are the structural tags (model +/// discriminators). [deprecatedColumnDirective] is a rejected `@block` alias. +/// Parse and serialize both use this set. +const reservedDirectiveNames = { + SectionBlock.key, + ContentBlock.key, + WidgetBlock.key, + deprecatedColumnDirective, +}; diff --git a/packages/builder/lib/src/parsers/slide_serializer.dart b/packages/builder/lib/src/parsers/slide_serializer.dart index b8edf6aa..593448f3 100644 --- a/packages/builder/lib/src/parsers/slide_serializer.dart +++ b/packages/builder/lib/src/parsers/slide_serializer.dart @@ -1,6 +1,7 @@ import 'package:superdeck_core/superdeck_core.dart'; import 'comment_parser.dart'; +import 'directive_names.dart'; /// Serializes [Slide] models back into SuperDeck-flavored Markdown. /// @@ -19,10 +20,6 @@ import 'comment_parser.dart'; class SlideSerializer { const SlideSerializer(); - /// Tag names that cannot be used as `@` widget shorthand because they - /// are reserved directives handled specially by the parser. - static const _reservedTags = {'section', 'block', 'widget', 'column'}; - static final _identifierPattern = RegExp(r'^[\w-]+$'); static final _directiveLinePattern = RegExp(r'^\s*@[\w-]+'); @@ -165,7 +162,7 @@ class SlideSerializer { case WidgetBlock(): final useShorthand = _identifierPattern.hasMatch(block.name) && - !_reservedTags.contains(block.name); + !reservedDirectiveNames.contains(block.name); final tag = useShorthand ? block.name : 'widget'; final options = _blockOptions(block); if (!useShorthand) options['name'] = block.name; diff --git a/packages/builder/test/src/parsers/directive_names_test.dart b/packages/builder/test/src/parsers/directive_names_test.dart new file mode 100644 index 00000000..5e780d27 --- /dev/null +++ b/packages/builder/test/src/parsers/directive_names_test.dart @@ -0,0 +1,42 @@ +import 'package:superdeck_builder/src/parsers/block_parser.dart'; +import 'package:superdeck_builder/src/parsers/directive_names.dart'; +import 'package:superdeck_core/superdeck_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('reservedDirectiveNames', () { + test('column is rejected as a bare authoring tag', () { + expect( + () => const BlockParser().parse('@$deprecatedColumnDirective'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Unsupported @$deprecatedColumnDirective directive'), + ), + ), + ); + }); + + test('structural tags stay structural, not widgets', () { + final section = const BlockParser().parse('@section').single; + final block = const BlockParser().parse('@block').single; + final widget = const BlockParser() + .parse('@widget { name: chart }') + .single; + + expect(section.type, SectionBlock.key); + expect(section.data['type'], SectionBlock.key); + expect(block.data['type'], ContentBlock.key); + expect(widget.data['type'], WidgetBlock.key); + expect(widget.data['name'], 'chart'); + }); + + test('unreserved tags become widget shorthand', () { + final parsed = const BlockParser().parse('@image').single; + expect(parsed.data['type'], WidgetBlock.key); + expect(parsed.data['name'], 'image'); + expect(reservedDirectiveNames.contains('image'), isFalse); + }); + }); +} diff --git a/packages/builder/test/src/parsers/slide_serializer_test.dart b/packages/builder/test/src/parsers/slide_serializer_test.dart index d1bc5893..82dded86 100644 --- a/packages/builder/test/src/parsers/slide_serializer_test.dart +++ b/packages/builder/test/src/parsers/slide_serializer_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:superdeck_builder/src/parsers/directive_names.dart'; import 'package:superdeck_builder/superdeck_builder.dart'; import 'package:superdeck_core/superdeck_core.dart'; import 'package:test/test.dart'; @@ -202,6 +203,42 @@ void main() { ); }); + test('reserved widget names emit @widget and reparse as widgets', () { + for (final name in reservedDirectiveNames) { + final slides = [ + Slide( + key: 'reserved-$name', + sections: [ + SectionBlock([WidgetBlock(name: name)]), + ], + ), + ]; + final markdown = const SlideSerializer().serialize(slides); + expect(markdown, contains('@widget'), reason: name); + expect(markdown, contains('name: $name'), reason: name); + if (name != WidgetBlock.key) { + expect( + RegExp('^@$name\\b', multiLine: true).hasMatch(markdown), + isFalse, + reason: 'must not emit @$name shorthand', + ); + } + + final block = + parseDeck(markdown).single.sections.single.blocks.single + as WidgetBlock; + expect(block.name, name, reason: name); + } + }); + + test('non-reserved widget names keep @name shorthand', () { + final markdown = const SlideSerializer().serialize( + parseDeck('@image { src: photo.png }'), + ); + expect(markdown, contains('@image')); + expect(markdown, isNot(contains('@widget'))); + }); + test('scrollable block', () { expectRoundTrip( parseDeck('@block {\n scrollable: true\n}\nLong content here'), From d96be79b925906970f1d6cddbd77ab1b83d3fb89 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 11:55:30 -0400 Subject: [PATCH 3/3] refactor(core,builder): expose fences as line indices Both builder consumers are line-based and were each rebuilding character offsets from their own line list: offset += lines[i].length + 1; That is one rule with two owners, and it only worked because MarkdownParser fed fencedCodeRanges the joined lines rather than the original content; passing the original would have desynced silently on CRLF input. Derive both views from one span walker and add fencedCodeLines, which returns the line indices (addressing text.split('\n')) that sit inside a fence. Slide splitting and serialize-escape now test membership directly. fencedCodeLines is the only fence function exported from the barrel; the offset-based range API has one in-package consumer, TagTokenizer. Neither was in a released version, so narrowing the export breaks nothing. Also record why the rule keeps a closer's info string and accepts any leading whitespace, since both depart from package:markdown deliberately. --- packages/builder/CHANGELOG.md | 8 + .../lib/src/parsers/markdown_parser.dart | 11 +- .../lib/src/parsers/slide_serializer.dart | 10 +- packages/core/CHANGELOG.md | 7 + .../lib/src/markdown/markdown_fences.dart | 162 ++++++++++++------ packages/core/lib/superdeck_core.dart | 4 +- .../src/markdown/markdown_fences_test.dart | 43 ++++- 7 files changed, 176 insertions(+), 69 deletions(-) diff --git a/packages/builder/CHANGELOG.md b/packages/builder/CHANGELOG.md index 738e2bd9..a525a484 100644 --- a/packages/builder/CHANGELOG.md +++ b/packages/builder/CHANGELOG.md @@ -11,6 +11,14 @@ - Add build-plugin `beginBuild` and `finishBuild` lifecycle hooks. - Make `DeckBuilder.dispose()` wait for queued builds before disposing plugins and reject new builds after disposal. +- Fix slide splitting treating `---` as a separator inside tilde fences and + inside fences carrying an info string (for example, ` ```dart {.hero}`). + A fence closed with an info string such as ` ```{.code}` still closes. +- Fix Markdown serialization letting a `~~~` line close a ` ``` ` fence, which + escaped `@` directives that were really inside code. +- Share one reserved directive-name set between `@column` rejection and + widget-shorthand escaping, so a `WidgetBlock` named `section`, `block`, + `widget`, or `column` always serializes as `@widget`. ## 1.0.0 diff --git a/packages/builder/lib/src/parsers/markdown_parser.dart b/packages/builder/lib/src/parsers/markdown_parser.dart index 12e9f199..a821dc0b 100644 --- a/packages/builder/lib/src/parsers/markdown_parser.dart +++ b/packages/builder/lib/src/parsers/markdown_parser.dart @@ -44,7 +44,7 @@ class MarkdownParser { /// A slide is bounded by `---` separator lines. A slide may begin with an /// optional YAML frontmatter block delimited by a `---` pair at its start. /// Fenced code (backtick or tilde, including info strings) is decided by - /// [fencedCodeRanges], so `---` inside a fence is never a separator. + /// [fencedCodeLines], so `---` inside a fence is never a separator. static List _splitSlides(String content) { content = content.trim(); if (content.isEmpty) return []; @@ -89,16 +89,11 @@ class MarkdownParser { /// Returns the indices of `---` lines that sit outside fenced code blocks. static Set _findSeparatorLines(List lines) { - final text = lines.join('\n'); - final fences = fencedCodeRanges(text); + final fenced = fencedCodeLines(lines.join('\n')); final separators = {}; - var offset = 0; for (var i = 0; i < lines.length; i++) { - if (!isInsideFencedCode(offset, fences) && lines[i].trim() == '---') { - separators.add(i); - } - offset += lines[i].length + 1; + if (!fenced.contains(i) && lines[i].trim() == '---') separators.add(i); } return separators; diff --git a/packages/builder/lib/src/parsers/slide_serializer.dart b/packages/builder/lib/src/parsers/slide_serializer.dart index 593448f3..7f453721 100644 --- a/packages/builder/lib/src/parsers/slide_serializer.dart +++ b/packages/builder/lib/src/parsers/slide_serializer.dart @@ -315,20 +315,18 @@ class SlideSerializer { /// `_@foo`; the parser restores it via `_updateIgnoredTags`. String _escapeContent(String content) { if (content.isEmpty) return content; - final fences = fencedCodeRanges(content); + final fenced = fencedCodeLines(content); final lines = content.split('\n'); final result = []; - var offset = 0; - for (final line in lines) { - if (!isInsideFencedCode(offset, fences) && - _directiveLinePattern.hasMatch(line)) { + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (!fenced.contains(i) && _directiveLinePattern.hasMatch(line)) { final atIndex = line.indexOf('@'); result.add('${line.substring(0, atIndex)}_${line.substring(atIndex)}'); } else { result.add(line); } - offset += line.length + 1; } return result.join('\n'); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c2bf7ac2..74929f3e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -21,6 +21,13 @@ - Reject explicitly authored `null` inset edges with their full field path (for example, `padding.left`) while continuing to normalize omitted edges to zero. +- Add `fencedCodeLines`, the shared rule deciding which lines sit inside + fenced code so `---` splits and `@` directives stay hidden there. Slide + splitting, directive tokenization, and Markdown serialization now resolve + fences through it instead of three separate regexes. +- Fix `TagTokenizer` skipping directives only in fences that start at column + zero and close with exactly the opening run length. Indented fences and + closing fences longer than their opener are now recognized. ## 1.0.0 diff --git a/packages/core/lib/src/markdown/markdown_fences.dart b/packages/core/lib/src/markdown/markdown_fences.dart index a426fa86..f7faabd8 100644 --- a/packages/core/lib/src/markdown/markdown_fences.dart +++ b/packages/core/lib/src/markdown/markdown_fences.dart @@ -1,79 +1,130 @@ /// Parse-phase fenced code blocks. /// /// SuperDeck hides structural syntax (`---` slide splits and `@` directives) -/// inside fenced code. Slide-split, directive tokenization, and serialize-escape -/// must all call [fencedCodeRanges] so they cannot drift. +/// inside fenced code. Slide-split, directive tokenization, and +/// serialize-escape all resolve fences here so they cannot drift apart. /// -/// A fence opens on a line that, after optional leading whitespace, starts -/// with three or more backticks or tildes. A backtick opener cannot contain -/// more backticks in its info string (so ` ```dart {.hero}` opens and -/// ` ``` `` ` does not). A closer is a later line whose marker uses the same -/// character and is at least as long — SuperDeck decks close fences with -/// ` ```{.code}` (an info string on the closer), so info does not block a -/// close. An unclosed fence extends to the end of the text. -List<({int start, int end})> fencedCodeRanges(String text) { - final ranges = <({int start, int end})>[]; - String? openChar; +/// The rule mirrors what SuperDeck already renders through `package:markdown` +/// (`codeFencePattern`): a fence opens on three or more backticks or tildes, +/// a backtick opener cannot carry backticks in its info string (so +/// ` ```dart {.hero}` opens), and a closer repeats the opening character at +/// least as many times. An unclosed fence extends to the end of the text. +/// +/// Two departures from `package:markdown` are deliberate: +/// +/// - **Closers keep their info string.** CommonMark rejects an info string on +/// a closer, but SuperDeck decks close fences with ` ```{.code}`. Treating +/// that as "not a closer" leaves the fence open and swallows the following +/// `---`, merging two slides. +/// - **Any leading whitespace opens a fence**, where `package:markdown` allows +/// at most three spaces. Over-hiding is safe here: to the renderer a more +/// deeply indented fence is an indented code block, which is still code. +/// Under-hiding would split a slide in the middle of a code sample. +library; + +/// Character ranges of the fenced code blocks in [text], each covering its +/// opening and closing fence lines. +List<({int start, int end})> fencedCodeRanges(String text) => [ + for (final span in _fenceSpans(text)) + (start: span.startOffset, end: span.endOffset), +]; + +/// Whether [offset] sits inside one of [ranges] (`start <= offset < end`). +bool isInsideFencedCode(int offset, List<({int start, int end})> ranges) { + for (final range in ranges) { + if (offset >= range.start && offset < range.end) return true; + } + return false; +} + +/// Indices of the lines of [text] that sit inside fenced code, including the +/// opening and closing fence lines. +/// +/// Indices address `text.split('\n')`, so a line-oriented caller can test +/// membership directly instead of tracking character offsets itself. +Set fencedCodeLines(String text) => { + for (final span in _fenceSpans(text)) + for (var line = span.startLine; line <= span.endLine; line++) line, +}; + +typedef _FenceSpan = ({ + int startLine, + int endLine, + int startOffset, + int endOffset, +}); + +/// Single owner of the open/close state machine. Both public views derive from +/// this so a fence can never be a range in one caller and not a line in another. +List<_FenceSpan> _fenceSpans(String text) { + final spans = <_FenceSpan>[]; + String? openCharacter; var openLength = 0; - var openStart = 0; + var openLine = 0; + var openOffset = 0; + var lastLine = 0; - _forEachLine(text, (start, end, next, line) { + _forEachLine(text, (index, start, next, line) { + lastLine = index; final fence = _readFenceLine(line); - if (openChar == null) { + + if (openCharacter == null) { if (fence != null && fence.canOpen) { - openChar = fence.character; + openCharacter = fence.character; openLength = fence.length; - openStart = start; + openLine = index; + openOffset = start; } return; } if (fence != null && - fence.character == openChar && + fence.character == openCharacter && fence.length >= openLength) { - ranges.add((start: openStart, end: next)); - openChar = null; + spans.add(( + startLine: openLine, + endLine: index, + startOffset: openOffset, + endOffset: next, + )); + openCharacter = null; } }); - if (openChar != null) { - ranges.add((start: openStart, end: text.length)); + if (openCharacter != null) { + spans.add(( + startLine: openLine, + endLine: lastLine, + startOffset: openOffset, + endOffset: text.length, + )); } - return ranges; -} - -/// Whether [offset] sits inside one of [ranges] (`start <= offset < end`). -bool isInsideFencedCode(int offset, List<({int start, int end})> ranges) { - for (final range in ranges) { - if (offset >= range.start && offset < range.end) return true; - } - return false; + return spans; } +/// Visits every line of [text] split on `\n`, so line indices match +/// `text.split('\n')`. A trailing `\r` is dropped from [line] so CRLF input +/// reads the same as LF input. void _forEachLine( String text, - void Function(int start, int end, int next, String line) visit, + void Function(int index, int start, int next, String line) visit, ) { - var i = 0; - while (i < text.length) { - final start = i; - while (i < text.length) { - final unit = text.codeUnitAt(i); - if (unit == 0x0A || unit == 0x0D) break; - i++; - } - final end = i; - if (i < text.length) { - if (text.codeUnitAt(i) == 0x0D && - i + 1 < text.length && - text.codeUnitAt(i + 1) == 0x0A) { - i += 2; - } else { - i += 1; - } - } - visit(start, end, i, text.substring(start, end)); + var index = 0; + var start = 0; + + while (true) { + final newline = text.indexOf('\n', start); + final end = newline == -1 ? text.length : newline; + final next = newline == -1 ? text.length : newline + 1; + var line = text.substring(start, end); + if (line.endsWith('\r')) line = line.substring(0, line.length - 1); + + visit(index, start, next, line); + + if (newline == -1) return; + index++; + start = next; } } @@ -98,7 +149,12 @@ void _forEachLine( final info = line.substring(i); final character = String.fromCharCode(markerUnit); - final canOpen = character == '~' || !info.contains('`'); - return (character: character, length: length, canOpen: canOpen); + return ( + character: character, + length: length, + // A backtick opener cannot contain backticks in its info string; a tilde + // opener may contain anything. Closers are not filtered by `canOpen`. + canOpen: character == '~' || !info.contains('`'), + ); } diff --git a/packages/core/lib/superdeck_core.dart b/packages/core/lib/superdeck_core.dart index a27085f0..99e9abf5 100644 --- a/packages/core/lib/superdeck_core.dart +++ b/packages/core/lib/superdeck_core.dart @@ -18,7 +18,9 @@ export 'src/deck/slide_contract.dart'; export 'src/deck/slide_model.dart'; // Markdown export 'src/markdown/hero_tag_helpers.dart'; -export 'src/markdown/markdown_fences.dart'; +// Only the line-oriented view is shared across packages; the offset-based +// range API has a single in-package consumer (TagTokenizer). +export 'src/markdown/markdown_fences.dart' show fencedCodeLines; export 'src/markdown/markdown_syntaxes.dart'; export 'src/markdown/tag_tokenizer.dart'; // Plugins diff --git a/packages/core/test/src/markdown/markdown_fences_test.dart b/packages/core/test/src/markdown/markdown_fences_test.dart index 1f836bed..196b29c1 100644 --- a/packages/core/test/src/markdown/markdown_fences_test.dart +++ b/packages/core/test/src/markdown/markdown_fences_test.dart @@ -1,4 +1,4 @@ -import 'package:superdeck_core/superdeck_core.dart'; +import 'package:superdeck_core/src/markdown/markdown_fences.dart'; import 'package:test/test.dart'; void main() { @@ -56,4 +56,45 @@ void main() { expect(isInsideFencedCode(text.indexOf('outside'), ranges), isFalse); }); }); + + group('fencedCodeLines', () { + test('covers the fence lines and their body', () { + // 0:before 1:``` 2:code 3:``` 4:after + const text = 'before\n```\ncode\n```\nafter'; + + expect(fencedCodeLines(text), {1, 2, 3}); + }); + + test('an unclosed fence covers every line to the end', () { + const text = 'before\n```\ncode\nmore'; + + expect(fencedCodeLines(text), {1, 2, 3}); + }); + + test('indices address text.split(chr(10)) for CRLF input', () { + const text = 'before\r\n```\r\ncode\r\n```\r\nafter'; + final lines = text.split('\n'); + + expect(fencedCodeLines(text), {1, 2, 3}); + expect(lines[2].trim(), 'code'); + }); + + test('reports no lines when there is no fence', () { + expect(fencedCodeLines('# Title\n\nBody text.'), isEmpty); + }); + + test('agrees with fencedCodeRanges on which lines are hidden', () { + const text = 'a\n```dart {.hero}\n---\n@tag\n```{.code}\nb\n~~~\nc'; + final ranges = fencedCodeRanges(text); + final lines = text.split('\n'); + final fromRanges = {}; + var offset = 0; + for (var i = 0; i < lines.length; i++) { + if (isInsideFencedCode(offset, ranges)) fromRanges.add(i); + offset += lines[i].length + 1; + } + + expect(fencedCodeLines(text), fromRanges); + }); + }); }