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/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/markdown_parser.dart b/packages/builder/lib/src/parsers/markdown_parser.dart index 8de9efdb..a821dc0b 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 + /// [fencedCodeLines], so `---` inside a fence is never a separator. static List _splitSlides(String content) { content = content.trim(); if (content.isEmpty) return []; @@ -92,24 +89,11 @@ class MarkdownParser { /// Returns the indices of `---` lines that sit outside fenced code blocks. static Set _findSeparatorLines(List lines) { + final fenced = fencedCodeLines(lines.join('\n')); final separators = {}; - int? codeFenceLength; 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 (codeFenceLength != null) continue; - if (trimmed == '---') separators.add(i); + 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 e53cbf5e..7f453721 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,13 +20,8 @@ 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-]+'); - static final _fencePattern = RegExp(r'^(`{3,}|~{3,})'); /// Serializes a list of [slides] into a single Markdown document. String serialize(List slides) { @@ -166,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; @@ -263,7 +259,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,24 +315,13 @@ class SlideSerializer { /// `_@foo`; the parser restores it via `_updateIgnoredTags`. String _escapeContent(String content) { if (content.isEmpty) return content; + final fenced = fencedCodeLines(content); final lines = content.split('\n'); final result = []; - int? fenceLength; - - 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)) { + 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 { 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/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..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'), @@ -228,6 +265,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/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 new file mode 100644 index 00000000..f7faabd8 --- /dev/null +++ b/packages/core/lib/src/markdown/markdown_fences.dart @@ -0,0 +1,160 @@ +/// Parse-phase fenced code blocks. +/// +/// SuperDeck hides structural syntax (`---` slide splits and `@` directives) +/// inside fenced code. Slide-split, directive tokenization, and +/// serialize-escape all resolve fences here so they cannot drift apart. +/// +/// 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 openLine = 0; + var openOffset = 0; + var lastLine = 0; + + _forEachLine(text, (index, start, next, line) { + lastLine = index; + final fence = _readFenceLine(line); + + if (openCharacter == null) { + if (fence != null && fence.canOpen) { + openCharacter = fence.character; + openLength = fence.length; + openLine = index; + openOffset = start; + } + return; + } + + if (fence != null && + fence.character == openCharacter && + fence.length >= openLength) { + spans.add(( + startLine: openLine, + endLine: index, + startOffset: openOffset, + endOffset: next, + )); + openCharacter = null; + } + }); + + if (openCharacter != null) { + spans.add(( + startLine: openLine, + endLine: lastLine, + startOffset: openOffset, + endOffset: text.length, + )); + } + + 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 index, int start, int next, String line) visit, +) { + 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; + } +} + +({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); + + 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/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..99e9abf5 100644 --- a/packages/core/lib/superdeck_core.dart +++ b/packages/core/lib/superdeck_core.dart @@ -18,6 +18,9 @@ export 'src/deck/slide_contract.dart'; export 'src/deck/slide_model.dart'; // Markdown export 'src/markdown/hero_tag_helpers.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 new file mode 100644 index 00000000..196b29c1 --- /dev/null +++ b/packages/core/test/src/markdown/markdown_fences_test.dart @@ -0,0 +1,100 @@ +import 'package:superdeck_core/src/markdown/markdown_fences.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); + }); + }); + + 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); + }); + }); +} 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', () {