For syntax the package does not know about.
[!TIP] Reach for this last. For
@mention-style tokens useInlinePattern— no subclassing, and it gets the nesting rules right for free. For appearance use a style object. A custom component is for genuinely new syntax.
[!IMPORTANT] 1.3.0 deprecates the legacy regex extension points: the
componentsandinlineComponentswidget arguments, theInlineMdandBlockMdbase classes, and theMarkdownComponent.globalComponentsandMarkdownComponent.inlineComponentslists. They keep working exactly as they did and are scheduled for removal in 2.0.0, but nothing new should be written against them. Migration has the before and after.
| What you are adding | Current route | Deprecated route |
|---|---|---|
| A block syntax | blockComponents, with MarkdownBlockComponent |
components, with a BlockMd subclass |
| An app-specific inline token | inlinePatterns, with InlinePattern |
inlineComponents, with an InlineMd subclass |
| A delimited payload that must not be parsed | inlineDirectives, with InlineDirective |
— |
The difference is not only spelling. Passing components or inlineComponents
— even an empty list — switches the widget to the legacy regex parser, which
ignores blockComponents and gives up the incremental segment cache, the
span-level streaming reveal and lazy sliver rendering. incremental: false does
the same. The current route keeps all three.
blockComponents registers a syntax and its renderer without switching off the
default parser or its segment caches.
// Keep this list in a field, rather than recreate it on every streamed chunk.
final blocks = <MarkdownBlockComponent>[
MarkdownBlockComponent(
syntax: const FencedBlockSyntax(
type: 'warning',
opening: ':::warning',
closing: ':::',
),
builder: (context, node, config) => Container(
padding: const EdgeInsets.all(12),
color: Colors.amber.shade100,
child: Text(node.body, style: config.style),
),
),
];
GptMarkdown(source, blockComponents: blocks);
This recognizes :::warning on its own line through a closing ::: line.
Blank lines stay inside the block, and node.closed is false while incomplete.
The body is opaque to inline patterns, directives, and dollar-math rewriting;
same-fence nesting is not interpreted. Custom blocks are
atomic for character reveal and can use the existing blockAnimation entrance.
For another grammar, subclass MarkdownBlockSyntax, supply a nonempty type
and prefix, and return MarkdownBlockMatch(node: MdCustomBlock(...),
endLine: exclusiveEnd). Return null to decline a match. The parser must be pure,
handle incomplete input, consume at least one line, and inspect only its consumed
region. An unfinished container should consume all remaining lines. Store any
extra immutable parsed data in node.data; the builder consumes it without
reparsing. Registrations must have unique types. Rules are tried in registration
order before built-ins, gated by their opening prefixes, and a match interrupts
an open paragraph — no blank line is needed before :::warning. Built-in
code-fence bodies remain opaque.
The registry is intentionally for local block syntax. Cross-document rules such as a later definition changing earlier blocks need a different invalidation strategy and should not be implemented by secretly inspecting other segments. Treat registered lists as immutable and replace component entries when behavior changes. See rendering architecture for caching and long-document rendering.
Inline syntax needs no component and no subclass. InlinePattern takes a regex
and a builder, works on both pipelines, and defaults to excluding link labels —
the nesting rule that is easiest to get wrong by hand.
// Keep this in a field too: pattern lists are compared by element identity.
final shout = <InlinePattern>[
InlinePattern(
pattern: RegExp(r'!![A-Za-z]+!!'),
builder: (context, match, style) => TextSpan(
text: match.group(0)!.replaceAll('!!', '').toUpperCase(),
style: style.copyWith(fontWeight: FontWeight.bold),
),
),
];
GptMarkdown('This is !!important!! text.', inlinePatterns: shout);
InlinePattern.prefixed and InlinePattern.delimited already carry the
boundary rules for @name, #channel and :emoji:. Use inlineDirectives
when a delimited payload has to reach the builder verbatim and must never be
read as Markdown. Inline syntax covers both, including
scopes and the WidgetSpan rules.
Everything in this section still works and behaves as it did in 1.2.x. It is documented so that a codebase already on it can understand what it has; it is not the route to take for new syntax, and removal is scheduled for 2.0.0.
Passing either components or inlineComponents selects the legacy parser. If
blockComponents is also supplied, the legacy lists take precedence and the
modern block extensions are ignored. Explicit incremental: false selects the
legacy parser too, as long as span reveal is off — an animation other than
GptMarkdownAnimation.none forces the modern path back on, because that is the
only pipeline the span-level reveal exists on. Modern block extensions
supplement the built-ins; a legacy component list replaces them, as below.
GptMarkdown(
text,
components: [...], // block pass: headings, lists, tables, fences
inlineComponents: [...], // inline pass: bold, links, code, images
)
[!WARNING] Passing a list replaces the defaults. Build on top of them or you lose every built-in construct:
// Wrong — bold, links and code stop working
inlineComponents: [MyComponent()],
// Right
inlineComponents: [MyComponent(), ...MarkdownComponent.inlineComponents],
MarkdownComponent.globalComponents is the same list for the block pass. Both
are deprecated alongside the arguments they are spread into.
InlineMdSay you want !!shout!! to render in caps. On the current route this is the
InlinePattern above; the legacy equivalent is a subclass:
class ShoutMd extends InlineMd {
@override
RegExp get exp => RegExp(r'!![A-Za-z]+!!');
@override
Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
@override
InlineSpan span(
BuildContext context,
String text,
GptMarkdownConfig config,
) {
return TextSpan(
text: text.replaceAll('!!', '').toUpperCase(),
style: config.style?.copyWith(fontWeight: FontWeight.bold),
);
}
}
GptMarkdown(
'This is !!important!! text.',
inlineComponents: [ShoutMd(), ...MarkdownComponent.inlineComponents],
)
text is the whole matched string, so re-run your regex if you need groups:
final match = exp.firstMatch(text);
final inner = match?.group(1) ?? text;
BlockMdExtend BlockMd, override expString and return a widget. The current route
for the same callout is a FencedBlockSyntax on blockComponents, which keeps
the segment cache and does not need the body re-parsed by a nested widget:
class CalloutMd extends BlockMd {
@override
String get expString => r':::(\w+)\n([\s\S]*?)\n:::';
@override
Widget build(
BuildContext context,
String text,
GptMarkdownConfig config,
) {
final match = exp.firstMatch(text);
final kind = match?.group(1) ?? 'note';
final body = match?.group(2) ?? '';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(kind == 'warning' ? Icons.warning : Icons.info),
const SizedBox(width: 8),
// Render the body as Markdown too, in the host's direction.
Flexible(
child: GptMarkdown(
body,
style: config.style,
textDirection: config.textDirection,
),
),
],
),
);
}
}
A nested GptMarkdown installs a Directionality of its own around everything
it renders, and textDirection defaults to TextDirection.ltr. Leave it out
and the callout body flips back to LTR inside an RTL document. Pass
config.textDirection down, as the built-in block components do.
These follow from how the legacy parser works: one combined regex built out of
the whole component list, and a subclass that has to declare by hand what
InlinePattern already handles.
@override
Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
[!WARNING] Without this a component fires everywhere, including inside link labels. If it returns a
WidgetSpan, that nests a placeholder inside the link’s own placeholder — which does not paint on iOS. The text is invisible, with no error and nothing in the logs.
InlinePattern defaults to allScopesExceptLinkLabel already.
List order decides two different things: which alternative the combined regex matches at a given position, and which handler claims the match. Earlier wins both times, so prepend to override:
inlineComponents: [MyLinkMd(), ...MarkdownComponent.inlineComponents],
[!WARNING] A paragraph lays inline children out in scaled space — it hands them
maxWidth / scaleand multiplies the reported size back. A child that also scales its own text is counted twice, and at a 2× system font setting can reserve many times the space it needs.
// Wrong at raised text scales
return WidgetSpan(child: MyChip());
// Right
return WidgetSpan(child: MediaQuery.withNoTextScaling(child: MyChip()));
// Also right, and baseline-aligned
return baselineWidgetSpan(MyChip());
InlinePattern does this for you.
The combined regex carries one set of flags. One component declaring
caseSensitive: false makes the whole alternation case-insensitive — required
for it to match at all, but be aware it affects the others.
A builder that gives up has to hand back the text the author typed, or it vanishes from the document with no warning:
// Wrong
if (match == null) return const TextSpan();
// Right, in an InlineMd subclass
if (match == null) return TextSpan(text: text, style: config.style);
// Right, in an InlinePattern builder
if (glyph == null) return TextSpan(text: match.group(0), style: style);
The package does the same for malformed links.
[!WARNING]
inlinePatterns,blockComponentsand the legacy component lists are all compared by element identity. Building one inline inbuildcreates new instances every frame, regenerating every span.
// Wrong
GptMarkdown(text, inlinePatterns: [InlinePattern(...)])
// Right
late final _patterns = [InlinePattern(...)];
GptMarkdown(text, inlinePatterns: _patterns)
The rendering pipeline owns scaling at the paragraph boundary. Standalone
blocks inherit the document’s MediaQuery.textScaler; a WidgetSpan child
receives disabled ambient scaling because Flutter scales its entire box.
This applies to built-in blocks, custom block renderers, inline patterns and
inline directives. Nested blocks retain the same rule.
Return Text with the original font size from custom builders. Do not capture
the outer context’s scaler and apply it again to a widget inside a paragraph.
For custom painters or math engines that do not use Text, resolve glyph sizes
at widget build time with MarkdownTextScaling.fontSize(context, baseSize).
That helper uses the effective scaler below the boundary and supports nonlinear
scalers. Images and decorations are not text; fixed block padding, borders and
control icon sizes need not grow with the font.
testWidgets('renders in caps', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: GptMarkdown('a !!loud!! word', inlinePatterns: shout),
),
),
);
await tester.pumpAndSettle();
// Markdown renders as spans, not Text widgets — read the span tree.
final buffer = StringBuffer();
for (final rt in tester.widgetList<RichText>(
find.byWidgetPredicate((w) => w is RichText),
)) {
buffer.write(rt.text.toPlainText(includePlaceholders: false));
}
expect(buffer.toString(), contains('LOUD'));
});
Add a case for your pattern or component inside a link label, since that is the one that fails silently:
await tester.pumpWidget(/* … '[!!loud!!](https://x.com)' … */);
// With allScopesExceptLinkLabel it should stay literal, not become a chip.
More in testing.