Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/builder/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions packages/builder/lib/src/parsers/block_parser.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:superdeck_core/superdeck_core.dart';

import 'directive_names.dart';

class ParsedBlock {
final String type;
final int startIndex;
Expand Down Expand Up @@ -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,
);
Expand Down
17 changes: 17 additions & 0 deletions packages/builder/lib/src/parsers/directive_names.dart
Original file line number Diff line number Diff line change
@@ -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,
};
24 changes: 4 additions & 20 deletions packages/builder/lib/src/parsers/markdown_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String> _splitSlides(String content) {
content = content.trim();
if (content.isEmpty) return [];
Expand Down Expand Up @@ -92,24 +89,11 @@ class MarkdownParser {

/// Returns the indices of `---` lines that sit outside fenced code blocks.
static Set<int> _findSeparatorLines(List<String> lines) {
final fenced = fencedCodeLines(lines.join('\n'));
final separators = <int>{};
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;
Expand Down
31 changes: 9 additions & 22 deletions packages/builder/lib/src/parsers/slide_serializer.dart
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -19,13 +20,8 @@ import 'comment_parser.dart';
class SlideSerializer {
const SlideSerializer();

/// Tag names that cannot be used as `@<name>` 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<Slide> slides) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = <String>[];
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 {
Expand Down
42 changes: 42 additions & 0 deletions packages/builder/test/src/parsers/directive_names_test.dart
Original file line number Diff line number Diff line change
@@ -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<DeckFormatException>().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);
});
});
}
75 changes: 75 additions & 0 deletions packages/builder/test/src/parsers/fence_agreement_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> tokens;

const _FenceCase(
this.name, {
required this.markdown,
required this.slides,
required this.tokens,
});
}
96 changes: 96 additions & 0 deletions packages/builder/test/src/parsers/markdown_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
}
Loading
Loading