Custom Components
For syntax the package does not know about — callout boxes, mention chips, custom citation styles.
Reach for this last
For @mention-style tokens use InlinePattern — no subclassing, and it handles nesting rules correctly by default. For appearance use a style object. A custom component is for genuinely new syntax.
components
Block components
Match multi-line patterns; return a Widget
inlineComponents
Inline components
Match within a line; return an InlineSpan
inlinePatterns
InlinePattern (v1.2.0)
@mention, #channel, :emoji: — no subclassing
Do not wipe the built-ins
Passing a list to components or inlineComponents replaces the defaults entirely. Always append the package defaults to keep bold, links, code, and all other built-in syntax working.
1// components → block pass: headings, lists, tables, fences, …
2// inlineComponents → inline pass: bold, links, code, images, …
3
4GptMarkdown(
5 text,
6 components: [...],
7 inlineComponents: [...],
8)
9
10// WARNING: passing a list REPLACES the defaults — bold, links, and code
11// stop working unless you keep the built-ins.
12
13// Wrong — loses all built-in inline syntax:
14inlineComponents: [MyComponent()],
15
16// Right — prepend your component, keep built-ins:
17inlineComponents: [MyComponent(), ...MarkdownComponent.inlineComponents],
18
19// Block list equivalent:
20components: [MyBlockComponent(), ...MarkdownComponent.globalComponents],InlinePattern — simple pattern
The recommended API for app-specific inline tokens in v1.2.0. Patterns are matched ahead of built-in components. No subclassing required.
1import 'package:flutter/gestures.dart';
2import 'package:flutter/material.dart';
3import 'package:gpt_markdown/gpt_markdown.dart';
4
5// inlinePatterns — the recommended API for @mention / #channel / :emoji:
6// No subclassing. Matched AHEAD of built-in components.
7
8GptMarkdown(
9 text,
10 inlinePatterns: [
11 InlinePattern(
12 // Anchor with lookarounds, not ^ — patterns match the whole document.
13 pattern: RegExp(r'(?<![w-])GH-(d+)'),
14 builder: (context, match, style) => TextSpan(
15 text: match.group(0),
16 style: style.copyWith(
17 color: Theme.of(context).colorScheme.primary,
18 fontWeight: FontWeight.w600,
19 ),
20 recognizer: TapGestureRecognizer()
21 ..onTap = () => openIssue(match.group(1)!),
22 ),
23 // TextSpan is safe inside link labels — opt back in:
24 scopes: MarkdownComponent.allScopes,
25 ),
26 ],
27)Advanced component authoring show advanced options
InlinePattern.prefixed — @mention & #channel
The InlinePattern.prefixed factory handles boundary rules automatically: @user in an email address is not claimed, and #frag in a URL fragment is not claimed. Longer names win over shorter ones (longest-first matching).
1// InlinePattern.prefixed — helper for @name and #channel.
2// Handles boundary rules so @user in user@example.com is not claimed,
3// and #frag in https://x.com/#frag is not claimed.
4
5GptMarkdown(
6 text,
7 inlinePatterns: [
8 InlinePattern.prefixed(
9 prefix: '#',
10 // Longer names win — #design-review is not shadowed by #design.
11 knownNames: channelNames, // ['general', 'design-review', …]
12 builder: (context, match, style) => WidgetSpan(
13 alignment: PlaceholderAlignment.baseline,
14 baseline: TextBaseline.alphabetic,
15 child: ChannelChip(name: match.group(0)!.substring(1)),
16 ),
17 // Default scope already excludes link labels (WidgetSpan safety).
18 // scopes: MarkdownComponent.allScopesExceptLinkLabel,
19 ),
20 InlinePattern.prefixed(
21 prefix: '@',
22 knownNames: memberNames,
23 builder: (context, match, style) => TextSpan(
24 text: match.group(0),
25 style: style.copyWith(color: Colors.indigo),
26 recognizer: TapGestureRecognizer()
27 ..onTap = () => openProfile(match.group(0)!.substring(1)),
28 ),
29 // Returns TextSpan — safe to include link labels:
30 scopes: MarkdownComponent.allScopes,
31 ),
32 ],
33)
34
35// Leave genericTokenPattern null to match ONLY known names.
36// A generic fallback chips #2959 when the author meant issue 2959 — a real
37// production bug. Only supply one when you genuinely want every #token.InlinePattern.delimited — :emoji: & ::spoiler::
The InlinePattern.delimited factory handles tokens with a closing delimiter, which prefixed cannot express. knownNames and genericTokenPattern behave exactly as they do on prefixed: known names match exactly, longest first, and leaving the generic pattern null matches only those names.
1// InlinePattern.delimited — for tokens with a CLOSING delimiter.
2// prefixed cannot express :tada: — it would match :tada and leave a
3// stray colon behind. The token name is the named group 'name' (and group 1).
4
5const emoji = {'tada': '🎉', 'rocket': '🚀', 'fire': '🔥'};
6
7GptMarkdown(
8 text,
9 inlinePatterns: [
10 InlinePattern.delimited(
11 open: ':', // close defaults to open
12 knownNames: emoji.keys,
13 builder: (context, match, style) {
14 final name = match.namedGroup('name');
15 final glyph = name == null ? null : emoji[name];
16 // Unknown name → return the raw match, never an empty span.
17 return TextSpan(text: glyph ?? match.group(0), style: style);
18 },
19 ),
20 // Asymmetric, multi-character delimiters work too:
21 InlinePattern.delimited(
22 open: '{{',
23 close: '}}',
24 knownNames: templateNames,
25 builder: buildTemplateSpan,
26 ),
27 ],
28)
29
30// Boundaries are handled: 10:30:45 and http://host:8080/x are not claimed,
31// :tada:xyz does not match, but adjacent :fire::fire: matches twice.Static helpers — buildPrefixedPattern & buildDelimitedPattern
When a factory is not flexible enough — you need custom scopes reasoning, or the regex inside your own MarkdownComponent subclass — InlinePattern.buildPrefixedPattern and InlinePattern.buildDelimitedPattern return the exact regexes the factories use, so the boundary rules stay correct without re-deriving them.
1// The regexes behind both factories are exposed as static helpers, for
2// building your own InlinePattern or MarkdownComponent while keeping the
3// fiddly boundary rules, longest-name-first matching, and case-insensitivity.
4
5// 1. buildPrefixedPattern — same rules as prefixed, your own pattern object.
6// Here: a TextSpan-only mention opted into EVERY scope, link labels included.
7InlinePattern(
8 pattern: InlinePattern.buildPrefixedPattern(
9 prefix: '@',
10 knownNames: userDirectory.handles,
11 // genericTokenPattern: r'[A-Za-z0-9_]+', // optional fallback
12 ),
13 builder: (context, match, style) => TextSpan(
14 text: match.group(0),
15 style: style.copyWith(color: Colors.indigo, fontWeight: FontWeight.w600),
16 recognizer: TapGestureRecognizer()
17 ..onTap = () => openProfile(match.group(0)!.substring(1)),
18 ),
19 scopes: MarkdownComponent.allScopes, // safe: builder returns a TextSpan
20)
21
22// 2. buildDelimitedPattern — the delimited regex inside a custom component.
23// Discord-style ||spoiler||, hidden text available as named group 'name'.
24class SpoilerMd extends InlineMd {
25
26 RegExp get exp => InlinePattern.buildDelimitedPattern(
27 open: '||',
28 genericTokenPattern: r'[^|\n]+', // tight, non-capturing
29 );
30
31
32 Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
33
34
35 InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
36 final hidden = exp.firstMatch(text)?.namedGroup('name') ?? text;
37 return WidgetSpan(
38 alignment: PlaceholderAlignment.baseline,
39 baseline: TextBaseline.alphabetic,
40 child: SpoilerChip(text: hidden, style: config.style),
41 );
42 }
43}
44
45// Register the component — keep the built-ins:
46GptMarkdown(
47 text,
48 inlineComponents: [SpoilerMd(), ...MarkdownComponent.inlineComponents],
49)
50
51// Write genericTokenPattern as tightly as the syntax allows and use
52// non-capturing groups — a loose .+ runs past the closing delimiter and
53// swallows the rest of the line.
54
55// Empty inputs return a regex that can never match, and the renderer skips
56// the pattern entirely — server-loaded lists need no guard at the call site:
57InlinePattern.buildPrefixedPattern(
58 prefix: '#',
59 knownNames: channelsFromServer, // may be empty
60)Inline component (subclass)
Extend InlineMd when you need more control than InlinePattern provides. The span method returns an InlineSpan.
1import 'package:flutter/material.dart';
2import 'package:gpt_markdown/gpt_markdown.dart';
3
4// Renders !!SHOUT!! in uppercase bold.
5class ShoutMd extends InlineMd {
6
7 RegExp get exp => RegExp(r'!![A-Za-z]+!!');
8
9 // allScopesExceptLinkLabel prevents a WidgetSpan from nesting inside
10 // the link's own WidgetSpan — which does not paint on iOS.
11
12 Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
13
14
15 InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
16 // text is the whole matched string; re-run the regex for groups.
17 return TextSpan(
18 text: text.replaceAll('!!', '').toUpperCase(),
19 style: config.style?.copyWith(fontWeight: FontWeight.bold),
20 );
21 }
22}
23
24// Register — keep built-in inline syntax:
25GptMarkdown(
26 'This is !!important!! text.',
27 inlineComponents: [ShoutMd(), ...MarkdownComponent.inlineComponents],
28)Block component (subclass)
Extend BlockMd, override expString, and return a Widget from build. BlockMd.exp is built automatically from expString.
1import 'package:flutter/material.dart';
2import 'package:gpt_markdown/gpt_markdown.dart';
3
4// Renders :::warning\n…\n::: as a styled callout box.
5class CalloutMd extends BlockMd {
6
7 String get expString => r':::(w+)
8([sS]*?)
9:::';
10
11
12 Widget build(BuildContext context, String text, GptMarkdownConfig config) {
13 final match = exp.firstMatch(text);
14 final kind = match?.group(1) ?? 'note';
15 final body = match?.group(2) ?? '';
16
17 return Container(
18 padding: const EdgeInsets.all(12),
19 decoration: BoxDecoration(
20 color: Theme.of(context).colorScheme.surfaceContainerHighest,
21 borderRadius: BorderRadius.circular(8),
22 ),
23 child: Row(
24 crossAxisAlignment: CrossAxisAlignment.start,
25 children: [
26 Icon(kind == 'warning' ? Icons.warning : Icons.info),
27 const SizedBox(width: 8),
28 // Recurse — render body as Markdown too.
29 Flexible(child: GptMarkdown(body, style: config.style)),
30 ],
31 ),
32 );
33 }
34}
35
36// Register — keep built-in block syntax:
37GptMarkdown(
38 text,
39 components: [CalloutMd(), ...MarkdownComponent.globalComponents],
40)MarkdownScope safety
A component declares which nesting contexts it renders in. Without this, a WidgetSpan inside a link label produces a nested placeholder that does not paint on iOS — invisible text, no error, nothing in the logs.
1// MarkdownScope — where a component is allowed to render.
2//
3// enum MarkdownScope { content, linkLabel, tableCell, heading }
4//
5// allScopes — every context (default for all components)
6// allScopesExceptLinkLabel — everything except inside [label](url)
7//
8// A WidgetSpan nested inside a link's WidgetSpan does not paint on iOS.
9// Declare allScopesExceptLinkLabel on any component that returns a WidgetSpan.
10
11class MyChipMd extends InlineMd {
12
13 Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
14
15
16 RegExp get exp => RegExp(r'#[A-Za-z0-9_-]+');
17
18
19 InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
20 return WidgetSpan(
21 child: MediaQuery.withNoTextScaling(child: MyChip(text)),
22 );
23 }
24}
25
26// Alternatively, restrict to prose only:
27// scopes: const {MarkdownScope.content}MarkdownScope values
| Scope | Where |
|---|---|
| content | Ordinary document and inline text. The default. |
| linkLabel | Inside the label half of [label](url). |
| tableCell | Inside a table cell. |
| heading | Inside a # heading. |
MarkdownComponent.allScopes — all four. MarkdownComponent.allScopesExceptLinkLabel — content, tableCell, heading. InlinePattern defaults to allScopesExceptLinkLabel.
Source tags / citations
The package has first-class support for AI citation chips ([1], [2], …) common in RAG answers. Use sourceTagBuilder to replace the chip widget, or just onSourceTagTap to handle taps without replacing it. Style the default chip with styleSheet: GptMarkdownStyleSheet(sourceTag: SourceTagStyle(…)).
1// Built-in support for AI citation chips: [1], [2], …
2// sourceTagBuilder receives the content between the brackets.
3
4GptMarkdown(
5 content,
6 sourceTagBuilder: (context, content, textStyle) {
7 return GestureDetector(
8 onTap: () => openSource(content),
9 child: Container(
10 margin: const EdgeInsets.only(left: 2),
11 padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
12 decoration: BoxDecoration(
13 color: Theme.of(context).colorScheme.primaryContainer,
14 borderRadius: BorderRadius.circular(4),
15 ),
16 child: Text(
17 content,
18 style: textStyle.copyWith(fontSize: 11),
19 ),
20 ),
21 );
22 },
23 // Or use the callback without replacing the widget:
24 onSourceTagTap: (content) => showSource(content),
25)TextSpan vs WidgetSpan
Prefer a TextSpan wherever the design allows. A WidgetSpan introduces layout constraints that affect selection, line-wrapping, and iOS rendering.
1// TextSpan — prefer this when possible.
2// ✅ Stays selectable
3// ✅ Wraps across lines
4// ✅ Sits on text baseline
5// ✅ Safe inside link labels ([label](url))
6
7builder: (context, match, style) => TextSpan(
8 text: match.group(0),
9 style: style.copyWith(color: Colors.indigo),
10),
11
12// WidgetSpan — only when a real widget is needed (icon, rounded chip, image).
13// ⚠️ Cannot wrap across lines
14// ⚠️ Excluded from text selection
15// ⚠️ MUST be excluded from link labels (allScopesExceptLinkLabel) or
16// it renders as nothing on iOS — no error, no warning.
17// ⚠️ MUST suppress text scaling or it reserves far more space than needed.
18
19// Wrong at raised text scales:
20return WidgetSpan(child: MyChip());
21
22// Right — scale compensation + baseline alignment:
23return baselineWidgetSpan(MyChip());
24// or:
25return WidgetSpan(child: MediaQuery.withNoTextScaling(child: MyChip()));
26
27// InlinePattern and InlinePatternMd do scale compensation automatically.Ordering & matching caveats
1// List order controls two things:
2// 1. Which alternative the combined regex matches (first match wins).
3// 2. Which handler claims that match.
4// Earlier items win both — prepend to override a built-in.
5
6// Cache the component instances. The config compares list entries by identity:
7// a fresh list with the same instances is fine, but a new component instance
8// tells the renderer to regenerate its spans.
9late final _inline = [ShoutMd(), ...MarkdownComponent.inlineComponents];
10
11GptMarkdown(text, inlineComponents: _inline)
12
13// On failure, return the source text — never an empty span:
14// Wrong: if (match == null) return const TextSpan();
15// Right: if (match == null) return TextSpan(text: text, style: config.style);
16
17// Case sensitivity is contagious: one caseSensitive: false component makes
18// the entire combined regex case-insensitive.Patterns with a top-level | need grouping
The combined regex anchors each component as ^(?:pattern)$. Without the non-capturing group, a top-level | in a component pattern would have ^ bind to the first alternative and $ to the last — claiming matches the component does not actually cover. The package wraps your pattern in (?:…) so this is handled, but verify your component's own alternation behaves as expected.
Case sensitivity is contagious
The combined regex carries one set of flags. One component with caseSensitive: false makes the whole alternation case-insensitive — required for that component to match, but it affects the others too.
Test a custom component, including a link label
Markdown output is a span tree, so assert on the rendered RichText content. Also include a fixture inside a link label: that is where an unsafe WidgetSpan silently fails on iOS. With MarkdownComponent.allScopesExceptLinkLabel, [!!loud!!](https://x.com) must remain literal inside the label rather than becoming a nested chip.
1testWidgets('renders in caps', (tester) async {
2 await tester.pumpWidget(
3 MaterialApp(
4 home: Scaffold(
5 body: GptMarkdown(
6 'a !!loud!! word',
7 inlineComponents: [ShoutMd(), ...MarkdownComponent.inlineComponents],
8 ),
9 ),
10 ),
11 );
12 await tester.pumpAndSettle();
13
14 final buffer = StringBuffer();
15 for (final richText in tester.widgetList<RichText>(
16 find.byWidgetPredicate((widget) => widget is RichText),
17 )) {
18 buffer.write(richText.text.toPlainText(includePlaceholders: false));
19 }
20 expect(buffer.toString(), contains('LOUD'));
21
22 // Also test '[!!loud!!](https://x.com)': with
23 // allScopesExceptLinkLabel it stays literal rather than becoming a nested chip.
24})