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')),