diff --git a/packages/builder/lib/src/assets/asset_generation_pipeline.dart b/packages/builder/lib/src/assets/asset_generation_pipeline.dart index 963029a9..25d47250 100644 --- a/packages/builder/lib/src/assets/asset_generation_pipeline.dart +++ b/packages/builder/lib/src/assets/asset_generation_pipeline.dart @@ -1,10 +1,13 @@ import 'dart:async'; import 'package:path/path.dart' as path; -import 'package:superdeck_builder/superdeck_builder.dart'; import 'package:superdeck_core/asset_cache_store_io.dart'; import 'package:superdeck_core/superdeck_core.dart'; +import '../parsers/fenced_code_parser.dart'; +import '../markdown_utils.dart'; +import 'asset_generator.dart'; + /// Result of asset generation pipeline processing on slide content. class AssetGenerationResult { /// The updated slide content with asset references replaced. @@ -28,7 +31,7 @@ class AssetGenerationPipeline { final List _generators; final DeckService _store; final AssetCacheStore _cache; - final Logger _logger = Logger('AssetGenerationPipeline'); + final _logger = Logger('AssetGenerationPipeline'); AssetGenerationPipeline({ required List generators, @@ -87,10 +90,9 @@ class AssetGenerationPipeline { ); } - /// Finds the appropriate generator for the given content type using pattern matching. + /// Finds the first generator that can process [contentType]. AssetGenerator? _findGenerator(String contentType) { for (final generator in _generators) { - // Use generator's canProcess method which might use pattern matching internally if (generator.canProcess(contentType)) { return generator; } @@ -123,13 +125,8 @@ class AssetGenerationPipeline { final generatedAsset = generator.createAssetReference(codeBlock.content); final assetPath = _store.getGeneratedAssetPath(generatedAsset); - final cachedUri = await _cache.resolve(generatedAsset.fileName); - if (cachedUri != null) { - _validateCachedAssetPath( - cacheUri: cachedUri, - expectedPath: assetPath, - assetKey: generatedAsset.fileName, - ); + var resolvedUri = await _cache.resolve(generatedAsset.fileName); + if (resolvedUri != null) { _logger.info( '${generator.type} asset already exists for slide $slideIndex', ); @@ -140,20 +137,21 @@ class AssetGenerationPipeline { codeBlock.content, assetPath, ); - final writtenUri = await _cache.write(generatedAsset.fileName, assetData); - if (writtenUri == null) { + resolvedUri = await _cache.write(generatedAsset.fileName, assetData); + if (resolvedUri == null) { throw StateError( 'Failed to write ${generator.type} asset to cache for key ' '"${generatedAsset.fileName}".', ); } - _validateCachedAssetPath( - cacheUri: writtenUri, - expectedPath: assetPath, - assetKey: generatedAsset.fileName, - ); } + _validateCachedAssetPath( + cacheUri: resolvedUri, + expectedPath: assetPath, + assetKey: generatedAsset.fileName, + ); + // Create replacement syntax with relative path from project directory final projectDir = _store.configuration.superdeckDir.parent.path; final relativePath = path.relative(assetPath, from: projectDir); diff --git a/packages/builder/lib/src/parsers/fenced_code_parser.dart b/packages/builder/lib/src/parsers/fenced_code_parser.dart index 17f73c86..7c9b3c06 100644 --- a/packages/builder/lib/src/parsers/fenced_code_parser.dart +++ b/packages/builder/lib/src/parsers/fenced_code_parser.dart @@ -1,10 +1,5 @@ import 'package:superdeck_core/superdeck_core.dart'; -final _codeFencePattern = RegExp( - r'```(?[^`]*)[\s\S]*?```', - multiLine: true, -); - // ``` {: , : , ...} // // ``` @@ -37,50 +32,99 @@ class FencedCodeParser { const FencedCodeParser(); List parse(String text) { - final matches = _codeFencePattern.allMatches(text); - List parsedBlocks = []; - - for (final match in matches) { - final backtickInfo = match.namedGroup('backtickInfo'); + final parsedBlocks = []; + + String? activeFence; + String? openingLine; + int? startIndex; + final blockContent = []; + + var offset = 0; + while (offset < text.length) { + final newlineIndex = text.indexOf('\n', offset); + final lineEnd = newlineIndex == -1 ? text.length : newlineIndex; + final line = text.substring(offset, lineEnd); + + final fence = parseCodeFenceLine(line); + if (activeFence == null) { + if (fence != null) { + activeFence = fence.marker; + openingLine = fence.rest; + startIndex = offset; + blockContent.clear(); + } + } else if (fence != null && + canCloseCodeFence( + marker: activeFence, + minLength: activeFence.length, + line: line, + )) { + final endIndex = lineEnd; + final header = _parseFenceHeader(openingLine!); + final optionsMap = _parseFenceOptions( + options: header.options, + startIndex: startIndex!, + endIndex: endIndex, + language: header.language, + ); + + parsedBlocks.add( + ParsedFencedCode( + options: optionsMap, + language: header.language, + content: blockContent.join('\n').trim(), + startIndex: startIndex, + endIndex: endIndex, + ), + ); + + activeFence = null; + openingLine = null; + startIndex = null; + } else { + blockContent.add(line); + } - final lines = backtickInfo?.split('\n'); - final firstLine = lines?.first ?? ''; - final rest = lines?.sublist(1).join('\n') ?? ''; + if (newlineIndex == -1) { + break; + } + offset = lineEnd + 1; + } - final language = firstLine.split(' ')[0]; - final options = firstLine.replaceFirst(language, '').trim(); + return parsedBlocks; + } - final content = rest; + ({String language, String options}) _parseFenceHeader(String openingLine) { + final firstLine = openingLine.trim(); + final spaceIndex = firstLine.indexOf(' '); - final startIndex = match.start; - final endIndex = match.end; + if (spaceIndex == -1) { + return (language: firstLine, options: ''); + } - final Map optionsMap; - if (options.isNotEmpty) { - try { - optionsMap = convertYamlToMap(options, strict: true); - } catch (e) { - throw Exception( - 'Failed to parse options for code block at position $startIndex-$endIndex. ' - 'Language: $language. Options: "$options". Error: $e', - ); - } - } else { - optionsMap = {}; - } + return ( + language: firstLine.substring(0, spaceIndex), + options: firstLine.substring(spaceIndex + 1).trim(), + ); + } - parsedBlocks.add( - ParsedFencedCode( - options: optionsMap, - language: language, - content: content.trim(), - startIndex: startIndex, - endIndex: endIndex, - ), + Map _parseFenceOptions({ + required String options, + required int startIndex, + required int endIndex, + required String language, + }) { + if (options.isEmpty) return {}; + + try { + return convertYamlToMap(options, strict: true); + } catch (e) { + throw Exception( + 'Failed to parse options for code block at position ' + '$startIndex-$endIndex. Language: $language. Options: "$options". ' + 'Error: $e', ); } - - return parsedBlocks; } } diff --git a/packages/builder/lib/src/parsers/front_matter_parser.dart b/packages/builder/lib/src/parsers/front_matter_parser.dart index 0e31b1fa..bdf72746 100644 --- a/packages/builder/lib/src/parsers/front_matter_parser.dart +++ b/packages/builder/lib/src/parsers/front_matter_parser.dart @@ -1,4 +1,4 @@ -import 'package:superdeck_core/superdeck_core.dart'; +import 'package:yaml/yaml.dart'; typedef ExtractedFrontmatter = ({ Map frontmatter, @@ -61,23 +61,48 @@ class FrontmatterParser { ExtractedFrontmatter parse(String content) { final result = parseFrontMatter(content); + return ( + frontmatter: _parseFrontmatterYaml(result.yaml), + contents: result.markdown, + ); + } - final yamlString = result.yaml; - final markdownContent = result.markdown; - Map yamlMap = {}; + Map _parseFrontmatterYaml(String yamlString) { + if (yamlString.isEmpty) return {}; - if (yamlString.isNotEmpty) { - try { - yamlMap = convertYamlToMap(yamlString); - } catch (e) { + try { + final yamlDoc = loadYaml(yamlString); + if (yamlDoc == null) { + return {}; + } + if (yamlDoc is! YamlMap) { throw FormatException( - 'Invalid YAML frontmatter in slide. ' - 'Check for syntax errors in your slide configuration. ' - 'Error: $e', + 'Frontmatter must be a YAML map. Received: ${yamlDoc.runtimeType}', ); } + return _toPlainMap(yamlDoc); + } catch (e) { + throw FormatException( + 'Invalid YAML frontmatter in slide. ' + 'Check for syntax errors in your slide configuration. ' + 'Error: $e', + ); } + } - return (frontmatter: yamlMap, contents: markdownContent); + Map _toPlainMap(YamlMap yamlMap) { + return Map.fromEntries( + yamlMap.entries.map( + (entry) => MapEntry(entry.key.toString(), _toPlainValue(entry.value)), + ), + ); + } + + Object? _toPlainValue(Object? value) { + if (value is YamlMap) return _toPlainMap(value); + if (value is YamlList) { + return value.map(_toPlainValue).toList(); + } + return value; } } diff --git a/packages/builder/lib/src/parsers/markdown_parser.dart b/packages/builder/lib/src/parsers/markdown_parser.dart index d5833c67..59191a31 100644 --- a/packages/builder/lib/src/parsers/markdown_parser.dart +++ b/packages/builder/lib/src/parsers/markdown_parser.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:superdeck_core/superdeck_core.dart'; +import 'package:yaml/yaml.dart'; import 'front_matter_parser.dart'; import 'raw_slide_schema.dart'; @@ -33,64 +34,178 @@ 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 _yamlMapKeyPattern = RegExp( + r'''^(?:[A-Za-z_][A-Za-z0-9_-]*|"(?:[^"\\]|\\.)+"|'(?:[^'\\]|\\.)+')\s*:''', + ); /// Splits the entire markdown into slides. /// - /// A "slide" is defined by frontmatter sections delimited with `---`. - /// Code blocks (fenced by ```) are respected, so `---` inside a code block - /// won't be treated as frontmatter delimiters. + /// A slide boundary is any `---` outside of fenced code blocks. + /// If a slide starts with a frontmatter block (`---` + YAML map + `---`), + /// that pair is kept with the slide instead of creating extra boundaries. static List _splitSlides(String content) { - content = content.trim(); - final lines = LineSplitter().convert(content); + final trimmedContent = content.trim(); + if (trimmedContent.isEmpty) return []; + + final lines = LineSplitter() + .convert(trimmedContent) + .map((line) => line.replaceAll('\r', '')) + .toList(); + final slides = []; - final buffer = StringBuffer(); - bool insideFrontMatter = false; - - int? codeFenceLength; // null = not in code block, otherwise = fence length - - for (var line in lines) { - final trimmed = line.trim(); - - // Check for code fence (opening or closing) - 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 - } + final currentSlide = []; + String? activeFence; - if (codeFenceLength != null) { - buffer.writeln(line); + void flushCurrentSlide() { + _appendSlide(currentSlide, slides); + currentSlide.clear(); + } + + for (var index = 0; index < lines.length; index++) { + final line = lines[index]; + final fenceState = _processFenceLine(activeFence, line); + activeFence = fenceState.activeFence; + if (fenceState.isFenceLine) { + currentSlide.add(line); continue; } - if (trimmed == '---') { - if (!insideFrontMatter) { - if (buffer.isNotEmpty) { - slides.add(buffer.toString().trim()); - buffer.clear(); + if (activeFence == null && line.trim() == '---') { + final candidateEndIndex = _findFrontMatterEnd(lines, index + 1); + + if (candidateEndIndex != null) { + final candidateYaml = lines + .sublist(index + 1, candidateEndIndex) + .join('\n'); + + if (_isFrontMatterCandidate(candidateYaml)) { + if (currentSlide.isNotEmpty) { + flushCurrentSlide(); + } + + for (var i = index; i <= candidateEndIndex; i++) { + currentSlide.add(lines[i]); + } + index = candidateEndIndex; + continue; } } - insideFrontMatter = !insideFrontMatter; + + flushCurrentSlide(); + continue; } - buffer.writeln(line); - } - if (buffer.isNotEmpty) { - slides.add(buffer.toString()); + currentSlide.add(line); } + _appendSlide(currentSlide, slides); + return slides; } + static int? _findFrontMatterEnd(List lines, int start) { + String? activeFence; + + for (var i = start; i < lines.length; i++) { + final line = lines[i]; + final fenceState = _processFenceLine(activeFence, line); + activeFence = fenceState.activeFence; + if (fenceState.isFenceLine) { + continue; + } + + if (activeFence == null && line.trim() == '---') { + return i; + } + } + + return null; + } + + static ({String? activeFence, bool isFenceLine}) _processFenceLine( + String? activeFence, + String line, + ) { + final fence = parseCodeFenceLine(line); + if (fence == null) { + return (activeFence: activeFence, isFenceLine: false); + } + if (activeFence == null) { + return (activeFence: fence.marker, isFenceLine: true); + } + if (canCloseCodeFence( + marker: activeFence, + minLength: activeFence.length, + line: line, + )) { + return (activeFence: null, isFenceLine: true); + } + return (activeFence: activeFence, isFenceLine: true); + } + + static bool _isFrontMatterCandidate(String value) { + final candidate = value.trim(); + if (candidate.isEmpty) return true; + if (_startsWithDirective(candidate)) { + return false; + } + + try { + final yaml = loadYaml(candidate); + return yaml is YamlMap || yaml is YamlList; + } on YamlException { + return _looksLikeFrontMatterCandidate(candidate); + } catch (_) { + return false; + } + } + + static bool _looksLikeFrontMatterCandidate(String value) { + var sawMapKey = false; + for (final rawLine in value.split('\n')) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) { + continue; + } + + if (_yamlMapKeyPattern.hasMatch(line)) { + sawMapKey = true; + continue; + } + + // Allow continuation lines that could belong to multiline YAML values. + if (rawLine.startsWith(' ') || rawLine.startsWith('\t')) { + continue; + } + + return false; + } + + return sawMapKey; + } + + static bool _startsWithDirective(String value) { + for (final rawLine in value.split('\n')) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) { + continue; + } + + return line.startsWith('@'); + } + + return false; + } + + static void _appendSlide(List lines, List slides) { + if (lines.isEmpty) return; + + final slideText = lines.join('\n').trim(); + if (slideText.isNotEmpty) { + slides.add(slideText); + } + } + List parse(String markdown) { final rawSlides = _splitSlides(markdown); diff --git a/packages/builder/lib/src/slide_processor.dart b/packages/builder/lib/src/slide_processor.dart index 70ed83f6..e69bbbb2 100644 --- a/packages/builder/lib/src/slide_processor.dart +++ b/packages/builder/lib/src/slide_processor.dart @@ -14,7 +14,7 @@ import 'tasks/task.dart'; /// Handles concurrency control and coordinates task execution for each slide. class SlideProcessor { final int _concurrentSlides; - final Logger _logger = Logger('SlideProcessor'); + final _logger = Logger('SlideProcessor'); SlideProcessor({int concurrentSlides = 4}) : _concurrentSlides = concurrentSlides; @@ -54,12 +54,8 @@ class SlideProcessor { // Wait for this batch to complete final results = await Future.wait(futures); - // Extract processed slides using functional approach - final slidesToAdd = await Future.wait( - results.map((result) => _buildSlide(result)), - ); - - processedSlides.addAll(slidesToAdd); + // Build final slides from processed contexts. + processedSlides.addAll(results.map(_buildSlide)); } return processedSlides; @@ -70,7 +66,7 @@ class SlideProcessor { SlideContext context, List tasks, ) async { - for (var task in tasks) { + for (final task in tasks) { await _runTask(task, context); } return context; @@ -104,7 +100,7 @@ class SlideProcessor { } /// Builds final Slide from processed context - Future _buildSlide(SlideContext result) async { + Slide _buildSlide(SlideContext result) { return Slide( key: result.slide.key, options: SlideOptions.parse(result.slide.frontmatter), diff --git a/packages/builder/test/src/assets/asset_generation_pipeline_test.dart b/packages/builder/test/src/assets/asset_generation_pipeline_test.dart index b65cee10..236f4cde 100644 --- a/packages/builder/test/src/assets/asset_generation_pipeline_test.dart +++ b/packages/builder/test/src/assets/asset_generation_pipeline_test.dart @@ -64,12 +64,18 @@ class MockDeckService extends DeckService { class InMemoryAssetCacheStore implements AssetCacheStore { final Directory _cacheDir; final Map> _bytesByKey = {}; + int resolveCallCount = 0; + int writeCallCount = 0; + String? lastResolvedKey; + String? lastWrittenKey; InMemoryAssetCacheStore(this._cacheDir); @override Future resolve(String assetKey) async { + resolveCallCount++; final normalizedKey = AssetCacheStore.validateAssetKey(assetKey); + lastResolvedKey = normalizedKey; final bytes = _bytesByKey[normalizedKey]; if (bytes == null || bytes.isEmpty) { return null; @@ -79,7 +85,9 @@ class InMemoryAssetCacheStore implements AssetCacheStore { @override Future write(String assetKey, List bytes) async { + writeCallCount++; final normalizedKey = AssetCacheStore.validateAssetKey(assetKey); + lastWrittenKey = normalizedKey; if (bytes.isEmpty) { return null; } @@ -225,6 +233,62 @@ graph TD expect(mockGenerator.generateCallCount, equals(1)); }); + test( + 'writes generated assets through injected cache store on cache miss', + () async { + final cacheStore = InMemoryAssetCacheStore( + Directory('${tempDir.path}/assets'), + ); + final customPipeline = AssetGenerationPipeline( + generators: [mockGenerator], + store: mockStore, + cacheStore: cacheStore, + ); + + const content = ''' +```mermaid +graph TD + A --> B +``` +'''; + + await customPipeline.processSlideContent(content, 0); + + expect(cacheStore.resolveCallCount, equals(1)); + expect(cacheStore.writeCallCount, equals(1)); + expect(cacheStore.lastResolvedKey, isNotNull); + expect(cacheStore.lastWrittenKey, equals(cacheStore.lastResolvedKey)); + }, + ); + + test( + 'uses injected cache store to skip regeneration on cache hit', + () async { + final cacheStore = InMemoryAssetCacheStore( + Directory('${tempDir.path}/assets'), + ); + final customPipeline = AssetGenerationPipeline( + generators: [mockGenerator], + store: mockStore, + cacheStore: cacheStore, + ); + + const content = ''' +```mermaid +graph TD + A --> B +``` +'''; + + await customPipeline.processSlideContent(content, 0); + await customPipeline.processSlideContent(content, 1); + + expect(cacheStore.resolveCallCount, equals(2)); + expect(cacheStore.writeCallCount, equals(1)); + expect(mockGenerator.generateCallCount, equals(1)); + }, + ); + test( 'throws when cache resolves to a different path than deck assets', () async { diff --git a/packages/builder/test/src/parsers/fenced_code_parser_test.dart b/packages/builder/test/src/parsers/fenced_code_parser_test.dart index 60086f50..54e6a2ef 100644 --- a/packages/builder/test/src/parsers/fenced_code_parser_test.dart +++ b/packages/builder/test/src/parsers/fenced_code_parser_test.dart @@ -46,6 +46,38 @@ second expect(blocks[1].language, equals('python')); expect(blocks[1].content, equals('second')); }); + + test('supports closing fence with more delimiter characters', () { + const content = ''' +``` +first +```` + +~~~dart +second +~~~~ +'''; + final blocks = parser.parse(content); + + expect(blocks, hasLength(2)); + expect(blocks[0].language, equals('')); + expect(blocks[0].content, equals('first')); + expect(blocks[1].language, equals('dart')); + expect(blocks[1].content, equals('second')); + }); + + test('supports closing tildes with longer matching fence', () { + const content = ''' +~~~js +const value = 1; +~~~~ +'''; + final blocks = parser.parse(content); + + expect(blocks, hasLength(1)); + expect(blocks[0].language, equals('js')); + expect(blocks[0].content, equals('const value = 1;')); + }); }); group('options parsing', () { @@ -62,6 +94,32 @@ code expect(blocks[0].options['foo'], equals('bar')); }); + test('parses tilde fenced code block', () { + const content = ''' +~~~dart +code +~~~ +'''; + final blocks = parser.parse(content); + + expect(blocks, hasLength(1)); + expect(blocks[0].language, equals('dart')); + expect(blocks[0].content, equals('code')); + }); + + test('parses options in tilde fenced code block', () { + const content = ''' +~~~dart {lineLength: 80, foo: bar} +code +~~~ +'''; + final blocks = parser.parse(content); + + expect(blocks, hasLength(1)); + expect(blocks[0].options['lineLength'], equals(80)); + expect(blocks[0].options['foo'], equals('bar')); + }); + test('handles code block without options', () { const content = ''' ```dart diff --git a/packages/builder/test/src/parsers/front_matter_parser_test.dart b/packages/builder/test/src/parsers/front_matter_parser_test.dart index 9b3789a9..3a1f64a9 100644 --- a/packages/builder/test/src/parsers/front_matter_parser_test.dart +++ b/packages/builder/test/src/parsers/front_matter_parser_test.dart @@ -50,22 +50,29 @@ Content after empty frontmatter. expect(result.contents, equals('Content after empty frontmatter.')); }); - test('Handles malformed YAML gracefully', () { + test('Throws for malformed YAML', () { const input = ''' --- -title Test Title: malformed YAML +title: [unclosed --- Content after malformed YAML. '''; - final result = parser.parse(input); + expect(() => parser.parse(input), throwsA(isA())); + }); + + test('Throws for non-map frontmatter payload', () { + const input = ''' +--- +- item one +- item two +--- + +Content after non-map frontmatter. +'''; - // The YamlUtils.convertYamlToMap function actually parses this as valid YAML - // with a key of "title Test Title" and value of "malformed YAML" - expect(result.frontmatter, isNotEmpty); - expect(result.frontmatter['title Test Title'], equals('malformed YAML')); - expect(result.contents, equals('Content after malformed YAML.')); + expect(() => parser.parse(input), throwsA(isA())); }); test('Handles missing closing delimiter', () { diff --git a/packages/builder/test/src/parsers/slide_parser_test.dart b/packages/builder/test/src/parsers/slide_parser_test.dart index 31ce2ebe..cc54dc10 100644 --- a/packages/builder/test/src/parsers/slide_parser_test.dart +++ b/packages/builder/test/src/parsers/slide_parser_test.dart @@ -42,6 +42,110 @@ void main() { }); group('MarkdownParser.parse', () { + test('handles plain --- separators without frontmatter', () async { + const markdown = ''' +A +--- +B +--- +C +'''; + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(3)); + expect(slides[0].frontmatter, isEmpty); + expect(slides[0].content, equals('A')); + expect(slides[1].frontmatter, isEmpty); + expect(slides[1].content, equals('B')); + expect(slides[2].frontmatter, isEmpty); + expect(slides[2].content, equals('C')); + }); + + test('throws when frontmatter is not a map', () { + const markdown = ''' +--- +- item one +- item two +--- + +Content after non-map frontmatter. +'''; + + expect( + () => markdownParser.parse(markdown), + throwsA(isA()), + ); + }); + + test('throws when frontmatter is malformed', () { + const markdown = ''' +--- +title: [unclosed +--- + +Content after malformed frontmatter. +'''; + + expect( + () => markdownParser.parse(markdown), + throwsA(isA()), + ); + }); + + test('does not treat @section slide content as malformed frontmatter', () { + const markdown = ''' +--- +title: Intro +--- +Intro content + +--- +@section { + flex: 2 +} +@column +Slide body + +--- +Final slide +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(3)); + expect(slides[0].frontmatter['title'], equals('Intro')); + expect(slides[0].content, equals('Intro content')); + expect( + slides[1].content, + equals('@section {\n flex: 2\n}\n@column\nSlide body'), + ); + expect(slides[2].content, equals('Final slide')); + }); + + test( + 'does not treat plain markdown with colons as frontmatter candidate', + () { + const markdown = ''' +Slide 1 +--- +API: Overview +This line is normal markdown content. +--- +Slide 3 +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(3)); + expect(slides[0].content, equals('Slide 1')); + expect( + slides[1].content, + equals('API: Overview\nThis line is normal markdown content.'), + ); + expect(slides[2].content, equals('Slide 3')); + }, + ); + test('parses valid markdown into RawSlides', () async { const markdown = ''' --- @@ -72,6 +176,56 @@ Content for slide 3 expect(slides[2].content, equals('Content for slide 3')); }); + test('does not split --- inside tilde fenced code blocks', () async { + const markdown = ''' +Slide 1 +~~~dart +--- +print('inside code'); +--- +~~~ + +--- + +Slide 2 +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(2)); + expect(slides[0].frontmatter, isEmpty); + expect( + slides[0].content, + equals("Slide 1\n~~~dart\n---\nprint('inside code');\n---\n~~~"), + ); + expect(slides[1].frontmatter, isEmpty); + expect(slides[1].content, equals('Slide 2')); + }); + + test('splits plain --- separators into multiple slides', () { + const markdown = ''' +Slide A + +--- + +Slide B + +--- + +Slide C +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(3)); + expect(slides[0].frontmatter, isEmpty); + expect(slides[0].content, equals('Slide A')); + expect(slides[1].frontmatter, isEmpty); + expect(slides[1].content, equals('Slide B')); + expect(slides[2].frontmatter, isEmpty); + expect(slides[2].content, equals('Slide C')); + }); + test( 'parses RawSlides with additional properties in YAML frontmatter', () async { @@ -276,6 +430,58 @@ Content for slide 3 expect(slides[2].content, equals('Content for slide 3')); }, ); + + test('supports separators before and after plain slides', () { + const markdown = ''' +--- +title: First +--- +Welcome + +--- +No frontmatter here + +--- +title: Second +--- + +Has YAML again +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(3)); + expect(slides[0].frontmatter['title'], equals('First')); + expect(slides[0].content, equals('Welcome')); + expect(slides[1].frontmatter, isEmpty); + expect(slides[1].content, equals('No frontmatter here')); + expect(slides[2].frontmatter['title'], equals('Second')); + expect(slides[2].content, equals('Has YAML again')); + }); + + test('ignores --- inside fenced code when splitting slides', () { + const markdown = ''' +--- +title: Slide 1 +--- +Code block below: + +~~~ +--- +Inside code +--- +~~~ +'''; + + final slides = markdownParser.parse(markdown); + + expect(slides.length, equals(1)); + expect(slides[0].frontmatter['title'], equals('Slide 1')); + expect( + slides[0].content, + equals('Code block below:\n\n~~~\n---\nInside code\n---\n~~~'), + ); + }); }); // Group test notes from comments diff --git a/packages/core/lib/src/deck_service.dart b/packages/core/lib/src/deck_service.dart index 3bfe4e55..b0a13e6e 100644 --- a/packages/core/lib/src/deck_service.dart +++ b/packages/core/lib/src/deck_service.dart @@ -4,8 +4,14 @@ import 'dart:io'; import 'package:markdown/markdown.dart' as md; import 'package:path/path.dart' as p; +import 'package:logging/logging.dart'; import 'package:superdeck_core/src/markdown_json.dart'; -import 'package:superdeck_core/superdeck_core.dart'; +import 'deck_configuration.dart'; +import 'models/asset_model.dart'; +import 'models/deck_model.dart'; +import 'models/slide_model.dart'; +import 'utils/extensions.dart'; +import 'utils/pretty_json.dart'; /// Service for managing deck data from the local file system. /// @@ -16,7 +22,7 @@ class DeckService { final DeckConfiguration configuration; final List _generatedAssets = []; - final Logger _logger = Logger('DeckService'); + final _logger = Logger('DeckService'); /// Initializes the repository by creating necessary directories and files. Future initialize() async { @@ -185,8 +191,8 @@ class DeckService { } /// Reads the markdown content of the slides file. - Future readDeckMarkdown() async { - return await configuration.slidesFile.readAsString(); + Future readDeckMarkdown() { + return configuration.slidesFile.readAsString(); } /// Creates an error deck with the specified details. diff --git a/packages/core/lib/src/models/asset_model.dart b/packages/core/lib/src/models/asset_model.dart index 87c648a1..1cc75bea 100644 --- a/packages/core/lib/src/models/asset_model.dart +++ b/packages/core/lib/src/models/asset_model.dart @@ -1,5 +1,8 @@ import 'package:collection/collection.dart'; -import 'package:superdeck_core/superdeck_core.dart'; +import 'package:ack/ack.dart'; + +import '../utils/extensions.dart'; +import '../utils/generate_hash.dart'; enum AssetExtension { png, @@ -72,9 +75,9 @@ class GeneratedAsset { } static final schema = Ack.object({ - "name": Ack.string(), - "extension": AssetExtension.schema, - "type": Ack.string(), + 'name': Ack.string(), + 'extension': AssetExtension.schema, + 'type': Ack.string(), }); static GeneratedAsset thumbnail(String slideKey) { diff --git a/packages/core/lib/src/models/block_model.dart b/packages/core/lib/src/models/block_model.dart index 03ccd36a..c7843922 100644 --- a/packages/core/lib/src/models/block_model.dart +++ b/packages/core/lib/src/models/block_model.dart @@ -1,5 +1,7 @@ import 'package:collection/collection.dart'; -import 'package:superdeck_core/superdeck_core.dart'; +import 'package:ack/ack.dart'; + +import '../utils/extensions.dart'; /// Base class for all content blocks in a slide. /// @@ -347,7 +349,6 @@ class WidgetBlock extends Block { } static WidgetBlock fromMap(Map map) { - // Extract known fields final name = map['name'] as String; final align = map['align'] != null ? ContentAlignment.fromJson(map['align']!) diff --git a/packages/core/lib/src/tag_tokenizer.dart b/packages/core/lib/src/tag_tokenizer.dart index 321f5293..3495bf2c 100644 --- a/packages/core/lib/src/tag_tokenizer.dart +++ b/packages/core/lib/src/tag_tokenizer.dart @@ -2,6 +2,7 @@ import 'package:yaml/yaml.dart'; import 'deck_format_exception.dart'; import 'utils/yaml_utils.dart'; +import 'utils/code_fence.dart'; class TagToken { final String name; @@ -27,19 +28,51 @@ class TagTokenizer { const TagTokenizer(); static final _tagPattern = RegExp(r'^\s*@([\w-]+)', multiLine: true); - static final _codeBlockPattern = RegExp( - r'^```.*?^```', - multiLine: true, - dotAll: true, - ); - List tokenize(String text) { - // Find all 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)); + static List<_Range> _collectCodeBlockRanges(String text) { + final ranges = <_Range>[]; + + String? activeFence; + var startIndex = 0; + + var offset = 0; + while (offset < text.length) { + final newlineIndex = text.indexOf('\n', offset); + final lineEnd = newlineIndex == -1 ? text.length : newlineIndex; + final line = text.substring(offset, lineEnd).replaceAll('\r', ''); + + final fence = parseCodeFenceLine(line); + if (activeFence == null) { + if (fence != null) { + activeFence = fence.marker; + startIndex = offset; + } + } else if (fence != null && + canCloseCodeFence( + marker: activeFence, + minLength: activeFence.length, + line: line, + )) { + ranges.add(_Range(startIndex, lineEnd)); + activeFence = null; + } + + if (newlineIndex == -1) { + break; + } + offset = lineEnd + 1; + } + + if (activeFence != null) { + ranges.add(_Range(startIndex, text.length)); } + return ranges; + } + + List tokenize(String text) { + final codeBlockRanges = _collectCodeBlockRanges(text); + final tokens = []; for (final match in _tagPattern.allMatches(text)) { diff --git a/packages/core/lib/src/utils/code_fence.dart b/packages/core/lib/src/utils/code_fence.dart new file mode 100644 index 00000000..4e99006c --- /dev/null +++ b/packages/core/lib/src/utils/code_fence.dart @@ -0,0 +1,28 @@ +final _codeFenceLinePattern = RegExp(r'^ {0,3}(`{3,}|~{3,})(.*)$'); + +/// Returns fence marker details for markdown code-fence-like lines. +/// +/// Supports both backtick (```) and tilde (~~~) fences with at least 3 +/// characters, including up to 3 leading spaces. +({String marker, String rest})? parseCodeFenceLine(String line) { + final match = _codeFenceLinePattern.firstMatch(line); + if (match == null) return null; + + return (marker: match.group(1)!, rest: match.group(2) ?? ''); +} + +/// Returns true if a fence line can close a currently opened block. +bool canCloseCodeFence({ + required String marker, + required int minLength, + required String line, +}) { + final fenceLine = parseCodeFenceLine(line); + if (fenceLine == null) return false; + + final closingMarker = fenceLine.marker; + if (closingMarker[0] != marker[0]) return false; + if (fenceLine.rest.trim().isNotEmpty) return false; + + return closingMarker.length >= minLength; +} diff --git a/packages/core/lib/superdeck_core.dart b/packages/core/lib/superdeck_core.dart index 362414b4..24249b4b 100644 --- a/packages/core/lib/superdeck_core.dart +++ b/packages/core/lib/superdeck_core.dart @@ -20,4 +20,5 @@ export 'src/utils/file_watcher.dart'; export 'src/utils/generate_hash.dart'; export 'src/utils/logging_utils.dart'; export 'src/utils/pretty_json.dart'; +export 'src/utils/code_fence.dart'; export 'src/utils/yaml_utils.dart'; diff --git a/packages/core/test/src/deck_service_test.dart b/packages/core/test/src/deck_service_test.dart index 9c73385d..a00851d1 100644 --- a/packages/core/test/src/deck_service_test.dart +++ b/packages/core/test/src/deck_service_test.dart @@ -7,6 +7,21 @@ import 'package:test/test.dart'; import 'helpers/testing_utils.dart'; +class _SlideWithNullBlocks extends Slide { + const _SlideWithNullBlocks({required super.key}); + + @override + Map toMap() { + return { + 'key': key, + 'sections': [ + {'type': 'section', 'blocks': null}, + ], + 'comments': const [], + }; + } +} + void main() { group('DeckService with LocalDeckReader', () { late MockDeckConfiguration mockConfig; @@ -166,6 +181,45 @@ void main() { }, ); + test( + 'saveReferences normalizes sections with missing or null blocks in full deck output', + () async { + final deck = Deck( + slides: [ + Slide(key: 'missing-blocks', sections: [SectionBlock([])]), + const _SlideWithNullBlocks(key: 'null-blocks'), + ], + configuration: config, + ); + + await deckService.saveReferences(deck); + + final fullDeck = + jsonDecode(await config.deckFullJson.readAsString()) + as Map; + final slides = (fullDeck['slides'] as List) + .cast>(); + + final missingBlocksSlide = slides.firstWhere( + (slide) => slide['key'] == 'missing-blocks', + ); + final nullBlocksSlide = slides.firstWhere( + (slide) => slide['key'] == 'null-blocks', + ); + + final missingSection = + (missingBlocksSlide['sections'] as List).first + as Map; + final nullSection = + (nullBlocksSlide['sections'] as List).first as Map; + + expect(missingSection['blocks'], isA()); + expect(missingSection['blocks'], isEmpty); + expect(nullSection['blocks'], isA()); + expect(nullSection['blocks'], isEmpty); + }, + ); + test('readDeckMarkdown reads the content of the slides file', () async { await mockConfig.slidesFile.writeAsString('# Test slides'); diff --git a/packages/core/test/src/tag_tokenizer_test.dart b/packages/core/test/src/tag_tokenizer_test.dart index ed5cf20e..b5744c1f 100644 --- a/packages/core/test/src/tag_tokenizer_test.dart +++ b/packages/core/test/src/tag_tokenizer_test.dart @@ -292,10 +292,51 @@ Some paragraph text. }); group('code block protection', () { - test('ignores tags inside fenced code blocks', () { + for (final marker in ['```', '~~~']) { + test('ignores tags inside $marker fenced code blocks', () { + final text = + ''' +$marker +@ignored +$marker +@found'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens[0].name, 'found'); + }); + } + + test('ignores tags inside code blocks with language', () { const text = ''' +```dart +@ignored ``` +@found'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens[0].name, 'found'); + }); + + test('ignores tags inside tilde fenced code blocks with language', () { + const text = ''' +~~~dart +@ignored +~~~ +@found'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens[0].name, 'found'); + }); + + test('does not treat trailing text as a closing fence', () { + const text = ''' +```dart @ignored +```not-closing +@stillIgnored ``` @found'''; final tokens = tokenizer.tokenize(text); @@ -304,10 +345,12 @@ Some paragraph text. expect(tokens[0].name, 'found'); }); - test('ignores tags inside code blocks with language', () { + test('does not close a fence with a different marker type', () { const text = ''' ```dart @ignored +~~~ +@stillIgnored ``` @found'''; final tokens = tokenizer.tokenize(text); @@ -316,6 +359,18 @@ Some paragraph text. expect(tokens[0].name, 'found'); }); + test('closes fence when marker length increases with same character', () { + const text = ''' +``` +@ignored +```` +@found'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens[0].name, 'found'); + }); + test('handles code block before tag', () { const text = ''' ```dart @@ -358,8 +413,20 @@ code2 expect(tokens[0].name, 'found'); }); + test('unclosed tilde fence with trailing tags is ignored', () { + const text = ''' +@before +~~~ +@ignored +@stillIgnored'''; + final tokens = tokenizer.tokenize(text); + + expect(tokens, hasLength(1)); + expect(tokens[0].name, 'before'); + }); + test('code block at end without closing is handled', () { - // This tests behavior when code block regex doesn't find closing + // Unclosed blocks should still prevent tags inside them from matching. const text = ''' @before ``` diff --git a/packages/core/test/src/utils/code_fence_test.dart b/packages/core/test/src/utils/code_fence_test.dart new file mode 100644 index 00000000..0174be9f --- /dev/null +++ b/packages/core/test/src/utils/code_fence_test.dart @@ -0,0 +1,192 @@ +import 'package:superdeck_core/superdeck_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseCodeFenceLine', () { + group('backtick fences', () { + test('parses 3 backticks', () { + final result = parseCodeFenceLine('```'); + expect(result, isNotNull); + expect(result!.marker, '```'); + expect(result.rest, ''); + }); + + test('parses 4+ backticks', () { + final result = parseCodeFenceLine('````'); + expect(result, isNotNull); + expect(result!.marker, '````'); + }); + + test('parses backticks with language', () { + final result = parseCodeFenceLine('```dart'); + expect(result, isNotNull); + expect(result!.marker, '```'); + expect(result.rest, 'dart'); + }); + + test('parses backticks with trailing content', () { + final result = parseCodeFenceLine('```dart {lineLength: 80}'); + expect(result, isNotNull); + expect(result!.marker, '```'); + expect(result.rest, 'dart {lineLength: 80}'); + }); + }); + + group('tilde fences', () { + test('parses 3 tildes', () { + final result = parseCodeFenceLine('~~~'); + expect(result, isNotNull); + expect(result!.marker, '~~~'); + expect(result.rest, ''); + }); + + test('parses 4+ tildes', () { + final result = parseCodeFenceLine('~~~~'); + expect(result, isNotNull); + expect(result!.marker, '~~~~'); + }); + + test('parses tildes with language', () { + final result = parseCodeFenceLine('~~~dart'); + expect(result, isNotNull); + expect(result!.marker, '~~~'); + expect(result.rest, 'dart'); + }); + }); + + group('leading spaces', () { + test('allows 1 leading space', () { + final result = parseCodeFenceLine(' ```'); + expect(result, isNotNull); + expect(result!.marker, '```'); + }); + + test('allows 2 leading spaces', () { + final result = parseCodeFenceLine(' ```'); + expect(result, isNotNull); + expect(result!.marker, '```'); + }); + + test('allows 3 leading spaces', () { + final result = parseCodeFenceLine(' ```'); + expect(result, isNotNull); + expect(result!.marker, '```'); + }); + + test('rejects 4 leading spaces', () { + final result = parseCodeFenceLine(' ```'); + expect(result, isNull); + }); + }); + + group('boundary lengths', () { + test('rejects 2 backticks', () { + final result = parseCodeFenceLine('``'); + expect(result, isNull); + }); + + test('rejects 2 tildes', () { + final result = parseCodeFenceLine('~~'); + expect(result, isNull); + }); + + test('rejects 1 backtick', () { + final result = parseCodeFenceLine('`'); + expect(result, isNull); + }); + }); + + group('non-fence lines', () { + test('returns null for plain text', () { + expect(parseCodeFenceLine('hello world'), isNull); + }); + + test('returns null for dashes', () { + expect(parseCodeFenceLine('---'), isNull); + }); + + test('returns null for empty string', () { + expect(parseCodeFenceLine(''), isNull); + }); + }); + }); + + group('canCloseCodeFence', () { + test('closes backtick with matching backtick', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: '```'), + isTrue, + ); + }); + + test('closes tilde with matching tilde', () { + expect( + canCloseCodeFence(marker: '~~~', minLength: 3, line: '~~~'), + isTrue, + ); + }); + + test('closes with longer marker', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: '````'), + isTrue, + ); + }); + + test('rejects shorter marker', () { + expect( + canCloseCodeFence(marker: '````', minLength: 4, line: '```'), + isFalse, + ); + }); + + test('rejects different marker type', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: '~~~'), + isFalse, + ); + }); + + test('rejects tilde closing backtick', () { + expect( + canCloseCodeFence(marker: '~~~', minLength: 3, line: '```'), + isFalse, + ); + }); + + test('rejects trailing content', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: '```dart'), + isFalse, + ); + }); + + test('allows trailing whitespace', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: '``` '), + isTrue, + ); + }); + + test('rejects non-fence line', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: 'not a fence'), + isFalse, + ); + }); + + test('allows leading spaces on closing fence', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: ' ```'), + isTrue, + ); + }); + + test('rejects 4 leading spaces on closing fence', () { + expect( + canCloseCodeFence(marker: '```', minLength: 3, line: ' ```'), + isFalse, + ); + }); + }); +} diff --git a/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart index 384c59f6..9ba4e65c 100644 --- a/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart +++ b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart @@ -1,10 +1,6 @@ part of 'deck_generator_service.dart'; extension _DeckGeneratorPipeline on DeckGeneratorService { - // =========================================================================== - // PHASE 1: Generate Outline - // =========================================================================== - /// Generates a lightweight presentation outline. /// /// Returns the outline JSON or null on failure. @@ -28,46 +24,17 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { 'DECK_GEN', 'Outline system prompt (${systemPrompt.length} chars)', ); - final request = google_ai.GenerateContentRequest( - model: outlineModelName, - contents: [ - google_ai.Content( - role: 'user', - parts: [google_ai.Part(text: prompt)], - ), - ], - generationConfig: google_ai.GenerationConfig( - responseMimeType: 'application/json', - responseSchema: adaptResult.schema, - ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), - ); - debugLog.log('DECK_GEN', 'Sending outline request to $outlineModelName...'); - final response = await retryPolicy.run( - () => service - .generateContent(request) - .timeout( - const Duration(minutes: 2), - onTimeout: () { - throw TimeoutException('Outline generation timed out'); - }, - ), - ); - debugLog.log( - 'DECK_GEN', - 'Outline response: ${response.candidates.length} candidates', + return _runJsonGeneration( + service: service, + modelName: outlineModelName, + prompt: prompt, + schema: adaptResult.schema!, + systemPrompt: systemPrompt, + phase: 'outline', ); - - return _parseJsonResponse(response, 'outline'); } - // =========================================================================== - // PHASE 2: Generate Images - // =========================================================================== - /// Extracts image requirements from the outline. List<_ImageRequirement> _extractImageRequirements( Map outline, @@ -76,7 +43,9 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { final slides = outline['slides'] as List?; if (slides == null) return requirements; - for (final slide in slides) { + for (final entry in slides.asMap().entries) { + final sourceSlideIndex = entry.key; + final slide = entry.value; if (slide is! Map) continue; final key = slide['key']?.toString(); @@ -88,15 +57,16 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { final subject = imageReq['subject']?.toString(); if (subject == null || subject.isEmpty) continue; - requirements.add(_ImageRequirement(slideKey: key, subject: subject)); - } - - // Hard cap at 3 images per presentation - if (requirements.length > 3) { - return requirements.take(3).toList(); + requirements.add( + _ImageRequirement( + slideKey: key, + subject: subject, + sourceSlideIndex: sourceSlideIndex, + ), + ); } - return requirements; + return requirements.take(3).toList(); } /// Generates images from outline requirements in parallel batches. @@ -139,14 +109,11 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { await Future.wait( batch.map((req) async { - final safeKey = - _fileSafeKey(req.slideKey, requirements.indexOf(req)); + final safeKey = _fileSafeKey(req.slideKey, req.sourceSlideIndex); final filename = 'slide-$safeKey-illustration.png'; final outputPath = p.join(Paths.superdeckAssetsPath, filename); try { - // Build prompt with style (if present) and always wrap with - // ImageGeneratorService.buildPrompt for presentation constraints final basePrompt = style != null ? style.buildPrompt(req.subject) : req.subject; @@ -162,8 +129,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { final imgStart = DateTime.now(); final result = await imageService.generateImage(prompt); - final imgMs = - DateTime.now().difference(imgStart).inMilliseconds; + final imgMs = DateTime.now().difference(imgStart).inMilliseconds; if (result.success && result.bytes != null) { final bytes = result.bytes as Uint8List; @@ -203,10 +169,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { return _ImageGenerationResults(successes: successes, failures: failures); } - // =========================================================================== - // PHASE 3: Generate Final Deck - // =========================================================================== - /// Generates the final deck with available images context. Future?> _generateFinalDeck( google_ai.GenerativeService service, @@ -237,43 +199,18 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { 'Final deck thinking budget: ${thinkingBudget > 0 ? thinkingBudget : 'disabled'}', ); - final request = google_ai.GenerateContentRequest( - model: modelName, - contents: [ - google_ai.Content( - role: 'user', - parts: [google_ai.Part(text: prompt)], - ), - ], - generationConfig: google_ai.GenerationConfig( - responseMimeType: 'application/json', - responseSchema: adaptResult.schema, - thinkingConfig: thinkingBudget > 0 - ? google_ai.ThinkingConfig(thinkingBudget: thinkingBudget) - : null, - ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), + return _runJsonGeneration( + service: service, + modelName: modelName, + prompt: prompt, + schema: adaptResult.schema!, + systemPrompt: systemPrompt, + phase: 'deck', + thinkingConfig: thinkingBudget > 0 + ? google_ai.ThinkingConfig(thinkingBudget: thinkingBudget) + : null, + timeout: const Duration(minutes: 3), ); - - debugLog.log('DECK_GEN', 'Sending final deck request to $modelName...'); - final response = await retryPolicy.run( - () => service - .generateContent(request) - .timeout( - const Duration(minutes: 3), - onTimeout: () { - throw TimeoutException('Final deck generation timed out'); - }, - ), - ); - debugLog.log( - 'DECK_GEN', - 'Final deck response: ${response.candidates.length} candidates', - ); - - return _parseJsonResponse(response, 'deck'); } /// Builds the system prompt for Phase 3 with outline and available images. @@ -327,9 +264,51 @@ $outlineContext return buffer.toString(); } - // =========================================================================== - // HELPERS - // =========================================================================== + Future?> _runJsonGeneration({ + required google_ai.GenerativeService service, + required String modelName, + required String prompt, + required google_ai.Schema schema, + required String systemPrompt, + required String phase, + google_ai.ThinkingConfig? thinkingConfig, + Duration timeout = const Duration(minutes: 2), + }) async { + final request = google_ai.GenerateContentRequest( + model: modelName, + contents: [ + google_ai.Content( + role: 'user', + parts: [google_ai.Part(text: prompt)], + ), + ], + generationConfig: google_ai.GenerationConfig( + responseMimeType: 'application/json', + responseSchema: schema, + thinkingConfig: thinkingConfig, + ), + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), + ); + + debugLog.log('DECK_GEN', 'Sending $phase request to $modelName...'); + final response = await retryPolicy.run( + () => service + .generateContent(request) + .timeout( + timeout, + onTimeout: () => throw TimeoutException( + '${phase[0].toUpperCase()}${phase.substring(1)} generation timed out', + ), + ), + ); + debugLog.log( + 'DECK_GEN', + '$phase response: ${response.candidates.length} candidates', + ); + return _parseJsonResponse(response, phase); + } /// Parses JSON from a Gemini response. Map? _parseJsonResponse( diff --git a/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart b/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart index 17cb707e..6805c45f 100644 --- a/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart +++ b/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart @@ -16,7 +16,10 @@ String _fileSafeKey(String key, int index) { } /// Removes stale asset files that don't match current slide keys. -Future _cleanupStaleAssets(List> slides) async { +Future _cleanupStaleAssets( + List> slides, { + Map sourceSlideIndicesByKey = const {}, +}) async { final assetsDir = Directory(Paths.superdeckAssetsPath); if (!await assetsDir.exists()) return; @@ -27,7 +30,8 @@ Future _cleanupStaleAssets(List> slides) async { for (var i = 0; i < slides.length; i++) { final key = slides[i]['key']?.toString(); if (key == null) continue; - final safeKey = _fileSafeKey(key, i); + final sourceIndex = sourceSlideIndicesByKey[key] ?? i; + final safeKey = _fileSafeKey(key, sourceIndex); validThumbnails.add('thumbnail_$safeKey.png'); validIllustrations.add('slide-$safeKey-illustration.png'); } @@ -66,10 +70,15 @@ Future _cleanupStaleAssets(List> slides) async { /// Image requirement extracted from outline. class _ImageRequirement { - const _ImageRequirement({required this.slideKey, required this.subject}); + const _ImageRequirement({ + required this.slideKey, + required this.subject, + required this.sourceSlideIndex, + }); final String slideKey; final String subject; + final int sourceSlideIndex; } /// Results from parallel image generation. diff --git a/packages/genui/lib/src/ai/services/deck_generator_service.dart b/packages/genui/lib/src/ai/services/deck_generator_service.dart index a1ac2894..3519c513 100644 --- a/packages/genui/lib/src/ai/services/deck_generator_service.dart +++ b/packages/genui/lib/src/ai/services/deck_generator_service.dart @@ -79,10 +79,12 @@ class _ImagePhaseData { const _ImagePhaseData({ required this.availableImages, required this.imageFailures, + required this.sourceSlideIndicesByKey, }); final Map availableImages; final Map? imageFailures; + final Map sourceSlideIndicesByKey; } class _StyleData { @@ -192,6 +194,7 @@ class DeckGeneratorService { deckJson: deckJson, availableImages: imagePhase.availableImages, imageFailures: imagePhase.imageFailures, + sourceSlideIndicesByKey: imagePhase.sourceSlideIndicesByKey, pipelineStart: pipelineStart, onProgress: onProgress, ); diff --git a/packages/genui/lib/src/ai/services/deck_generator_workflow.dart b/packages/genui/lib/src/ai/services/deck_generator_workflow.dart index fe329954..ba4d45e6 100644 --- a/packages/genui/lib/src/ai/services/deck_generator_workflow.dart +++ b/packages/genui/lib/src/ai/services/deck_generator_workflow.dart @@ -63,6 +63,10 @@ Future<_ImagePhaseData> _runImagePhase( final availableImages = {}; Map? imageFailures; + final sourceSlideIndicesByKey = { + for (final requirement in imageRequirements) + requirement.slideKey: requirement.sourceSlideIndex, + }; if (imageRequirements.isNotEmpty && imageStyleId != null) { debugLog.log( @@ -119,6 +123,7 @@ Future<_ImagePhaseData> _runImagePhase( return _ImagePhaseData( availableImages: availableImages, imageFailures: imageFailures, + sourceSlideIndicesByKey: sourceSlideIndicesByKey, ); } @@ -163,6 +168,7 @@ Future _finalizeDeck( required Map deckJson, required Map availableImages, required Map? imageFailures, + required Map sourceSlideIndicesByKey, required DateTime pipelineStart, required GenerationProgressCallback? onProgress, }) async { @@ -200,7 +206,10 @@ Future _finalizeDeck( 'Wrote deck to ${file.path} (${jsonString.length} chars)', ); - await _cleanupStaleAssets(sanitizedSlides); + await _cleanupStaleAssets( + sanitizedSlides, + sourceSlideIndicesByKey: sourceSlideIndicesByKey, + ); final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; debugLog.log( diff --git a/packages/genui/lib/src/tools/deck_tools_service.dart b/packages/genui/lib/src/tools/deck_tools_service.dart index 88bc3d5f..974fde27 100644 --- a/packages/genui/lib/src/tools/deck_tools_service.dart +++ b/packages/genui/lib/src/tools/deck_tools_service.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -49,6 +48,7 @@ class DeckToolsService { final BuildContextProvider _contextProvider; final SlideCaptureFn? _captureSlide; final ReadSlideConfigurationBuilder _buildReadSlideConfiguration; + static const _invalidSlideSchemaMessage = 'Invalid slide schema payload'; SlideCaptureService? _captureService; Future _mutationQueue = Future.value(); @@ -62,18 +62,24 @@ class DeckToolsService { final document = await _documentStore.readRequired(); final index = request.index; mutation.validateReadIndex(index, document.slides.length); - final context = _requireMountedContext(); + final slide = document.slides[index]; final slideConfiguration = _buildReadSlideConfiguration( slide: slide, configuration: _documentStore.configuration, style: document.style, index: index, - ).copyWith(slideIndex: index); + ); + final indexedSlideConfiguration = slideConfiguration.copyWith( + slideIndex: index, + ); - // ignore: use_build_context_synchronously - final imageBytes = await _captureSlideBytes(slideConfiguration, context); + final imageBytes = await _captureSlideBytes( + indexedSlideConfiguration, + // ignore: use_build_context_synchronously + context, + ); final snapshot = mutation.buildDeckSnapshot( document.slides, style: document.style, @@ -255,16 +261,14 @@ class DeckToolsService { bool allowMissingKey = false, }) { if (rawSchema is! Map) { - throw DeckToolException.deckSchemaInvalid('Invalid slide schema payload'); + throw DeckToolException.deckSchemaInvalid(_invalidSlideSchemaMessage); } final schemaMap = Map.from(rawSchema); if (allowMissingKey) { final typedSchema = CreateSlideType.safeParse(schemaMap).getOrNull(); if (typedSchema == null) { - throw DeckToolException.deckSchemaInvalid( - 'Invalid slide schema payload', - ); + throw DeckToolException.deckSchemaInvalid(_invalidSlideSchemaMessage); } return Map.from(typedSchema); @@ -272,7 +276,7 @@ class DeckToolsService { final typedSchema = SlideType.safeParse(schemaMap).getOrNull(); if (typedSchema == null) { - throw DeckToolException.deckSchemaInvalid('Invalid slide schema payload'); + throw DeckToolException.deckSchemaInvalid(_invalidSlideSchemaMessage); } return Map.from(typedSchema); @@ -305,19 +309,15 @@ class DeckToolsService { } Future _runSerializedMutation(Future Function() operation) { - final completer = Completer(); - _mutationQueue = _mutationQueue + final future = _mutationQueue .catchError((Object error, StackTrace stackTrace) { debugPrint('DeckToolsService: previous mutation failed: $error'); }) - .then((_) async { - try { - completer.complete(await operation()); - } catch (error, stackTrace) { - completer.completeError(error, stackTrace); - } - }); - return completer.future; + .then((_) => operation()); + _mutationQueue = future + .then((_) {}) + .catchError((Object _, StackTrace __) {}); + return future; } BuildContext _requireMountedContext() { @@ -357,9 +357,6 @@ class DeckToolsService { configuration: configuration, ); final options = buildDeckOptionsFromStyle(style); - return slideBuilder - .buildConfigurations([slide], options) - .single - .copyWith(slideIndex: index); + return slideBuilder.buildConfigurations([slide], options).single; } } diff --git a/packages/superdeck/lib/src/export/async_thumbnail.dart b/packages/superdeck/lib/src/export/async_thumbnail.dart index 994dd559..24d0f7a6 100644 --- a/packages/superdeck/lib/src/export/async_thumbnail.dart +++ b/packages/superdeck/lib/src/export/async_thumbnail.dart @@ -38,8 +38,7 @@ class AsyncThumbnail { _isGenerating = true; _status.value = AsyncFileStatus.loading; - final currentUri = _imageUri.value; - if (currentUri != null) { + if (_imageUri.value != null) { // Evict only the previous thumbnail provider to avoid global cache churn. final cachedProvider = _cachedProvider; if (cachedProvider != null) { @@ -77,11 +76,10 @@ class AsyncThumbnail { _cachedProvider = null; } finally { _isGenerating = false; - if (!_disposed && _pendingForce && context.mounted) { - _pendingForce = false; + final shouldForce = _pendingForce; + _pendingForce = false; + if (!_disposed && shouldForce && context.mounted) { unawaited(_generate(context, force: true)); - } else { - _pendingForce = false; } } } @@ -89,6 +87,10 @@ class AsyncThumbnail { void dispose() { _disposed = true; _pendingForce = false; + final cachedProvider = _cachedProvider; + if (cachedProvider != null) { + imageCache.evict(cachedProvider); + } _cachedProvider = null; // Dispose signals @@ -116,10 +118,10 @@ class AsyncThumbnail { }; } - Widget _errorWidget(BuildContext context, AsyncThumbnail thumbnail) { + Widget _errorWidget(BuildContext context) { return ErrorWidgets.withRetry( 'Failed to load thumbnail', - () => thumbnail.load(context, true), + () => load(context, true), ); } @@ -136,7 +138,7 @@ class AsyncThumbnail { AsyncFileStatus.idle => const IsometricLoading(), AsyncFileStatus.loading => const IsometricLoading(), AsyncFileStatus.done => _buildLoadedImage(context), - AsyncFileStatus.error => _errorWidget(context, this), + AsyncFileStatus.error => _errorWidget(context), }; }); } @@ -144,13 +146,13 @@ class AsyncThumbnail { Widget _buildLoadedImage(BuildContext context) { final provider = imageProvider; if (provider == null) { - return _errorWidget(context, this); + return _errorWidget(context); } return Image( gaplessPlayback: false, image: provider, - errorBuilder: (context, error, _) => _errorWidget(context, this), + errorBuilder: (context, error, _) => _errorWidget(context), ); } }