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.
blockComponents
Block components
Parse custom blocks with syntax + a builder
inlinePatterns
Inline patterns
Match @mention, #channel, and :emoji: tokens
inlineDirectives
Inline directives
Protect delimited payloads from Markdown parsing
Keep the modern pipeline
In 1.3, built-in syntax stays registered automatically. Use blockComponents for new block syntax and inlinePatterns or inlineDirectives for inline extensions. Passing the deprecated components or inlineComponents lists opts into the legacy parser and can silently disable caching and lazy rendering.
1// Do not pass the deprecated legacy lists in 1.3.
2// Even an empty components list selects the legacy regex parser.
3GptMarkdown(
4 text,
5 blockComponents: [
6 MarkdownBlockComponent(
7 syntax: const FencedBlockSyntax(
8 type: 'callout',
9 opening: ':::callout',
10 ),
11 builder: (context, node, config) => CalloutBox(body: node.body),
12 ),
13 ],
14 inlinePatterns: [mentionPattern],
15)
16
17// Built-ins stay registered automatically. Use inlineDirectives when
18// the parser must not inspect a delimited payload.InlinePattern — simple pattern
The recommended API for app-specific inline tokens in v1.3.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)Legacy inline component reference
Existing InlineMd subclasses still compile until 2.0. Prefer InlinePattern for text matched in the document, or InlineDirective for opaque delimited payloads. Keeping the old subclass means keeping the legacy registration path.
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)Legacy block component reference
Existing BlockMd subclasses still work until 2.0, but they select the legacy regex parser. Use MarkdownBlockComponent with a pure MarkdownBlockSyntax instead; the modern version can be cached and rendered lazily by SliverGptMarkdown.
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 inlineSourceTagBuilder to replace the chip span, 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 inlinePatterns: [shoutPattern],
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})