diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index f804464..d2a7075 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -103,3 +103,14 @@ jobs: timeout-minutes: 5 run: | flutter test --coverage --concurrency=40 test/unit_test.dart + + - name: 📊 Upload coverage to Codecov + id: upload-coverage + timeout-minutes: 2 + uses: codecov/codecov-action@v5 + continue-on-error: true + with: + files: ./coverage/lcov.info + flags: unittests + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index c6a9e17..ccf9a29 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ build/ coverage/ .dart_tool/ pubspec.lock -*.exe \ No newline at end of file +*.exe +# Benchmark comparison baseline (machine-specific, generated by benchmark/compare.dart --save) +benchmark/.baseline.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 79583ff..14c22c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +## 0.1.0 + +- **ADDED**: GitHub-style alert blocks (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, + `> [!WARNING]`, `> [!CAUTION]`) via the new `MD$Alert` block and `MD$AlertType`. +- **ADDED**: GitHub task-list items (`- [ ]` / `- [x]`) via `MD$ListItem.checked` + and `MD$ListItem.isTask`, rendered with a checkbox. +- **ADDED**: Table column alignment (`:---`, `:--:`, `---:`) captured on + `MD$Table.alignments` and applied when rendering. +- **ADDED**: `linkStyle` on `MarkdownThemeData` to customize link text styling + (thanks @inamhusain, #22). +- **ADDED**: Per-type alert accent colors via `MarkdownThemeData.alertColors` + and `alertColorFor`. +- **ADDED**: Opt-in `$...$` inline LaTeX math conversion to Unicode, **disabled + by default**. Enable with `MarkdownDecoder(inlineMath: true)` or + `Markdown.fromString(text, inlineMath: true)`. Supports LaTeX commands + (`\alpha`, `\rightarrow`, ...), superscripts/subscripts (`x^2`, `H_2O`, + `x^{10}`), is code-span and code-block safe, and preserves currency (`$5`). + The command table is configurable via `mathReplacements` (extend the + exported `kMarkdownMathCommands`). Originally proposed in #21 by + @ibragimov05. +- **FIXED**: `\$` is now a recognized backslash escape, producing a literal + dollar sign (and opting a `$...$` run out of math conversion). +- **CHANGED**: Thematic breaks now support `***` and `___` (and spaced variants + like `- - -`), and no longer greedily consume text after `---`. +- **CHANGED**: `~~~` fenced code blocks are now recognized in addition to ` ``` `. +- **FIXED**: Emphasis no longer leaks to the end of the line for stray or + unterminated markers (e.g. `5 * 6 = 30`, `**bold never closed`). +- **FIXED**: Intraword underscores are no longer treated as emphasis + (e.g. `snake_case`, `object_id` are preserved). +- **FIXED**: ATX headings require a space after `#`; `#hashtag` and 7+ `#` + are no longer headings, and trailing `#` sequences are stripped. +- **FIXED**: Emphasis surrounding a link/image is now merged onto the link span. +- **FIXED**: Link/image targets support `` and single-quoted titles. +- **FIXED**: `MarkdownThemeData.copyWith` no longer drops `builder` and `onLinkTap`. +- **BREAKING**: `MD$Block.map`/`maybeMap` gained an `alert` branch for the new + `MD$Alert` block type. +- **PERFORMANCE**: Rewrote the parser hot path — a single-span fast path for + plain text, first-code-unit guards that keep regexes off paragraph lines, + hand-rolled list-line and link-target parsing (removing per-line / per-link + `RegExp` allocation), lazy link-extraction gated on `[`, and a range-copy + escape rebuild (no more per-character hash-set lookups). Together with math + now being opt-in, the default parse path is roughly **45% faster** across + representative workloads (links −68%, lists −61%, escapes −68%). Output is + byte-identical, guarded by a golden snapshot test. +- **TESTS**: Added a golden characterization snapshot, a corner-case regression + suite, span-offset invariants, and unit tests for the node model, theme, and + widget; wired every test file into `test/unit_test.dart` so CI runs the full + suite (**370+ tests**, previously only a fraction ran). `parser.dart`, + `nodes.dart`, `markdown.dart`, `theme.dart`, and `widget.dart` are now at + ~100% line coverage. +- **ADDED**: `benchmark/parser_benchmark.dart` (a multi-scenario + `benchmark_harness` suite) and `benchmark/compare.dart` (a low-noise + before/after comparison tool). +- **CI**: Upload coverage to Codecov and add a coverage badge to the README. +- **DOCS**: Documented alerts, task lists, table alignment, thematic-break + variants, and opt-in inline math in the README. + ## 0.0.8 - **CHANGED**: New table render diff --git a/README.md b/README.md index 3a45cf4..b7efc14 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # flutter_md - Markdown Parser and Renderer for Flutter [![Checkout](https://github.com/DoctorinaAI/md/actions/workflows/checkout.yml/badge.svg)](https://github.com/DoctorinaAI/md/actions/workflows/checkout.yml) +[![codecov](https://codecov.io/gh/DoctorinaAI/md/branch/master/graph/badge.svg)](https://codecov.io/gh/DoctorinaAI/md) [![Pub Package](https://img.shields.io/pub/v/flutter_md.svg)](https://pub.dev/packages/flutter_md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Dart](https://img.shields.io/badge/Dart-%230175C2.svg?style=flat&logo=dart&logoColor=white)](https://dart.dev) @@ -10,25 +11,57 @@ A high-performance, lightweight Markdown parser and renderer specifically design ## 🌟 Features -- **🚀 High Performance**: Optimized parsing with minimal memory footprint +- **🚀 High Performance**: Hand-tuned single-pass parser with minimal allocations - **🎨 Fully Customizable**: Theme-based styling with complete control over appearance - **📱 Flutter Native**: Built from the ground up for Flutter with custom render objects - **🔗 Interactive Elements**: Clickable links with customizable tap handlers - **🌐 Cross Platform**: Works on all Flutter-supported platforms -- **📝 Rich Syntax Support**: Comprehensive Markdown syntax coverage +- **📝 GitHub Flavored**: Alerts (`> [!NOTE]`), task lists (`- [x]`), tables with + column alignment, thematic breaks, strikethrough, and more +- **🧮 Inline Math**: Opt-in `$...$` LaTeX → Unicode (commands + super/subscripts) - **🎯 AI-Optimized**: Specifically designed for AI-generated content display - **🔧 Extensible**: Easy to extend with custom block and span renderers +- **✅ Well Tested**: 370+ tests; parser and node model at ~100% line coverage ## 📋 Supported Markdown Syntax ### Text Formatting -- **Bold**: `**text**` or `__text__` +- **Bold**: `**text**` +- **Underline**: `__text__` - _Italic_: `*text*` or `_text_` - ~~Strikethrough~~: `~~text~~` - `Inline code`: `` `code` `` - ==Highlight==: `==text==` - ||Spoiler||: `||text||` +- Inline math (**opt-in**): `$\alpha$`, `$\pi \approx 3.14$`, `$x^2$`, `$H_2O$` + (common LaTeX commands + super/subscripts → Unicode) + +Emphasis follows CommonMark-inspired flanking rules, so stray markers +(`5 * 6 = 30`), intraword underscores (`snake_case`), and unterminated markers +(`**oops`) are left as literal text instead of leaking styles. + +#### Inline math (opt-in) + +Inline `$...$` LaTeX math is **disabled by default** (so prices like `$5` and +shell variables like `$HOME` are never altered). Enable it per parse or per +decoder: + +```dart +// Per parse: +final md = Markdown.fromString(r'The angle $\alpha$ and $x^2 + y^2$.', + inlineMath: true); + +// Or a reusable decoder, optionally extending the command table: +const decoder = MarkdownDecoder( + inlineMath: true, + mathReplacements: {...kMarkdownMathCommands, r'\R': 'ℝ'}, +); +``` + +It converts LaTeX commands (`\alpha`, `\rightarrow`, ...) and super/subscripts +(`x^2`, `H_2O`, `x^{10}`), is code-span and code-block safe, and treats `\$` as +a literal dollar. Write `\$\alpha\$` to keep a literal `$\alpha$`. ### Headers @@ -58,8 +91,14 @@ A high-performance, lightweight Markdown parser and renderer specifically design 2. Another numbered item 1. Nested numbered item 2. Another nested item + +- [x] Completed task-list item +- [ ] Pending task-list item ``` +Task-list state is exposed on `MD$ListItem.checked` (`true`/`false`/`null`) and +`MD$ListItem.isTask`, and rendered as a checkbox. + ### Blockquotes ```markdown @@ -69,6 +108,31 @@ A high-performance, lightweight Markdown parser and renderer specifically design > And have multiple paragraphs ``` +### Alerts (Admonitions) + +GitHub-style alerts are rendered from blockquotes with a type marker: + +```markdown +> [!NOTE] +> Highlights information that users should take into account. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention. + +> [!CAUTION] +> Negative potential consequences of an action. +``` + +Each alert becomes an `MD$Alert` block (`MD$AlertType.note`, `.tip`, +`.important`, `.warning`, `.caution`). Per-type accent colors are configurable +via `MarkdownThemeData.alertColors` / `alertColorFor`. + ### Code Blocks ````markdown @@ -81,9 +145,12 @@ void main() { ### Tables +Column alignment is supported via the delimiter row (`:---` left, `:--:` +center, `---:` right): + ```markdown -| Header 1 | Header 2 | Header 3 | -| -------- | -------- | -------- | +| Left | Center | Right | +| :------- | :------: | -------: | | Cell 1 | Cell 2 | Cell 3 | | **Bold** | _Italic_ | `Code` | ``` @@ -99,8 +166,12 @@ Images currently not displayed! ### Horizontal Rules +Any of `---`, `***`, or `___` (optionally spaced, e.g. `- - -`) produce a rule: + ```markdown --- +*** +___ ``` ## 🚀 Quick Start @@ -143,15 +214,23 @@ MarkdownTheme( fontStyle: FontStyle.italic, color: Colors.grey[600], ), + // Customize link text styling (merged on top of linkColor) + linkStyle: const TextStyle( + decoration: TextDecoration.underline, + ), + // Per-type accent colors for GitHub alert blocks + alertColors: const { + MD$AlertType.warning: Color(0xFF9A6700), + }, // Handle link taps onLinkTap: (title, url) { print('Tapped link: $title -> $url'); // Launch URL or navigate }, - // Filter blocks (e.g., exclude images) - blockFilter: (block) => block is! MD$Image, - // Filter spans (e.g., exclude certain styles) - spanFilter: (span) => !span.style.contains(MD$Style.spoiler), + // Filter blocks (e.g., hide code blocks) + blockFilter: (block) => block is! MD$Code, + // Filter spans (e.g., exclude images or certain styles) + spanFilter: (span) => !span.style.contains(MD$Style.image), ), child: MarkdownWidget( markdown: yourMarkdown, @@ -207,10 +286,20 @@ class _MyWidgetState extends State { ## 📊 Performance -- **Parsing**: ~300 us for typical AI responses, 15x times faster than `markdown` package +- **Parsing**: single-pass, lookup-table driven parser with a plain-text fast + path and hand-rolled (regex-free) block/inline scanning. The hot path was + rewritten for a ~45% speedup, and it parses typical AI responses roughly + **10× faster** than the `markdown` package. - **Rendering**: 120 FPS smooth scrolling for chat-like interfaces - **Memory**: Minimal memory footprint with efficient span filtering +Benchmarks live in `benchmark/`: + +```bash +dart run benchmark/parser_benchmark.dart # multi-scenario, vs. `markdown` +dart run benchmark/compare.dart --save # low-noise before/after tool +``` + ## 🔧 Advanced Features ### Custom Styles diff --git a/benchmark/compare.dart b/benchmark/compare.dart new file mode 100644 index 0000000..e953b7f --- /dev/null +++ b/benchmark/compare.dart @@ -0,0 +1,131 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:flutter_md/src/markdown.dart' show Markdown; + +import 'scenarios.dart'; + +/// A fast, low-noise timing tool used while iterating on parser optimizations. +/// +/// For each scenario it runs a warmup, then times many batches and reports the +/// *minimum* per-op time (the minimum is the most stable estimator, least +/// affected by GC pauses and scheduler noise). It optionally reads a previous +/// baseline from `benchmark/.baseline.txt` and prints the delta. +/// +/// Usage: +/// ```shell +/// # Record the current numbers as the baseline: +/// dart run benchmark/compare.dart --save +/// +/// # Compare current numbers against the saved baseline: +/// dart run benchmark/compare.dart +/// ``` +/// Accumulator that consumes parse output so the optimizer cannot eliminate +/// the parsing work as dead code. +int _sink = 0; + +void main(List args) { + final save = args.contains('--save'); + final results = {}; + + for (final entry in scenarios.entries) { + results[entry.key] = _bench(entry.value); + } + + // Reference the sink so the whole benchmark cannot be optimized away. + if (_sink == 0x7fffffff) print('(unreachable sink marker)'); + + final baseline = save ? null : _loadBaseline(); + + print('scenario us/op baseline delta'); + print('---------------------- ---------- ---------- ----------'); + var total = 0.0; + var baseTotal = 0.0; + for (final entry in results.entries) { + final us = entry.value; + total += us; + final base = baseline?[entry.key]; + final baseStr = base == null ? '-' : base.toStringAsFixed(2); + final deltaStr = base == null ? '-' : _delta(base, us); + if (base != null) baseTotal += base; + print('${entry.key.padRight(22)} ' + '${us.toStringAsFixed(2).padLeft(10)} ' + '${baseStr.padLeft(10)} ' + '${deltaStr.padLeft(10)}'); + } + print('---------------------- ---------- ---------- ----------'); + final totalDelta = baseTotal > 0 ? _delta(baseTotal, total) : '-'; + print('${'TOTAL'.padRight(22)} ' + '${total.toStringAsFixed(2).padLeft(10)} ' + '${(baseTotal > 0 ? baseTotal.toStringAsFixed(2) : '-').padLeft(10)} ' + '${totalDelta.padLeft(10)}'); + + if (save) { + _saveBaseline(results); + print('\nSaved baseline to benchmark/.baseline.txt'); + } +} + +/// Returns the minimum per-op time in microseconds for parsing [input]. +double _bench(String input, + {int warmupMs = 200, int batches = 25, int minBatchMs = 8}) { + // Warmup to trigger JIT compilation / reach steady state. + final warmupSw = Stopwatch()..start(); + while (warmupSw.elapsedMilliseconds < warmupMs) { + _sink ^= Markdown.fromString(input).blocks.length; + } + + // Calibrate iterations so a batch runs for at least [minBatchMs]. + var iters = 1; + while (true) { + final sw = Stopwatch()..start(); + for (var i = 0; i < iters; i++) { + _sink ^= Markdown.fromString(input).blocks.length; + } + sw.stop(); + if (sw.elapsedMicroseconds >= minBatchMs * 1000) break; + iters = iters < 2 ? 2 : (iters * 2); + if (iters > 1 << 22) break; + } + + var best = double.infinity; + for (var b = 0; b < batches; b++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < iters; i++) { + _sink ^= Markdown.fromString(input).blocks.length; + } + sw.stop(); + final us = sw.elapsedMicroseconds / iters; + if (us < best) best = us; + } + return best; +} + +String _delta(double base, double now) { + final pct = (now - base) / base * 100; + final sign = pct <= 0 ? '' : '+'; + return '$sign${pct.toStringAsFixed(1)}%'; +} + +Map? _loadBaseline() { + final file = File('benchmark/.baseline.txt'); + if (!file.existsSync()) return null; + final map = {}; + for (final line in file.readAsLinesSync()) { + final parts = line.split('\t'); + if (parts.length == 2) { + final value = double.tryParse(parts[1]); + if (value != null) map[parts[0]] = value; + } + } + return map; +} + +void _saveBaseline(Map results) { + final buffer = StringBuffer(); + for (final entry in results.entries) { + buffer.writeln('${entry.key}\t${entry.value.toStringAsFixed(3)}'); + } + File('benchmark/.baseline.txt').writeAsStringSync(buffer.toString()); +} diff --git a/benchmark/parser_benchmark.dart b/benchmark/parser_benchmark.dart new file mode 100644 index 0000000..b779c6f --- /dev/null +++ b/benchmark/parser_benchmark.dart @@ -0,0 +1,84 @@ +// ignore_for_file: avoid_print + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flutter_md/src/markdown.dart' show Markdown; +import 'package:markdown/markdown.dart' as gmd; + +import 'scenarios.dart'; + +/// Multi-scenario benchmark suite for the [Markdown] parser. +/// +/// Unlike `parse_benchmark.dart` (which compares one mixed document against the +/// `markdown` package), this suite isolates individual workloads so that the +/// effect of a specific optimization can be observed per scenario. +/// +/// Run: +/// ```shell +/// dart run benchmark/parser_benchmark.dart +/// ``` +/// Or compile for stable numbers: +/// ```shell +/// dart compile exe benchmark/parser_benchmark.dart -o /tmp/pb && /tmp/pb +/// ``` +void main() { + print('scenario bytes us/op MB/s'); + print('---------------------- ------- -------- -------'); + var total = 0.0; + for (final entry in scenarios.entries) { + final us = _ParseBenchmark(entry.key, entry.value).measure(); + total += us; + final bytes = entry.value.length; + final mbps = bytes / us; // bytes/us == MB/s + print('${entry.key.padRight(22)} ' + '${bytes.toString().padLeft(7)} ' + '${us.toStringAsFixed(2).padLeft(8)} ' + '${mbps.toStringAsFixed(1).padLeft(7)}'); + } + print('---------------------- ------- -------- -------'); + print('${'TOTAL'.padRight(22)} ${' '.padLeft(7)} ' + '${total.toStringAsFixed(2).padLeft(8)}'); + + // Keep a head-to-head comparison against the `markdown` package on the + // representative mixed document, so regressions relative to it stay visible. + print(''); + final current = _ParseBenchmark('current', mixed).measure(); + final google = _GoogleBenchmark(mixed).measure(); + final ratio = google / current; + print('mixed vs. `markdown` pkg: ' + '${ratio.toStringAsFixed(2)}x faster ' + '(current ${current.toStringAsFixed(1)} us, ' + 'google ${google.toStringAsFixed(1)} us)'); +} + +class _ParseBenchmark extends BenchmarkBase { + _ParseBenchmark(super.name, this.input); + + final String input; + Markdown? _result; + + @override + void run() => _result = Markdown.fromString(input); + + @override + void teardown() { + super.teardown(); + if (_result == null) throw StateError('result is null'); + } +} + +class _GoogleBenchmark extends BenchmarkBase { + _GoogleBenchmark(this.input) : super('google'); + + final String input; + List? _result; + + @override + void run() => _result = + gmd.Document(extensionSet: gmd.ExtensionSet.gitHubFlavored).parse(input); + + @override + void teardown() { + super.teardown(); + if (_result == null) throw StateError('result is null'); + } +} diff --git a/benchmark/scenarios.dart b/benchmark/scenarios.dart new file mode 100644 index 0000000..9405ab4 --- /dev/null +++ b/benchmark/scenarios.dart @@ -0,0 +1,196 @@ +/// Shared benchmark corpus used by `parser_benchmark.dart` (benchmark_harness) +/// and `compare.dart` (fast min-of-batches comparison tool). +library; + +/// The benchmark scenarios, keyed by a short name. Each value is a few KB of +/// Markdown so that per-op timings are stable and throughput is meaningful. +final Map scenarios = { + 'prose': _repeat(_prose, 8), + 'inline': _repeat(_inline, 8), + 'links': _repeat(_links, 8), + 'lists': _repeat(_lists, 6), + 'table': _repeat(_table, 6), + 'code': _repeat(_code, 6), + 'quotes': _repeat(_quotes, 8), + 'escapes': _repeat(_escapes, 10), + 'currency': _repeat(_currency, 10), + 'pathological': _pathological, + 'mixed': mixed, +}; + +String _repeat(String block, int times) { + final buffer = StringBuffer(); + for (var i = 0; i < times; i++) { + buffer + ..write(block) + ..write('\n\n'); + } + return buffer.toString(); +} + +const _prose = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, +quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo. + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, +sunt in culpa qui officia deserunt mollit anim id est laborum. 5 * 6 = 30 and +a_b_c stays literal while 3 < 4 and x > y hold true here.'''; + +const _inline = ''' +This has **bold**, *italic*, __underline__, ~~strike~~, `code`, ==mark== and +||spoiler|| all mixed together in **one _deeply *nested* emphasis_ chain** to +stress the inline parser with `several inline code` spans and *many* markers.'''; + +const _links = ''' +See [the docs](https://example.com/docs "Docs") and [the repo](https://x.io) +plus an image ![alt text](https://cdn.example.com/pic.png "caption") inline. +Also [a](b) [c](d) [e](f) [g](h) short links and .'''; + +const _lists = ''' +- First item with **bold** +- Second item with [a link](https://x.io) + - Nested item one + - Nested item two with `code` + - Deep item +- [ ] Pending task +- [x] Completed task + +1. Ordered one +2. Ordered two + 1. Sub one + 2. Sub two +3. Ordered three'''; + +const _table = ''' +| Name | Age | Role | Notes | +| :------ | --: | :----------: | ---------------- | +| Alice | 25 | Developer | Likes **bold** | +| Bob | 30 | *Designer* | Uses `tools` | +| Charlie | 35 | ~~Manager~~ | [link](https://) |'''; + +const _code = ''' +```dart +void main() { + final greeting = 'Hello, world!'; + for (var i = 0; i < 10; i++) { + print('\$greeting \$i'); + } +} +``` + +Some text between the code blocks to break them apart cleanly here. + +~~~python +def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a +~~~'''; + +const _quotes = ''' +> This is a blockquote that spans +> several lines and contains **bold** +> and `code` and [a link](https://x.io). + +> [!NOTE] +> A GitHub-style alert with some *emphasis* inside the body text here. + +> [!WARNING] +> Another alert to exercise the alert parsing branch of the block loop.'''; + +const _escapes = r''' +Escaped \*asterisks\* and \_underscores\_ and \`backticks\` stay literal. +A path C:\\Users\\name and a \[bracket\] and \(paren\) plus \# and \! here. +Money like $5 and $10 is preserved but $\alpha$ becomes a Greek letter now.'''; + +const _currency = ''' +The subscription costs \$9.99 per month or \$99 per year, saving you \$20. +Enterprise plans start at \$499 and scale to \$4,999 for large teams here. +A one-time setup fee of \$5 applies, with volume discounts above \$10,000. +Refunds up to \$1,000 are processed within 30 days for orders over \$250.'''; + +/// Worst-case-ish input: long runs of emphasis markers and stray delimiters +/// that stress the closer-lookahead scans without producing much output. +final String _pathological = [ + '*' * 300, + '', + '_' * 300, + '', + '${'a * b ' * 100}c', + '', + '${'x_y_' * 150}z', + '', + '`' * 200, +].join('\n'); + +/// A representative mixed document (also used for the head-to-head comparison +/// against the `markdown` package). +const String mixed = r''' +# Markdown parser test + +This is a **bold** paragraph with *italic*, __underline__, ~~strike~~, +`monospace` and [a link](https://example.com). + +This is a ==highlighted== text on one line. + +--- + +## Multiline paragraph + +Lorem ipsum dolor sit amet, +consectetur adipiscing elit. +Sed do eiusmod **tempor** incididunt +*ut labore* et dolore `magna aliqua`. + +### Quote + +> This is a simple quote. +> +> It may contain **several lines**, +> and even nested formatting like `code` or [links](https://example.com). + +> [!TIP] +> Use alerts to draw attention. + +### Code blocks + +```javascript +function helloWorld() { + console.log("Hello, world!"); +} +``` + +Inline code works like this: `let x = 42;` + +### Lists + +- First item +- Second item with *italic* + - Subitem with **bold** + - Third level ~~strike~~ +- [x] Done task +- [ ] Pending task + +1. First step +2. Second step + 1. Substep 2.1 + 2. Substep 2.2 +3. Final step + +### Table + +| Name | Age | Role | +| :------ | --: | :----------: | +| Alice | 25 | Developer | +| **Bob** | 30 | *Designer* | +| Charlie | 35 | ~~Manager~~ | + +### Images + +![Alt text](https://example.com/image.png) + +That is all for the *test* document. +'''; diff --git a/example/lib/main.dart b/example/lib/main.dart index e6a7210..6373591 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -109,7 +109,10 @@ class _HomeScreenState extends State { @override void initState() { super.initState(); - final initialMarkdown = Markdown.fromString(_inputController.text); + // `inlineMath` is opt-in (disabled by default); enabled here to showcase + // the `$...$` LaTeX conversion. + final initialMarkdown = + Markdown.fromString(_inputController.text, inlineMath: true); _outputController.value = initialMarkdown; _inputController.addListener(_onInputChanged); } @@ -129,7 +132,7 @@ class _HomeScreenState extends State { } else if (text == _outputController.value.markdown) { return; // No change, no need to update } else { - final markdown = Markdown.fromString(text); + final markdown = Markdown.fromString(text, inlineMath: true); _outputController.value = markdown; } } @@ -363,6 +366,59 @@ This example is using `package:flutter_md/flutter_md.dart`. --- +## Alerts + +> [!NOTE] +> Highlights information that users should take into account. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention. + +> [!CAUTION] +> Negative potential consequences of an action. + +--- + +## Task lists + +- [x] Write the parser +- [x] Add GitHub alerts +- [ ] Ship selection support + - [x] Nested done + - [ ] Nested todo + +--- + +## Aligned tables + +| Left | Center | Right | +| :----- | :----: | ----: | +| a | b | c | +| longer | text | here | + +--- + +## Inline math (opt-in) + +Enabled via `inlineMath: true`. Greek letters and operators: $\alpha$, $\beta$, $\pi \approx 3.14$, $x \rightarrow \infty$, plus superscripts and subscripts like $x^2$ and $H_2O$. + +--- + +## Thematic breaks + +Dashes, asterisks and underscores all produce a horizontal rule: + +*** +___ + +--- + ## Special symbols > "Quotes" and 'single quotes' with 👉 <, >, &, ©, ®, ™, €, £, ¥, •, …, ±, §, ¶, †, ‡, ‰, µ, ° diff --git a/lib/src/markdown.dart b/lib/src/markdown.dart index cf85c01..3645738 100644 --- a/lib/src/markdown.dart +++ b/lib/src/markdown.dart @@ -1,7 +1,7 @@ import 'package:meta/meta.dart'; import 'nodes.dart'; -import 'parser.dart' show markdownDecoder; +import 'parser.dart' show MarkdownDecoder, markdownDecoder; /// {@template markdown} /// Markdown entity. @@ -25,11 +25,16 @@ final class Markdown { /// This method uses the [markdownDecoder] to parse the string /// and convert it into a list of [MD$Block] objects. /// + /// Set [inlineMath] to `true` to convert simple `$...$` inline LaTeX math to + /// Unicode (disabled by default). For finer control — such as a custom + /// command table — construct a [MarkdownDecoder] directly. + /// /// This method is relatively expensive and should be used /// sparingly, outside build phase, especially for large markdown strings. /// {@macro markdown} - factory Markdown.fromString(String markdown) => - markdownDecoder.convert(markdown); + factory Markdown.fromString(String markdown, {bool inlineMath = false}) => + (inlineMath ? const MarkdownDecoder(inlineMath: true) : markdownDecoder) + .convert(markdown); /// The original markdown string. final String markdown; @@ -66,6 +71,10 @@ final class Markdown { for (final span in spans) { buffer.write(span.text); } + case MD$Alert(:List spans): + for (final span in spans) { + buffer.write(span.text); + } case MD$Code(:String text): buffer.write(text); case MD$List(:List items): diff --git a/lib/src/nodes.dart b/lib/src/nodes.dart index b63423c..818b0b3 100644 --- a/lib/src/nodes.dart +++ b/lib/src/nodes.dart @@ -149,6 +149,90 @@ final class MD$Span { String toString() => text; } +/// {@template markdown_alert_type} +/// The kind of a GitHub-style alert (admonition) block. +/// +/// Rendered from the blockquote marker syntax, e.g.: +/// ```markdown +/// > [!NOTE] +/// > Useful information that users should know. +/// ``` +/// {@endtemplate} +enum MD$AlertType { + /// Highlights information that users should take into account. + /// Symbol: `> [!NOTE]`. + note('NOTE'), + + /// Optional information to help a user be more successful. + /// Symbol: `> [!TIP]`. + tip('TIP'), + + /// Crucial information necessary for users to succeed. + /// Symbol: `> [!IMPORTANT]`. + important('IMPORTANT'), + + /// Critical content demanding immediate user attention due to risks. + /// Symbol: `> [!WARNING]`. + warning('WARNING'), + + /// Negative potential consequences of an action. + /// Symbol: `> [!CAUTION]`. + caution('CAUTION'); + + /// {@macro markdown_alert_type} + const MD$AlertType(this.marker); + + /// The uppercase marker keyword used in the source syntax, + /// e.g. `NOTE` for `> [!NOTE]`. + final String marker; + + /// A human-readable title for the alert, e.g. `Note` for [MD$AlertType.note]. + String get title => switch (this) { + MD$AlertType.note => 'Note', + MD$AlertType.tip => 'Tip', + MD$AlertType.important => 'Important', + MD$AlertType.warning => 'Warning', + MD$AlertType.caution => 'Caution', + }; + + /// Parses an [MD$AlertType] from its [marker] keyword (case-insensitive). + /// Returns `null` if the keyword does not match any known alert type. + static MD$AlertType? tryParse(String keyword) { + switch (keyword.toUpperCase()) { + case 'NOTE': + return MD$AlertType.note; + case 'TIP': + return MD$AlertType.tip; + case 'IMPORTANT': + return MD$AlertType.important; + case 'WARNING': + return MD$AlertType.warning; + case 'CAUTION': + return MD$AlertType.caution; + default: + return null; + } + } +} + +/// {@template markdown_table_align} +/// The horizontal alignment of a Markdown table column, +/// derived from the delimiter row, e.g. `:---`, `:--:`, `---:`. +/// {@endtemplate} +enum MD$TableColumnAlign { + /// No explicit alignment specified (`---`). + none, + + /// Left aligned (`:---`). + left, + + /// Center aligned (`:--:`). + center, + + /// Right aligned (`---:`). + right, +} + /// {@template markdown_block} /// A base class for all Markdown blocks. /// {@endtemplate} @@ -173,6 +257,7 @@ sealed class MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }); @@ -186,6 +271,7 @@ sealed class MD$Block { T Function(MD$List l)? list, T Function(MD$Divider d)? divider, T Function(MD$Table t)? table, + T Function(MD$Alert a)? alert, T Function(MD$Spacer s)? spacer, required T Function(MD$Block b) orElse, }) => @@ -197,6 +283,7 @@ sealed class MD$Block { list: list ?? orElse, divider: divider ?? orElse, table: table ?? orElse, + alert: alert ?? orElse, spacer: spacer ?? orElse, ); @@ -235,6 +322,7 @@ final class MD$Paragraph extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => paragraph(this); @@ -274,6 +362,7 @@ final class MD$Heading extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => heading(this); @@ -315,11 +404,53 @@ final class MD$Quote extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => quote(this); } +/// A block representing a GitHub-style alert (admonition) in Markdown. +/// Built from a blockquote whose first line is an alert marker such as +/// `> [!NOTE]`, `> [!WARNING]`, etc. +/// Always a leaf node in the Markdown tree. +/// {@macro markdown_block} +final class MD$Alert extends MD$Block { + /// Creates a new instance of [MD$Alert]. + /// {@macro markdown_block} + const MD$Alert({ + required this.alert, + required this.text, + required this.spans, + }); + + @override + String get type => 'alert'; + + /// The kind of the alert (note, tip, important, warning, caution). + final MD$AlertType alert; + + @override + final String text; + + /// The inline text spans within the alert body. + final List spans; + + @override + T map({ + required T Function(MD$Paragraph p) paragraph, + required T Function(MD$Heading h) heading, + required T Function(MD$Quote q) quote, + required T Function(MD$Code c) code, + required T Function(MD$List l) list, + required T Function(MD$Divider d) divider, + required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, + required T Function(MD$Spacer s) spacer, + }) => + alert(this); +} + /// A block representing a code block in Markdown. /// Contains the code text and an optional programming language. /// Always a leaf node in the Markdown tree. @@ -350,6 +481,7 @@ final class MD$Code extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => code(this); @@ -369,6 +501,7 @@ final class MD$ListItem { required this.text, required this.spans, this.indent = 0, + this.checked, this.children = const [], }); @@ -376,6 +509,16 @@ final class MD$ListItem { /// This is used to determine the indentation level of the list. final int indent; + /// Task-list checkbox state for this item. + /// + /// * `null` — the item is a regular (non-task) list item. + /// * `false` — an unchecked task item (`- [ ]`). + /// * `true` — a checked task item (`- [x]`). + final bool? checked; + + /// Whether this list item is a GitHub task-list item (`- [ ]` / `- [x]`). + bool get isTask => checked != null; + /// The marker used for the list item. final String marker; @@ -396,6 +539,7 @@ final class MD$ListItem { String? text, List? spans, int? indent, + bool? checked, List? children, }) => MD$ListItem( @@ -403,6 +547,7 @@ final class MD$ListItem { text: text ?? this.text, spans: spans ?? this.spans, indent: indent ?? this.indent, + checked: checked ?? this.checked, children: children ?? this.children, ); @@ -456,6 +601,7 @@ final class MD$List extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => list(this); @@ -486,6 +632,7 @@ final class MD$Divider extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => divider(this); @@ -528,6 +675,7 @@ final class MD$Table extends MD$Block { required this.text, required this.header, required this.rows, + this.alignments = const [], }); @override @@ -542,6 +690,18 @@ final class MD$Table extends MD$Block { /// The rows of the table. final List rows; + /// The per-column horizontal alignment, derived from the delimiter row. + /// May be shorter than the number of columns; use [alignmentFor] for + /// safe access. + final List alignments; + + /// Returns the alignment for the given column [index], + /// defaulting to [MD$TableColumnAlign.none] when unspecified. + MD$TableColumnAlign alignmentFor(int index) => + index >= 0 && index < alignments.length + ? alignments[index] + : MD$TableColumnAlign.none; + @override T map({ required T Function(MD$Paragraph p) paragraph, @@ -551,6 +711,7 @@ final class MD$Table extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => table(this); @@ -596,6 +757,7 @@ final class MD$Image extends MD$Block { required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, required T Function(MD$Image i) image, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => image(this); @@ -630,6 +792,7 @@ class MD$Spacer extends MD$Block { required T Function(MD$List l) list, required T Function(MD$Divider d) divider, required T Function(MD$Table t) table, + required T Function(MD$Alert a) alert, required T Function(MD$Spacer s) spacer, }) => spacer(this); diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 00bb8a3..312190a 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -17,23 +17,167 @@ const Converter markdownDecoder = MarkdownDecoder(); /// {@endtemplate} class MarkdownDecoder extends Converter { /// Creates a new instance of [MarkdownDecoder]. + /// + /// Set [inlineMath] to `true` to convert simple `$...$` inline LaTeX math to + /// Unicode (disabled by default). Provide [mathReplacements] to override the + /// built-in command table ([kMarkdownMathCommands]); when omitted, the + /// defaults are used. /// {@macro markdown_decoder} - const MarkdownDecoder(); + const MarkdownDecoder({ + this.inlineMath = false, + this.mathReplacements, + }); + + /// Whether to convert simple `$...$` inline LaTeX math (e.g. `$\alpha$`, + /// `$x^2$`, `$H_2O$`) to Unicode. + /// + /// Disabled by default so that literal dollar signs — prices (`$5`), shell + /// variables (`$HOME`), and the like — are never altered. Enable it with + /// `const MarkdownDecoder(inlineMath: true)` or via + /// `Markdown.fromString(text, inlineMath: true)`. + final bool inlineMath; + + /// Optional override for the LaTeX command → Unicode table used when + /// [inlineMath] is enabled. Defaults to [kMarkdownMathCommands]. + /// + /// To extend rather than replace the defaults, spread them: + /// `mathReplacements: {...kMarkdownMathCommands, r'\R': 'ℝ'}`. + final Map? mathReplacements; - /// A regular expression pattern to match empty lines. - static final RegExp _emptyPattern = RegExp(r'^(?:[ \t]*)$'); + /// Whether [line] is blank (empty or only spaces/tabs). Replaces a regular + /// expression on the hot path of the block loop, since blank-line detection + /// runs for every line of the document. + static bool _isBlank(String line) { + for (var i = 0; i < line.length; i++) { + final c = line.codeUnitAt(i); + if (c != 0x20 && c != 0x09) return false; + } + return true; + } /// Leading (and trailing) `#` define atx-style headers. /// - /// Starts with 1-6 unescaped `#` characters which must not be followed by a - /// non-space character. Line may end with any number of `#` characters,. - static final RegExp _headerPattern = RegExp(r'^(#{1,6})'); + /// Starts with 1-6 unescaped `#` characters which must be followed by a + /// space/tab or the end of the line (so `#hashtag` and 7+ `#` are not + /// headings). Group 2 captures the heading text; a trailing run of `#` + /// characters is stripped separately. + static final RegExp _headingPattern = + RegExp(r'^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$'); + + /// Matches an optional ATX closing sequence of `#` characters. + static final RegExp _headingClosingPattern = RegExp(r'[ \t]+#+$'); + + /// Parses a list-item [line] into its indent width, marker, and trailing + /// text, or returns `null` when the line is not a list item. + /// + /// This is a hand-rolled replacement for the named-group regular expression + /// `^([ \t]{0,8})((\d{1,9})[.)]|[*+-])([ \t]+.*)?$`, which previously ran for + /// every candidate line (once to open a list, then once per line to find its + /// end). The returned [text] still includes leading whitespace, matching the + /// old capture group; callers trim it as before. + static ({int indent, String marker, String text})? _parseListLine( + String line) { + final len = line.length; + // Leading indent: at most 8 spaces or tabs. + var i = 0; + while (i < len && + i < 8 && + (line.codeUnitAt(i) == 0x20 || line.codeUnitAt(i) == 0x09)) { + i++; + } + if (i >= len) return null; + final indent = i; + final c = line.codeUnitAt(i); + final String marker; + final int textStart; + if (c >= 0x30 && c <= 0x39) { + // Ordered marker: 1-9 digits followed by '.' or ')'. + var d = i; + while (d < len && + d - i < 9 && + line.codeUnitAt(d) >= 0x30 && + line.codeUnitAt(d) <= 0x39) { + d++; + } + if (d >= len) return null; + final delim = line.codeUnitAt(d); + if (delim != 0x2E /* . */ && delim != 0x29 /* ) */) return null; + marker = line.substring(i, d + 1); + textStart = d + 1; + } else if (c == 0x2A /* * */ || c == 0x2B /* + */ || c == 0x2D /* - */) { + marker = line.substring(i, i + 1); + textStart = i + 1; + } else { + return null; + } + if (textStart >= len) return (indent: indent, marker: marker, text: ''); + // The marker must be followed by whitespace (or the end of the line). + final nc = line.codeUnitAt(textStart); + if (nc != 0x20 && nc != 0x09) return null; + return (indent: indent, marker: marker, text: line.substring(textStart)); + } + + /// A regular expression pattern to match thematic breaks (horizontal rules). + /// + /// A thematic break is a line consisting of three or more matching + /// `-`, `*`, or `_` characters, optionally separated by spaces or tabs, + /// with up to three leading spaces and nothing else. For example: + /// `---`, `***`, `___`, `- - -`, `* * *`. + static final RegExp _thematicBreakPattern = + RegExp(r'^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$'); - /// A regular expression pattern to match ordered lists. - /// Matches lines that start with a number followed by a period - /// or parenthesis, or with a bullet point (`*`, `+`, or `-`). - static final RegExp _listPattern = RegExp( - r'^(?[ \t]{0,8})(?(\d{1,9})[\.)]|[*+-])(?[ \t]+(.*))?$'); + /// A regular expression pattern to match GitHub alert markers, + /// e.g. `[!NOTE]`, `[!WARNING]`. Matched case-insensitively against the + /// first line of a blockquote. + static final RegExp _alertPattern = RegExp( + r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$', + caseSensitive: false); + + /// A regular expression pattern to match a GitHub task-list checkbox at the + /// start of a list item, e.g. `[ ] todo`, `[x] done`, `[X] done`. + static final RegExp _taskPattern = RegExp(r'^\[([ xX])\](?:[ \t]+(.*))?$'); + + /// Strips a single leading `>` (and one optional following space) from a + /// blockquote line, matching the historical trim behavior. + static String _stripQuoteMarker(String line) => line.substring(1).trim(); + + /// Parses the per-column alignment from a table delimiter row such as + /// `| :--- | :--: | ---: |`. Returns `null` when [line] is not a valid + /// delimiter row (which is also used to reject malformed tables). + static List? _parseTableAlignments(String line) { + if (!line.startsWith('|')) return null; + final cells = line.split('|'); + if (cells.length < 3) return null; // Need at least one column: `|---|`. + final inner = cells.sublist(1, cells.length - 1); + final aligns = []; + for (final raw in inner) { + final cell = raw.trim(); + // Each delimiter cell must be dashes with optional leading/trailing `:`. + if (!RegExp(r'^:?-+:?$').hasMatch(cell)) return null; + final left = cell.startsWith(':'); + final right = cell.endsWith(':'); + aligns.add(left && right + ? MD$TableColumnAlign.center + : right + ? MD$TableColumnAlign.right + : left + ? MD$TableColumnAlign.left + : MD$TableColumnAlign.none); + } + return aligns; + } + + /// Detects a GitHub task-list checkbox at the start of [raw]. + /// Returns the remaining text and the checked state (`null` when not a task). + static ({String text, bool? checked}) _parseTask(String raw) { + final match = _taskPattern.firstMatch(raw); + if (match == null) return (text: raw, checked: null); + final mark = match.group(1)!; + return ( + text: match.group(2)?.trim() ?? '', + checked: mark == 'x' || mark == 'X', + ); + } @override Markdown convert(String input) { @@ -42,6 +186,11 @@ class MarkdownDecoder extends Converter { final blocks = Queue(); // Queue to accumulate blocks final length = lines.length; + // Resolve the inline-math replacement table once; `null` disables math and + // keeps `_parseInlineSpans` from touching it at all. + final math = + inlineMath ? (mathReplacements ?? kMarkdownMathCommands) : null; + final paragraph = StringBuffer(); // To accumulate lines for paragraphs void maybeCommitParagraph() { @@ -50,7 +199,7 @@ class MarkdownDecoder extends Converter { paragraph.clear(); blocks.addLast(MD$Paragraph( text: text, - spans: _parseInlineSpans(text), + spans: _parseInlineSpans(text, math: math), )); } @@ -60,59 +209,90 @@ class MarkdownDecoder extends Converter { } for (var i = 0; i < length; i++) { - // Trim trailing whitespace for consistent parsing final line = lines[i]; - // Here you would implement the logic to parse the line - // and create the appropriate MD$Block instances. - // This is a placeholder for demonstration purposes. - if (line.isEmpty || _emptyPattern.hasMatch(line)) { - /// Parse empty lines and combine them into a spacing block. + if (_isBlank(line)) { + // Parse empty lines and combine them into a spacing block. var j = i + 1; - for (; j < length && _emptyPattern.hasMatch(lines[j]); j++) continue; + for (; j < length && _isBlank(lines[j]); j++) continue; final count = j - i; pushBlock(MD$Spacer(count: count)); if (i + count == length) break; // Last line is empty i = j - 1; // Skip the empty lines continue; - } else if (line.startsWith('---')) { - // Parse horizontal rules + } + + // The first code unit cheaply gates the block-type checks below, so a + // plain paragraph line runs no regular expressions at all. + final c0 = line.codeUnitAt(0); + + if ((c0 == 0x20 /* space */ || + c0 == 0x2D /* - */ || + c0 == 0x2A /* * */ || + c0 == 0x5F /* _ */) && + _thematicBreakPattern.hasMatch(line)) { + // Parse thematic breaks (horizontal rules): ---, ***, ___, - - -, etc. pushBlock(const MD$Divider()); continue; - } else if (line.startsWith('#')) { - // Parse headings - final level = - _headerPattern.firstMatch(line)?.group(0)?.length.clamp(1, 6) ?? 1; - final text = line.substring(level).trim(); + } else if (c0 == 0x23 /* # */) { + // Parse ATX headings (1-6 `#` followed by a space or end of line). + final match = _headingPattern.firstMatch(line); + if (match == null) { + // Not a valid heading (e.g. "#hashtag" or 7+ `#`); treat as text. + if (paragraph.isNotEmpty) paragraph.writeln(); + paragraph.write(line); + continue; + } + final level = match.group(1)!.length; + // Strip an optional closing sequence of `#` (e.g. "## Heading ##"). + final text = + (match.group(2) ?? '').replaceFirst(_headingClosingPattern, ''); pushBlock(MD$Heading( - level: level, text: text, spans: _parseInlineSpans(text))); + level: level, + text: text, + spans: _parseInlineSpans(text, math: math))); continue; - } else if (line.startsWith('>')) { - // Parse quotes - final buffer = StringBuffer()..write(line.substring(1).trim()); + } else if (c0 == 0x3E /* > */) { + // Parse quotes and GitHub-style alerts. + final quoteLines = [_stripQuoteMarker(line)]; var j = i + 1; for (; j < length && lines[j].startsWith('>'); j++) { - buffer - ..writeln() - ..write(lines[j].substring(1).trim()); + quoteLines.add(_stripQuoteMarker(lines[j])); } - final text = buffer.toString(); final count = j - i; - // TODO(plugfox): Implement indentation for quotes - // Mike Matiunin , 16 June 2025 - pushBlock(MD$Quote( - indent: 1, // Indentation level for quotes - text: text, - spans: _parseInlineSpans(text), - )); - if (i + count == length) break; // Last line is quote - i = j - 1; // Skip the empty lines + + // A blockquote whose first line is `[!TYPE]` becomes an alert block. + final alertMatch = _alertPattern.firstMatch(quoteLines.first); + final alertType = alertMatch != null + ? MD$AlertType.tryParse(alertMatch.group(1)!) + : null; + if (alertType != null) { + // The alert body is everything after the marker line. + final body = quoteLines.skip(1).join('\n').trim(); + pushBlock(MD$Alert( + alert: alertType, + text: body, + spans: _parseInlineSpans(body, math: math), + )); + } else { + final text = quoteLines.join('\n'); + // TODO(plugfox): Implement indentation for quotes + // Mike Matiunin , 16 June 2025 + pushBlock(MD$Quote( + indent: 1, // Indentation level for quotes + text: text, + spans: _parseInlineSpans(text, math: math), + )); + } + if (i + count == length) break; // Last line is quote/alert + i = j - 1; // Skip the consumed lines continue; - } else if (line.startsWith('```')) { - // Parse code blocks + } else if (line.startsWith('```') || line.startsWith('~~~')) { + // Parse fenced code blocks (``` or ~~~). + final fence = line.startsWith('~~~') ? '~~~' : '```'; final language = line.length > 3 ? line.substring(3).trim() : ''; var j = i + 1; - for (; j < length && !lines[j].startsWith('```'); j++) continue; + for (; j < length && !lines[j].startsWith(fence); j++) continue; final codeText = lines.sublist(i + 1, j).join('\n'); pushBlock(MD$Code( text: codeText, @@ -121,26 +301,38 @@ class MarkdownDecoder extends Converter { if (j == length - 1) break; // Last line is a code block i = j; // Skip to the end of the code block continue; - } else if (_listPattern.firstMatch(line) case RegExpMatch match - when match.namedGroup('indent')?.isEmpty == true) { - final marker = match.namedGroup('marker') ?? '*'; - final list = <({int intent, String marker, String text})>[ + } else if ((c0 >= 0x30 && c0 <= 0x39) /* 0-9 */ || + c0 == 0x2A /* * */ || + c0 == 0x2B /* + */ || + c0 == 0x2D /* - */) { + final first = _parseListLine(line); + if (first == null || first.indent != 0) { + // A marker-like first character that is not actually a top-level + // list item (e.g. "-> arrow", "*emphasis*"); treat it as paragraph. + if (paragraph.isNotEmpty) paragraph.writeln(); + paragraph.write(line); + continue; + } + final firstTask = _parseTask(first.text.trim()); + final list = + <({int intent, String marker, String text, bool? checked})>[ ( intent: 0, - marker: marker, - text: match.namedGroup('text')?.trim() ?? '', + marker: first.marker, + text: firstTask.text, + checked: firstTask.checked, ) ]; var j = i + 1; for (; j < length; j++) { - final line = lines[j]; - final match = _listPattern.firstMatch(line); - final indent = match?.namedGroup('indent')?.length; - if (indent == null) break; + final parsed = _parseListLine(lines[j]); + if (parsed == null) break; + final task = _parseTask(parsed.text.trim()); list.add(( - intent: indent, - marker: match?.namedGroup('marker') ?? '*', - text: match?.namedGroup('text')?.trim() ?? '', + intent: parsed.indent, + marker: parsed.marker, + text: task.text, + checked: task.checked, )); } // Convert to tree structure of [MD$ListItem]s @@ -155,8 +347,9 @@ class MarkdownDecoder extends Converter { items.add(MD$ListItem( text: item.text, marker: item.marker, // '•', - spans: _parseInlineSpans(item.text), + spans: _parseInlineSpans(item.text, math: math), indent: item.intent, + checked: item.checked, )); } else if (item.intent > indent) { // If the current item's indent is greater, @@ -171,8 +364,9 @@ class MarkdownDecoder extends Converter { items.add(MD$ListItem( marker: item.marker, // '•', text: item.text, - spans: _parseInlineSpans(item.text), + spans: _parseInlineSpans(item.text, math: math), indent: item.intent, + checked: item.checked, children: children, )); } @@ -197,7 +391,7 @@ class MarkdownDecoder extends Converter { if (i + count == length) break; // Last line is a list item i = j - 1; // Skip the list items continue; - } else if (line.startsWith('|')) { + } else if (c0 == 0x7C /* | */) { // Parse tables MD$TableRow textToRow(String text) { final cells = text.split('|'); @@ -206,14 +400,14 @@ class MarkdownDecoder extends Converter { cells: List>.unmodifiable(cells .sublist(1, cells.length - 1) .map((cell) => cell.trim()) - .map(_parseInlineSpans)), + .map((cell) => _parseInlineSpans(cell, math: math))), ); } final header = textToRow(line); - final separator = lines.length > i + 1 - ? RegExp(r'^\|[ -:]+[ -|:]*\|$').hasMatch(lines[i + 1]) - : false; // Separator line for the table header + // The delimiter row also carries per-column alignment. + final alignments = + lines.length > i + 1 ? _parseTableAlignments(lines[i + 1]) : null; final rows = []; var j = i + 2; // Skip the header and separator line for (; j < length && lines[j].startsWith('|'); j++) @@ -221,7 +415,7 @@ class MarkdownDecoder extends Converter { // Validate final columns = header.cells.length; if (columns > 0 && - separator && + alignments != null && rows.every((row) => row.cells.length == columns)) { // All rows have the same number of cells as the header final text = lines.sublist(i, j).join('\n'); @@ -229,6 +423,7 @@ class MarkdownDecoder extends Converter { text: text, header: header, rows: List.unmodifiable(rows), + alignments: List.unmodifiable(alignments), )); } else { // Table is malformed, treat it as a paragraph @@ -259,6 +454,20 @@ class MarkdownDecoder extends Converter { } } +/// Lookup table of code units that can start an inline construct: emphasis +/// markers (`* _ ~ = | ` `` ` ``), a link/image label (`[`), or an escape +/// (`\`). Used by [_parseInlineSpans] to take a fast path for plain text that +/// contains none of them — by far the most common case for prose. +final Uint8List _special = Uint8List(128) + ..[0x2A] = 1 // * + ..[0x5F] = 1 // _ + ..[0x7E] = 1 // ~ + ..[0x3D] = 1 // = + ..[0x7C] = 1 // | + ..[0x60] = 1 // ` + ..[0x5B] = 1 // [ + ..[0x5C] = 1; // \ + /// Type of special inline markers final Uint8List _kind = Uint8List(2048) ..[42] = 1 // * - italic and bold (single and double) @@ -272,6 +481,7 @@ final Uint8List _kind = Uint8List(2048) final Uint8List _escapedChars = Uint8List(126) ..[33] = 1 // ! Exclamation mark ..[35] = 1 // # Hash mark + ..[36] = 1 // $ Dollar sign (so `\$` is a literal dollar / opts out of math) ..[40] = 1 // ( Left parenthesis ..[41] = 1 // ) Right parenthesis ..[42] = 1 // * Asterisk @@ -286,21 +496,384 @@ final Uint8List _escapedChars = Uint8List(126) ..[123] = 1 // { Left curly brace ..[125] = 1; // } Right curly brace -List _parseInlineSpans(String text) { +/// The default mapping of common LaTeX inline-math commands to their Unicode +/// equivalents, used when [MarkdownDecoder.inlineMath] is enabled and no custom +/// [MarkdownDecoder.mathReplacements] is provided. +/// +/// Spread it to extend rather than replace the defaults: +/// `{...kMarkdownMathCommands, r'\R': 'ℝ'}`. +const Map kMarkdownMathCommands = { + // Arrows + r'\to': '→', r'\rightarrow': '→', r'\gets': '←', + r'\leftarrow': '←', r'\leftrightarrow': '↔', + r'\Rightarrow': '⇒', r'\Leftarrow': '⇐', + r'\Leftrightarrow': '⇔', r'\uparrow': '↑', + r'\downarrow': '↓', r'\mapsto': '↦', + r'\implies': '⟹', r'\iff': '⟺', r'\longrightarrow': '⟶', + r'\longleftarrow': '⟵', r'\hookrightarrow': '↪', + // Relations & operators + r'\leq': '≤', r'\le': '≤', r'\geq': '≥', r'\ge': '≥', + r'\neq': '≠', r'\ne': '≠', r'\approx': '≈', + r'\equiv': '≡', r'\sim': '∼', r'\simeq': '≃', + r'\cong': '≅', r'\propto': '∝', r'\asymp': '≍', + r'\ll': '≪', r'\gg': '≫', + r'\times': '×', r'\div': '÷', r'\pm': '±', r'\mp': '∓', + r'\cdot': '⋅', r'\ast': '∗', r'\star': '⋆', + r'\circ': '∘', r'\bullet': '∙', r'\oplus': '⊕', + r'\otimes': '⊗', r'\odot': '⊙', r'\setminus': '∖', + r'\perp': '⊥', r'\parallel': '∥', r'\mid': '∣', + // Set theory & logic + r'\in': '∈', r'\notin': '∉', r'\ni': '∋', + r'\subset': '⊂', r'\subseteq': '⊆', r'\supset': '⊃', + r'\supseteq': '⊇', r'\cup': '∪', r'\cap': '∩', + r'\emptyset': '∅', r'\varnothing': '∅', + r'\forall': '∀', r'\exists': '∃', r'\nexists': '∄', + r'\neg': '¬', r'\lnot': '¬', r'\land': '∧', r'\wedge': '∧', + r'\lor': '∨', r'\vee': '∨', r'\top': '⊤', r'\bot': '⊥', + r'\models': '⊨', r'\vdash': '⊢', r'\therefore': '∴', + r'\because': '∵', + // Big operators & calculus + r'\sum': '∑', r'\prod': '∏', r'\int': '∫', + r'\oint': '∮', r'\iint': '∬', + r'\infty': '∞', r'\partial': '∂', r'\nabla': '∇', + r'\sqrt': '√', r'\angle': '∠', r'\degree': '°', + r'\prime': '′', r'\hbar': 'ℏ', r'\ell': 'ℓ', + r'\Re': 'ℜ', r'\Im': 'ℑ', r'\aleph': 'ℵ', + // Dots + r'\ldots': '…', r'\cdots': '⋯', r'\dots': '…', r'\vdots': '⋮', + // Greek lowercase + r'\alpha': 'α', r'\beta': 'β', r'\gamma': 'γ', + r'\delta': 'δ', r'\epsilon': 'ε', r'\varepsilon': 'ε', + r'\zeta': 'ζ', r'\eta': 'η', r'\theta': 'θ', + r'\vartheta': 'ϑ', r'\iota': 'ι', r'\kappa': 'κ', + r'\lambda': 'λ', r'\mu': 'μ', r'\nu': 'ν', r'\xi': 'ξ', + r'\pi': 'π', r'\varpi': 'ϖ', r'\rho': 'ρ', r'\varrho': 'ϱ', + r'\sigma': 'σ', r'\varsigma': 'ς', r'\tau': 'τ', + r'\upsilon': 'υ', r'\phi': 'φ', r'\varphi': 'ϕ', + r'\chi': 'χ', r'\psi': 'ψ', r'\omega': 'ω', + // Greek uppercase + r'\Gamma': 'Γ', r'\Delta': 'Δ', r'\Theta': 'Θ', + r'\Lambda': 'Λ', r'\Xi': 'Ξ', r'\Pi': 'Π', + r'\Sigma': 'Σ', r'\Upsilon': 'Υ', r'\Phi': 'Φ', + r'\Psi': 'Ψ', r'\Omega': 'Ω', +}; + +/// Superscript code-unit map (base character → Unicode superscript), used for +/// `$x^2$`-style math. Characters without a mapping are left as-is. +const Map _superscripts = { + 0x30: 0x2070, 0x31: 0x00B9, 0x32: 0x00B2, 0x33: 0x00B3, // 0 1 2 3 + 0x34: 0x2074, 0x35: 0x2075, 0x36: 0x2076, 0x37: 0x2077, // 4 5 6 7 + 0x38: 0x2078, 0x39: 0x2079, // 8 9 + 0x2B: 0x207A, 0x2D: 0x207B, 0x3D: 0x207C, // + - = + 0x28: 0x207D, 0x29: 0x207E, // ( ) + 0x69: 0x2071, 0x6E: 0x207F, // i n +}; + +/// Subscript code-unit map (base character → Unicode subscript), used for +/// `$H_2O$`-style math. Characters without a mapping are left as-is. +const Map _subscripts = { + 0x30: 0x2080, 0x31: 0x2081, 0x32: 0x2082, 0x33: 0x2083, // 0 1 2 3 + 0x34: 0x2084, 0x35: 0x2085, 0x36: 0x2086, 0x37: 0x2087, // 4 5 6 7 + 0x38: 0x2088, 0x39: 0x2089, // 8 9 + 0x2B: 0x208A, 0x2D: 0x208B, 0x3D: 0x208C, // + - = + 0x28: 0x208D, 0x29: 0x208E, // ( ) + 0x61: 0x2090, 0x65: 0x2091, 0x6F: 0x2092, 0x78: 0x2093, // a e o x + 0x68: 0x2095, 0x6B: 0x2096, 0x6C: 0x2097, 0x6D: 0x2098, // h k l m + 0x6E: 0x2099, 0x70: 0x209A, 0x73: 0x209B, 0x74: 0x209C, // n p s t + 0x69: 0x1D62, 0x72: 0x1D63, 0x75: 0x1D64, 0x76: 0x1D65, // i r u v + 0x6A: 0x2C7C, // j +}; + +/// Matches a run of `$...$` inline math with tight delimiters (no space right +/// after the opening `$` or right before the closing `$`) and no `$` inside. +/// The opening `$` must not be backslash-escaped, so `\$5` never starts math. +final RegExp _inlineMathPattern = RegExp(r'(? replacements) { + var replaced = false; + var result = content.replaceAllMapped(_mathCommandPattern, (match) { + final unicode = replacements[match.group(0)]; + if (unicode == null) return match.group(0)!; + replaced = true; + return unicode; + }); + final scripted = _applyScripts(result); + if (scripted != null) { + result = scripted; + replaced = true; + } + return replaced ? result : null; +} + +/// Converts `^`/`_` super/subscripts in already-command-substituted math +/// [content] to Unicode, supporting a single character (`x^2`, `a_i`) or a +/// braced group (`x^{10}`, `H_{2}O`). Returns `null` when nothing changed. +/// A script run whose characters are not all mappable is left untouched. +String? _applyScripts(String content) { + if (!content.contains('^') && !content.contains('_')) return null; + final length = content.length; + StringBuffer? buffer; + var last = 0; // Start of the not-yet-copied tail. + var i = 0; + while (i < length) { + final c = content.codeUnitAt(i); + if (c != 0x5E /* ^ */ && c != 0x5F /* _ */) { + i++; + continue; + } + final table = c == 0x5E ? _superscripts : _subscripts; + final String? mapped; + final int next; // Index just past the consumed script expression. + if (i + 1 < length && content.codeUnitAt(i + 1) == 0x7B /* { */) { + final close = content.indexOf('}', i + 2); + if (close == -1) { + i++; + continue; + } + mapped = _mapScriptRun(content, i + 2, close, table); + next = close + 1; + } else if (i + 1 < length) { + final m = table[content.codeUnitAt(i + 1)]; + mapped = m == null ? null : String.fromCharCode(m); + next = i + 2; + } else { + break; + } + if (mapped == null) { + i++; + continue; + } + (buffer ??= StringBuffer()).write(content.substring(last, i)); + buffer.write(mapped); + last = next; + i = next; + } + if (buffer == null) return null; + buffer.write(content.substring(last)); + return buffer.toString(); +} + +/// Maps every code unit in `content[start..end)` through [table], returning +/// `null` if any character has no mapping. +String? _mapScriptRun(String content, int start, int end, Map table) { + final out = StringBuffer(); + for (var k = start; k < end; k++) { + final mapped = table[content.codeUnitAt(k)]; + if (mapped == null) return null; + out.writeCharCode(mapped); + } + return out.toString(); +} + +/// Replaces `$...$` inline math with Unicode equivalents, leaving inline code +/// spans (delimited by backticks) untouched. Only segments that contain at +/// least one recognized LaTeX command are converted; everything else — such as +/// currency (`$5`) — is preserved verbatim. +String _applyInlineMath(String text, Map replacements) { + // A `$...$` span can only convert if it contains a command (`\`) or a + // super/subscript (`^`/`_`). Text with none of those — e.g. prices like + // `$5` — skips the scan entirely. + if (!text.contains(r'$')) return text; + if (!text.contains(r'\') && !text.contains('^') && !text.contains('_')) { + return text; + } + + String convertSegment(String segment) => + segment.replaceAllMapped(_inlineMathPattern, (match) { + final converted = _convertMathContent(match.group(1)!, replacements); + return converted ?? match.group(0)!; + }); + + // Fast path: no backticks, convert the whole string. + if (!text.contains('`')) return convertSegment(text); + + // Otherwise, protect inline code spans while converting the rest. + final buffer = StringBuffer(); + var segmentStart = 0; + var i = 0; + final length = text.length; + while (i < length) { + if (text.codeUnitAt(i) == 0x60 /* ` */) { + buffer.write(convertSegment(text.substring(segmentStart, i))); + final close = text.indexOf('`', i + 1); + if (close == -1) { + segmentStart = i; // Unterminated code span: treat the rest as text. + break; + } + buffer.write(text.substring(i, close + 1)); // Verbatim code span. + i = close + 1; + segmentStart = i; + } else { + i++; + } + } + buffer.write(convertSegment(text.substring(segmentStart))); + return buffer.toString(); +} + +/// Parses a link/image destination into its URL and optional title. +/// +/// Supports angle-bracketed URLs (``) and titles wrapped in +/// double quotes, single quotes, or parentheses, e.g. `url "title"`, +/// `url 'title'`, `url (title)`. +({String url, String? title}) _parseLinkTarget(String raw) { + var rest = raw.trim(); + String url; + if (rest.startsWith('<')) { + final close = rest.indexOf('>'); + if (close != -1) { + url = rest.substring(1, close); + rest = rest.substring(close + 1).trim(); + } else { + url = rest.substring(1); + rest = ''; + } + } else { + // Find the first whitespace separating the URL from an optional title. + // A manual scan avoids allocating and compiling a RegExp on every call. + var space = -1; + for (var k = 0; k < rest.length; k++) { + final ch = rest.codeUnitAt(k); + if (ch == 0x20 || ch == 0x09) { + space = k; + break; + } + } + if (space == -1) { + url = rest; + rest = ''; + } else { + url = rest.substring(0, space); + rest = rest.substring(space + 1).trim(); + } + } + if (rest.isEmpty) return (url: url, title: null); + // Strip a matching pair of title delimiters when present. + final first = rest[0], last = rest[rest.length - 1]; + final quoted = rest.length >= 2 && + ((first == '"' && last == '"') || + (first == "'" && last == "'") || + (first == '(' && last == ')')); + return (url: url, title: quoted ? rest.substring(1, rest.length - 1) : rest); +} + +/// Whether [c] is an inline whitespace code unit (space, tab, CR, LF). +bool _isInlineSpace(int c) => c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D; + +/// Whether [c] is a "word" code unit: ASCII alphanumeric or any non-ASCII +/// code unit (letters, digits, emoji). Used for intraword `_` detection. +bool _isWordChar(int c) => + (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5A) || // A-Z + (c >= 0x61 && c <= 0x7A) || // a-z + c >= 0x80; // Treat non-ASCII (Cyrillic, CJK, emoji, ...) as word chars. + +/// Whether there is a non-escaped closing backtick at or after [from]. +bool _hasClosingBacktick(List codes, int length, int from) { + for (var j = from; j < length; j++) { + if (codes[j] == 0x60 /* ` */ && codes[j - 1] != 0x5C /* \ */) return true; + } + return false; +} + +/// Whether a valid closing emphasis delimiter for [ch] (of run length +/// [markerLen]) exists at or after [from]. A closer must be right-flanking +/// (preceded by a non-space); underscore closers must also be at a word +/// boundary. Escaped characters are skipped. +bool _hasEmphasisCloser( + List codes, int length, int from, int ch, int markerLen) { + for (var j = from; j < length; j++) { + if (codes[j] == 0x5C /* \ */) { + j++; // Skip the escaped character. + continue; + } + if (codes[j] != ch) continue; + if (markerLen == 2) { + if (j + 1 < length && codes[j + 1] == ch) { + if (j > 0 && !_isInlineSpace(codes[j - 1])) return true; + j++; // Consume the delimiter pair. + } + } else if (j > 0 && !_isInlineSpace(codes[j - 1])) { + if (ch != 0x5F /* _ */) return true; + // Underscore: also require a word boundary after the closer. + final after = j + 1; + if (after >= length || !_isWordChar(codes[after])) return true; + } + } + return false; +} + +/// Validates an emphasis marker occurrence against CommonMark-inspired +/// flanking and word-boundary rules. Returns `true` when the marker should +/// toggle the style; `false` leaves it as literal text. +bool _emphasisValid( + List codes, int length, int i, int ch, int markerLen, bool isOpen) { + if (isOpen) { + // Left-flanking: a non-space must immediately follow the marker run. + final after = i + markerLen; + if (after >= length || _isInlineSpace(codes[after])) return false; + // Underscore cannot open inside a word (e.g. snake_case). + if (ch == 0x5F /* _ */ && i > 0 && _isWordChar(codes[i - 1])) return false; + // Require a matching closer somewhere ahead. + return _hasEmphasisCloser(codes, length, after, ch, markerLen); + } else { + // Right-flanking: a non-space must immediately precede the marker. + if (i == 0 || _isInlineSpace(codes[i - 1])) return false; + // Underscore cannot close inside a word. + final after = i + markerLen; + if (ch == 0x5F /* _ */ && after < length && _isWordChar(codes[after])) + return false; + return true; + } +} + +List _parseInlineSpans(String text, {Map? math}) { if (text.isEmpty) return const []; + // Resolve simple `$...$` inline math to Unicode before span parsing, but + // only when enabled (a non-null replacement table is supplied). + if (math != null) text = _applyInlineMath(text, math); + // Convert the text to a list of code units for easier processing // This allows us to handle UTF-16 characters correctly. final codes = text.codeUnits; final length = codes.length; + // Fast path: text without any inline markers, links, or escapes is a single + // unstyled span. Detect that with one early-terminating scan and skip both + // the link-extraction and emphasis passes (and their allocations). + var hasSpecial = false; + for (var i = 0; i < length; i++) { + final c = codes[i]; + if (c < 128 && _special[c] != 0) { + hasSpecial = true; + break; + } + } + if (!hasSpecial) { + return [ + MD$Span(start: 0, end: length, text: text), + ]; + } + /// Escaped characters in Markdown const int esc = 0x5C; // '\' - // Phase 1: Extract links and images - final links = []; - final skip = Uint16List(length); // Skip links during inline parsing - { + // Phase 1: Extract links and images. + // + // Only run when a label bracket is actually present, and allocate the + // `links`/`skip` structures lazily on the first match, so text with emphasis + // but no links (a common case) pays neither the scan nor the allocation. + List? links; + Uint16List? skip; + if (text.contains('[')) { const img$symbol = 0x21, // '!' (33) label$start = 0x5B, // '[' (91) label$end = 0x5D, // ']' (93) @@ -365,14 +938,12 @@ List _parseInlineSpans(String text) { // If there is no closing ')', there is no more links or images if (urlEnd == -1) break; - // Create a link or image span - final parts = text.substring(urlIdx + 1, urlEnd).split(' '); - final src = parts.firstOrNull ?? ''; - var alt = parts.length > 1 ? parts.skip(1).join(' ') : null; - if (alt != null && alt.startsWith('"') && alt.endsWith('"')) { - // Remove quotes from alt text - alt = alt.substring(1, alt.length - 1); - } + // Create a link or image span. + final target = _parseLinkTarget(text.substring(urlIdx + 1, urlEnd)); + final src = target.url; + final alt = target.title; + links ??= []; + skip ??= Uint16List(length); links.add( MD$Span( start: img ? i - 1 : i, // include the '!' for images @@ -406,29 +977,37 @@ List _parseInlineSpans(String text) { final spans = []; var hasExcluded = false; // Flag to check if we have excluded characters - late final excluded = HashSet(); // Set of excluded indices + // Backslash indices to drop from the current span, in ascending order. This + // is cleared after every pushed span, so it only ever holds the escapes of + // the span currently being built. Allocated lazily on the first escape. + late final excluded = []; { // Add span to the list of spans void maybePushSpan(int end) { if (start >= end) return; // No valid span to push if (hasExcluded) { - // If we have excluded characters, we should create a new span - // from the bytes that are not excluded. - final spanLength = end - start - excluded.length; + // Rebuild the span text without the excluded backslash characters by + // copying the ranges between them. Because the indices are already + // sorted, this avoids both a hash-set lookup per character and an + // intermediate code-unit buffer. + final removed = excluded.length; + final spanLength = end - start - removed; if (spanLength > 0) { - // If the span has any valid text - final bytes = Uint16List(spanLength); - var j = 0; // Index for the new bytes array - for (var i = start; i < end; i++) { - if (excluded.contains(i)) continue; // Skip excluded indices - bytes[j++] = codes[i]; // Copy the character to the new array + final buffer = StringBuffer(); + var segmentStart = start; + for (var e = 0; e < excluded.length; e++) { + final idx = excluded[e]; + if (idx > segmentStart) + buffer.write(text.substring(segmentStart, idx)); + segmentStart = idx + 1; } - final txt = String.fromCharCodes(bytes); + if (segmentStart < end) + buffer.write(text.substring(segmentStart, end)); spans.add( MD$Span( start: start, - end: end - excluded.length, - text: txt, + end: end - removed, + text: buffer.toString(), style: mask, ), ); @@ -469,13 +1048,22 @@ List _parseInlineSpans(String text) { } // If this character is part of a link or image, skip it - if (skip[i] != 0) { + if (skip != null && skip[i] != 0) { // Finish the current span if it exists maybePushSpan(i); - final span = links[skip[i] - 1]; - spans.add(span); - i = span.end - 1; // -1 because the loop will increment i + final link = links![skip[i] - 1]; + // Combine any active emphasis (bold/italic/...) with the link style. + spans.add(mask.isEmpty + ? link + : MD$Span( + start: link.start, + end: link.end, + text: link.text, + style: MD$Style(link.style.value | mask.value), + extra: link.extra, + )); + i = link.end - 1; // -1 because the loop will increment i start = i + 1; continue; } @@ -498,88 +1086,50 @@ List _parseInlineSpans(String text) { // Check if the next character is the same kind // This is used to determine if it's a single or double marker. - late final isDouble = i + 1 < length && codes[i + 1] == ch; - - // Find the style for this marker - switch (ch) { - case 42: // '*' - // Can be used for italic (single) or bold (double) - if (isDouble) { - maybePushSpan(i); - // Bold (double) - mask ^= MD$Style.bold; - start = i + 2; - } else { - maybePushSpan(i); - // Italic (single) - mask ^= MD$Style.italic; - start = i + 1; - } - case 61: // '=' - // Highlight (double) - if (isDouble) { - maybePushSpan(i); - // Highlight - mask ^= MD$Style.highlight; - start = i + 2; - } else { - // This is just a single `=` character, so we skip it - continue; - } - case 95: // '_' - // Underline (double) - if (isDouble) { - maybePushSpan(i); - // Underline (double) - mask ^= MD$Style.underline; - start = i + 2; - } else { - maybePushSpan(i); - // Italic (single) - mask ^= MD$Style.italic; - start = i + 1; - } - case 96: // '`' - // Monospace (single) - if (isDouble) { - // This is a double backtick, we should skip as it is not valid - i++; // skip next character - continue; - } else { - maybePushSpan(i); - // Monospace - mask ^= MD$Style.monospace; - start = i + 1; - } - case 124: // '|' - // Spoiler (double) - if (isDouble) { - maybePushSpan(i); - // Spoiler - mask ^= MD$Style.spoiler; - start = i + 2; - } else { - // Single - this is just a single `|` character, so we skip it - continue; - } - case 126: // '~' - // Strikethrough (double) - if (isDouble) { - maybePushSpan(i); - // Strikethrough - mask ^= MD$Style.strikethrough; - start = i + 2; - } else { - // Single - this is just a single `~` character, so we skip it - continue; - } - default: - // Here we would handle any other inline markers, - // such as custom markers or any other special symbols. - continue; // Skip unknown markers + final isDouble = i + 1 < length && codes[i + 1] == ch; + + // Monospace is handled separately: it opens only when a matching + // closing backtick exists later on the line, otherwise the backtick is + // treated as literal text (so unterminated code does not leak). + if (ch == 96 /* ` */) { + if (isDouble) { + i++; // Double backtick is not supported; skip the next character. + continue; + } + if (!_hasClosingBacktick(codes, length, i + 1)) continue; // literal ` + maybePushSpan(i); + mask ^= MD$Style.monospace; + start = i + 1; + continue; } - if (isDouble) i++; // if it's a double marker, skip the next character + // Resolve the emphasis style and marker length for this marker. + final ({MD$Style style, int len})? emphasis = switch (ch) { + 42 => ( + style: isDouble ? MD$Style.bold : MD$Style.italic, + len: isDouble ? 2 : 1, + ), // '*' + 95 => ( + style: isDouble ? MD$Style.underline : MD$Style.italic, + len: isDouble ? 2 : 1, + ), // '_' + 126 when isDouble => (style: MD$Style.strikethrough, len: 2), // '~~' + 61 when isDouble => (style: MD$Style.highlight, len: 2), // '==' + 124 when isDouble => (style: MD$Style.spoiler, len: 2), // '||' + _ => null, // Lone =, |, ~ (and unknown markers) are literal. + }; + if (emphasis == null) continue; + + // Validate the marker against flanking / word-boundary rules and require + // a matching closer, so stray or unterminated markers stay literal + // instead of leaking their style to the end of the line. + final isOpen = !mask.contains(emphasis.style); + if (!_emphasisValid(codes, length, i, ch, emphasis.len, isOpen)) continue; + + maybePushSpan(i); + mask ^= emphasis.style; + start = i + emphasis.len; + if (emphasis.len == 2) i++; // Skip the second marker character. } // If we have any remaining text after the last marker, add it as a span maybePushSpan(length); diff --git a/lib/src/render.dart b/lib/src/render.dart index 57b6a94..a8848b2 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -230,6 +230,12 @@ class MarkdownPainter { table: (t) => BlockPainter$Table( header: t.header, rows: t.rows, + alignments: t.alignments, + theme: theme, + ), + alert: (a) => BlockPainter$Alert( + alert: a.alert, + spans: a.spans, theme: theme, ), spacer: (s) => BlockPainter$Spacer( @@ -885,6 +891,150 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { } } +/// A class for painting a GitHub-style alert (admonition) block in markdown. +@meta.internal +class BlockPainter$Alert with ParagraphGestureHandler implements BlockPainter { + BlockPainter$Alert({ + required this.alert, + required List spans, + required this.theme, + }) : _accent = theme.alertColorFor(alert), + titlePainter = TextPainter( + text: TextSpan( + text: alert.title, + style: theme.textStyle.copyWith( + color: theme.alertColorFor(alert), + fontWeight: FontWeight.bold, + ), + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ), + bodyPainter = TextPainter( + text: _paragraphFromMarkdownSpans(spans: spans, theme: theme), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + /// The kind of the alert being painted. + final MD$AlertType alert; + + final MarkdownThemeData theme; + + final Color _accent; + + /// Painter for the alert title (e.g. "Note"). + final TextPainter titlePainter; + + /// Painter for the alert body content. + final TextPainter bodyPainter; + + static const double padding = 10.0; + static const double barWidth = 4.0; + static const double gap = 10.0; + static const double titleGap = 4.0; + + /// Left offset where the title/body content begins. + double get _contentLeft => padding + barWidth + gap; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Whether the body has any content to paint. + bool _hasBody = false; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + Offset get _bodyOrigin => + Offset(_contentLeft, padding + titlePainter.height + titleGap); + + TextSpan? _spanForPosition(Offset localPosition) { + if (!_hasBody) return null; + final local = localPosition - _bodyOrigin; + if (local.dx < 0 || + local.dy < 0 || + local.dx > bodyPainter.width || + local.dy > bodyPainter.height) return null; + final position = bodyPainter.getPositionForOffset(local); + final span = bodyPainter.text?.getSpanForPosition(position); + return span is TextSpan ? span : null; + } + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = _spanForPosition(event.localPosition); + } + + @override + void handleTapUp(PointerUpEvent event) { + if (_lastSpan == null) return; + final span = _spanForPosition(event.localPosition); + if (span != null && _lastSpan == span) { + if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) + onTap?.call(); + } + _lastSpan = null; + } + + @override + Size layout(double width) { + final available = math.max(0.0, width - _contentLeft - padding); + titlePainter.layout(minWidth: 0, maxWidth: available); + bodyPainter.layout(minWidth: 0, maxWidth: available); + _hasBody = bodyPainter.text?.toPlainText().isNotEmpty ?? false; + + final contentWidth = math.max( + titlePainter.width, + _hasBody ? bodyPainter.width : 0.0, + ); + final contentHeight = + titlePainter.height + (_hasBody ? titleGap + bodyPainter.height : 0.0); + return _size = Size( + _contentLeft + contentWidth + padding, + contentHeight + padding * 2, + ); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + if (size.width < _size.width) return; + + final rect = Rect.fromLTWH(0, offset, size.width, _size.height); + // Tinted background. + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(6.0)), + Paint() + ..color = _accent.withValues(alpha: 0.10) + ..style = PaintingStyle.fill, + ); + // Accent bar on the left. + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, offset, barWidth, _size.height), + const Radius.circular(barWidth / 2), + ), + Paint() + ..color = _accent + ..style = PaintingStyle.fill, + ); + + titlePainter.paint(canvas, Offset(_contentLeft, offset + padding)); + if (_hasBody) { + bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset)); + } + } + + @override + void dispose() { + titlePainter.dispose(); + bodyPainter.dispose(); + } +} + /// A helper class to store layout information for a single list item. class _ListItemMetrics { _ListItemMetrics({ @@ -982,15 +1132,17 @@ class BlockPainter$List with ParagraphGestureHandler implements BlockPainter { void layoutItems(List items, int level) { final indent = _baseIndent + level * _levelIndent; for (final item in items) { + // Task-list items render a checkbox instead of a bullet/number. + final bulletText = switch (item.checked) { + true => '☑', + false => '☐', + null => switch (item.marker) { + '-' || '*' || '+' => '•', + _ => item.marker, + }, + }; final bulletPainter = TextPainter( - text: TextSpan( - text: '${switch (item.marker) { - '-' => '•', - '*' => '•', - '+' => '•', - _ => item.marker, - }} ', - style: theme.textStyle), + text: TextSpan(text: '$bulletText ', style: theme.textStyle), textDirection: theme.textDirection, textScaler: theme.textScaler, )..layout(); @@ -1219,6 +1371,7 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { required this.header, required this.rows, required this.theme, + this.alignments = const [], }) : columns = header.cells.length, _columnWidths = List.filled(header.cells.length, 0.0), _rowHeights = List.filled(rows.length + 1, 0.0), @@ -1242,6 +1395,26 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { /// The number of columns in the table. final int columns; + /// The per-column alignment derived from the delimiter row. + final List alignments; + + /// Resolves the alignment for column [c], defaulting to + /// [MD$TableColumnAlign.none] when unspecified. + MD$TableColumnAlign _columnAlign(int c) => c >= 0 && c < alignments.length + ? alignments[c] + : MD$TableColumnAlign.none; + + /// The horizontal offset of a cell's text within its column, honoring the + /// column alignment (falling back to centered headers / left-aligned data). + double _cellHorizontalPadding(int r, int c, double painterWidth) => + switch (_columnAlign(c)) { + MD$TableColumnAlign.left => padding, + MD$TableColumnAlign.center => (_columnWidths[c] - painterWidth) / 2, + MD$TableColumnAlign.right => _columnWidths[c] - painterWidth - padding, + MD$TableColumnAlign.none => + (r == 0) ? (_columnWidths[c] - painterWidth) / 2 : padding, + }; + final List _columnWidths; final List _rowHeights; final Paint _borderPaint; @@ -1310,7 +1483,7 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { // In this cell. final verticalPadding = (rowHeight - painter.height) / 2; final horizontalPadding = - (r == 0) ? (columnWidth - painter.width) / 2 : padding; + _cellHorizontalPadding(r, c, painter.width); final painterOffset = Offset( currentX + horizontalPadding, currentY + verticalPadding); @@ -1369,7 +1542,13 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { final textPainter = TextPainter( text: _paragraphFromMarkdownSpans( spans: cell, theme: theme, textStyle: style), - textAlign: (r == 0) ? TextAlign.center : TextAlign.start, + textAlign: switch (_columnAlign(c)) { + MD$TableColumnAlign.left => TextAlign.left, + MD$TableColumnAlign.center => TextAlign.center, + MD$TableColumnAlign.right => TextAlign.right, + MD$TableColumnAlign.none => + (r == 0) ? TextAlign.center : TextAlign.start, + }, textDirection: theme.textDirection, textScaler: theme.textScaler, ); @@ -1475,9 +1654,7 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { } final verticalPadding = (rowHeights[r] - painter.height) / 2; - final horizontalPadding = (r == 0) - ? (_columnWidths[c] - painter.width) / 2 // Center for header rows - : padding; // Left align for data rows + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); painter.paint( canvas, diff --git a/lib/src/theme.dart b/lib/src/theme.dart index e749c2c..60be404 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -22,10 +22,12 @@ class MarkdownThemeData implements ThemeExtension { this.h6Style, this.quoteStyle, this.linkColor = Colors.indigo, + this.linkStyle, this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), this.dividerColor, + this.alertColors, this.blockFilter, this.spanFilter, this.builder, @@ -47,10 +49,12 @@ class MarkdownThemeData implements ThemeExtension { TextStyle? h6Style, TextStyle? quoteStyle, Color? linkColor, + TextStyle? linkStyle, Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, Color? dividerColor, + Map? alertColors, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, @@ -73,12 +77,14 @@ class MarkdownThemeData implements ThemeExtension { color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), linkColor: linkColor ?? theme.colorScheme.primary, + linkStyle: linkStyle, surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, highlightBackgroundColor: highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), + alertColors: alertColors, blockFilter: blockFilter, spanFilter: spanFilter, builder: builder, @@ -122,6 +128,10 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for link text. final Color? linkColor; + /// An optional text style to merge into link spans, applied on top of the + /// default link styling (bold + [linkColor]). + final TextStyle? linkStyle; + /// The color to use for the background of the quote, block, table and etc. final Color? surfaceColor; @@ -134,6 +144,26 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the divider. final Color? dividerColor; + /// Optional accent colors for GitHub-style alert blocks, keyed by type. + /// Missing entries fall back to the GitHub default palette + /// (see [alertColorFor]). + final Map? alertColors; + + /// The default GitHub-style accent color for each alert type (light theme). + static const Map _defaultAlertColors = + { + MD$AlertType.note: Color(0xFF0969DA), // blue + MD$AlertType.tip: Color(0xFF1A7F37), // green + MD$AlertType.important: Color(0xFF8250DF), // purple + MD$AlertType.warning: Color(0xFF9A6700), // amber + MD$AlertType.caution: Color(0xFFCF222E), // red + }; + + /// Returns the accent color for the given alert [type], using [alertColors] + /// when provided and falling back to the GitHub default palette. + Color alertColorFor(MD$AlertType type) => + alertColors?[type] ?? _defaultAlertColors[type]!; + /// A filter function to determine whether a block should be rendered. /// If the function returns `true`, the block will be rendered. /// @@ -209,34 +239,42 @@ class MarkdownThemeData implements ThemeExtension { /// Returns a [TextStyle] for the given [MD$Style]. TextStyle textStyleFor(MD$Style style) => _textStyles.putIfAbsent( style.hashCode, - () => textStyle.copyWith( - fontWeight: switch (style) { - var s when s.contains(MD$Style.bold) => FontWeight.bold, - var s when s.contains(MD$Style.link) => FontWeight.bold, - var s when s.contains(MD$Style.highlight) => FontWeight.bold, - _ => null, - }, - fontStyle: style.contains(MD$Style.italic) ? FontStyle.italic : null, - decoration: switch (style) { - var s when s.contains(MD$Style.underline) => - TextDecoration.underline, - var s when s.contains(MD$Style.strikethrough) => - TextDecoration.lineThrough, - _ => null, - }, - fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, - color: switch (style) { - var s when s.contains(MD$Style.link) => linkColor, - _ => null, - }, - backgroundColor: switch (style) { - var s when s.contains(MD$Style.highlight) => - highlightBackgroundColor, - var s when s.contains(MD$Style.monospace) => - monospaceBackgroundColor, - _ => null, - }, - ), + () { + final resolved = textStyle.copyWith( + fontWeight: switch (style) { + var s when s.contains(MD$Style.bold) => FontWeight.bold, + var s when s.contains(MD$Style.link) => FontWeight.bold, + var s when s.contains(MD$Style.highlight) => FontWeight.bold, + _ => null, + }, + fontStyle: + style.contains(MD$Style.italic) ? FontStyle.italic : null, + decoration: switch (style) { + var s when s.contains(MD$Style.underline) => + TextDecoration.underline, + var s when s.contains(MD$Style.strikethrough) => + TextDecoration.lineThrough, + _ => null, + }, + fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, + color: switch (style) { + var s when s.contains(MD$Style.link) => linkColor, + _ => null, + }, + backgroundColor: switch (style) { + var s when s.contains(MD$Style.highlight) => + highlightBackgroundColor, + var s when s.contains(MD$Style.monospace) => + monospaceBackgroundColor, + _ => null, + }, + ); + // Merge the optional link style on top of the default link styling. + if (linkStyle != null && style.contains(MD$Style.link)) { + return resolved.merge(linkStyle); + } + return resolved; + }, ); @override @@ -252,12 +290,16 @@ class MarkdownThemeData implements ThemeExtension { TextStyle? h6Style, TextStyle? quoteStyle, Color? linkColor, + TextStyle? linkStyle, Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, Color? dividerColor, + Map? alertColors, bool Function(MD$Block block)? blockFilter, bool Function(MD$Span span)? spanFilter, + BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, + void Function(String title, String url)? onLinkTap, }) => MarkdownThemeData( textDirection: textDirection ?? this.textDirection, @@ -271,14 +313,18 @@ class MarkdownThemeData implements ThemeExtension { h6Style: h6Style ?? this.h6Style, quoteStyle: quoteStyle ?? this.quoteStyle, linkColor: linkColor ?? this.linkColor, + linkStyle: linkStyle ?? this.linkStyle, surfaceColor: surfaceColor ?? this.surfaceColor, highlightBackgroundColor: highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, dividerColor: dividerColor ?? this.dividerColor, + alertColors: alertColors ?? this.alertColors, blockFilter: blockFilter ?? this.blockFilter, spanFilter: spanFilter ?? this.spanFilter, + builder: builder ?? this.builder, + onLinkTap: onLinkTap ?? this.onLinkTap, ); @override @@ -302,12 +348,14 @@ class MarkdownThemeData implements ThemeExtension { h6Style: TextStyle.lerp(h6Style, other?.h6Style, t), quoteStyle: TextStyle.lerp(quoteStyle, other?.quoteStyle, t), linkColor: Color.lerp(linkColor, other?.linkColor, t), + linkStyle: TextStyle.lerp(linkStyle, other?.linkStyle, t), surfaceColor: Color.lerp(surfaceColor, other?.surfaceColor, t), highlightBackgroundColor: Color.lerp( highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), + alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, builder: t < 0.5 ? builder : other?.builder, diff --git a/pubspec.yaml b/pubspec.yaml index 3be30af..fb86420 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: > Markdown library written in Dart. It can parse and display Markdown. -version: 0.0.8 +version: 0.1.0 homepage: https://github.com/DoctorinaAI/md repository: https://github.com/DoctorinaAI/md diff --git a/test/nodes/nodes_test.dart b/test/nodes/nodes_test.dart new file mode 100644 index 0000000..1db947a --- /dev/null +++ b/test/nodes/nodes_test.dart @@ -0,0 +1,263 @@ +// Unit tests for the node model (`nodes.dart`): the `MD$Style` bitmask helpers +// and the block/data-class `type`, `toString`, `maybeMap`, and `copyWith` +// members that the parser and renderer do not otherwise exercise. +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A document that yields every block type once. +const String _doc = '# Heading\n' + '\n' + 'A paragraph\n' + '\n' + '> A quote\n' + '\n' + '> [!NOTE]\n' + '> Alert body\n' + '\n' + '```dart\n' + 'code();\n' + '```\n' + '\n' + '- item a\n' + ' - item b\n' + '\n' + '| x | y |\n' + '| - | - |\n' + '| 1 | 2 |\n' + '\n' + '---\n'; + +void main() { + group('MD\$Style', () { + test('add sets a flag', () { + final s = MD$Style.bold.add(MD$Style.italic); + expect(s.contains(MD$Style.bold), isTrue); + expect(s.contains(MD$Style.italic), isTrue); + }); + + test('remove clears a flag', () { + final s = MD$Style.bold.add(MD$Style.italic).remove(MD$Style.bold); + expect(s.contains(MD$Style.bold), isFalse); + expect(s.contains(MD$Style.italic), isTrue); + }); + + test('toggle flips a flag', () { + expect(MD$Style.none.toggle(MD$Style.bold), MD$Style.bold); + expect(MD$Style.bold.toggle(MD$Style.bold), MD$Style.none); + }); + + test('operator ^ toggles', () { + expect(MD$Style.bold ^ MD$Style.bold, MD$Style.none); + expect(MD$Style.none ^ MD$Style.italic, MD$Style.italic); + }); + + test('isEmpty / isNotEmpty', () { + expect(MD$Style.none.isEmpty, isTrue); + expect(MD$Style.none.isNotEmpty, isFalse); + expect(MD$Style.bold.isNotEmpty, isTrue); + expect(MD$Style.bold.isEmpty, isFalse); + }); + + test('styles names every flag and is empty for none', () { + expect(MD$Style.none.styles, isEmpty); + var all = MD$Style.none; + for (final flag in MD$Style.values) { + all = all.add(flag); + } + expect( + all.styles, + containsAll([ + 'italic', + 'bold', + 'underline', + 'strikethrough', + 'monospace', + 'link', + 'image', + 'highlight', + 'spoiler', + ]), + ); + }); + }); + + group('Block members', () { + final blocks = markdownDecoder.convert(_doc).blocks; + T pick() => blocks.whereType().first; + + test('every block type is produced', () { + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + expect(blocks.whereType(), isNotEmpty); + }); + + test('type getters', () { + expect(pick().type, 'heading'); + expect(pick().type, 'paragraph'); + expect(pick().type, 'quote'); + expect(pick().type, 'alert'); + expect(pick().type, 'code'); + expect(pick().type, 'list'); + expect(pick().type, 'table'); + expect(pick().type, 'divider'); + expect(pick().type, 'spacer'); + }); + + test('toString equals text', () { + for (final block in blocks) { + expect(block.toString(), block.text); + } + }); + + test('maybeMap dispatches to the matching branch', () { + expect( + pick().maybeMap(heading: (h) => h.level, orElse: (_) => -1), + 1, + ); + expect( + pick().maybeMap(code: (c) => c.language, orElse: (_) => null), + 'dart', + ); + // Unhandled branch falls through to orElse. + expect( + pick().maybeMap(code: (_) => 'x', orElse: (_) => 'else'), + 'else', + ); + }); + + test('alert exposes its type and title', () { + final alert = pick(); + expect(alert.alert, MD$AlertType.note); + expect(alert.alert.title, 'Note'); + }); + + test('divider text is a rule', () { + expect(pick().text, '---'); + }); + + test('spacer text is newlines', () { + final spacer = pick(); + expect(spacer.text, '\n' * spacer.count); + }); + }); + + group('MD\$AlertType', () { + test('tryParse is case-insensitive and rejects unknowns', () { + expect(MD$AlertType.tryParse('note'), MD$AlertType.note); + expect(MD$AlertType.tryParse('Warning'), MD$AlertType.warning); + expect(MD$AlertType.tryParse('nope'), isNull); + }); + + test('every type has a marker and a title', () { + for (final type in MD$AlertType.values) { + expect(type.marker, isNotEmpty); + expect(type.title, isNotEmpty); + } + }); + }); + + group('MD\$ListItem', () { + final list = markdownDecoder + .convert('- a with **bold**\n - b\n - c') + .blocks + .single as MD$List; + + test('toString renders nested children with indentation', () { + final rendered = list.items.single.toString(); + expect(rendered, contains('a with')); + expect(rendered, contains('b')); + expect(rendered, contains('c')); + expect(rendered, contains('\n')); + }); + + test('toString of a childless item is its text', () { + const leaf = MD$ListItem(marker: '-', text: 'leaf', spans: []); + expect(leaf.toString(), 'leaf'); + }); + + test('copyWith overrides only provided fields', () { + final item = list.items.single; + final copy = item.copyWith( + marker: '*', + checked: true, + children: const [], + ); + expect(copy.marker, '*'); + expect(copy.checked, isTrue); + expect(copy.isTask, isTrue); + expect(copy.children, isEmpty); + // Untouched fields are preserved. + expect(copy.text, item.text); + expect(copy.indent, item.indent); + }); + + test('copyWith without children keeps the originals', () { + final item = list.items.single; + final copy = item.copyWith(indent: 9); + expect(copy.indent, 9); + expect(copy.children, same(item.children)); + expect(copy.marker, item.marker); + }); + }); + + group('MD\$TableRow', () { + test('toString returns the row text', () { + final table = markdownDecoder + .convert('| x | y |\n| - | - |\n| 1 | 2 |') + .blocks + .single as MD$Table; + expect(table.header.toString(), table.header.text); + expect(table.header.toString(), contains('x')); + }); + }); + + group('MD\$Table.alignmentFor', () { + test('returns none out of range and the value in range', () { + final table = markdownDecoder + .convert('| L | R |\n| :-- | --: |\n| 1 | 2 |') + .blocks + .single as MD$Table; + expect(table.alignmentFor(0), MD$TableColumnAlign.left); + expect(table.alignmentFor(1), MD$TableColumnAlign.right); + expect(table.alignmentFor(99), MD$TableColumnAlign.none); + expect(table.alignmentFor(-1), MD$TableColumnAlign.none); + }); + }); + + group('Markdown facade', () { + test('isEmpty / isNotEmpty', () { + expect(const Markdown.empty().isEmpty, isTrue); + expect(const Markdown.empty().isNotEmpty, isFalse); + final md = markdownDecoder.convert('hello'); + expect(md.isNotEmpty, isTrue); + expect(md.isEmpty, isFalse); + }); + + test('toString returns the original source', () { + expect(markdownDecoder.convert('# Title').toString(), '# Title'); + }); + + test('text flattens list item spans', () { + final text = markdownDecoder.convert('- alpha\n- beta').text; + expect(text, contains('alpha')); + expect(text, contains('beta')); + }); + + test('empty markdown text falls back to the source', () { + expect(const Markdown.empty().text, isEmpty); + }); + + test('text traverses every block type without error', () { + final text = markdownDecoder.convert(_doc).text; + expect(text, contains('Heading')); + expect(text, contains('Alert body')); + expect(text, contains('code();')); + }); + }); +} diff --git a/test/parser/block_test.dart b/test/parser/block_test.dart new file mode 100644 index 0000000..652cce4 --- /dev/null +++ b/test/parser/block_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _blocks(String input) => markdownDecoder.convert(input).blocks; + +void main() => group('Block parsing', () { + group('Headings', () { + test('levels 1 through 6', () { + for (var level = 1; level <= 6; level++) { + final block = _blocks('${'#' * level} Title').single as MD$Heading; + expect(block.level, level); + expect(block.text, 'Title'); + } + }); + + test('heading without a space is a paragraph', () { + expect(_blocks('#hashtag').single, isA()); + }); + + test('seven or more hashes is a paragraph', () { + expect(_blocks('####### seven').single, isA()); + }); + + test('bare hash is an empty heading', () { + final h = _blocks('#').single as MD$Heading; + expect(h.level, 1); + expect(h.text, isEmpty); + }); + + test('trailing hashes are stripped', () { + expect( + (_blocks('## Heading ##').single as MD$Heading).text, 'Heading'); + expect((_blocks('### Title ###').single as MD$Heading).text, 'Title'); + }); + + test('inline styles inside a heading are parsed', () { + final h = _blocks('# Title with **bold**').single as MD$Heading; + expect( + h.spans, + contains(isA() + .having((s) => s.text, 'text', 'bold') + .having((s) => s.style, 'style', MD$Style.bold)), + ); + }); + }); + + group('Blockquotes', () { + test('single line quote', () { + expect(_blocks('> quoted').single, isA()); + }); + + test('multi-line quote is merged', () { + final q = _blocks('> A\n> B\n> C').single as MD$Quote; + expect(q.text, 'A\nB\nC'); + }); + + test('quote with inline styles', () { + final q = _blocks('> quote with **bold**').single as MD$Quote; + expect( + q.spans, + contains( + isA().having((s) => s.style, 'style', MD$Style.bold)), + ); + }); + }); + + group('Fenced code', () { + test('backtick fence with language', () { + final code = + _blocks('```dart\nvoid main() {}\n```').single as MD$Code; + expect(code.language, 'dart'); + expect(code.text, 'void main() {}'); + }); + + test('tilde fence is supported', () { + final code = _blocks('~~~python\nprint(1)\n~~~').single as MD$Code; + expect(code.language, 'python'); + expect(code.text, 'print(1)'); + }); + + test('code content is never interpreted as markdown', () { + final code = _blocks('```\n# not a heading\n- not a list\n```').single + as MD$Code; + expect(code.text, '# not a heading\n- not a list'); + }); + + test('multi-line code preserves line breaks', () { + final code = _blocks('```\na\nb\nc\n```').single as MD$Code; + expect(code.text, 'a\nb\nc'); + }); + + test('empty language for a bare fence', () { + final code = _blocks('```\nx\n```').single as MD$Code; + expect(code.language, isEmpty); + }); + }); + + group('Lists', () { + test('unordered markers -, *, + all parse', () { + for (final marker in ['-', '*', '+']) { + final list = _blocks('$marker item').single as MD$List; + expect(list.items, hasLength(1)); + expect(list.items.single.text, 'item'); + } + }); + + test('ordered list preserves its markers', () { + final list = _blocks('1. one\n2. two\n3. three').single as MD$List; + expect(list.items, hasLength(3)); + expect(list.items.map((i) => i.marker).toList(), ['1.', '2.', '3.']); + }); + + test('nested unordered list', () { + final list = _blocks( + '- a\n' + '- b\n' + ' - b1\n' + ' - b2\n' + '- c', + ).single as MD$List; + expect(list.items, hasLength(3)); + expect(list.items[1].children, hasLength(2)); + }); + + test('inline styles inside list items', () { + final list = _blocks('- item with *italic*').single as MD$List; + expect( + list.items.single.spans, + contains(isA() + .having((s) => s.style, 'style', MD$Style.italic)), + ); + }); + + test('list items may contain links', () { + final list = _blocks('- see [docs](https://x.com)').single as MD$List; + expect( + list.items.single.spans, + contains( + isA().having((s) => s.style, 'style', MD$Style.link)), + ); + }); + }); + + group('Tables', () { + test('basic table with header and rows', () { + final table = _blocks( + '| Name | Age |\n' + '| ---- | --- |\n' + '| Bob | 30 |\n' + '| Ann | 25 |', + ).single as MD$Table; + expect(table.header.cells, hasLength(2)); + expect(table.rows, hasLength(2)); + }); + + test('table cells parse inline styles', () { + final table = _blocks( + '| A | B |\n' + '| - | - |\n' + '| **x** | _y_ |', + ).single as MD$Table; + final firstCell = table.rows.single.cells.first; + expect( + firstCell, + contains( + isA().having((s) => s.style, 'style', MD$Style.bold)), + ); + }); + + test('a table without a delimiter row is not a table', () { + expect( + _blocks('| a | b |\n| c | d |').every((b) => b is! MD$Table), + isTrue, + ); + }); + }); + + group('Spacers & paragraphs', () { + test('consecutive blank lines collapse into one spacer', () { + final blocks = _blocks('a\n\n\n\nb'); + expect(blocks.whereType(), hasLength(1)); + expect(blocks.whereType().single.count, 3); + }); + + test('soft line breaks are preserved within a paragraph', () { + final p = _blocks('line one\nline two').single as MD$Paragraph; + expect(p.text, 'line one\nline two'); + }); + + test('a blank line separates two paragraphs', () { + final blocks = _blocks('first\n\nsecond'); + expect(blocks.whereType(), hasLength(2)); + }); + }); + }); diff --git a/test/parser/edge_cases_test.dart b/test/parser/edge_cases_test.dart new file mode 100644 index 0000000..a4b1150 --- /dev/null +++ b/test/parser/edge_cases_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Asserts that parsing [input] does not throw and yields a [Markdown]. +void _parsesCleanly(String input) { + expect(() => markdownDecoder.convert(input), returnsNormally); + expect(markdownDecoder.convert(input), isA()); +} + +void main() => group('Edge cases & robustness', () { + group('Does not crash on pathological input', () { + final inputs = { + 'empty': '', + 'only whitespace': ' \t ', + 'only newlines': '\n\n\n', + 'unterminated code fence': '```dart\nvoid main() {}', + 'unterminated tilde fence': '~~~\ncode', + 'unterminated link': '[label](http://x.com', + 'unterminated image': '![alt](', + 'unclosed label bracket': '[unclosed and [real](http://x.com)', + 'lonely markers': '* _ ~ = | ` #', + 'many hashes': '#############', + 'nested quotes': '> outer\n>> inner\n>>> deepest', + 'ragged table': '| a | b |\n|---|---|\n| 1 |\n| x | y |', + 'table missing trailing pipe': '| a | b\n| --- | --- |\n| 1 | 2', + 'pipes without table': 'a | b | c', + 'deeply nested list': '- a\n - b\n - c\n - d\n' + ' - e\n - f', + 'non-monotonic list indent': '- a\n - b\n - c', + 'mixed tabs and spaces list': '- a\n\t- b\n - c', + 'crlf line endings': 'line one\r\nline two\r\n\r\nsecond', + 'trailing whitespace': 'text with trailing \nnext line', + 'huge emphasis run': '*' * 200, + 'all special chars': r'*_~=|`#>-+.![]()\{}', + 'backslash at end': 'text\\', + 'lone backslash': '\\', + }; + for (final entry in inputs.entries) { + test(entry.key, () => _parsesCleanly(entry.value)); + } + }); + + group('Unicode & emoji', () { + test('cyrillic text parses and round-trips', () { + final md = markdownDecoder.convert('Привет **мир**'); + expect(md.text, contains('Привет')); + expect(md.text, contains('мир')); + }); + + test('emoji are preserved', () { + final md = markdownDecoder.convert('Hello 👋 world 🌍'); + expect(md.text, contains('👋')); + expect(md.text, contains('🌍')); + }); + + test('cyrillic underscores are not intraword emphasis', () { + final md = markdownDecoder.convert('переменная_значение_тут'); + final spans = (md.blocks.single as MD$Paragraph).spans; + expect(spans.every((s) => s.style.isEmpty), isTrue); + expect(spans.map((s) => s.text).join(), 'переменная_значение_тут'); + }); + + test('emphasis works around unicode content', () { + final md = markdownDecoder.convert('**жирный**'); + expect((md.blocks.single as MD$Paragraph).spans.single.style, + MD$Style.bold); + }); + }); + + group('Boundary conditions', () { + test('single character', () => _parsesCleanly('a')); + + test('very long single-line paragraph', () { + _parsesCleanly('word ' * 5000); + }); + + test('many blocks', () { + final buffer = StringBuffer(); + for (var i = 0; i < 500; i++) { + buffer.writeln('# Heading $i'); + buffer.writeln('Paragraph $i with **bold** and `code`.'); + buffer.writeln(); + } + _parsesCleanly(buffer.toString()); + }); + + test('markdown reference round-trips via the markdown field', () { + const source = '# Title\n\nBody with **bold**.'; + expect(markdownDecoder.convert(source).markdown, source); + }); + + test('empty input yields no blocks', () { + expect(markdownDecoder.convert('').blocks, isEmpty); + expect(markdownDecoder.convert('').isEmpty, isTrue); + }); + }); + + group('Whitespace-only markers stay literal', () { + test('a line of only pipes is not a table', () { + expect( + markdownDecoder.convert('|||').blocks.every((b) => b is! MD$Table), + isTrue, + ); + }); + + test('a lone hash line with text after space is a heading', () { + expect( + markdownDecoder.convert('# ok').blocks.single, isA()); + }); + }); + }); diff --git a/test/parser/gfm_test.dart b/test/parser/gfm_test.dart new file mode 100644 index 0000000..81da1c9 --- /dev/null +++ b/test/parser/gfm_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Concatenates the text of every span in a block. +String _spanText(List spans) => spans.map((s) => s.text).join(); + +void main() => group('GFM extensions', () { + group('Alerts', () { + test('parses every alert type', () { + const cases = { + 'NOTE': MD$AlertType.note, + 'TIP': MD$AlertType.tip, + 'IMPORTANT': MD$AlertType.important, + 'WARNING': MD$AlertType.warning, + 'CAUTION': MD$AlertType.caution, + }; + for (final entry in cases.entries) { + final md = markdownDecoder + .convert('> [!${entry.key}]\n> Body of the alert.'); + expect(md.blocks, hasLength(1), + reason: 'alert ${entry.key} is a single block'); + expect( + md.blocks.single, + isA() + .having((a) => a.alert, 'alert', entry.value) + .having( + (a) => _spanText(a.spans), 'body', 'Body of the alert.'), + ); + } + }); + + test('alert markers are case-insensitive', () { + for (final marker in ['note', 'Note', 'nOtE']) { + final md = markdownDecoder.convert('> [!$marker]\n> Text'); + expect(md.blocks.single, isA()); + expect((md.blocks.single as MD$Alert).alert, MD$AlertType.note); + } + }); + + test('marker-only alert has an empty body', () { + final md = markdownDecoder.convert('> [!WARNING]'); + expect( + md.blocks.single, + isA() + .having((a) => a.alert, 'alert', MD$AlertType.warning) + .having((a) => a.spans, 'spans', isEmpty), + ); + }); + + test('multi-line alert body preserves inline styles and links', () { + final md = markdownDecoder.convert( + '> [!TIP]\n' + '> First line with **bold**\n' + '> and a [link](https://example.com).', + ); + final alert = md.blocks.single as MD$Alert; + expect(alert.alert, MD$AlertType.tip); + expect(_spanText(alert.spans), contains('bold')); + expect( + alert.spans, + contains(isA() + .having((s) => s.style.contains(MD$Style.bold), 'bold', true)), + ); + expect( + alert.spans, + contains(isA() + .having((s) => s.style.contains(MD$Style.link), 'link', true)), + ); + }); + + test('unknown alert marker falls back to a plain quote', () { + final md = markdownDecoder.convert('> [!FOOBAR]\n> body'); + expect(md.blocks.single, isA()); + }); + + test('regular blockquote is not treated as an alert', () { + final md = markdownDecoder.convert('> just a normal quote'); + expect(md.blocks.single, isA()); + }); + + test('alert type exposes a human-readable title', () { + expect(MD$AlertType.note.title, 'Note'); + expect(MD$AlertType.important.title, 'Important'); + expect(MD$AlertType.caution.title, 'Caution'); + }); + + test('MD\$AlertType.tryParse is case-insensitive and safe', () { + expect(MD$AlertType.tryParse('tip'), MD$AlertType.tip); + expect(MD$AlertType.tryParse('WARNING'), MD$AlertType.warning); + expect(MD$AlertType.tryParse('nope'), isNull); + }); + }); + + group('Task lists', () { + test('unchecked task item', () { + final md = markdownDecoder.convert('- [ ] todo'); + final list = md.blocks.single as MD$List; + expect(list.items, hasLength(1)); + final item = list.items.single; + expect(item.checked, isFalse); + expect(item.isTask, isTrue); + expect(item.text, 'todo'); + }); + + test('checked task item (lowercase and uppercase x)', () { + for (final input in ['- [x] done', '- [X] done']) { + final list = + markdownDecoder.convert(input).blocks.single as MD$List; + expect(list.items.single.checked, isTrue); + expect(list.items.single.text, 'done'); + } + }); + + test('empty brackets are not a task item', () { + final list = + markdownDecoder.convert('- [] literal').blocks.single as MD$List; + expect(list.items.single.checked, isNull); + expect(list.items.single.isTask, isFalse); + expect(list.items.single.text, '[] literal'); + }); + + test('checkbox with no label yields empty text', () { + final list = + markdownDecoder.convert('- [ ]').blocks.single as MD$List; + expect(list.items.single.checked, isFalse); + expect(list.items.single.text, isEmpty); + }); + + test('mixed task and non-task items in one list', () { + final list = markdownDecoder + .convert('- [ ] a\n- [x] b\n- normal') + .blocks + .single as MD$List; + expect(list.items.map((i) => i.checked).toList(), + [false, true, null]); + }); + + test('nested task items keep their state', () { + final list = markdownDecoder + .convert('- [ ] parent\n - [x] child') + .blocks + .single as MD$List; + expect(list.items.single.checked, isFalse); + expect(list.items.single.children, hasLength(1)); + expect(list.items.single.children.single.checked, isTrue); + }); + + test('ordered task items are supported', () { + final list = markdownDecoder + .convert('1. [x] first\n2. [ ] second') + .blocks + .single as MD$List; + expect( + list.items.map((i) => i.checked).toList(), [true, false]); + }); + }); + + group('Thematic breaks', () { + for (final input in ['---', '***', '___', '- - -', '* * *', '_ _ _']) { + test('"$input" is a divider', () { + expect(markdownDecoder.convert(input).blocks.single, + isA()); + }); + } + + test('four or more markers still form a divider', () { + expect( + markdownDecoder.convert('----').blocks.single, isA()); + expect(markdownDecoder.convert('**********').blocks.single, + isA()); + }); + + test('marker followed by text is NOT a divider', () { + expect(markdownDecoder.convert('----text').blocks.single, + isA()); + }); + + test('fewer than three markers is NOT a divider', () { + expect( + markdownDecoder.convert('--').blocks.single, isA()); + expect( + markdownDecoder.convert('**').blocks.single, isA()); + }); + + test('mixed markers are NOT a divider', () { + expect(markdownDecoder.convert('-*-').blocks.single, + isA()); + }); + + test('up to three leading spaces are allowed', () { + expect(markdownDecoder.convert(' ---').blocks.single, + isA()); + }); + }); + + group('Table alignment', () { + test('captures left / center / right from the delimiter row', () { + final table = markdownDecoder + .convert('| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |') + .blocks + .single as MD$Table; + expect(table.alignments, [ + MD$TableColumnAlign.left, + MD$TableColumnAlign.center, + MD$TableColumnAlign.right, + ]); + expect(table.alignmentFor(0), MD$TableColumnAlign.left); + expect(table.alignmentFor(2), MD$TableColumnAlign.right); + }); + + test('plain dashes yield no alignment', () { + final table = markdownDecoder + .convert('|a|b|\n|---|---|\n|1|2|') + .blocks + .single as MD$Table; + expect( + table.alignments, everyElement(equals(MD$TableColumnAlign.none))); + }); + + test('alignmentFor is safe for out-of-range indices', () { + final table = markdownDecoder.convert('|a|\n|:-:|\n|1|').blocks.single + as MD$Table; + expect(table.alignmentFor(5), MD$TableColumnAlign.none); + expect(table.alignmentFor(-1), MD$TableColumnAlign.none); + }); + + test('invalid delimiter row is not a table', () { + final md = markdownDecoder.convert('|a|b|\n|xx|yy|\n|1|2|'); + expect(md.blocks.every((b) => b is! MD$Table), isTrue); + }); + }); + }); diff --git a/test/parser/golden_test.dart b/test/parser/golden_test.dart new file mode 100644 index 0000000..0470619 --- /dev/null +++ b/test/parser/golden_test.dart @@ -0,0 +1,329 @@ +// Golden characterization test for the Markdown parser. +// +// This test freezes the *exact* structural output of the parser for a diverse +// corpus of inputs. Its purpose is to guard performance refactors: any change +// that alters the produced AST (blocks, spans, styles, offsets, extras) will +// fail here immediately, even if the higher-level behavioural tests miss it. +// +// The expected snapshot lives in `parser_golden.txt` next to this file. When a +// change to parser output is *intentional*, regenerate it with: +// +// ```shell +// REGEN_GOLDEN=1 flutter test test/parser/golden_test.dart +// ``` +// +// Then review the diff carefully before committing. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Path to the frozen snapshot, relative to the package root (the working +/// directory used by `flutter test`). +const String _goldenPath = 'test/parser/parser_golden.txt'; + +/// Section delimiter prefix inside the golden file. +const String _sectionPrefix = '@@@ '; + +void main() { + final actual = _buildSections(); + + // Bootstrap or regenerate the golden file on request (or first run). + final file = File(_goldenPath); + final regen = Platform.environment['REGEN_GOLDEN'] == '1'; + if (regen || !file.existsSync()) { + file.writeAsStringSync(_encode(actual)); + // ignore: avoid_print + print('Wrote golden snapshot to $_goldenPath (${actual.length} entries).'); + } + + final expected = _decode(File(_goldenPath).readAsStringSync()); + + group('Golden parser snapshot', () { + test('corpus keys are unchanged', () { + expect(actual.keys, expected.keys, + reason: 'Corpus changed; regenerate with REGEN_GOLDEN=1.'); + }); + + for (final name in actual.keys) { + test(name, () { + expect( + actual[name], + expected[name], + reason: 'Parser output for "$name" changed vs the golden snapshot. ' + 'If intentional, regenerate with REGEN_GOLDEN=1.', + ); + }); + } + }); +} + +/// Builds the serialized output for every corpus entry. +Map _buildSections() { + final out = {}; + for (final entry in _corpus.entries) { + out[entry.key] = _serialize(markdownDecoder.convert(entry.value)); + } + return out; +} + +/// Encodes the section map into a single golden-file string. +String _encode(Map sections) { + final buffer = StringBuffer(); + for (final entry in sections.entries) { + buffer + ..write(_sectionPrefix) + ..writeln(entry.key) + ..writeln(entry.value); + } + return buffer.toString(); +} + +/// Decodes a golden-file string back into a section map. +Map _decode(String raw) { + final sections = {}; + String? current; + final body = StringBuffer(); + void flush() { + final key = current; + if (key != null) { + var text = body.toString(); + if (text.endsWith('\n')) text = text.substring(0, text.length - 1); + sections[key] = text; + } + body.clear(); + } + + for (final line in const LineSplitter().convert(raw)) { + if (line.startsWith(_sectionPrefix)) { + flush(); + current = line.substring(_sectionPrefix.length); + } else { + body.writeln(line); + } + } + flush(); + return sections; +} + +// --------------------------------------------------------------------------- +// Serialization: a compact, deterministic textual form of the whole AST. +// --------------------------------------------------------------------------- + +/// Serializes a [Markdown] document into a canonical multi-line string. +String _serialize(Markdown md) { + final buffer = StringBuffer(); + for (final block in md.blocks) { + _serializeBlock(block, buffer, 0); + } + return buffer.toString().trimRight(); +} + +void _serializeBlock(MD$Block block, StringBuffer out, int depth) { + final pad = ' ' * depth; + block.map( + paragraph: (p) { + out.writeln('${pad}P'); + _serializeSpans(p.spans, out, depth + 1); + }, + heading: (h) { + out.writeln('${pad}H${h.level}'); + _serializeSpans(h.spans, out, depth + 1); + }, + quote: (q) { + out.writeln('${pad}Q indent=${q.indent}'); + _serializeSpans(q.spans, out, depth + 1); + }, + alert: (a) { + out.writeln('${pad}ALERT ${a.alert.name}'); + _serializeSpans(a.spans, out, depth + 1); + }, + code: (c) { + out.writeln('${pad}CODE lang=${_q(c.language ?? '')} ' + 'text=${_q(c.text)}'); + }, + list: (l) { + out.writeln('${pad}LIST'); + for (final item in l.items) { + _serializeItem(item, out, depth + 1); + } + }, + table: (t) { + final aligns = t.alignments.map((a) => a.name).join(','); + out.writeln('${pad}TABLE aligns=[$aligns]'); + out.writeln('$pad header'); + _serializeRow(t.header, out, depth + 2); + for (final row in t.rows) { + out.writeln('$pad row'); + _serializeRow(row, out, depth + 2); + } + }, + divider: (d) => out.writeln('${pad}HR'), + spacer: (s) => out.writeln('${pad}SPACER count=${s.count}'), + ); +} + +void _serializeItem(MD$ListItem item, StringBuffer out, int depth) { + final pad = ' ' * depth; + out.writeln('${pad}ITEM marker=${_q(item.marker)} ' + 'indent=${item.indent} checked=${item.checked}'); + _serializeSpans(item.spans, out, depth + 1); + for (final child in item.children) { + _serializeItem(child, out, depth + 1); + } +} + +void _serializeRow(MD$TableRow row, StringBuffer out, int depth) { + final pad = ' ' * depth; + for (var i = 0; i < row.cells.length; i++) { + out.writeln('${pad}cell$i'); + _serializeSpans(row.cells[i], out, depth + 1); + } +} + +void _serializeSpans(List spans, StringBuffer out, int depth) { + final pad = ' ' * depth; + for (final span in spans) { + out.writeln('$pad${_span(span)}'); + } +} + +String _span(MD$Span s) { + final buffer = StringBuffer() + ..write('[${s.start},${s.end}] style=${s.style.value} ${_q(s.text)}'); + final extra = s.extra; + if (extra != null && extra.isNotEmpty) { + final keys = extra.keys.toList(growable: false)..sort(); + buffer.write(' {'); + for (var i = 0; i < keys.length; i++) { + if (i > 0) buffer.write(', '); + buffer.write('${keys[i]}=${_q('${extra[keys[i]]}')}'); + } + buffer.write('}'); + } + return buffer.toString(); +} + +/// Quotes and escapes a string so newlines/tabs stay on a single line. +String _q(String s) { + final escaped = s + .replaceAll(r'\', r'\\') + .replaceAll('\n', r'\n') + .replaceAll('\t', r'\t') + .replaceAll('"', r'\"'); + return '"$escaped"'; +} + +// --------------------------------------------------------------------------- +// Corpus: a broad set of inputs exercising every parser code path and the +// tricky corner cases that optimizations must not regress. +// --------------------------------------------------------------------------- + +const Map _corpus = { + // Plain prose (the fast-path target). + 'prose-simple': 'The quick brown fox jumps over the lazy dog.', + 'prose-multiline': 'First line of a paragraph\nsecond line here\nthird line.', + 'prose-two-paragraphs': 'Paragraph one.\n\nParagraph two.', + 'prose-operators': '5 * 6 = 30 and 3 < 4 and a_b_c and x > y stay literal.', + 'prose-unicode': 'Привет мир 👋 and 日本語 text 🌍 mixed together.', + + // Emphasis. + 'em-bold': 'This is **bold** text.', + 'em-italic-star': 'This is *italic* text.', + 'em-italic-underscore': 'This is _italic_ text.', + 'em-underline': 'This is __underline__ text.', + 'em-strike': 'This is ~~strike~~ text.', + 'em-highlight': 'This is ==highlight== text.', + 'em-spoiler': 'This is ||spoiler|| text.', + 'em-code': 'This is `code` text.', + 'em-nested': '**bold _and italic_ together**', + 'em-adjacent': '*a**b**c*', + 'em-unterminated-star': '**bold never closed', + 'em-unterminated-single': 'a * lonely asterisk', + 'em-intraword-underscore': 'snake_case and object_id remain literal', + 'em-cyrillic-underscore': 'переменная_значение_тут', + 'em-mixed-line': '**b** *i* __u__ ~~s~~ `c` ==h== ||sp||', + 'em-unterminated-code': 'a `code span never closed', + 'em-double-backtick': 'use ``double`` backticks', + + // Links & images. + 'link-simple': '[text](https://example.com)', + 'link-title': '[text](https://example.com "the title")', + 'link-single-quote-title': "[text](https://example.com 'the title')", + 'link-angle': '[text]()', + 'link-in-text': 'See [the docs](https://x.io) for more.', + 'link-emphasis': 'A **[bold link](https://x.io)** here.', + 'image-simple': '![alt](https://example.com/i.png)', + 'image-title': '![alt](https://example.com/i.png "cap")', + 'link-nested-parens': '[t](https://x.io/path(with)parens)', + 'link-two': '[a](b) and [c](d)', + + // Escapes. + 'escape-emphasis': r'\*not italic\* and \_not underline\_', + 'escape-backtick': r'\`not code\`', + 'escape-brackets': r'\[not a link\](nope)', + 'escape-backslash-eol': 'trailing backslash\\', + 'escape-lone': '\\', + + // Inline math. + 'math-alpha': r'The angle $\alpha$ is small.', + 'math-currency': r'It costs $5 and $10 today.', + 'math-in-code': r'`$\alpha$` stays literal in code.', + 'math-mixed': r'$\pi \approx 3.14$ but $plain$ is untouched.', + + // Headings. + 'heading-h1': '# Heading one', + 'heading-h6': '###### Heading six', + 'heading-closing': '## Heading ##', + 'heading-no-space': '#hashtag is not a heading', + 'heading-seven': '####### too many hashes', + 'heading-empty': '#', + + // Quotes & alerts. + 'quote-simple': '> quoted text', + 'quote-multiline': '> line one\n> line two', + 'alert-note': '> [!NOTE]\n> Take note of this.', + 'alert-tip': '> [!TIP]\n> A helpful tip.', + 'alert-important': '> [!IMPORTANT]\n> Important detail.', + 'alert-warning': '> [!WARNING]\n> A warning here.', + 'alert-caution': '> [!CAUTION]\n> Be cautious.', + 'alert-lowercase': '> [!note]\n> lowercase marker.', + 'alert-with-emphasis': '> [!NOTE]\n> Body with **bold** and `code`.', + + // Code blocks. + 'code-fenced': '```dart\nvoid main() {}\n```', + 'code-tilde': '~~~\nplain code\n~~~', + 'code-no-lang': '```\nno language\n```', + 'code-unterminated': '```dart\nvoid main() {}', + + // Lists. + 'list-unordered': '- one\n- two\n- three', + 'list-ordered': '1. one\n2. two\n3. three', + 'list-nested': '- a\n - b\n - c\n- d', + 'list-task': '- [ ] todo\n- [x] done\n- [X] also done', + 'list-mixed-marker': '* star\n+ plus\n- dash', + 'list-with-inline': '- item with **bold** and [link](https://x.io)', + + // Tables. + 'table-basic': '| a | b |\n| --- | --- |\n| 1 | 2 |', + 'table-aligned': '| L | C | R |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |', + 'table-inline': '| a | b |\n| --- | --- |\n| **x** | `y` |', + 'table-malformed-ragged': '| a | b |\n| --- | --- |\n| 1 |', + 'table-no-delimiter': '| a | b |\n| 1 | 2 |', + + // Thematic breaks & spacers. + 'hr-dash': '---', + 'hr-star': '***', + 'hr-underscore': '___', + 'hr-spaced': '- - -', + 'hr-between': 'above\n\n---\n\nbelow', + 'spacer-multi': 'a\n\n\n\nb', + + // Whole documents / robustness. + 'doc-mixed': '# Title\n\nA **para** with `code`.\n\n> quote\n\n- x\n- y\n\n' + '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n---\n\nDone.', + 'empty': '', + 'whitespace-only': ' \t ', + 'newlines-only': '\n\n\n', +}; diff --git a/test/parser/inline_test.dart b/test/parser/inline_test.dart new file mode 100644 index 0000000..04abf79 --- /dev/null +++ b/test/parser/inline_test.dart @@ -0,0 +1,223 @@ +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Parses [input] and returns the spans of its single paragraph block. +List _spans(String input) { + final md = markdownDecoder.convert(input); + return (md.blocks.single as MD$Paragraph).spans; +} + +/// The concatenated visible text of a single-paragraph [input]. +String _visible(String input) => _spans(input).map((s) => s.text).join(); + +/// The style of the (single) span whose text equals [text]. +MD$Style _styleOf(List spans, String text) => + spans.firstWhere((s) => s.text == text).style; + +void main() => group('Inline parsing', () { + group('Basic emphasis', () { + test('italic with *', () { + final spans = _spans('*italic*'); + expect(spans.single.text, 'italic'); + expect(spans.single.style, MD$Style.italic); + }); + + test('italic with _', () { + expect(_spans('_italic_').single.style, MD$Style.italic); + }); + + test('bold with **', () { + final spans = _spans('**bold**'); + expect(spans.single.text, 'bold'); + expect(spans.single.style, MD$Style.bold); + }); + + test('underline with __', () { + expect(_spans('__under__').single.style, MD$Style.underline); + }); + + test('strikethrough with ~~', () { + expect(_spans('~~strike~~').single.style, MD$Style.strikethrough); + }); + + test('highlight with ==', () { + expect(_spans('==mark==').single.style, MD$Style.highlight); + }); + + test('spoiler with ||', () { + expect(_spans('||hidden||').single.style, MD$Style.spoiler); + }); + + test('monospace with backticks', () { + expect(_spans('`code`').single.style, MD$Style.monospace); + }); + + test('all inline styles in one line', () { + final spans = _spans('*i* **b** _u_ __U__ ~~s~~ ==h== ||sp|| `c`'); + expect(_styleOf(spans, 'i'), MD$Style.italic); + expect(_styleOf(spans, 'b'), MD$Style.bold); + expect(_styleOf(spans, 'u'), MD$Style.italic); + expect(_styleOf(spans, 'U'), MD$Style.underline); + expect(_styleOf(spans, 's'), MD$Style.strikethrough); + expect(_styleOf(spans, 'h'), MD$Style.highlight); + expect(_styleOf(spans, 'sp'), MD$Style.spoiler); + expect(_styleOf(spans, 'c'), MD$Style.monospace); + }); + }); + + group('Combined & nested styles', () { + test('bold inside italic', () { + final spans = _spans('_a **b** c_'); + expect(_styleOf(spans, 'b').contains(MD$Style.italic), isTrue); + expect(_styleOf(spans, 'b').contains(MD$Style.bold), isTrue); + }); + + test('rich combination keeps every style', () { + final spans = _spans('_`You` **can** __combine__ ~~them~~_'); + expect(_styleOf(spans, 'You').contains(MD$Style.monospace), isTrue); + expect(_styleOf(spans, 'You').contains(MD$Style.italic), isTrue); + expect(_styleOf(spans, 'can').contains(MD$Style.bold), isTrue); + expect( + _styleOf(spans, 'combine').contains(MD$Style.underline), isTrue); + expect( + _styleOf(spans, 'them').contains(MD$Style.strikethrough), isTrue); + }); + }); + + group('Stray & unterminated markers stay literal', () { + test('lone asterisk (multiplication) is not emphasis', () { + expect(_visible('5 * 6 = 30'), '5 * 6 = 30'); + expect(_spans('5 * 6 = 30').every((s) => s.style.isEmpty), isTrue); + }); + + test('unterminated bold does not leak', () { + final spans = _spans('**bold never closed'); + expect(_visible('**bold never closed'), '**bold never closed'); + expect(spans.every((s) => s.style.isEmpty), isTrue); + }); + + test('unterminated strikethrough does not leak', () { + expect(_spans('~~strike me').every((s) => s.style.isEmpty), isTrue); + }); + + test('unterminated backtick does not leak monospace', () { + expect( + _spans('Use the `find command').every((s) => s.style.isEmpty), + isTrue, + ); + }); + + test('lone double markers surrounded by spaces are literal', () { + expect(_spans('a ~~ b').every((s) => s.style.isEmpty), isTrue); + expect(_spans('a == b').every((s) => s.style.isEmpty), isTrue); + }); + }); + + group('Intraword underscores (snake_case)', () { + test('single identifier is preserved', () { + expect(_visible('flutter_test_package'), 'flutter_test_package'); + expect( + _spans('flutter_test_package').every((s) => s.style.isEmpty), + isTrue, + ); + }); + + test('stray underscore mid-sentence does not italicize', () { + expect(_visible('the object_id value'), 'the object_id value'); + expect( + _spans('the object_id value').every((s) => s.style.isEmpty), + isTrue, + ); + }); + + test('asterisks still emphasize intraword', () { + // `*` (unlike `_`) may emphasize within a word. + final spans = _spans('a*b*c'); + expect(_styleOf(spans, 'b'), MD$Style.italic); + }); + }); + + group('Links & images', () { + test('basic link exposes url in extra', () { + final spans = _spans('[text](https://example.com)'); + expect(spans.single.style, MD$Style.link); + expect(spans.single.text, 'text'); + expect( + spans.single.extra, containsPair('url', 'https://example.com')); + }); + + test('link with double-quoted title', () { + final spans = _spans('[a](https://x.com "Title")'); + expect(spans.single.extra, containsPair('url', 'https://x.com')); + expect(spans.single.extra, containsPair('alt', 'Title')); + }); + + test('link with single-quoted title', () { + final spans = _spans("[a](https://x.com 'My Title')"); + expect(spans.single.extra, containsPair('alt', 'My Title')); + }); + + test('angle-bracketed url preserves spaces', () { + final spans = _spans('[a]()'); + expect(spans.single.extra, containsPair('url', 'my file.pdf')); + }); + + test('url containing balanced parentheses', () { + final spans = _spans('[a](https://x.com/a_(b)_c)'); + expect( + spans.single.extra, containsPair('url', 'https://x.com/a_(b)_c')); + }); + + test('image exposes src and image style', () { + final spans = _spans('![alt](https://x.com/i.png)'); + expect(spans.single.style, MD$Style.image); + expect( + spans.single.extra, containsPair('src', 'https://x.com/i.png')); + }); + + test('emphasis wrapping a link merges styles', () { + final spans = _spans('**[bold link](https://x.com)**'); + expect(spans.single.style.contains(MD$Style.link), isTrue); + expect(spans.single.style.contains(MD$Style.bold), isTrue); + }); + + test('link inside a longer sentence', () { + final spans = _spans('see [docs](https://x.com) now'); + expect(spans.map((s) => s.text).join(), 'see docs now'); + expect( + spans.firstWhere((s) => s.text == 'docs').style, + MD$Style.link, + ); + }); + }); + + group('Inline code is literal', () { + test('markdown inside code is not parsed', () { + final spans = _spans('`**not bold** _nor italic_`'); + expect(spans.single.style, MD$Style.monospace); + expect(spans.single.text, '**not bold** _nor italic_'); + }); + + test('code span among styled text', () { + final spans = _spans('run `dart test` please'); + expect( + spans.firstWhere((s) => s.text == 'dart test').style, + MD$Style.monospace, + ); + }); + }); + + group('Escaping', () { + test('escaped asterisks are literal', () { + expect(_visible(r'\*not italic\*'), '*not italic*'); + }); + + test('escaped backtick is literal', () { + expect(_visible(r'\`not code\`'), '`not code`'); + }); + + test('escaped underscore is literal', () { + expect(_visible(r'a\_b\_c'), 'a_b_c'); + }); + }); + }); diff --git a/test/parser/math_test.dart b/test/parser/math_test.dart new file mode 100644 index 0000000..862fa06 --- /dev/null +++ b/test/parser/math_test.dart @@ -0,0 +1,190 @@ +// Tests for the opt-in inline LaTeX math feature +// (`MarkdownDecoder(inlineMath: true)`). +// +// Math is disabled by default (see regression_test.dart for the off-by-default +// behaviour); this file exercises the enabled path: command substitution, +// super/subscripts, code protection, escaping, and configurability. +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A decoder with inline math enabled. +const MarkdownDecoder _math = MarkdownDecoder(inlineMath: true); + +/// Concatenated span text of the first paragraph, using [decoder]. +String _render(String input, [MarkdownDecoder decoder = _math]) { + final blocks = decoder.convert(input).blocks; + final paragraph = blocks.whereType().first; + return paragraph.spans.map((s) => s.text).join(); +} + +List _spans(String input, [MarkdownDecoder decoder = _math]) => + (decoder.convert(input).blocks.first as MD$Paragraph).spans; + +void main() { + group('Commands', () { + test('single greek command', () { + expect(_render(r'$\alpha$'), 'α'); + }); + + test('command within a sentence', () { + expect(_render(r'A $\rightarrow$ B'), 'A → B'); + }); + + test('multiple commands in one expression', () { + expect(_render(r'$\alpha + \beta \leq \gamma$'), 'α + β ≤ γ'); + }); + + test('newly added symbols convert', () { + expect(_render(r'$\implies$'), '⟹'); + expect(_render(r'$\perp$'), '⊥'); + expect(_render(r'$\oplus$'), '⊕'); + expect(_render(r'$\hbar$'), 'ℏ'); + expect(_render(r'$\therefore$'), '∴'); + }); + + test('unknown command is left literal', () { + expect(_render(r'$\foobar$'), r'$\foobar$'); + }); + + test('two separate math spans on one line', () { + expect(_render(r'$\alpha$ and $\beta$'), 'α and β'); + }); + }); + + group('Superscripts and subscripts', () { + test('single-digit superscript', () { + expect(_render(r'$x^2$'), 'x²'); + }); + + test('single-digit subscript', () { + expect(_render(r'$x_1$'), 'x₁'); + }); + + test('water formula', () { + expect(_render(r'$H_2O$'), 'H₂O'); + }); + + test('braced multi-digit superscript', () { + expect(_render(r'$x^{10}$'), 'x¹⁰'); + }); + + test('braced multi-char subscript', () { + expect(_render(r'$a_{12}$'), 'a₁₂'); + }); + + test('subscript letter i', () { + expect(_render(r'$a_i$'), 'aᵢ'); + }); + + test('superscript with sign', () { + expect(_render(r'$x^{-1}$'), 'x⁻¹'); + }); + + test('combined with a command', () { + expect(_render(r'$\alpha^2$'), 'α²'); + }); + + test('unmappable script char is left literal', () { + // `z` has no subscript form, so the run stays verbatim. + expect(_render(r'$q_z$'), r'$q_z$'); + }); + + test('superscript letter n', () { + expect(_render(r'$2^n$'), '2ⁿ'); + }); + + test('unterminated brace group is left literal', () { + expect(_render(r'$x^{2$'), r'$x^{2$'); + }); + }); + + group('Code is protected', () { + test('inline code span is never converted', () { + final span = _spans(r'`$\alpha$`').single; + expect(span.text, r'$\alpha$'); + expect(span.style, MD$Style.monospace); + }); + + test('text around code is still converted', () { + final rendered = + _spans(r'$\alpha$ `$\beta$` $\gamma$').map((s) => s.text).join(); + expect(rendered, r'α $\beta$ γ'); + }); + + test('fenced code block is never converted', () { + final code = + _math.convert('```\n\$\\alpha\$\n```').blocks.single as MD$Code; + expect(code.text, r'$\alpha$'); + }); + }); + + group('Dollars that must not convert', () { + test('currency is untouched even with math on', () { + expect(_render(r'I have $5 and $10 left'), r'I have $5 and $10 left'); + }); + + test('a single dollar is untouched', () { + expect(_render(r'costs $5 total'), r'costs $5 total'); + }); + + test('non-command dollar span is untouched', () { + expect(_render(r'$x$'), r'$x$'); + }); + + test(r'escaped \$ is a literal dollar and blocks math', () { + expect(_render(r'price \$5'), r'price $5'); + expect(_render(r'\$\alpha\$'), r'$\alpha$'); + }); + }); + + group('Configurability', () { + test('Markdown.fromString gates math behind the flag', () { + expect(Markdown.fromString(r'$\alpha$').text, r'$\alpha$'); + expect(Markdown.fromString(r'$\alpha$', inlineMath: true).text, 'α'); + }); + + test('custom replacements replace the defaults', () { + const decoder = MarkdownDecoder( + inlineMath: true, + mathReplacements: {r'\R': 'ℝ'}, + ); + expect(_render(r'$\R$', decoder), 'ℝ'); + // A default command is no longer known with a fully custom table. + expect(_render(r'$\alpha$', decoder), r'$\alpha$'); + }); + + test('extending the defaults keeps them working', () { + const decoder = MarkdownDecoder( + inlineMath: true, + mathReplacements: {...kMarkdownMathCommands, r'\R': 'ℝ'}, + ); + expect(_render(r'$\R$', decoder), 'ℝ'); + expect(_render(r'$\alpha$', decoder), 'α'); + }); + + test('kMarkdownMathCommands is non-empty and contains greek', () { + expect(kMarkdownMathCommands, isNotEmpty); + expect(kMarkdownMathCommands[r'\alpha'], 'α'); + }); + }); + + group('Span integrity with math on', () { + test('converted math still yields well-formed spans', () { + for (final input in const [ + r'$\alpha$ + $\beta$ = result', + r'water is $H_2O$ here', + r'x equals $x^{2}$ today', + ]) { + final spans = _spans(input); + for (var i = 0; i < spans.length; i++) { + expect(spans[i].start, lessThanOrEqualTo(spans[i].end), + reason: input); + if (i > 0) { + expect(spans[i].start, greaterThanOrEqualTo(spans[i - 1].start), + reason: input); + } + } + } + }); + }); +} diff --git a/test/parser/parser_golden.txt b/test/parser/parser_golden.txt new file mode 100644 index 0000000..9d1f214 --- /dev/null +++ b/test/parser/parser_golden.txt @@ -0,0 +1,380 @@ +@@@ prose-simple +P + [0,44] style=0 "The quick brown fox jumps over the lazy dog." +@@@ prose-multiline +P + [0,54] style=0 "First line of a paragraph\nsecond line here\nthird line." +@@@ prose-two-paragraphs +P + [0,14] style=0 "Paragraph one." +SPACER count=1 +P + [0,14] style=0 "Paragraph two." +@@@ prose-operators +P + [0,54] style=0 "5 * 6 = 30 and 3 < 4 and a_b_c and x > y stay literal." +@@@ prose-unicode +P + [0,45] style=0 "Привет мир 👋 and 日本語 text 🌍 mixed together." +@@@ em-bold +P + [0,8] style=0 "This is " + [10,14] style=2 "bold" + [16,22] style=0 " text." +@@@ em-italic-star +P + [0,8] style=0 "This is " + [9,15] style=1 "italic" + [16,22] style=0 " text." +@@@ em-italic-underscore +P + [0,8] style=0 "This is " + [9,15] style=1 "italic" + [16,22] style=0 " text." +@@@ em-underline +P + [0,8] style=0 "This is " + [10,19] style=4 "underline" + [21,27] style=0 " text." +@@@ em-strike +P + [0,8] style=0 "This is " + [10,16] style=8 "strike" + [18,24] style=0 " text." +@@@ em-highlight +P + [0,8] style=0 "This is " + [10,19] style=128 "highlight" + [21,27] style=0 " text." +@@@ em-spoiler +P + [0,8] style=0 "This is " + [10,17] style=256 "spoiler" + [19,25] style=0 " text." +@@@ em-code +P + [0,8] style=0 "This is " + [9,13] style=16 "code" + [14,20] style=0 " text." +@@@ em-nested +P + [2,7] style=2 "bold " + [8,18] style=3 "and italic" + [19,28] style=2 " together" +@@@ em-adjacent +P + [1,2] style=1 "a" + [4,5] style=3 "b" + [7,8] style=1 "c" +@@@ em-unterminated-star +P + [0,19] style=0 "**bold never closed" +@@@ em-unterminated-single +P + [0,19] style=0 "a * lonely asterisk" +@@@ em-intraword-underscore +P + [0,39] style=0 "snake_case and object_id remain literal" +@@@ em-cyrillic-underscore +P + [0,23] style=0 "переменная_значение_тут" +@@@ em-mixed-line +P + [2,3] style=2 "b" + [5,6] style=0 " " + [7,8] style=1 "i" + [9,10] style=0 " " + [12,13] style=4 "u" + [15,16] style=0 " " + [18,19] style=8 "s" + [21,22] style=0 " " + [23,24] style=16 "c" + [25,26] style=0 " " + [28,29] style=128 "h" + [31,32] style=0 " " + [34,36] style=256 "sp" +@@@ em-unterminated-code +P + [0,25] style=0 "a `code span never closed" +@@@ em-double-backtick +P + [0,24] style=0 "use ``double`` backticks" +@@@ link-simple +P + [0,27] style=32 "text" {href="https://example.com", type="link", url="https://example.com"} +@@@ link-title +P + [0,39] style=32 "text" {alt="the title", href="https://example.com", type="link", url="https://example.com"} +@@@ link-single-quote-title +P + [0,39] style=32 "text" {alt="the title", href="https://example.com", type="link", url="https://example.com"} +@@@ link-angle +P + [0,33] style=32 "text" {href="https://example.com/a b", type="link", url="https://example.com/a b"} +@@@ link-in-text +P + [0,4] style=0 "See " + [4,28] style=32 "the docs" {href="https://x.io", type="link", url="https://x.io"} + [28,38] style=0 " for more." +@@@ link-emphasis +P + [0,2] style=0 "A " + [4,29] style=34 "bold link" {href="https://x.io", type="link", url="https://x.io"} + [31,37] style=0 " here." +@@@ image-simple +P + [0,33] style=64 "alt" {src="https://example.com/i.png", type="image", url="https://example.com/i.png"} +@@@ image-title +P + [0,39] style=64 "alt" {alt="cap", src="https://example.com/i.png", type="image", url="https://example.com/i.png"} +@@@ link-nested-parens +P + [0,34] style=32 "t" {href="https://x.io/path(with)parens", type="link", url="https://x.io/path(with)parens"} +@@@ link-two +P + [0,6] style=32 "a" {href="b", type="link", url="b"} + [6,11] style=0 " and " + [11,17] style=32 "c" {href="d", type="link", url="d"} +@@@ escape-emphasis +P + [0,32] style=0 "*not italic* and _not underline_" +@@@ escape-backtick +P + [0,10] style=0 "`not code`" +@@@ escape-brackets +P + [0,18] style=0 "[not a link](nope)" +@@@ escape-backslash-eol +P + [0,19] style=0 "trailing backslash\\" +@@@ escape-lone +P + [0,1] style=0 "\\" +@@@ math-alpha +P + [0,28] style=0 "The angle $\\alpha$ is small." +@@@ math-currency +P + [0,26] style=0 "It costs $5 and $10 today." +@@@ math-in-code +P + [1,9] style=16 "$\\alpha$" + [10,33] style=0 " stays literal in code." +@@@ math-mixed +P + [0,44] style=0 "$\\pi \\approx 3.14$ but $plain$ is untouched." +@@@ heading-h1 +H1 + [0,11] style=0 "Heading one" +@@@ heading-h6 +H6 + [0,11] style=0 "Heading six" +@@@ heading-closing +H2 + [0,7] style=0 "Heading" +@@@ heading-no-space +P + [0,25] style=0 "#hashtag is not a heading" +@@@ heading-seven +P + [0,23] style=0 "####### too many hashes" +@@@ heading-empty +H1 +@@@ quote-simple +Q indent=1 + [0,11] style=0 "quoted text" +@@@ quote-multiline +Q indent=1 + [0,17] style=0 "line one\nline two" +@@@ alert-note +ALERT note + [0,18] style=0 "Take note of this." +@@@ alert-tip +ALERT tip + [0,14] style=0 "A helpful tip." +@@@ alert-important +ALERT important + [0,17] style=0 "Important detail." +@@@ alert-warning +ALERT warning + [0,15] style=0 "A warning here." +@@@ alert-caution +ALERT caution + [0,12] style=0 "Be cautious." +@@@ alert-lowercase +ALERT note + [0,17] style=0 "lowercase marker." +@@@ alert-with-emphasis +ALERT note + [0,10] style=0 "Body with " + [12,16] style=2 "bold" + [18,23] style=0 " and " + [24,28] style=16 "code" + [29,30] style=0 "." +@@@ code-fenced +CODE lang="dart" text="void main() {}" +@@@ code-tilde +CODE lang="" text="plain code" +@@@ code-no-lang +CODE lang="" text="no language" +@@@ code-unterminated +CODE lang="dart" text="void main() {}" +@@@ list-unordered +LIST + ITEM marker="-" indent=0 checked=null + [0,3] style=0 "one" + ITEM marker="-" indent=0 checked=null + [0,3] style=0 "two" + ITEM marker="-" indent=0 checked=null + [0,5] style=0 "three" +@@@ list-ordered +LIST + ITEM marker="1." indent=0 checked=null + [0,3] style=0 "one" + ITEM marker="2." indent=0 checked=null + [0,3] style=0 "two" + ITEM marker="3." indent=0 checked=null + [0,5] style=0 "three" +@@@ list-nested +LIST + ITEM marker="-" indent=0 checked=null + [0,1] style=0 "a" + ITEM marker="-" indent=2 checked=null + [0,1] style=0 "b" + ITEM marker="-" indent=4 checked=null + [0,1] style=0 "c" + ITEM marker="-" indent=0 checked=null + [0,1] style=0 "d" +@@@ list-task +LIST + ITEM marker="-" indent=0 checked=false + [0,4] style=0 "todo" + ITEM marker="-" indent=0 checked=true + [0,4] style=0 "done" + ITEM marker="-" indent=0 checked=true + [0,9] style=0 "also done" +@@@ list-mixed-marker +LIST + ITEM marker="*" indent=0 checked=null + [0,4] style=0 "star" + ITEM marker="+" indent=0 checked=null + [0,4] style=0 "plus" + ITEM marker="-" indent=0 checked=null + [0,4] style=0 "dash" +@@@ list-with-inline +LIST + ITEM marker="-" indent=0 checked=null + [0,10] style=0 "item with " + [12,16] style=2 "bold" + [18,23] style=0 " and " + [23,43] style=32 "link" {href="https://x.io", type="link", url="https://x.io"} +@@@ table-basic +TABLE aligns=[none,none] + header + cell0 + [0,1] style=0 "a" + cell1 + [0,1] style=0 "b" + row + cell0 + [0,1] style=0 "1" + cell1 + [0,1] style=0 "2" +@@@ table-aligned +TABLE aligns=[left,center,right] + header + cell0 + [0,1] style=0 "L" + cell1 + [0,1] style=0 "C" + cell2 + [0,1] style=0 "R" + row + cell0 + [0,1] style=0 "1" + cell1 + [0,1] style=0 "2" + cell2 + [0,1] style=0 "3" +@@@ table-inline +TABLE aligns=[none,none] + header + cell0 + [0,1] style=0 "a" + cell1 + [0,1] style=0 "b" + row + cell0 + [2,3] style=2 "x" + cell1 + [1,2] style=16 "y" +@@@ table-malformed-ragged +P + [0,29] style=0 "| a | b |\n| --- | --- |\n| 1 |" +@@@ table-no-delimiter +P + [0,19] style=0 "| a | b |\n| 1 | 2 |" +@@@ hr-dash +HR +@@@ hr-star +HR +@@@ hr-underscore +HR +@@@ hr-spaced +HR +@@@ hr-between +P + [0,5] style=0 "above" +SPACER count=1 +HR +SPACER count=1 +P + [0,5] style=0 "below" +@@@ spacer-multi +P + [0,1] style=0 "a" +SPACER count=3 +P + [0,1] style=0 "b" +@@@ doc-mixed +H1 + [0,5] style=0 "Title" +SPACER count=1 +P + [0,2] style=0 "A " + [4,8] style=2 "para" + [10,16] style=0 " with " + [17,21] style=16 "code" + [22,23] style=0 "." +SPACER count=1 +Q indent=1 + [0,5] style=0 "quote" +SPACER count=1 +LIST + ITEM marker="-" indent=0 checked=null + [0,1] style=0 "x" + ITEM marker="-" indent=0 checked=null + [0,1] style=0 "y" +SPACER count=1 +TABLE aligns=[none,none] + header + cell0 + [0,1] style=0 "a" + cell1 + [0,1] style=0 "b" + row + cell0 + [0,1] style=0 "1" + cell1 + [0,1] style=0 "2" +SPACER count=1 +HR +SPACER count=1 +P + [0,5] style=0 "Done." +@@@ empty + +@@@ whitespace-only +SPACER count=1 +@@@ newlines-only +SPACER count=3 diff --git a/test/parser/regression_test.dart b/test/parser/regression_test.dart new file mode 100644 index 0000000..a218f68 --- /dev/null +++ b/test/parser/regression_test.dart @@ -0,0 +1,323 @@ +// Corner-case regression tests targeting the parser paths that were rewritten +// for performance (hand-rolled list-line parsing, block-loop first-code-unit +// guards, manual link-target scanning, escape rebuilding, and the inline-math +// fast bail). These lock the behaviour of those paths independently of the +// golden snapshot, and document the exact edge semantics. +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _blocks(String input) => markdownDecoder.convert(input).blocks; + +MD$Paragraph _para(String input) => _blocks(input).single as MD$Paragraph; + +List _spans(String input) => _para(input).spans; + +MD$List _list(String input) => _blocks(input).single as MD$List; + +String _text(List spans) => spans.map((s) => s.text).join(); + +void main() { + group('List-line parsing (hand-rolled)', () { + test('ordered marker with dot', () { + final list = _list('1. one\n2. two'); + expect(list.items, hasLength(2)); + expect(list.items.first.marker, '1.'); + expect(list.items.first.text, 'one'); + expect(list.items[1].marker, '2.'); + }); + + test('ordered marker with parenthesis', () { + final list = _list('1) one\n2) two'); + expect(list.items.first.marker, '1)'); + expect(list.items[1].marker, '2)'); + }); + + test('up to nine digits is a valid ordered marker', () { + final list = _list('123456789. item'); + expect(list.items.single.marker, '123456789.'); + expect(list.items.single.text, 'item'); + }); + + test('ten or more digits is not a list', () { + expect(_blocks('1234567890. item').single, isA()); + }); + + test('bullet markers *, +, - are recognised', () { + for (final marker in const ['*', '+', '-']) { + final list = _list('$marker item'); + expect(list.items.single.marker, marker); + expect(list.items.single.text, 'item'); + } + }); + + test('marker not followed by whitespace is not a list', () { + expect(_blocks('-x').single, isA()); + expect(_blocks('1.x').single, isA()); + expect(_blocks('+y').single, isA()); + }); + + test('arrow "-> text" is a paragraph, not a list', () { + expect(_blocks('-> arrow').single, isA()); + }); + + test('emphasis at line start is a paragraph, not a list', () { + final para = _para('*emphasis here*'); + expect( + para.spans, + contains(isA() + .having((s) => s.text, 'text', 'emphasis here') + .having((s) => s.style, 'style', MD$Style.italic)), + ); + }); + + test('nested items track their indent width', () { + final list = _list('- a\n - b\n - c'); + final a = list.items.single; + expect(a.text, 'a'); + expect(a.children.single.text, 'b'); + expect(a.children.single.indent, 2); + expect(a.children.single.children.single.text, 'c'); + expect(a.children.single.children.single.indent, 4); + }); + + test('indent beyond eight columns ends the list', () { + // Nine leading spaces is past the {0,8} cap, so the line is not part of + // the list and becomes a separate paragraph. + final blocks = _blocks('- a\n - too deep'); + expect(blocks.first, isA()); + expect(blocks.any((b) => b is MD$Paragraph), isTrue); + }); + + test('tab indentation is accepted', () { + final list = _list('- a\n\t- b'); + expect(list.items.single.children.single.text, 'b'); + }); + + test('task-list checkbox states', () { + final list = _list('- [ ] todo\n- [x] done\n- [X] also'); + expect(list.items[0].checked, isFalse); + expect(list.items[0].isTask, isTrue); + expect(list.items[0].text, 'todo'); + expect(list.items[1].checked, isTrue); + expect(list.items[2].checked, isTrue); + }); + + test('plain item has null checked (not a task)', () { + final list = _list('- plain'); + expect(list.items.single.checked, isNull); + expect(list.items.single.isTask, isFalse); + }); + }); + + group('Block-loop first-code-unit guards', () { + test('underscore-led line is a paragraph with italic', () { + final para = _para('_italic_ at the start'); + expect(_text(para.spans), '_italic_ at the start'.replaceAll('_', '')); + expect( + para.spans, + contains( + isA().having((s) => s.style, 'style', MD$Style.italic)), + ); + }); + + test('thematic breaks: ---, ***, ___, spaced', () { + for (final rule in const ['---', '***', '___', '- - -', '* * *']) { + expect(_blocks(rule).single, isA(), reason: rule); + } + }); + + test('two dashes is not a thematic break', () { + expect(_blocks('--').single, isA()); + }); + + test('single pipe line without delimiter row is a paragraph', () { + expect(_blocks('| a | b |').single, isA()); + }); + + test('headings still parse after the guard refactor', () { + expect((_blocks('### Title').single as MD$Heading).level, 3); + }); + + test('quote still parses after the guard refactor', () { + expect(_blocks('> quoted').single, isA()); + }); + + test('digit-led prose is a paragraph', () { + expect(_blocks('1st place and 2nd place').single, isA()); + }); + }); + + group('Link-target parsing (manual scan)', () { + Map? extraOf(String input) => + _spans(input).firstWhere((s) => s.extra != null).extra; + + test('plain link exposes url', () { + expect(extraOf('[t](https://x.io)')?['url'], 'https://x.io'); + }); + + test('double-quoted title', () { + expect(extraOf('[t](https://x.io "the title")')?['alt'], 'the title'); + }); + + test('single-quoted title', () { + expect(extraOf("[t](https://x.io 'the title')")?['alt'], 'the title'); + }); + + test('parenthesised title', () { + expect(extraOf('[t](https://x.io (the title))')?['alt'], 'the title'); + }); + + test('angle-bracketed url with spaces', () { + expect(extraOf('[t]()')?['url'], 'https://x.io/a b'); + }); + + test('tab separates url and title', () { + expect(extraOf('[t](https://x.io\ttitle)')?['alt'], 'title'); + }); + + test('image carries the src key and image style', () { + final span = _spans('![alt](https://x.io/i.png)') + .firstWhere((s) => s.extra != null); + expect(span.style.contains(MD$Style.image), isTrue); + expect(span.extra?['src'], 'https://x.io/i.png'); + }); + }); + + group('Escape rebuilding (range copy)', () { + test('escaped emphasis markers stay literal', () { + final spans = _spans(r'\*not italic\*'); + expect(_text(spans), '*not italic*'); + expect(spans.every((s) => s.style.isEmpty), isTrue); + }); + + test('double backslash yields a single backslash', () { + expect(_text(_spans(r'a\\b')), r'a\b'); + }); + + test('several escapes in one run', () { + expect(_text(_spans(r'\# \! \[ \] \(')), '# ! [ ] ('); + }); + + test('escape followed by real emphasis', () { + final spans = _spans(r'\* *italic*'); + expect(_text(spans), '* italic'); + expect( + spans, + contains(isA() + .having((s) => s.text, 'text', 'italic') + .having((s) => s.style, 'style', MD$Style.italic)), + ); + }); + + test('trailing backslash is literal', () { + expect(_text(_spans('text\\')), 'text\\'); + }); + + test('escape at the very start of a span', () { + expect(_text(_spans(r'\*abc')), '*abc'); + }); + }); + + group('Inline math is off by default', () { + // With the default decoder, `$...$` is never converted (see math_test.dart + // for the opt-in behaviour). + test('greek command stays literal', () { + expect(_text(_spans(r'angle $\alpha$ small')), r'angle $\alpha$ small'); + }); + + test('currency is unchanged', () { + expect(_text(_spans(r'It costs $5 and $10 today.')), + r'It costs $5 and $10 today.'); + }); + + test(r'escaped dollar \$ becomes a literal dollar', () { + expect(_text(_spans(r'price \$5 today')), r'price $5 today'); + }); + }); + + group('Link & emphasis edge cases', () { + MD$Span linkOf(String input) => + _spans(input).firstWhere((s) => s.extra != null); + + test('unterminated angle-bracket url keeps the rest as url', () { + expect(linkOf('[t](() + .having((s) => s.text, 'text', 'a ** b') + .having((s) => s.style, 'style', MD$Style.bold)), + ); + }); + }); + + group('Span offset invariants', () { + // These guard the fast path and the selection-critical offset invariant: + // spans must stay ordered, well-formed, and cover plain text exactly. + const inputs = [ + 'just plain prose here', + 'a **b** c *d* e', + 'text with [link](https://x.io) and `code`', + '**bold** _under_ ~~strike~~ ==mark== ||spoiler||', + r'escapes \* and \_ here', + 'Привет **мир** 🌍', + r'price $5 and math $\alpha$ mixed', + '![img](https://x.io/i.png) then text', + ]; + + test('plain paragraph is a single unstyled span covering the text', () { + const text = 'just plain prose here'; + final spans = _spans(text); + expect(spans, hasLength(1)); + expect(spans.single.start, 0); + expect(spans.single.end, text.length); + expect(spans.single.style, MD$Style.none); + expect(spans.single.text, text); + }); + + test('unicode-only plain paragraph is still a single span', () { + expect(_spans('Привет мир 🌍 японский 日本語'), hasLength(1)); + }); + + test('spans are ordered by ascending start offset', () { + for (final input in inputs) { + final spans = _spans(input); + for (var i = 1; i < spans.length; i++) { + expect(spans[i].start, greaterThanOrEqualTo(spans[i - 1].start), + reason: input); + } + } + }); + + test('every span has start <= end', () { + for (final input in inputs) { + for (final span in _spans(input)) { + expect(span.start, lessThanOrEqualTo(span.end), reason: input); + } + } + }); + + test('no span is empty (start strictly less than end)', () { + for (final input in inputs) { + for (final span in _spans(input)) { + expect(span.start, lessThan(span.end), reason: '$input :: $span'); + } + } + }); + }); +} diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart new file mode 100644 index 0000000..aedde4f --- /dev/null +++ b/test/theme/theme_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('MarkdownThemeData', () { + MarkdownThemeData base() => + MarkdownThemeData(textStyle: const TextStyle(fontSize: 14)); + + group('Alert colors', () { + test('defaults to the GitHub palette', () { + final theme = base(); + expect( + theme.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + expect(theme.alertColorFor(MD$AlertType.warning), + const Color(0xFF9A6700)); + expect(theme.alertColorFor(MD$AlertType.caution), + const Color(0xFFCF222E)); + }); + + test('respects overrides while keeping defaults for the rest', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + alertColors: const { + MD$AlertType.note: Color(0xFF123456), + }, + ); + expect( + theme.alertColorFor(MD$AlertType.note), const Color(0xFF123456)); + // Unspecified types still use the default palette. + expect( + theme.alertColorFor(MD$AlertType.tip), const Color(0xFF1A7F37)); + }); + }); + + group('linkStyle', () { + test('is merged into link spans', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + linkColor: Colors.blue, + linkStyle: const TextStyle( + color: Colors.red, + decoration: TextDecoration.underline, + ), + ); + final linkStyle = theme.textStyleFor(MD$Style.link); + expect(linkStyle.color, Colors.red); // linkStyle overrides linkColor + expect(linkStyle.decoration, TextDecoration.underline); + }); + + test('non-link styles are unaffected by linkStyle', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + linkStyle: const TextStyle(color: Colors.red), + ); + expect(theme.textStyleFor(MD$Style.bold).color, isNot(Colors.red)); + }); + }); + + group('copyWith', () { + test('preserves builder and onLinkTap (regression)', () { + var called = false; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + onLinkTap: (_, __) => called = true, + ); + final copy = theme.copyWith() as MarkdownThemeData; + expect(copy.onLinkTap, isNotNull); + copy.onLinkTap!('t', 'u'); + expect(called, isTrue); + }); + + test('overrides provided fields', () { + final copy = base().copyWith( + linkColor: Colors.green, + linkStyle: const TextStyle(fontStyle: FontStyle.italic), + ) as MarkdownThemeData; + expect(copy.linkColor, Colors.green); + expect(copy.linkStyle?.fontStyle, FontStyle.italic); + }); + }); + + group('lerp', () { + test('identical returns the same instance', () { + final theme = base(); + expect(identical(theme.lerp(theme, 0.5), theme), isTrue); + }); + + test('interpolates the text style', () { + final a = MarkdownThemeData(textStyle: const TextStyle(fontSize: 10)); + final b = MarkdownThemeData(textStyle: const TextStyle(fontSize: 20)); + final mid = a.lerp(b, 0.5) as MarkdownThemeData; + expect(mid.textStyle.fontSize, 15); + }); + }); + + testWidgets('mergeTheme derives from ThemeData', (tester) async { + late MarkdownThemeData derived; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (context) { + derived = MarkdownThemeData.mergeTheme( + Theme.of(context), + linkStyle: const TextStyle(color: Colors.purple), + ); + return const SizedBox(); + }, + ), + ), + ); + expect(derived.linkStyle?.color, Colors.purple); + expect( + derived.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + }); + + group('headingStyleFor', () { + test('derives a distinct style for each level 1-6', () { + final theme = base(); + final sizes = [ + for (var level = 1; level <= 6; level++) + theme.headingStyleFor(level).fontSize, + ]; + // Every level resolves to a size and they decrease h1 > ... > h6. + expect(sizes.every((s) => s != null), isTrue); + for (var i = 1; i < sizes.length; i++) { + expect(sizes[i]!, lessThan(sizes[i - 1]!)); + } + for (var level = 1; level <= 6; level++) { + expect(theme.headingStyleFor(level).fontWeight, FontWeight.bold); + } + }); + + test('levels outside 1-6 fall back to the base text style', () { + final theme = base(); + expect(theme.headingStyleFor(7).fontSize, theme.textStyle.fontSize); + }); + + test('caches the resolved style', () { + final theme = base(); + expect( + identical(theme.headingStyleFor(2), theme.headingStyleFor(2)), + isTrue, + ); + }); + + test('honors explicit per-level overrides', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + h2Style: const TextStyle(fontSize: 99), + ); + expect(theme.headingStyleFor(2).fontSize, 99); + }); + }); + + group('type and toString', () { + test('type is the data class', () { + expect(base().type, MarkdownThemeData); + }); + + test('toString is stable', () { + expect(base().toString(), 'MarkdownThemeData{}'); + }); + }); + + group('MarkdownTheme inherited widget', () { + testWidgets('of / maybeOf find the nearest data', (tester) async { + final data = MarkdownThemeData(textStyle: const TextStyle()); + late MarkdownThemeData viaOf; + late MarkdownThemeData viaOfNoListen; + late MarkdownThemeData? viaMaybe; + await tester.pumpWidget(MarkdownTheme( + data: data, + child: Builder(builder: (context) { + viaOf = MarkdownTheme.of(context); + viaOfNoListen = MarkdownTheme.of(context, listen: false); + viaMaybe = MarkdownTheme.maybeOf(context, listen: false); + return const SizedBox(); + }), + )); + expect(identical(viaOf, data), isTrue); + expect(identical(viaOfNoListen, data), isTrue); + expect(identical(viaMaybe, data), isTrue); + }); + + testWidgets('maybeOf returns null without an ancestor', (tester) async { + MarkdownThemeData? maybe = base(); + await tester.pumpWidget(Builder(builder: (context) { + maybe = MarkdownTheme.maybeOf(context); + return const SizedBox(); + })); + expect(maybe, isNull); + }); + + testWidgets('of throws without an ancestor', (tester) async { + await tester.pumpWidget(Builder(builder: (context) { + expect(() => MarkdownTheme.of(context), throwsArgumentError); + return const SizedBox(); + })); + }); + + test('updateShouldNotify compares data identity', () { + final a = MarkdownTheme(data: base(), child: const SizedBox()); + final same = MarkdownTheme(data: a.data, child: const SizedBox()); + final different = + MarkdownTheme(data: base(), child: const SizedBox()); + expect(a.updateShouldNotify(same), isFalse); + expect(a.updateShouldNotify(different), isTrue); + }); + }); + }); diff --git a/test/unit_test.dart b/test/unit_test.dart index 9ae590b..7552576 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,7 +1,29 @@ import 'package:flutter_test/flutter_test.dart'; +import 'parser/block_test.dart' as block_test; +import 'parser/edge_cases_test.dart' as edge_cases_test; +import 'parser/gfm_test.dart' as gfm_test; +import 'parser/golden_test.dart' as golden_test; +import 'nodes/nodes_test.dart' as nodes_test; +import 'parser/inline_test.dart' as inline_test; +import 'parser/math_test.dart' as math_test; import 'parser/parser_test.dart' as parser_test; +import 'parser/regression_test.dart' as regression_test; +import 'theme/theme_test.dart' as theme_test; +import 'widget/render_test.dart' as render_test; +import 'widget/widget_test.dart' as widget_test; void main() => group('Unit', () { parser_test.main(); + block_test.main(); + inline_test.main(); + gfm_test.main(); + edge_cases_test.main(); + math_test.main(); + regression_test.main(); + golden_test.main(); + nodes_test.main(); + theme_test.main(); + render_test.main(); + widget_test.main(); }); diff --git a/test/widget/render_test.dart b/test/widget/render_test.dart new file mode 100644 index 0000000..02bd4ce --- /dev/null +++ b/test/widget/render_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps a [MarkdownWidget] for [source] inside a sized, themed scaffold and +/// returns the tester for further inspection. +Future _pumpMarkdown( + WidgetTester tester, + String source, { + MarkdownThemeData? theme, + void Function(String title, String url)? onLinkTap, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownTheme( + data: theme ?? + MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + onLinkTap: onLinkTap, + ), + child: Center( + child: SizedBox( + width: 400, + child: MarkdownWidget(markdown: Markdown.fromString(source)), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Size _mdSize(WidgetTester tester) => + tester.renderObject(find.byType(MarkdownWidget)).size; + +void main() { + group('MarkdownWidget rendering', () { + testWidgets('renders a rich document without exceptions', (tester) async { + const source = '# Heading\n\n' + 'A paragraph with **bold**, _italic_, `code`, ~~strike~~, ' + '==mark==, ||spoiler|| and a [link](https://example.com).\n\n' + '> A blockquote\n\n' + '```dart\nvoid main() {}\n```\n\n' + '- one\n- two\n - nested\n\n' + '1. first\n2. second\n\n' + '| A | B |\n|:--|--:|\n| 1 | 2 |\n\n' + '---\n'; + await _pumpMarkdown(tester, source); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + + testWidgets('empty markdown produces zero size', (tester) async { + await _pumpMarkdown(tester, ''); + expect(tester.takeException(), isNull); + // Width is constrained by the parent SizedBox; the content height is 0. + expect(_mdSize(tester).height, 0); + }); + + group('GitHub alerts render', () { + for (final type in ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION']) { + testWidgets('$type alert', (tester) async { + await _pumpMarkdown( + tester, + '> [!$type]\n> Body of the $type alert with **bold**.', + ); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + } + + testWidgets('marker-only alert still lays out', (tester) async { + await _pumpMarkdown(tester, '> [!WARNING]'); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + }); + + testWidgets('task lists render without exceptions', (tester) async { + await _pumpMarkdown( + tester, + '- [ ] todo\n- [x] done\n- normal item\n - [ ] nested todo', + ); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + + testWidgets('aligned tables render without exceptions', (tester) async { + await _pumpMarkdown( + tester, + '| Left | Center | Right |\n' + '| :--- | :----: | ----: |\n' + '| a | b | c |\n' + '| longer text | x | y |', + ); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + + testWidgets('inline math renders', (tester) async { + await _pumpMarkdown(tester, r'The angle $\alpha \leq \beta$ holds.'); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + + testWidgets('thematic break variants render', (tester) async { + await _pumpMarkdown(tester, 'a\n\n---\n\nb\n\n***\n\nc\n\n___\n\nd'); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + + testWidgets('tapping a link invokes onLinkTap', (tester) async { + final tapped = []; + await _pumpMarkdown( + tester, + '[click me](https://example.com)', + onLinkTap: (title, url) => tapped.add(url), + ); + // The whole first line is the link; tap near its start. + final topLeft = tester.getTopLeft(find.byType(MarkdownWidget)); + await tester.tapAt(topLeft + const Offset(8, 8)); + await tester.pumpAndSettle(); + expect(tapped, ['https://example.com']); + }); + + testWidgets('updating markdown relayouts', (tester) async { + await _pumpMarkdown(tester, 'short'); + final firstHeight = _mdSize(tester).height; + await _pumpMarkdown(tester, 'line one\n\nline two\n\nline three'); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(firstHeight)); + }); + + testWidgets('custom link style is applied via theme', (tester) async { + await _pumpMarkdown( + tester, + '[styled](https://example.com)', + theme: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + linkStyle: const TextStyle( + color: Colors.red, decoration: TextDecoration.underline), + ), + ); + expect(tester.takeException(), isNull); + expect(_mdSize(tester).height, greaterThan(0)); + }); + }); +} diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart new file mode 100644 index 0000000..fa75705 --- /dev/null +++ b/test/widget/widget_test.dart @@ -0,0 +1,67 @@ +// Widget tests for `MarkdownWidget` that exercise the theme fallback used when +// neither an explicit `theme` nor a `MarkdownTheme` ancestor is provided — the +// branch that builds a default `MarkdownThemeData` from the build context in +// both `createRenderObject` and `updateRenderObject`. +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MarkdownWidget without an explicit theme', () { + // No MarkdownTheme ancestor and no `theme:` argument, so the widget must + // derive a default theme from the surrounding context. + Widget wrap(Markdown markdown) => MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: MarkdownWidget(markdown: markdown), + ), + ), + ), + ); + + double heightOf(WidgetTester tester) => + tester.renderObject(find.byType(MarkdownWidget)).size.height; + + testWidgets('builds a default theme from context', (tester) async { + await tester.pumpWidget( + wrap(Markdown.fromString('# Title\n\nBody with **bold** text.')), + ); + expect(tester.takeException(), isNull); + expect(heightOf(tester), greaterThan(0)); + }); + + testWidgets('rebuilds with a default theme on update', (tester) async { + await tester.pumpWidget(wrap(Markdown.fromString('first'))); + final first = heightOf(tester); + await tester.pumpWidget( + wrap(Markdown.fromString('second\n\nwith another paragraph here')), + ); + expect(tester.takeException(), isNull); + expect(heightOf(tester), greaterThan(0)); + // The taller document should not be smaller than the single line. + expect(heightOf(tester), greaterThanOrEqualTo(first)); + }); + + testWidgets('honors an explicitly passed theme', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: MarkdownWidget( + markdown: Markdown.fromString('# Heading'), + theme: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 20), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(heightOf(tester), greaterThan(0)); + }); + }); +}