overhaul ClideMarkdown renderer
Inline grouping collapses consecutive inline nodes (strong, em, code, a, del) into a single RichText widget instead of rendering each as a separate block — fixes tight list items like `builtin.git` appearing on its own line. HTML entity unescaping moved from pre-parse (useless) to post-AST extraction where the markdown package actually introduces the entities. Explicit Josefin Sans Light font family and 1.25 line height on all RichText spans. Body text bumped to 16px; inline code uses clideFontMono for visual weight parity. Adds onRecordTap callback — markdown links matching [DQRT]-\d+ render as tappable WidgetSpans with hover underline. New typography constants clideFontSmall (12), clideFontBadge (11), clideLineHeight (1.25). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,22 @@ import 'package:clide/kernel/src/theme/tokens.dart';
|
|||||||
import 'package:clide/widgets/src/clide_code_block.dart';
|
import 'package:clide/widgets/src/clide_code_block.dart';
|
||||||
import 'package:clide/widgets/src/clide_divider.dart';
|
import 'package:clide/widgets/src/clide_divider.dart';
|
||||||
import 'package:clide/widgets/src/clide_text.dart';
|
import 'package:clide/widgets/src/clide_text.dart';
|
||||||
|
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||||
import 'package:clide/widgets/src/typography.dart';
|
import 'package:clide/widgets/src/typography.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:markdown/markdown.dart' as md;
|
import 'package:markdown/markdown.dart' as md;
|
||||||
|
|
||||||
|
typedef RecordTapCallback = void Function(String id);
|
||||||
|
|
||||||
class ClideMarkdown extends StatelessWidget {
|
class ClideMarkdown extends StatelessWidget {
|
||||||
const ClideMarkdown(this.source, {super.key});
|
const ClideMarkdown(this.source, {super.key, this.onRecordTap});
|
||||||
|
|
||||||
|
static const double _fontSize = 16;
|
||||||
|
static const double _lineHeight = clideLineHeight;
|
||||||
|
static final _recordPattern = RegExp(r'^[DQRT]-\d+$');
|
||||||
|
|
||||||
final String source;
|
final String source;
|
||||||
|
final RecordTapCallback? onRecordTap;
|
||||||
|
|
||||||
static String _unescapeHtml(String s) {
|
static String _unescapeHtml(String s) {
|
||||||
return s
|
return s
|
||||||
@@ -25,10 +33,9 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final cleaned = _unescapeHtml(source);
|
|
||||||
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
|
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
|
||||||
final nodes = doc.parseLines(cleaned.split('\n'));
|
final nodes = doc.parseLines(source.split('\n'));
|
||||||
final widgets = _buildNodes(nodes, tokens);
|
final widgets = _buildNodes(nodes, tokens, onRecordTap);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -36,82 +43,122 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens) {
|
static const _inlineTags = {'strong', 'em', 'code', 'a', 'del', 'br', 'img', 'span'};
|
||||||
|
|
||||||
|
static bool _isInline(md.Node node) {
|
||||||
|
if (node is md.Text) return true;
|
||||||
|
if (node is md.Element) return _inlineTags.contains(node.tag);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||||
final out = <Widget>[];
|
final out = <Widget>[];
|
||||||
|
final inlineRun = <md.Node>[];
|
||||||
|
|
||||||
|
void flushInline() {
|
||||||
|
if (inlineRun.isEmpty) return;
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
for (final n in inlineRun) {
|
||||||
|
if (n is md.Text) {
|
||||||
|
spans.add(TextSpan(text: _unescapeHtml(n.text)));
|
||||||
|
} else if (n is md.Element) {
|
||||||
|
spans.add(_inlineElementSpan(n, tokens, onRecordTap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add(RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: clideUiFamily,
|
||||||
|
fontFamilyFallback: clideUiFamilyFallback,
|
||||||
|
fontWeight: clideUiDefaultWeight,
|
||||||
|
color: tokens.globalForeground,
|
||||||
|
fontSize: _fontSize,
|
||||||
|
height: _lineHeight,
|
||||||
|
),
|
||||||
|
children: spans,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
inlineRun.clear();
|
||||||
|
}
|
||||||
|
|
||||||
for (final node in nodes) {
|
for (final node in nodes) {
|
||||||
if (node is md.Element) {
|
if (_isInline(node)) {
|
||||||
out.add(_buildElement(node, tokens));
|
inlineRun.add(node);
|
||||||
} else if (node is md.Text) {
|
} else {
|
||||||
out.add(ClideText(node.text, fontSize: clideFontBody));
|
flushInline();
|
||||||
|
if (node is md.Element) {
|
||||||
|
out.add(_buildElement(node, tokens, onRecordTap));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flushInline();
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _buildElement(md.Element el, SurfaceTokens tokens) {
|
static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||||
switch (el.tag) {
|
switch (el.tag) {
|
||||||
case 'h1':
|
case 'h1':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||||
child: _inlineText(el, tokens, fontSize: 22, fontWeight: FontWeight.w500),
|
child: _inlineText(el, tokens, onRecordTap, fontSize: 22, fontWeight: FontWeight.w500),
|
||||||
);
|
);
|
||||||
case 'h2':
|
case 'h2':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 18, bottom: 8),
|
padding: const EdgeInsets.only(top: 18, bottom: 10),
|
||||||
child: _inlineText(el, tokens, fontSize: 18, fontWeight: FontWeight.w500),
|
child: _inlineText(el, tokens, onRecordTap, fontSize: 18, fontWeight: FontWeight.w500),
|
||||||
);
|
);
|
||||||
case 'h3':
|
case 'h3':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 6),
|
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||||
child: _inlineText(el, tokens, fontSize: 16, fontWeight: FontWeight.w500),
|
child: _inlineText(el, tokens, onRecordTap, fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
);
|
);
|
||||||
case 'h4':
|
case 'h4':
|
||||||
case 'h5':
|
case 'h5':
|
||||||
case 'h6':
|
case 'h6':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 14, bottom: 6),
|
padding: const EdgeInsets.only(top: 14, bottom: 8),
|
||||||
child: _inlineText(el, tokens, fontSize: clideFontBody, fontWeight: FontWeight.w600),
|
child: _inlineText(el, tokens, onRecordTap, fontSize: clideFontBody, fontWeight: FontWeight.w600),
|
||||||
);
|
);
|
||||||
case 'p':
|
case 'p':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: _inlineRichText(el, tokens),
|
child: _inlineRichText(el, tokens, onRecordTap),
|
||||||
);
|
);
|
||||||
case 'ul':
|
case 'ul':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 12),
|
padding: const EdgeInsets.only(left: 2, bottom: 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, ordered: false)],
|
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false)],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 'ol':
|
case 'ol':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 12),
|
padding: const EdgeInsets.only(left: 2, bottom: 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < (el.children?.length ?? 0); i++)
|
for (var i = 0; i < (el.children?.length ?? 0); i++)
|
||||||
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, ordered: true, index: i + 1),
|
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, onRecordTap, ordered: true, index: i + 1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 6),
|
||||||
decoration: BoxDecoration(border: Border(left: BorderSide(color: tokens.globalTextMuted, width: 3))),
|
decoration: BoxDecoration(border: Border(left: BorderSide(color: tokens.globalTextMuted, width: 3))),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
|
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 'pre':
|
case 'pre':
|
||||||
final codeEl = el.children?.whereType<md.Element>().firstOrNull;
|
final codeEl = el.children?.whereType<md.Element>().firstOrNull;
|
||||||
final code = codeEl?.textContent ?? el.textContent;
|
final code = _unescapeHtml(codeEl?.textContent ?? el.textContent);
|
||||||
String? lang;
|
String? lang;
|
||||||
final cls = codeEl?.attributes['class'];
|
final cls = codeEl?.attributes['class'];
|
||||||
if (cls != null && cls.startsWith('language-')) {
|
if (cls != null && cls.startsWith('language-')) {
|
||||||
@@ -126,26 +173,26 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
case 'table':
|
case 'table':
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: _buildTable(el, tokens),
|
child: _buildTable(el, tokens, onRecordTap),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return _inlineRichText(el, tokens);
|
return _inlineRichText(el, tokens, onRecordTap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, {bool ordered = false, int index = 1}) {
|
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {bool ordered = false, int index = 1}) {
|
||||||
final bullet = ordered ? '$index. ' : '• ';
|
final bullet = ordered ? '$index. ' : '• ';
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClideText(bullet, color: tokens.globalTextMuted, fontSize: clideFontBody),
|
ClideText(bullet, color: tokens.globalTextMuted, fontSize: _fontSize),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
|
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -153,7 +200,7 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _buildTable(md.Element table, SurfaceTokens tokens) {
|
static Widget _buildTable(md.Element table, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||||
final rows = <TableRow>[];
|
final rows = <TableRow>[];
|
||||||
for (final child in table.children ?? const []) {
|
for (final child in table.children ?? const []) {
|
||||||
if (child is! md.Element) continue;
|
if (child is! md.Element) continue;
|
||||||
@@ -165,7 +212,7 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
if (cell is! md.Element) continue;
|
if (cell is! md.Element) continue;
|
||||||
cells.add(Padding(
|
cells.add(Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
child: _inlineText(cell, tokens, fontWeight: isHeader ? FontWeight.w600 : null),
|
child: _inlineText(cell, tokens, onRecordTap, fontWeight: isHeader ? FontWeight.w600 : null),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if (cells.isNotEmpty) {
|
if (cells.isNotEmpty) {
|
||||||
@@ -184,62 +231,86 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _inlineText(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
|
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||||
return RichText(text: _buildInlineSpan(el, tokens, fontSize: fontSize, fontWeight: fontWeight));
|
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens) {
|
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||||
return RichText(text: _buildInlineSpan(el, tokens));
|
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap));
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
|
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||||
final children = <InlineSpan>[];
|
final children = <InlineSpan>[];
|
||||||
for (final child in el.children ?? const []) {
|
for (final child in el.children ?? const []) {
|
||||||
if (child is md.Text) {
|
if (child is md.Text) {
|
||||||
children.add(TextSpan(text: child.text));
|
children.add(TextSpan(text: _unescapeHtml(child.text)));
|
||||||
} else if (child is md.Element) {
|
} else if (child is md.Element) {
|
||||||
children.add(_inlineElementSpan(child, tokens));
|
children.add(_inlineElementSpan(child, tokens, onRecordTap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
fontFamily: clideUiFamily,
|
||||||
|
fontFamilyFallback: clideUiFamilyFallback,
|
||||||
|
fontWeight: fontWeight ?? clideUiDefaultWeight,
|
||||||
color: tokens.globalForeground,
|
color: tokens.globalForeground,
|
||||||
fontSize: fontSize ?? clideFontBody,
|
fontSize: fontSize ?? _fontSize,
|
||||||
fontWeight: fontWeight,
|
height: _lineHeight,
|
||||||
),
|
),
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens) {
|
static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||||
switch (el.tag) {
|
switch (el.tag) {
|
||||||
case 'strong':
|
case 'strong':
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)],
|
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||||
);
|
);
|
||||||
case 'em':
|
case 'em':
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)],
|
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||||
);
|
);
|
||||||
case 'code':
|
case 'code':
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
text: el.textContent,
|
text: _unescapeHtml(el.textContent),
|
||||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
|
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
|
||||||
);
|
);
|
||||||
case 'a':
|
case 'a':
|
||||||
|
final text = _unescapeHtml(el.textContent);
|
||||||
|
if (onRecordTap != null && _recordPattern.hasMatch(text)) {
|
||||||
|
return WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: () => onRecordTap(text),
|
||||||
|
builder: (_, hovered, __) => Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: tokens.globalFocus,
|
||||||
|
fontSize: _fontSize,
|
||||||
|
height: _lineHeight,
|
||||||
|
fontFamily: clideMonoFamily,
|
||||||
|
decoration: hovered ? TextDecoration.underline : null,
|
||||||
|
decorationColor: tokens.globalFocus,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
text: el.textContent,
|
text: text,
|
||||||
style: TextStyle(color: tokens.globalFocus),
|
style: TextStyle(color: tokens.globalFocus),
|
||||||
);
|
);
|
||||||
case 'del':
|
case 'del':
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
text: el.textContent,
|
text: _unescapeHtml(el.textContent),
|
||||||
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return TextSpan(text: el.textContent);
|
return TextSpan(text: _unescapeHtml(el.textContent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ const String clideMonoFamily = 'JetBrainsMono';
|
|||||||
const double clideFontBody = 15;
|
const double clideFontBody = 15;
|
||||||
const double clideFontCaption = 14;
|
const double clideFontCaption = 14;
|
||||||
const double clideFontMono = 14;
|
const double clideFontMono = 14;
|
||||||
|
const double clideFontSmall = 12;
|
||||||
|
const double clideFontBadge = 11;
|
||||||
|
const double clideLineHeight = 1.25;
|
||||||
|
|
||||||
/// System fallback chain. Ordered by platform prevalence + quality of
|
/// System fallback chain. Ordered by platform prevalence + quality of
|
||||||
/// programming-ligature / box-drawing coverage.
|
/// programming-ligature / box-drawing coverage.
|
||||||
|
|||||||
Reference in New Issue
Block a user