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.

two_lists.dart
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.

inline_pattern.dart
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).

inline_pattern_prefixed.dart
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.

inline_pattern_delimited.dart
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.

pattern_helpers.dart
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.

shout_md.dart
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.

callout_md.dart
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.

markdown_scope.dart
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

ScopeWhere
contentOrdinary document and inline text. The default.
linkLabelInside the label half of [label](url).
tableCellInside a table cell.
headingInside 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(…)).

source_tags.dart
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.

span_guidance.dart
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

ordering.dart
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.

custom_component_test.dart
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})