Implement a popup card that renders description markdown

This commit is contained in:
2025-03-18 23:51:37 +01:00
parent f90371109c
commit 9eb71e94c1
6 changed files with 592 additions and 3 deletions

174
lib/bbcode_converter.dart Normal file
View File

@@ -0,0 +1,174 @@
/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions
class BBCodeConverter {
/// Converts BBCode formatted text to Markdown format
static String toMarkdown(String bbcode) {
if (bbcode.isEmpty) return '';
// First, normalize line endings
String result = bbcode.replaceAll('\r\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
result = result.replaceAllMapped(
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
(match) => '# ${match.group(1)}'
);
result = result.replaceAllMapped(
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
(match) => '## ${match.group(1)}'
);
result = result.replaceAllMapped(
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
(match) => '### ${match.group(1)}'
);
// Lists - handle nested lists too
result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n');
// Handle list items - giving them proper indentation
int listLevel = 0;
result = result.replaceAllMapped(
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
return '* $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] -> ![Image](url)
result = result.replaceAllMapped(
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
(match) => '![Image](${match.group(1)})'
);
// Image with size [img width=300]url[/img] -> ![Image](url)
result = result.replaceAllMapped(
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
(match) => '![Image](${match.group(2)})'
);
// 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);
final markdownTable = StringBuffer();
var isFirstRow = true;
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('|');
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 |');
}
markdownTable.writeln(rowBuffer.toString());
// Add header separator after first row
if (isFirstRow) {
final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length;
markdownTable.writeln('|${' --- |' * cellCount}');
isFirstRow = false;
}
}
result = result.replaceFirst(tableMatch.group(0)!, '\n${markdownTable.toString()}\n');
}
}
// 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';
}
);
// 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) => '# ${match.group(1)}\n'
);
// Replace multiple newlines with at most two newlines
result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return result;
}
}