diff --git a/README.md b/README.md
index 414f1fc..86869d9 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,9 @@ Designed to make using the Notion SDK and API easier. Notion API version 1.0.
- All headers (header levels >= 3 are treated as header level 3)
- Code blocks, with language highlighting support
- Block quotes
+ - Supports Notion callouts when blockquote starts with an emoji
+ - Automatically maps common emojis to appropriate background colors
+ - Preserves formatting and nested blocks within callouts
- Tables
- Equations
- Images
@@ -85,6 +88,10 @@ hello _world_
***
## heading2
* [x] todo
+
+> 📘 **Note:** Important _information_
+
+> Some other blockquote
`);
```
@@ -128,6 +135,11 @@ hello _world_
]
}
},
+ {
+ "object": "block",
+ "type": "divider",
+ "divider": {}
+ },
{
"object": "block",
"type": "heading_2",
@@ -172,11 +184,108 @@ hello _world_
],
"checked": true
}
+ },
+ {
+ "type": "callout",
+ "callout": {
+ "rich_text": [
+ {
+ "type": "text",
+ "text": {
+ "content": "Note:"
+ },
+ "annotations": {
+ "bold": true,
+ "strikethrough": false,
+ "underline": false,
+ "italic": false,
+ "code": false,
+ "color": "default"
+ }
+ },
+ {
+ "type": "text",
+ "text": {
+ "content": " Important "
+ }
+ },
+ {
+ "type": "text",
+ "text": {
+ "content": "information"
+ },
+ "annotations": {
+ "bold": false,
+ "strikethrough": false,
+ "underline": false,
+ "italic": true,
+ "code": false,
+ "color": "default"
+ }
+ }
+ ],
+ "icon": {
+ "type": "emoji",
+ "emoji": "📘"
+ },
+ "color": "blue_background"
+ }
+ },
+ {
+ "type": "quote",
+ "quote": {
+ "rich_text": [
+ {
+ "type": "text",
+ "text": {
+ "content": "Some other blockquote"
+ },
+ "annotations": {
+ "bold": false,
+ "strikethrough": false,
+ "underline": false,
+ "italic": false,
+ "code": false,
+ "color": "default"
+ }
+ }
+ ]
+ }
}
]
+### Working with blockquotes
+
+Martian supports two types of blockquotes:
+
+1. Standard blockquotes:
+
+```md
+> This is a regular blockquote
+> It can span multiple lines
+```
+
+2. Callouts (inspired by [ReadMe's markdown callouts](https://docs.readme.com/rdmd/docs/callouts)):
+
+```md
+> 📘 **Note:** This is a callout with a blue background
+> It supports all markdown formatting and can span multiple lines
+
+> ❗ **Warning:** This is a callout with a red background
+> Perfect for important warnings
+```
+
+Callouts are automatically detected when a blockquote starts with an emoji. The emoji determines the callout's background color:
+
+- 📘 (blue): Perfect for notes and information
+- 👍 (green): Success messages and tips
+- ❗ (red): Warnings and important notices
+- 🚧 (yellow): Work in progress or caution notices
+
+If a blockquote doesn't start with one of these emojis, it will be rendered as notion quote block.
+
### Working with Notion's limits
Sometimes a Markdown input would result in an output that would be rejected by the Notion API: here are some options to deal with that.
@@ -338,11 +447,8 @@ Error: Unsupported markdown element: {"type":"heading","depth":1,"children":[{"t
-
-
---
Built with 💙 by the team behind [Fabric](https://tryfabric.com).
-
diff --git a/src/notion/blocks.ts b/src/notion/blocks.ts
index 03391af..e476ba7 100644
--- a/src/notion/blocks.ts
+++ b/src/notion/blocks.ts
@@ -1,4 +1,4 @@
-import {richText, supportedCodeLang} from './common';
+import {richText, supportedCodeLang, supportedCalloutColor} from './common';
import {AppendBlockChildrenParameters} from '@notionhq/client/build/src/api-endpoints';
export type Block = AppendBlockChildrenParameters['children'][number];
@@ -188,3 +188,26 @@ export function equation(value: string): Block {
},
};
}
+
+export function callout(
+ text: RichText[] = [],
+ emoji = '👍',
+ color = 'default',
+ children: Block[] = []
+): Block {
+ return {
+ object: 'block',
+ type: 'callout',
+ callout: {
+ rich_text: text.length ? text : [richText('')],
+ icon: {
+ type: 'emoji',
+ // @ts-expect-error Notion API accepts emoji strings but types are not exported
+ emoji,
+ },
+ color: color as supportedCalloutColor,
+ // @ts-expect-error Typings are not perfect
+ children,
+ },
+ };
+}
diff --git a/src/notion/common.ts b/src/notion/common.ts
index dd4dec4..f9f7574 100644
--- a/src/notion/common.ts
+++ b/src/notion/common.ts
@@ -154,3 +154,33 @@ export type supportedCodeLang = typeof SUPPORTED_CODE_BLOCK_LANGUAGES[number];
export function isSupportedCodeLang(lang: string): lang is supportedCodeLang {
return (SUPPORTED_CODE_BLOCK_LANGUAGES as readonly string[]).includes(lang);
}
+
+export const SUPPORTED_CALLOUT_BLOCK_COLORS = [
+ 'default',
+ 'gray_background',
+ 'brown_background',
+ 'orange_background',
+ 'yellow_background',
+ 'green_background',
+ 'blue_background',
+ 'purple_background',
+ 'pink_background',
+ 'red_background',
+] as const;
+
+export type supportedCalloutColor =
+ typeof SUPPORTED_CALLOUT_BLOCK_COLORS[number];
+
+export function isSupportedCalloutColor(
+ color: string
+): color is supportedCalloutColor {
+ return (SUPPORTED_CALLOUT_BLOCK_COLORS as readonly string[]).includes(color);
+}
+
+export const SUPPORTED_EMOJI_COLOR_MAP: Record =
+ {
+ '👍': 'green_background',
+ '📘': 'blue_background',
+ '🚧': 'yellow_background',
+ '❗': 'red_background',
+ };
diff --git a/src/notion/index.ts b/src/notion/index.ts
index 0459bad..4fc96b3 100644
--- a/src/notion/index.ts
+++ b/src/notion/index.ts
@@ -1,4 +1,8 @@
-import {supportedCodeLang} from './common';
+import {
+ supportedCodeLang,
+ supportedCalloutColor,
+ SUPPORTED_EMOJI_COLOR_MAP,
+} from './common';
import lm from './languageMap.json';
export * from './blocks';
@@ -11,3 +15,31 @@ export function parseCodeLanguage(
? (lm as Record)[lang.toLowerCase()]
: undefined;
}
+
+/**
+ * Parses text to find a leading emoji and determines its corresponding Notion callout color
+ * Uses Unicode 15.0 emoji pattern to detect emoji at start of text
+ * @returns Emoji and color data if text starts with an emoji, null otherwise
+ */
+export function parseCalloutEmoji(
+ text: string
+): {emoji: string; color: supportedCalloutColor} | null {
+ if (!text) return null;
+
+ // Get the first line of text
+ const firstLine = text.split('\n')[0];
+
+ // Match text that starts with an emoji (with optional variation selector)
+ const match = firstLine.match(
+ /^([\p{Emoji_Presentation}\p{Extended_Pictographic}][\u{FE0F}\u{FE0E}]?).*$/u
+ );
+
+ if (!match) return null;
+
+ const emoji = match[1];
+
+ return {
+ emoji,
+ color: SUPPORTED_EMOJI_COLOR_MAP[emoji] || 'default',
+ };
+}
diff --git a/src/parser/internal.ts b/src/parser/internal.ts
index 4d565fc..a474928 100644
--- a/src/parser/internal.ts
+++ b/src/parser/internal.ts
@@ -141,6 +141,47 @@ function parseBlockquote(
element: md.Blockquote,
options: BlocksOptions
): notion.Block {
+ const firstChild = element.children[0];
+ const firstTextNode =
+ firstChild?.type === 'paragraph'
+ ? (firstChild as md.Paragraph).children[0]
+ : null;
+
+ if (firstTextNode?.type === 'text') {
+ const emojiData = notion.parseCalloutEmoji(firstTextNode.value);
+ if (emojiData) {
+ const paragraph = firstChild as md.Paragraph;
+ const richText = paragraph.children.flatMap(child => {
+ if (child === firstTextNode) {
+ const textWithoutEmoji = firstTextNode.value
+ .slice(emojiData.emoji.length)
+ .trimStart();
+ return textWithoutEmoji
+ ? (parseInline({
+ type: 'text',
+ value: textWithoutEmoji,
+ }) as notion.RichText[])
+ : [];
+ }
+ return parseInline(child) as notion.RichText[];
+ });
+
+ const children =
+ element.children.length > 1
+ ? element.children
+ .slice(1)
+ .flatMap(child => parseNode(child, options))
+ : [];
+
+ return notion.callout(
+ richText,
+ emojiData.emoji,
+ emojiData.color,
+ children
+ );
+ }
+ }
+
const children = element.children.flatMap(child => parseNode(child, options));
return notion.blockquote([], children);
}
diff --git a/test/parser.spec.ts b/test/parser.spec.ts
index f3951c0..09c271f 100644
--- a/test/parser.spec.ts
+++ b/test/parser.spec.ts
@@ -179,6 +179,62 @@ describe('gfm parser', () => {
expect(actual).toStrictEqual(expected);
});
+ it('should parse callout with emoji and formatting', () => {
+ const ast = md.root(
+ md.blockquote(
+ md.paragraph(
+ md.text('📘 '),
+ md.strong(md.text('Note:')),
+ md.text(' Important '),
+ md.emphasis(md.text('information'))
+ )
+ )
+ );
+
+ const actual = parseBlocks(ast, options);
+
+ const expected = [
+ notion.callout(
+ [
+ notion.richText('Note:', {annotations: {bold: true}}),
+ notion.richText(' Important '),
+ notion.richText('information', {annotations: {italic: true}}),
+ ],
+ '📘',
+ 'blue_background',
+ []
+ ),
+ ];
+
+ expect(actual).toStrictEqual(expected);
+ });
+
+ it('should parse callout with children blocks', () => {
+ const ast = md.root(
+ md.blockquote(
+ md.paragraph(md.text('🚧 Under Construction')),
+ md.paragraph(md.text('More details:')),
+ md.unorderedList(md.listItem(md.paragraph(md.text('Work in progress'))))
+ )
+ );
+
+ const actual = parseBlocks(ast, options);
+
+ const expected = [
+ notion.callout(
+ [notion.richText('Under Construction')],
+ '🚧',
+ 'yellow_background',
+ [
+ notion.paragraph([notion.richText('More details:')]),
+ notion.bulletedListItem([notion.richText('Work in progress')], []),
+ ]
+ ),
+ ];
+
+ expect(actual).toStrictEqual(expected);
+ });
+
it('should parse list', () => {
const ast = md.root(
md.paragraph(md.text('hello')),