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
6 changes: 3 additions & 3 deletions demo/slides.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
@block {
align: topCenter
}
### Markdown {.heading}
### Markdown {.feat-markdown}

- Simple syntax
- Code blocks
Expand All @@ -65,7 +65,7 @@
@block {
align: topCenter
}
### Flutter {.heading}
### Flutter {.feat-flutter}

- Custom widgets
- Hot reload
Expand All @@ -74,7 +74,7 @@
@block {
align: topCenter
}
### Styling {.heading}
### Styling {.feat-styling}

- Themes
- Custom styles
Expand Down
116 changes: 90 additions & 26 deletions packages/builder/lib/src/parsers/markdown_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,59 +36,123 @@ class 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*:');

/// Splits the entire markdown into slides.
///
/// A "slide" is defined by frontmatter sections delimited with `---`.
/// 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 frontmatter delimiters.
/// won't be treated as a separator.
static List<String> _splitSlides(String content) {
content = content.trim();
if (content.isEmpty) return [];

final lines = LineSplitter().convert(content);
final separators = _findSeparatorLines(lines);

final slides = <String>[];
final buffer = StringBuffer();
bool insideFrontMatter = false;

int? codeFenceLength; // null = not in code block, otherwise = fence length
var i = 0;
while (i < lines.length) {
if (separators.contains(i)) {
final pending = buffer.toString().trim();
if (pending.isNotEmpty) {
slides.add(pending);
buffer.clear();
}

final closeIdx = _findFrontmatterClose(lines, i, separators);
if (closeIdx != null) {
for (var j = i; j <= closeIdx; j++) {
buffer.writeln(lines[j]);
}
i = closeIdx + 1;
continue;
}

i++;
continue;
}

for (var line in lines) {
final trimmed = line.trim();
buffer.writeln(lines[i]);
i++;
}

// Check for code fence (opening or closing)
final tail = buffer.toString().trim();
if (tail.isNotEmpty) slides.add(tail);

return slides;
}

/// Returns the indices of `---` lines that sit outside fenced code blocks.
static Set<int> _findSeparatorLines(List<String> lines) {
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) {
// Opening a code block
codeFenceLength = backticks;
} else if (backticks >= codeFenceLength) {
// Closing the code block (needs same or more backticks)
codeFenceLength = null;
}
// If backticks < codeFenceLength, it's content inside the block
}

if (codeFenceLength != null) {
buffer.writeln(line);
continue;
}

if (trimmed == '---') {
if (!insideFrontMatter) {
if (buffer.isNotEmpty) {
slides.add(buffer.toString().trim());
buffer.clear();
}
}
insideFrontMatter = !insideFrontMatter;
if (codeFenceLength != null) continue;
if (trimmed == '---') separators.add(i);
}

return separators;
}

/// If [openIdx] opens a YAML frontmatter block, returns the index of the
/// closing `---`. Returns null when the next separator is too far away or
/// the lines between look like markdown content rather than YAML.
static int? _findFrontmatterClose(
List<String> lines,
int openIdx,
Set<int> separators,
) {
int? closeIdx;
for (var j = openIdx + 1; j < lines.length; j++) {
if (separators.contains(j)) {
closeIdx = j;
break;
}
final trimmed = lines[j].trimLeft();
if (trimmed.isEmpty) continue;
// Distinctive markdown body indicators rule out frontmatter.
final firstChar = trimmed[0];
if (firstChar == '#' ||
firstChar == '@' ||
firstChar == '>' ||
firstChar == '!') {
return null;
}
buffer.writeln(line);
}

if (buffer.isNotEmpty) {
slides.add(buffer.toString());
if (closeIdx == null) return null;

var hasContent = false;
var hasYamlMarker = false;
for (var j = openIdx + 1; j < closeIdx; j++) {
final trimmed = lines[j].trim();
if (trimmed.isEmpty) continue;
hasContent = true;
if (_yamlKeyPattern.hasMatch(trimmed) || trimmed.startsWith('- ')) {
hasYamlMarker = true;
break;
}
}

return slides;
if (!hasContent || hasYamlMarker) return closeIdx;
return null;
}

List<RawSlideMarkdown> parse(String markdown) {
Expand Down
26 changes: 25 additions & 1 deletion packages/builder/test/src/parsers/markdown_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ Content for slide 2
const markdown = '''
---
title: Slide 1
---
---
Content for slide 1

---
Expand All @@ -352,5 +352,29 @@ Content for the second slide
expect(slides[1].frontmatter, {});
expect(slides[1].content, equals('Content for the second slide'));
});

test(
'returns the correct slide count when slides without frontmatter are separated by --- (issue #63)',
() async {
const markdown = '''
# Slide 1

---

# Slide 2

---

# Slide 3
''';

final slides = markdownParser.parse(markdown);

expect(slides.length, equals(3));
expect(slides[0].content, equals('# Slide 1'));
expect(slides[1].content, equals('# Slide 2'));
expect(slides[2].content, equals('# Slide 3'));
},
);
});
}
Loading