Skip to content

Commit

Permalink
feat: add support for notion callout blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
kaspernowak committed Nov 29, 2024
1 parent dfae74c commit 59d44e2
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 5 deletions.
112 changes: 109 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +88,10 @@ hello _world_
***
## heading2
* [x] todo
> 📘 **Note:** Important _information_
> Some other blockquote
`);
```

Expand Down Expand Up @@ -128,6 +135,11 @@ hello _world_
]
}
},
{
"object": "block",
"type": "divider",
"divider": {}
},
{
"object": "block",
"type": "heading_2",
Expand Down Expand Up @@ -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"
}
}
]
}
}
]
</pre>
</details>

### 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.
Expand Down Expand Up @@ -338,11 +447,8 @@ Error: Unsupported markdown element: {"type":"heading","depth":1,"children":[{"t
</pre>
</details>



---

Built with 💙 by the team behind [Fabric](https://tryfabric.com).

<img src="https://static.scarf.sh/a.png?x-pxid=79ae4e0a-7e48-4965-8a83-808c009aa47a" />

25 changes: 24 additions & 1 deletion src/notion/blocks.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -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,
},
};
}
30 changes: 30 additions & 0 deletions src/notion/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, supportedCalloutColor> =
{
'👍': 'green_background',
'📘': 'blue_background',
'🚧': 'yellow_background',
'❗': 'red_background',
};
34 changes: 33 additions & 1 deletion src/notion/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,3 +15,31 @@ export function parseCodeLanguage(
? (lm as Record<string, supportedCodeLang>)[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',
};
}
41 changes: 41 additions & 0 deletions src/parser/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
56 changes: 56 additions & 0 deletions test/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down

0 comments on commit 59d44e2

Please sign in to comment.