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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## Next

### Fixed

* Fix inline images losing `title` attribute when rendered inside paragraphs or list items (e.g., `![alt](src "title")`).
* Fix redundant O(n) key lookup per entry in YAML map formatting.
* Fix hardcoded 4-space checkbox continuation indent to use actual prefix length.

### Changed

* Remove unused normalization functions from `normalizer.dart` (`normalizeEmphasis`, `normalizeHorizontalRule`, `normalizeUnorderedListMarker`, `orderedListContentIndent`, `normalizeCodeFence`) — these were superseded by AST-based formatting.
* Remove unused utility functions from `text_utils.dart` (`displayWidth`, `indent`, `repeat`, `trimTrailingWhitespace`).

### Added

* Add idempotency integration tests for Markdown and YAML formatting.
* Add integration test for inline image `title` attribute in list items.

## 1.4.4

### Refactored
Expand Down
8 changes: 6 additions & 2 deletions lib/src/markdown/ast_printer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ class MarkdownPrinter {
for (var i = 1; i < lines.length; i++) {
_writeIndent();
if (prefix.isNotEmpty) {
_write(' '); // Indent for checkbox
_write(' ' * prefix.length); // Indent for checkbox
}
_writeLine(lines[i]);
}
Expand Down Expand Up @@ -270,7 +270,7 @@ class MarkdownPrinter {
for (var i = 1; i < lines.length; i++) {
_writeIndent();
if (prefix.isNotEmpty) {
_write(' '); // Indent for checkbox
_write(' ' * prefix.length); // Indent for checkbox
}
_writeLine(lines[i]);
}
Expand Down Expand Up @@ -601,6 +601,10 @@ class MarkdownPrinter {
case 'img':
final src = node.attributes['src'] ?? '';
final alt = node.attributes['alt'] ?? '';
final title = node.attributes['title'];
if (title != null) {
return '![$alt]($src "$title")';
}
return '![$alt]($src)';
case 'br':
return ' \n';
Expand Down
94 changes: 0 additions & 94 deletions lib/src/markdown/normalizer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,100 +3,6 @@
/// Implements Prettier-style normalization for Markdown elements.
library;

/// Normalizes emphasis markers from `*` to `_` where appropriate.
///
/// Following Prettier's convention:
/// - `*text*` becomes `_text_`
/// - `**text**` stays as `**text**` (strong emphasis)
/// - `***text***` becomes `_**text**_` or `**_text_**`
/// - Emphasis within words stays as-is (e.g., `foo*bar*baz`)
String normalizeEmphasis(String text) {
// Pattern for emphasis not within words:
// - Start of string or non-word character before `*`
// - `*content*` where content doesn't contain unescaped `*`
// - End of string or non-word character after `*`

// Handle single emphasis: *text* -> _text_
// Only convert when not part of a word (word boundaries)
final singleEmphasisPattern = RegExp(r'(?<![*\w])(\*)([^*\n]+?)\1(?![*\w])');

var result = text;

// Replace *text* with _text_ when at word boundaries
result = result.replaceAllMapped(singleEmphasisPattern, (match) {
final content = match.group(2)!;
// Don't convert if content starts or ends with space
if (content.startsWith(' ') || content.endsWith(' ')) {
return match.group(0)!;
}
return '_${content}_';
});

// Handle ***text*** -> **_text_** (bold + italic)
final boldItalicPattern = RegExp(
r'(?<![*\w])\*\*\*([^*\n]+?)\*\*\*(?![*\w])',
);
result = result.replaceAllMapped(boldItalicPattern, (match) {
final content = match.group(1)!;
return '**_${content}_**';
});

return result;
}

/// Normalizes horizontal rules to `---`.
///
/// Converts various horizontal rule styles:
/// - `***`, `* * *` -> `---`
/// - `___`, `_ _ _` -> `---`
/// - `---`, `- - -` -> `---`
String normalizeHorizontalRule(String text) {
// Match various horizontal rule patterns
final hrPattern = RegExp(
r'^[ \t]*([-*_])[ \t]*(?:\1[ \t]*){2,}$',
multiLine: true,
);

return text.replaceAllMapped(hrPattern, (match) => '---');
}

/// Normalizes list markers.
///
/// - Unordered lists: various markers (`*`, `+`) -> `-`
/// - Ordered lists: keep numbers, normalize spacing
String normalizeUnorderedListMarker(String marker) {
// Convert *, + to -
if (marker == '*' || marker == '+') {
return '-';
}
return marker;
}

/// Calculates the proper indentation for ordered list items.
///
/// For ordered lists, the content should align based on the widest number:
/// ```
/// 1. First
/// 2. Second
/// ...
/// 10. Tenth (number width = 2)
/// ```
int orderedListContentIndent(int maxNumber, int tabWidth) {
final numberWidth = maxNumber.toString().length;
// Number + dot + space
return numberWidth + 2;
}

/// Normalizes a code fence marker to use backticks.
///
/// Converts `~~~` to ` ``` `.
String normalizeCodeFence(String fence) {
if (fence.startsWith('~')) {
return '`' * fence.length;
}
return fence;
}

/// Normalizes heading to ATX style.
///
/// Ensures consistent spacing: `# Heading` (one space after #).
Expand Down
31 changes: 0 additions & 31 deletions lib/src/utils/text_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,39 +47,8 @@ String normalizeWhitespace(String text) {
return text.replaceAll(RegExp(r'[ \t]+'), ' ').trim();
}

/// Removes trailing whitespace from each line.
String trimTrailingWhitespace(String text) {
return text.split('\n').map((line) => line.trimRight()).join('\n');
}

/// Ensures the text ends with exactly one newline.
String ensureTrailingNewline(String text) {
final trimmed = text.trimRight();
return trimmed.isEmpty ? '' : '$trimmed\n';
}

/// Counts the display width of a string.
///
/// Returns the character count (string length).
int displayWidth(String text) {
return text.length;
}

/// Creates an indentation string of the specified width.
String indent(int width, {bool useTabs = false, int tabWidth = 2}) {
if (width <= 0) return '';

if (useTabs) {
final tabs = width ~/ tabWidth;
final spaces = width % tabWidth;
return '\t' * tabs + ' ' * spaces;
}

return ' ' * width;
}

/// Repeats a string [count] times.
String repeat(String text, int count) {
if (count <= 0) return '';
return text * count;
}
6 changes: 2 additions & 4 deletions lib/src/yaml/yaml_formatter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,13 @@ class _YamlPrinter {

for (var i = 0; i < sortedKeys.length; i++) {
final key = sortedKeys[i];
final keyNode = map.nodes.keys.firstWhere(
(k) => k == key,
); // Get the key node to access span
final keyNode = key as YamlNode;
final valueNode = map.nodes[key]!;

// Handle comments between keys, trimming leading newlines for the first key
_printGap(
_lastOffset,
(keyNode as YamlNode).span.start.offset,
keyNode.span.start.offset,
trimLeadingNewlines: i == 0,
);

Expand Down
170 changes: 170 additions & 0 deletions test/markdown_integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,176 @@ Next paragraph here.
});
});

test('normalizes tilde code fences to backticks', () {
const input = '''
~~~dart
void main() {
print('Hello');
}
~~~
''';

const expected = '''
```dart
void main() {
print('Hello');
}
```
''';

const formatter = MarkdownFormatter();
expect(formatter.format(input), expected);
});

test('preserves inline image title attribute', () {
const input = '''
Here is an image: ![alt text](image.png "Image Title")
''';

const formatter = MarkdownFormatter();
final result = formatter.format(input);
expect(result, contains('![alt text](image.png "Image Title")'));
});

test('preserves inline image title in list items', () {
const input = '''
* ![logo](logo.png "Logo Title")
* Text with ![icon](icon.svg "Icon Title") inline
''';

const formatter = MarkdownFormatter();
final result = formatter.format(input);
expect(result, contains('![logo](logo.png "Logo Title")'));
expect(result, contains('![icon](icon.svg "Icon Title")'));
});

group('Idempotency', () {
const formatter = MarkdownFormatter();

test('full document is idempotent', () {
const input = '''
---
title: Dapper Documentation
author: Koji
---

# Introduction

Dapper is a *fantastic* formatter.

## Features

- YAML formatting
- Markdown formatting

## `dapper` Usage

## Code Example

```dart
void main() {
print('Hello');
}
```

## Definition List

Term
: Definition
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('messy document is idempotent', () {
const input = '''
# Heading with spaces

* Misaligned list item
* Nested item
* Emphasis: *bold*

| Column 1 | Column 2 |
| --- | :---: |
| Value 1 | Value 2 |

> Blockquote with spaces
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('tilde code fence is idempotent', () {
const input = '''
~~~dart
void main() {
print('Hello');
}
~~~
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('inline image with title is idempotent', () {
const input = '''
Here is ![alt](image.png "Title") in a paragraph.

* ![logo](logo.png "Logo") in a list
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('loose list is idempotent', () {
const input = '''
* **test**
* test
* **test**
* test

* **test**
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('nested blockquote is idempotent', () {
const input = '''
> Level 1
>> Level 2
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});

test('checkbox list with code block is idempotent', () {
const input = '''
- [ ] Task 1
```
code inside
```
- [x] Task 2
''';

final once = formatter.format(input);
final twice = formatter.format(once);
expect(twice, once, reason: 'Formatting should be idempotent');
});
});

test('formats loose lists by normalizing to tight lists', () {
const input = '''
* **test**
Expand Down
3 changes: 1 addition & 2 deletions test/src/markdown/ast_printer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,7 @@ void main() {
img.attributes['title'] = 'Title';
final p = md.Element('p', [img]);
final result = printer.print([p]);
// Note: Current implementation doesn't preserve title in inline rendering
expect(result, contains('![alt](src'));
expect(result, contains('![alt](src "Title")'));
});

test('prints horizontal rule', () {
Expand Down
Loading