228 lines
7.9 KiB
Dart
228 lines
7.9 KiB
Dart
/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions
|
|
library bbcode_converter;
|
|
|
|
import 'dart:math' as math;
|
|
|
|
class BBCodeConverter {
|
|
/// Converts BBCode formatted text to Markdown format
|
|
static String toMarkdown(String bbcode) {
|
|
if (bbcode.isEmpty) return '';
|
|
|
|
// First, normalize line endings and escape any literal backslashes
|
|
String result = bbcode.replaceAll('\r\n', '\n').replaceAll('\\', '\\\\');
|
|
|
|
// Ensure paragraphs - double newlines between paragraphs
|
|
// First normalize any consecutive newlines to a single one
|
|
result = result.replaceAll(RegExp(r'\n+'), '\n');
|
|
|
|
// Then add empty line after each paragraph where needed
|
|
result = result.replaceAll('.\n', '.\n\n');
|
|
result = result.replaceAll('!\n', '!\n\n');
|
|
result = result.replaceAll('?\n', '?\n\n');
|
|
|
|
// Fix unclosed tags - RimWorld descriptions often have unclosed tags
|
|
final List<String> tagTypes = ['b', 'i', 'color', 'size', 'url', 'code', 'quote'];
|
|
for (final tag in tagTypes) {
|
|
final openCount = '[$tag'.allMatches(result).length;
|
|
final closeCount = '[/$tag]'.allMatches(result).length;
|
|
if (openCount > closeCount) {
|
|
result = result + '[/$tag]' * (openCount - closeCount);
|
|
}
|
|
}
|
|
|
|
// URLs
|
|
// [url=http://example.com]text[/url] -> [text](http://example.com)
|
|
result = RegExp(r'\[url=([^\]]+)\](.*?)\[/url\]', dotAll: true)
|
|
.allMatches(result)
|
|
.fold(result, (prev, match) {
|
|
final url = match.group(1);
|
|
final text = match.group(2);
|
|
return prev.replaceFirst(
|
|
match.group(0)!,
|
|
'[$text]($url)'
|
|
);
|
|
});
|
|
|
|
// Simple URL [url]http://example.com[/url] -> <http://example.com>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[url\](.*?)\[/url\]', dotAll: true),
|
|
(match) => '<${match.group(1)}>'
|
|
);
|
|
|
|
// Bold
|
|
result = result.replaceAll('[b]', '**').replaceAll('[/b]', '**');
|
|
|
|
// Italic
|
|
result = result.replaceAll('[i]', '_').replaceAll('[/i]', '_');
|
|
|
|
// Headers - ensure they start on their own line
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
|
|
(match) => '\n\n# ${match.group(1)?.trim()}\n\n'
|
|
);
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
|
|
(match) => '\n\n## ${match.group(1)?.trim()}\n\n'
|
|
);
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
|
|
(match) => '\n\n### ${match.group(1)?.trim()}\n\n'
|
|
);
|
|
|
|
// Lists - handle nested lists too
|
|
result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n');
|
|
|
|
// Handle list items - giving them proper indentation
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
|
|
(match) {
|
|
final content = match.group(1)?.trim() ?? '';
|
|
return '\n* $content\n';
|
|
}
|
|
);
|
|
|
|
// Color - convert to bold since Markdown doesn't support color
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true),
|
|
(match) {
|
|
final content = match.group(2) ?? '';
|
|
if (content.trim().isEmpty) return '';
|
|
return '**${match.group(2)}**';
|
|
}
|
|
);
|
|
|
|
// Images
|
|
// [img]url[/img] -> 
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
|
|
(match) => '})'
|
|
);
|
|
|
|
// Image with size [img width=300]url[/img] -> 
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
|
|
(match) => '})'
|
|
);
|
|
|
|
// Tables - convert tables to markdown tables
|
|
if (result.contains('[table]')) {
|
|
// Process tables
|
|
final tableRegex = RegExp(r'\[table\](.*?)\[/table\]', dotAll: true);
|
|
final tables = tableRegex.allMatches(result);
|
|
|
|
for (final tableMatch in tables) {
|
|
final tableContent = tableMatch.group(1) ?? '';
|
|
final rows = RegExp(r'\[tr\](.*?)\[/tr\]', dotAll: true).allMatches(tableContent);
|
|
|
|
// Only process tables that have rows
|
|
if (rows.isEmpty) {
|
|
result = result.replaceFirst(tableMatch.group(0)!, '');
|
|
continue;
|
|
}
|
|
|
|
final markdownTable = StringBuffer('\n');
|
|
var isFirstRow = true;
|
|
|
|
// First determine the number of columns by examining all rows
|
|
int maxColumns = 0;
|
|
for (final rowMatch in rows) {
|
|
final rowContent = rowMatch.group(1) ?? '';
|
|
final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length;
|
|
maxColumns = math.max(maxColumns, cellCount);
|
|
}
|
|
|
|
// Ensure we have at least 1 column
|
|
maxColumns = math.max(1, maxColumns);
|
|
|
|
for (final rowMatch in rows) {
|
|
final rowContent = rowMatch.group(1) ?? '';
|
|
final cells = RegExp(r'\[td\](.*?)\[/td\]', dotAll: true).allMatches(rowContent);
|
|
|
|
if (cells.isEmpty) continue;
|
|
|
|
final rowBuffer = StringBuffer('| ');
|
|
|
|
int cellsAdded = 0;
|
|
for (final cellMatch in cells) {
|
|
final cellContent = cellMatch.group(1)?.trim() ?? '';
|
|
// Clean up any newlines inside cell content
|
|
final cleanCell = cellContent.replaceAll('\n', ' ').trim();
|
|
rowBuffer.write('$cleanCell | ');
|
|
cellsAdded++;
|
|
}
|
|
|
|
// Add empty cells if needed to maintain table structure
|
|
while (cellsAdded < maxColumns) {
|
|
rowBuffer.write(' | ');
|
|
cellsAdded++;
|
|
}
|
|
|
|
markdownTable.writeln(rowBuffer.toString());
|
|
|
|
// Add header separator after first row
|
|
if (isFirstRow) {
|
|
final headerRow = StringBuffer('| ');
|
|
for (int i = 0; i < maxColumns; i++) {
|
|
headerRow.write('--- | ');
|
|
}
|
|
markdownTable.writeln(headerRow.toString());
|
|
isFirstRow = false;
|
|
}
|
|
}
|
|
|
|
markdownTable.write('\n');
|
|
result = result.replaceFirst(tableMatch.group(0)!, markdownTable.toString());
|
|
}
|
|
}
|
|
|
|
// Size - remove since Markdown doesn't directly support font size
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[size=[^\]]+\](.*?)\[/size\]', dotAll: true),
|
|
(match) => match.group(1) ?? ''
|
|
);
|
|
|
|
// Code
|
|
result = result.replaceAll('[code]', '\n```\n').replaceAll('[/code]', '\n```\n');
|
|
|
|
// Quote
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true),
|
|
(match) {
|
|
final content = match.group(1)?.trim() ?? '';
|
|
if (content.isEmpty) return '';
|
|
return '\n> ${content.replaceAll('\n', '\n> ')}\n\n';
|
|
}
|
|
);
|
|
|
|
// Handle any remaining custom BBCode tags - just remove them
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true),
|
|
(match) => match.group(2) ?? ''
|
|
);
|
|
|
|
// Handle RimWorld-specific patterns
|
|
// [h1] without closing tag is common
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h1\]([^\[]+)'),
|
|
(match) => '\n\n# ${match.group(1)?.trim()}\n\n'
|
|
);
|
|
|
|
// Convert simple newlines to double newlines for proper markdown rendering
|
|
// But avoid doing this for lines that are already marked up as headings, lists, or tables
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'([^\n#*>|])\n([^\n#*>|-])'),
|
|
(match) => '${match.group(1)}\n\n${match.group(2)}'
|
|
);
|
|
|
|
// Normalize multiple spaces
|
|
result = result.replaceAll(RegExp(r' {2,}'), ' ');
|
|
|
|
// Remove excessive newlines (more than 2 consecutive)
|
|
result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
|
|
|
// Ensure document starts and ends cleanly
|
|
result = result.trim();
|
|
|
|
return result;
|
|
}
|
|
} |