Inline syntax
Render standard Markdown links correctly, then layer your product's own tokens—mentions, channels, emoji, issue references, or template tags—into the same flowing text.
Autolinks
Bare URLs, www. hosts, email addresses, and angle-bracket autolinks become links without pre-processing. The parser follows GFM-style boundary rules, so surrounding punctuation and balanced parentheses stay out of the destination.
1GptMarkdown(
2 'Ship it: https://pub.dev or mail ada@example.com',
3 onLinkTap: (url, title) => launchUrlString(url),
4)| Input | Result |
|---|---|
| see https://x.com. | The period stays outside the link. |
| (https://x.com) | The unbalanced closing parenthesis stays outside. |
| https://en.wikipedia.org/wiki/Foo_(bar) | Balanced parentheses stay inside. |
| www.example.com | Links as http://www.example.com. |
| ada@example.com | Links as mailto:ada@example.com. |
| **https://x.com** | Renders as a bold link; the bold markers never enter the destination. |
| `https://x.com` | Remains code, not a link. |
Angle autolinks such as <https://x.com>, <mailto:a@b.com>, and <a@b.com> follow CommonMark's deliberate-author syntax and accept any scheme. Bare links remain limited to the allowlist shown above.
1// Bare http, https, mailto, and xmpp links work automatically.
2// Add app-specific URL schemes only when your product expects them:
3GptMarkdown(
4 text,
5 autolinkSchemes: const {'myapp', 'slack'},
6)
7
8// Or leave bare URLs as plain text. Explicit [label](url) links still work.
9GptMarkdown(text, autolink: false)Rewriting bare URLs into Markdown links before rendering can capture punctuation or formatting markers. Let the inline parser see the surrounding syntax first, or turn autolink off if a legacy pre-processor must remain.
App-specific tokens with InlinePattern
InlinePattern is the recommended extension point for inline product syntax. Patterns are matched before built-in inline components, so a matching pattern deliberately takes precedence.
1import 'package:flutter/gestures.dart';
2import 'package:flutter/material.dart';
3import 'package:gpt_markdown/gpt_markdown.dart';
4
5GptMarkdown(
6 text,
7 inlinePatterns: [
8 InlinePattern(
9 pattern: RegExp(r'(?<![\w-])GH-(\d+)\b'),
10 builder: (context, match, style) => TextSpan(
11 text: match.group(0),
12 style: style.copyWith(
13 color: Theme.of(context).colorScheme.primary,
14 fontWeight: FontWeight.w600,
15 ),
16 recognizer: TapGestureRecognizer()
17 ..onTap = () => openIssue(match.group(1)!),
18 ),
19 // TextSpan is safe to opt into every Markdown scope.
20 scopes: MarkdownComponent.allScopes,
21 ),
22 ],
23)Use lookarounds and word boundaries to describe a token's actual boundaries. Patterns are matched against the whole document, so ^ and $ are rarely the right anchors.
Mentions and channels
Use InlinePattern.prefixed for @name and #channel. It knows not to claim an @ inside an email address or a # inside a URL fragment, and longer known names win over shorter ones.
1// Recommended: only known names match.
2InlinePattern.prefixed(
3 prefix: '#',
4 knownNames: myChannelNames, // ['general', 'design-review', ...]
5 builder: (context, match, style) => WidgetSpan(
6 alignment: PlaceholderAlignment.baseline,
7 baseline: TextBaseline.alphabetic,
8 child: ChannelChip(name: match.group(0)!.substring(1)),
9 ),
10)
11
12// This avoids treating #2959, a URL fragment, or a hex colour as a channel.
13//
14// Opt in only when every token is meaningful in your product:
15InlinePattern.prefixed(
16 prefix: '#',
17 knownNames: myChannelNames,
18 genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
19 builder: (context, match, style) => TextSpan(
20 text: match.group(0),
21 style: style,
22 ),
23)# fallback can turn issue numbers, mid-sentence headings, and colors into chips. Matching is case-insensitive.Emoji and delimited syntax
InlinePattern.delimited handles closing delimiters that a prefixed token cannot express. It keeps ordinary times such as 10:30:45 and URLs with ports from becoming shortcodes.
1const emoji = {'tada': '🎉', 'rocket': '🚀', 'fire': '🔥'};
2
3InlinePattern.delimited(
4 open: ':',
5 knownNames: emoji.keys,
6 builder: (context, match, style) {
7 final name = match.namedGroup('name');
8 final glyph = name == null ? null : emoji[name];
9 return TextSpan(text: glyph ?? match.group(0), style: style);
10 },
11)
12
13// Delimited patterns can also be asymmetric or multi-character:
14InlinePattern.delimited(
15 open: '{{',
16 close: '}}',
17 knownNames: templateNames,
18 builder: buildTemplateSpan,
19)The token is available as the named name group (and group 1), even when a generic token pattern includes its own groups. Boundaries prevent :tada:xyz from matching while adjacent :fire::fire: tokens still match twice. Return the raw match for an unknown name so author text never becomes an empty gap.
Helper methods: reuse the boundary rules
The regexes behind the two factories are exposed as static helpers — InlinePattern.buildPrefixedPattern and InlinePattern.buildDelimitedPattern — for consumers that build their own InlinePattern or MarkdownComponent. You keep the fiddly parts (the email/URL-fragment boundaries, longest-name-first matching, case-insensitivity) while supplying your own builder, scopes, or component.
1// Same boundary rules as InlinePattern.prefixed, your own pattern object —
2// here to opt a TextSpan-only mention into every scope, including link labels:
3InlinePattern(
4 pattern: InlinePattern.buildPrefixedPattern(
5 prefix: '@',
6 knownNames: userDirectory.handles,
7 ),
8 builder: (context, match, style) => TextSpan(
9 text: match.group(0),
10 style: style.copyWith(color: Colors.indigo, fontWeight: FontWeight.w600),
11 recognizer: TapGestureRecognizer()
12 ..onTap = () => openProfile(match.group(0)!.substring(1)),
13 ),
14 scopes: MarkdownComponent.allScopes, // safe: builder returns a TextSpan
15)1// The delimited regex inside a custom MarkdownComponent — Discord-style
2// ||spoiler|| text, with the token available as the named group 'name':
3class SpoilerMd extends InlineMd {
4
5 RegExp get exp => InlinePattern.buildDelimitedPattern(
6 open: '||',
7 genericTokenPattern: r'[^|\n]+', // tight, non-capturing
8 );
9
10 // Returns a WidgetSpan — keep it out of link labels (iOS paint safety).
11
12 Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
13
14
15 InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
16 final hidden = exp.firstMatch(text)?.namedGroup('name') ?? text;
17 return WidgetSpan(
18 alignment: PlaceholderAlignment.baseline,
19 baseline: TextBaseline.alphabetic,
20 child: SpoilerChip(text: hidden, style: config.style),
21 );
22 }
23}In a delimited pattern the token name is captured as the named group name (and as group 1) before any group inside your genericTokenPattern, whatever that pattern contains. Write the generic pattern as tightly as the syntax allows and use non-capturing groups in it — a loose pattern such as .+ runs past the closing delimiter and swallows the rest of the line.
1// Empty inputs produce a regex that can never match, so an app with no
2// channels yet renders its text untouched — no guard needed at the call site:
3InlinePattern.prefixed(
4 prefix: '#',
5 knownNames: channelsFromServer, // may be empty
6 builder: buildChannelSpan,
7)Both helpers return a regex that can never match when knownNames is empty and genericTokenPattern is null, and the renderer skips such a pattern entirely — so lists that load from a server can be passed straight through.
TextSpan, WidgetSpan, and scopes
Prefer a TextSpan whenever your token can be text. It wraps, participates in selection, and aligns to the surrounding baseline. A WidgetSpan is for a real chip, icon, or image.
1// A TextSpan wraps, remains selectable, and stays on the text baseline.
2builder: (context, match, style) => TextSpan(
3 text: match.group(0),
4 style: style,
5)
6
7// Use WidgetSpan only for actual UI. The package compensates for text scaling
8// when InlinePattern returns one:
9builder: (context, match, style) => WidgetSpan(
10 alignment: PlaceholderAlignment.middle,
11 child: Icon(Icons.tag, size: (style.fontSize ?? 14) * 1.15),
12)A widget inside a Markdown link label becomes a nested placeholder and can disappear on iOS. Patterns default to MarkdownComponent.allScopesExceptLinkLabel; keep that default for widget-based UI. Without it, [#design](https://example.com) can become blank on iOS with no visible error. Opt into MarkdownComponent.allScopes only when returning a safe TextSpan.
Common mistakes
- Returning an empty span for an unknown token. Return the original match so the author's text never vanishes.
- Building the pattern list on every frame. Cache it in a field or make it
const; list identity participates in rendering work. - Using a custom component for a simple token. Start with
InlinePattern; use a component only for genuinely new Markdown syntax.
