├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── scripts └── languageMap.ts ├── src ├── index.ts ├── markdown │ ├── ast.ts │ ├── index.ts │ └── types.ts ├── notion │ ├── blocks.ts │ ├── common.ts │ ├── index.ts │ └── languageMap.json └── parser │ └── internal.ts ├── test ├── fixtures │ ├── complex-items.md │ ├── divider.md │ ├── images.md │ ├── large-item.md │ ├── list.md │ ├── math.md │ └── table.md ├── integration.spec.ts └── parser.spec.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | jest.config.ts 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - './node_modules/gts/' 3 | rules: 4 | '@typescript-eslint/no-namespace': off 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x, 22.x, 24.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | publish-npm: 9 | name: Publish on NPM 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Node.js for NPM 16 | uses: actions/setup-node@v4 17 | with: 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - run: npm ci 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | 25 | publish-gpr: 26 | name: Publish on GPR 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up Node.js for GPR 33 | uses: actions/setup-node@v4 34 | with: 35 | registry-url: 'https://npm.pkg.github.com/' 36 | 37 | - run: npm ci 38 | - run: npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /node_modules/ 3 | /.idea/ 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json') 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hi@tryfabric.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Instantish, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Martian: Markdown to Notion Parser 2 | 3 | Convert Markdown and GitHub Flavoured Markdown to Notion API Blocks and RichText. 4 | 5 | [![Node.js CI](https://github.com/tryfabric/martian/actions/workflows/ci.yml/badge.svg)](https://github.com/tryfabric/martian/actions/workflows/ci.yml) 6 | [![Code Style: Google](https://img.shields.io/badge/code%20style-google-blueviolet.svg)](https://github.com/google/gts) 7 | 8 | Martian is a Markdown parser to convert any Markdown content to Notion API block or RichText objects. It 9 | uses [unified](https://github.com/unifiedjs/unified) to create a Markdown AST, then converts the AST into Notion 10 | objects. 11 | 12 | Designed to make using the Notion SDK and API easier. Notion API version 1.0. 13 | 14 | ### Supported Markdown Elements 15 | 16 | - All inline elements (italics, bold, strikethrough, inline code, hyperlinks, equations) 17 | - Lists (ordered, unordered, checkboxes) - to any level of depth 18 | - All headers (header levels >= 3 are treated as header level 3) 19 | - Code blocks, with language highlighting support 20 | - Block quotes 21 | - Supports GFM alerts (e.g. [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) 22 | - Supports Notion callouts when blockquote starts with an emoji (optional, enabled with `enableEmojiCallouts`) 23 | - Automatically maps common emojis and alert types to appropriate background colors 24 | - Preserves formatting and nested blocks within callouts 25 | - Tables 26 | - Equations 27 | - Images 28 | - Inline images are extracted from the paragraph and added afterwards (as these are not supported in notion) 29 | - Image urls are validated, if they are not valid as per the Notion external spec, they will be inserted as text for you to fix manually 30 | 31 | ## Usage 32 | 33 | ### Basic usage: 34 | 35 | The package exports two functions, which you can import like this: 36 | 37 | ```ts 38 | // JS 39 | const {markdownToBlocks, markdownToRichText} = require('@tryfabric/martian'); 40 | // TS 41 | import {markdownToBlocks, markdownToRichText} from '@tryfabric/martian'; 42 | ``` 43 | 44 | Here are couple of examples with both of them: 45 | 46 | ```ts 47 | markdownToRichText(`**Hello _world_**`); 48 | ``` 49 | 50 |
51 | Result 52 |
 53 | [
 54 |   {
 55 |     "type": "text",
 56 |     "annotations": {
 57 |       "bold": true,
 58 |       "strikethrough": false,
 59 |       "underline": false,
 60 |       "italic": false,
 61 |       "code": false,
 62 |       "color": "default"
 63 |     },
 64 |     "text": {
 65 |       "content": "Hello "
 66 |     }
 67 |   },
 68 |   {
 69 |     "type": "text",
 70 |     "annotations": {
 71 |       "bold": true,
 72 |       "strikethrough": false,
 73 |       "underline": false,
 74 |       "italic": true,
 75 |       "code": false,
 76 |       "color": "default"
 77 |     },
 78 |     "text": {
 79 |       "content": "world"
 80 |     }
 81 |   }
 82 | ]
 83 | 
84 |
85 | 86 | ```ts 87 | markdownToBlocks(` 88 | hello _world_ 89 | *** 90 | ## heading2 91 | * [x] todo 92 | 93 | > 📘 **Note:** Important _information_ 94 | 95 | > Some other blockquote 96 | `); 97 | ``` 98 | 99 |
100 | Result 101 |
102 | [
103 |   {
104 |     "object": "block",
105 |     "type": "paragraph",
106 |     "paragraph": {
107 |       "rich_text": [
108 |         {
109 |           "type": "text",
110 |           "annotations": {
111 |             "bold": false,
112 |             "strikethrough": false,
113 |             "underline": false,
114 |             "italic": false,
115 |             "code": false,
116 |             "color": "default"
117 |           },
118 |           "text": {
119 |             "content": "hello "
120 |           }
121 |         },
122 |         {
123 |           "type": "text",
124 |           "annotations": {
125 |             "bold": false,
126 |             "strikethrough": false,
127 |             "underline": false,
128 |             "italic": true,
129 |             "code": false,
130 |             "color": "default"
131 |           },
132 |           "text": {
133 |             "content": "world"
134 |           }
135 |         }
136 |       ]
137 |     }
138 |   },
139 |   {
140 |     "object": "block",
141 |     "type": "divider",
142 |     "divider": {}
143 |   },
144 |   {
145 |     "object": "block",
146 |     "type": "heading_2",
147 |     "heading_2": {
148 |       "rich_text": [
149 |         {
150 |           "type": "text",
151 |           "annotations": {
152 |             "bold": false,
153 |             "strikethrough": false,
154 |             "underline": false,
155 |             "italic": false,
156 |             "code": false,
157 |             "color": "default"
158 |           },
159 |           "text": {
160 |             "content": "heading2"
161 |           }
162 |         }
163 |       ]
164 |     }
165 |   },
166 |   {
167 |     "object": "block",
168 |     "type": "to_do",
169 |     "to_do": {
170 |       "rich_text": [
171 |         {
172 |           "type": "text",
173 |           "annotations": {
174 |             "bold": false,
175 |             "strikethrough": false,
176 |             "underline": false,
177 |             "italic": false,
178 |             "code": false,
179 |             "color": "default"
180 |           },
181 |           "text": {
182 |             "content": "todo"
183 |           }
184 |         }
185 |       ],
186 |       "checked": true
187 |     }
188 |   },
189 |   {
190 |     "type": "callout",
191 |     "callout": {
192 |       "rich_text": [
193 |         {
194 |           "type": "text",
195 |           "text": {
196 |             "content": "Note:"
197 |           },
198 |           "annotations": {
199 |             "bold": true,
200 |             "strikethrough": false,
201 |             "underline": false,
202 |             "italic": false,
203 |             "code": false,
204 |             "color": "default"
205 |           }
206 |         },
207 |         {
208 |           "type": "text",
209 |           "text": {
210 |             "content": " Important "
211 |           }
212 |         },
213 |         {
214 |           "type": "text",
215 |           "text": {
216 |             "content": "information"
217 |           },
218 |           "annotations": {
219 |             "bold": false,
220 |             "strikethrough": false,
221 |             "underline": false,
222 |             "italic": true,
223 |             "code": false,
224 |             "color": "default"
225 |           }
226 |         }
227 |       ],
228 |       "icon": {
229 |         "type": "emoji",
230 |         "emoji": "📘"
231 |       },
232 |       "color": "blue_background"
233 |     }
234 |   },
235 |   {
236 |     "type": "quote",
237 |     "quote": {
238 |       "rich_text": [
239 |         {
240 |           "type": "text",
241 |           "text": {
242 |             "content": "Some other blockquote"
243 |           },
244 |           "annotations": {
245 |             "bold": false,
246 |             "strikethrough": false,
247 |             "underline": false,
248 |             "italic": false,
249 |             "code": false,
250 |             "color": "default"
251 |           }
252 |         }
253 |       ]
254 |     }
255 |   }
256 | ]
257 | 
258 |
259 | 260 | ### Working with blockquotes 261 | 262 | Martian supports three types of blockquotes: 263 | 264 | 1. Standard blockquotes: 265 | 266 | ```md 267 | > This is a regular blockquote 268 | > It can span multiple lines 269 | ``` 270 | 271 | 2. GFM alerts (based on [GFM Alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)): 272 | 273 | ```md 274 | > [!NOTE] 275 | > Important information that users should know 276 | 277 | > [!WARNING] 278 | > Critical information that needs attention 279 | ``` 280 | 281 | 3. Emoji-style callouts (optional) (based on [ReadMe's markdown callouts](https://docs.readme.com/rdmd/docs/callouts)): 282 | 283 | ```md 284 | > 📘 **Note:** This is a callout with a blue background 285 | > It supports all markdown formatting and can span multiple lines 286 | 287 | > ❗ **Warning:** This is a callout with a red background 288 | > Perfect for important warnings 289 | ``` 290 | 291 | #### GFM Alerts 292 | 293 | GFM alerts are automatically converted to Notion callouts with appropriate icons and colors: 294 | 295 | - NOTE (📘, blue): Useful information that users should know 296 | - TIP (💡, green): Helpful advice for doing things better 297 | - IMPORTANT (☝️, purple): Key information users need to know 298 | - WARNING (⚠️, yellow): Urgent info that needs immediate attention 299 | - CAUTION (❗, red): Advises about risks or negative outcomes 300 | 301 | #### Emoji-style Callouts 302 | 303 | By default, emoji-style callouts are disabled. You can enable them using the `enableEmojiCallouts` option: 304 | 305 | ```ts 306 | const options = { 307 | enableEmojiCallouts: true, 308 | }; 309 | ``` 310 | 311 | When enabled, callouts are detected when a blockquote starts with an emoji. The emoji determines the callout's background color. The current supported color mappings are: 312 | 313 | - 📘 (blue): Perfect for notes and information 314 | - 👍 (green): Success messages and tips 315 | - ❗ (red): Warnings and important notices 316 | - 🚧 (yellow): Work in progress or caution notices 317 | 318 | All other emojis will have a default background color. The supported emoji color mappings can be expanded easily if needed. 319 | 320 | If a blockquote doesn't match either GFM alert syntax or emoji-style callout syntax (when enabled), it will be rendered as a Notion quote block. 321 | 322 | ##### Examples 323 | 324 | Standard blockquote: 325 | 326 | ```ts 327 | markdownToBlocks('> A regular blockquote'); 328 | ``` 329 | 330 |
331 | Result 332 |
333 | [
334 |   {
335 |     "object": "block",
336 |     "type": "quote",
337 |     "quote": {
338 |       "rich_text": [
339 |         {
340 |           "type": "text",
341 |           "text": {
342 |             "content": "A regular blockquote"
343 |           }
344 |         }
345 |       ]
346 |     }
347 |   }
348 | ]
349 | 
350 |
351 | 352 | GFM alert: 353 | 354 | ```ts 355 | markdownToBlocks('> [!NOTE]\n> Important information'); 356 | ``` 357 | 358 |
359 | Result 360 |
361 | [
362 |   {
363 |     "object": "block",
364 |     "type": "callout",
365 |     "callout": {
366 |       "rich_text": [
367 |         {
368 |           "type": "text",
369 |           "text": {
370 |             "content": "Note"
371 |           }
372 |         }
373 |       ],
374 |       "icon": {
375 |         "type": "emoji",
376 |         "emoji": "ℹ️"
377 |       },
378 |       "color": "blue_background",
379 |       "children": [
380 |         {
381 |           "type": "paragraph",
382 |           "paragraph": {
383 |             "rich_text": [
384 |               {
385 |                 "type": "text",
386 |                 "text": {
387 |                   "content": "Important information"
388 |                 }
389 |               }
390 |             ]
391 |           }
392 |         }
393 |       ]
394 |     }
395 |   }
396 | ]
397 | 
398 |
399 | 400 | Emoji-style callout (with `enableEmojiCallouts: true`): 401 | 402 | ```ts 403 | markdownToBlocks('> 📘 Note: Important information', { 404 | enableEmojiCallouts: true, 405 | }); 406 | ``` 407 | 408 |
409 | Result 410 |
411 | [
412 |   {
413 |     "object": "block",
414 |     "type": "callout",
415 |     "callout": {
416 |       "rich_text": [
417 |         {
418 |           "type": "text",
419 |           "text": {
420 |             "content": "Note: Important information"
421 |           }
422 |         }
423 |       ],
424 |       "icon": {
425 |         "type": "emoji",
426 |         "emoji": "📘"
427 |       },
428 |       "color": "blue_background"
429 |     }
430 |   }
431 | ]
432 | 
433 |
434 | 435 | ### Working with Notion's limits 436 | 437 | 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. 438 | 439 | #### An item exceeds the children or character limit 440 | 441 | By default, the package will try to resolve these kind of issues by re-distributing the content to multiple blocks: when that's not possible, `martian` will truncate the output to avoid your request resulting in an error. 442 | If you want to disable this kind of behavior, you can use this option: 443 | 444 | ```ts 445 | const options = { 446 | notionLimits: { 447 | truncate: false, 448 | }, 449 | }; 450 | 451 | markdownToBlocks('input', options); 452 | markdownToRichText('input', options); 453 | ``` 454 | 455 | #### Manually handling errors related to Notions's limits 456 | 457 | You can set a callback for when one of the resulting items would exceed Notion's limits. Please note that this function will be called regardless of whether the final output will be truncated. 458 | 459 | ```ts 460 | const options = { 461 | notionLimits: { 462 | // truncate: true, // by default 463 | onError: (err: Error) => { 464 | // Something has appened! 465 | console.error(err); 466 | }, 467 | }, 468 | }; 469 | 470 | markdownToBlocks('input', options); 471 | markdownToRichText('input', options); 472 | ``` 473 | 474 | ### Working with images 475 | 476 | If an image as an invalid URL, the Notion API will reject the whole request: `martian` prevents this issue by converting images with invalid links into text, so that request are successfull and you can fix the links later. 477 | If you want to disable this kind of behavior, you can use this option: 478 | 479 | ```ts 480 | const options = { 481 | strictImageUrls: false, 482 | }; 483 | ``` 484 | 485 | Default behavior: 486 | 487 | ```ts 488 | markdownToBlocks('![](InvalidURL)'); 489 | ``` 490 | 491 |
492 | Result 493 |
494 | [
495 |   {
496 |     "object": "block",
497 |     "type": "paragraph",
498 |     "paragraph": {
499 |       "rich_text": [
500 |         {
501 |           "type": "text",
502 |           "annotations": {
503 |             "bold": false,
504 |             "strikethrough": false,
505 |             "underline": false,
506 |             "italic": false,
507 |             "code": false,
508 |             "color": "default"
509 |           },
510 |           "text": {
511 |             "content": "InvalidURL"
512 |           }
513 |         }
514 |       ]
515 |     }
516 |   }
517 | ]
518 | 
519 |
520 | 521 | `strictImageUrls` disabled: 522 | 523 | ```ts 524 | markdownToBlocks('![](InvalidURL)', { 525 | strictImageUrls: false, 526 | }); 527 | ``` 528 | 529 |
530 | Result 531 |
532 | [
533 |   {
534 |     "object": "block",
535 |     "type": "image",
536 |     "image": {
537 |       "type": "external",
538 |       "external": {
539 |         "url": "InvalidURL"
540 |       }
541 |     }
542 |   }
543 | ]
544 | 
545 |
546 | 547 | ### Non-inline elements when parsing rich text 548 | 549 | By default, if the text provided to `markdownToRichText` would result in one or more non-inline elements, the package will ignore those and only parse paragraphs. 550 | You can make the package throw an error when a non-inline element is detected by setting the `nonInline` option to `'throw'`. 551 | 552 | Default behavior: 553 | 554 | ```ts 555 | markdownToRichText('# Header\nAbc', { 556 | // nonInline: 'ignore', // Default 557 | }); 558 | ``` 559 | 560 |
561 | Result 562 |
563 | [
564 |   {
565 |     type: 'text',
566 |     annotations: {
567 |       bold: false,
568 |       strikethrough: false,
569 |       underline: false,
570 |       italic: false,
571 |       code: false,
572 |       color: 'default'
573 |     },
574 |     text: { content: 'Abc', link: undefined }
575 |   }
576 | ]
577 | 
578 |
579 | 580 | Throw an error: 581 | 582 | ```ts 583 | markdownToRichText('# Header\nAbc', { 584 | nonInline: 'throw', 585 | }); 586 | ``` 587 | 588 |
589 | Result 590 |
591 | Error: Unsupported markdown element: {"type":"heading","depth":1,"children":[{"type":"text","value":"Header","position":{"start":{"line":1,"column":3,
592 | "offset":2},"end":{"line":1,"column":9,"offset":8}}}],"position":{"start":{"line":1,"column":1,"offset":0},"end":{"line":1,"column":9,"offset":8}}}  
593 | 
594 |
595 | 596 | --- 597 | 598 | Built with 💙 by the team behind [Fabric](https://tryfabric.com). 599 | 600 | 601 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: 'ts-jest', 3 | testPathIgnorePatterns: ['/node_modules/', '/build/'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.spec.ts'], 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tryfabric/martian", 3 | "version": "1.2.4", 4 | "description": "Converts Markdown to Notion Blocks and RichText", 5 | "repository": "https://github.com/tryfabric/martian", 6 | "main": "build/src/index.js", 7 | "types": "build/src/index.d.ts", 8 | "scripts": { 9 | "test": "jest", 10 | "lint": "gts lint", 11 | "clean": "gts clean", 12 | "precompile": "ts-node scripts/languageMap.ts", 13 | "compile": "tsc", 14 | "fix": "gts fix", 15 | "prepare": "npm run compile", 16 | "pretest": "npm run compile", 17 | "posttest": "npm run lint" 18 | }, 19 | "keywords": [ 20 | "markdown", 21 | "notion", 22 | "parser", 23 | "gfm" 24 | ], 25 | "engines": { 26 | "node": ">=20" 27 | }, 28 | "author": "Richard Robinson", 29 | "license": "ISC", 30 | "dependencies": { 31 | "@notionhq/client": "^3.1.2", 32 | "remark-gfm": "^1.0.0", 33 | "remark-math": "^4.0.0", 34 | "remark-parse": "^9.0.0", 35 | "unified": "^9.2.1" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^29.5.14", 39 | "@types/node": "^20.17.51", 40 | "gts": "^6.0.2", 41 | "jest": "^29.7.0", 42 | "linguist-languages": "^7.15.0", 43 | "ts-jest": "^29.3.4", 44 | "ts-node": "^10.0.0", 45 | "typescript": "^5.8.3" 46 | }, 47 | "publishConfig": { 48 | "access": "public" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/languageMap.ts: -------------------------------------------------------------------------------- 1 | // This script is responsible for generating src/notion/languageMap.json 2 | 3 | /* eslint-disable n/no-unpublished-import */ 4 | import l, {Language} from 'linguist-languages'; 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | import {supportedCodeLang} from '../src/notion'; 8 | 9 | export const languages: Record< 10 | supportedCodeLang, 11 | Language | Language[] | undefined 12 | > = { 13 | abap: l.ABAP, 14 | arduino: undefined, // Handled as C++ 15 | bash: l.Shell, 16 | basic: l.BASIC, 17 | c: l.C, 18 | clojure: l.Clojure, 19 | coffeescript: l.CoffeeScript, 20 | 'c++': l['C++'], 21 | 'c#': l['C#'], 22 | css: l.CSS, 23 | dart: l.Dart, 24 | diff: l.Diff, 25 | docker: l.Dockerfile, 26 | elixir: l.Elixir, 27 | elm: l.Elm, 28 | erlang: l.Erlang, 29 | flow: undefined, // Handled as JavaScript 30 | fortran: l.Fortran, 31 | 'f#': l['F#'], 32 | gherkin: l.Gherkin, 33 | glsl: l.GLSL, 34 | go: l.Go, 35 | graphql: l.GraphQL, 36 | groovy: l.Groovy, 37 | haskell: l.Haskell, 38 | html: l.HTML, 39 | java: l.Java, 40 | javascript: l.JavaScript, 41 | json: l.JSON, 42 | julia: l.Julia, 43 | kotlin: l.Kotlin, 44 | latex: l.TeX, 45 | less: l.Less, 46 | lisp: l['Common Lisp'], 47 | livescript: l.LiveScript, 48 | lua: l.Lua, 49 | makefile: l.Makefile, 50 | markdown: l.Markdown, 51 | markup: undefined, // Handled as ? 52 | matlab: l.MATLAB, 53 | mermaid: undefined, // Handled as Markdown 54 | nix: l.Nix, 55 | 'objective-c': l['Objective-C'], 56 | ocaml: l.OCaml, 57 | pascal: l.Pascal, 58 | perl: l.Perl, 59 | php: l.PHP, 60 | 'plain text': undefined, 61 | powershell: l.PowerShell, 62 | prolog: l.Prolog, 63 | protobuf: l['Protocol Buffer'], 64 | python: l.Python, 65 | r: l.R, 66 | reason: l.Reason, 67 | ruby: l.Ruby, 68 | rust: l.Rust, 69 | sass: l.Sass, 70 | scala: l.Scala, 71 | scheme: l.Scheme, 72 | scss: l.SCSS, 73 | shell: l.Shell, 74 | sql: l.SQL, 75 | swift: l.Swift, 76 | typescript: l.TypeScript, 77 | 'vb.net': l['Visual Basic .NET'], 78 | verilog: l.Verilog, 79 | vhdl: l.VHDL, 80 | 'visual basic': undefined, // Handled as VB.Net 81 | webassembly: l.WebAssembly, 82 | xml: l.XML, 83 | yaml: l.YAML, 84 | 'java/c/c++/c#': l.Java, // Other languages have their own tag 85 | }; 86 | 87 | const map: Record = {}; 88 | 89 | Object.entries(languages).forEach(([notionKey, value]) => { 90 | ([value].flat().filter(e => !!e) as Language[]).forEach(lang => { 91 | map[lang.aceMode] = notionKey; 92 | lang.aliases?.forEach(alias => { 93 | map[alias] = notionKey; 94 | }); 95 | }); 96 | }); 97 | 98 | fs.writeFileSync( 99 | path.join(__dirname, '../src/notion/languageMap.json'), 100 | JSON.stringify(map, null, 2), 101 | ); 102 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import unified from 'unified'; 2 | import markdown from 'remark-parse'; 3 | import type * as notion from './notion'; 4 | import { 5 | BlocksOptions, 6 | parseBlocks, 7 | parseRichText, 8 | RichTextOptions, 9 | } from './parser/internal'; 10 | import type * as md from './markdown'; 11 | import gfm from 'remark-gfm'; 12 | import remarkMath from 'remark-math'; 13 | 14 | /** 15 | * Parses Markdown content into Notion Blocks. 16 | * 17 | * @param body Any Markdown or GFM content 18 | * @param options Any additional option 19 | */ 20 | export function markdownToBlocks( 21 | body: string, 22 | 23 | options?: BlocksOptions, 24 | ): notion.Block[] { 25 | const root = unified().use(markdown).use(gfm).use(remarkMath).parse(body); 26 | return parseBlocks(root as unknown as md.Root, options); 27 | } 28 | 29 | /** 30 | * Parses inline Markdown content into Notion RichText objects. 31 | * Only supports plain text, italics, bold, strikethrough, inline code, and hyperlinks. 32 | * 33 | * @param text any inline Markdown or GFM content 34 | * @param options Any additional option 35 | */ 36 | export function markdownToRichText( 37 | text: string, 38 | options?: RichTextOptions, 39 | ): notion.RichText[] { 40 | const root = unified().use(markdown).use(gfm).parse(text); 41 | return parseRichText(root as unknown as md.Root, options); 42 | } 43 | -------------------------------------------------------------------------------- /src/markdown/ast.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Blockquote, 3 | Code, 4 | Delete, 5 | Emphasis, 6 | FlowContent, 7 | Heading, 8 | Image, 9 | InlineCode, 10 | InlineMath, 11 | Link, 12 | List, 13 | ListContent, 14 | ListItem, 15 | Math, 16 | Paragraph, 17 | PhrasingContent, 18 | Root, 19 | RowContent, 20 | StaticPhrasingContent, 21 | Strong, 22 | Table, 23 | TableContent, 24 | Text, 25 | ThematicBreak, 26 | } from './types'; 27 | 28 | export function text(value: string): Text { 29 | return { 30 | type: 'text', 31 | value: value, 32 | }; 33 | } 34 | 35 | export function image(url: string, alt: string, title: string): Image { 36 | return { 37 | type: 'image', 38 | url: url, 39 | title: title, 40 | }; 41 | } 42 | 43 | export function emphasis(...children: PhrasingContent[]): Emphasis { 44 | return { 45 | type: 'emphasis', 46 | children: children, 47 | }; 48 | } 49 | 50 | export function strong(...children: PhrasingContent[]): Strong { 51 | return { 52 | type: 'strong', 53 | children: children, 54 | }; 55 | } 56 | 57 | export function inlineCode(value: string): InlineCode { 58 | return { 59 | type: 'inlineCode', 60 | value: value, 61 | }; 62 | } 63 | 64 | export function inlineMath(value: string): InlineMath { 65 | return { 66 | type: 'inlineMath', 67 | value, 68 | }; 69 | } 70 | 71 | export function paragraph(...children: PhrasingContent[]): Paragraph { 72 | return { 73 | type: 'paragraph', 74 | children: children, 75 | }; 76 | } 77 | 78 | export function root(...children: FlowContent[]): Root { 79 | return { 80 | type: 'root', 81 | children: children, 82 | }; 83 | } 84 | 85 | export function link(url: string, ...children: StaticPhrasingContent[]): Link { 86 | return { 87 | type: 'link', 88 | children: children, 89 | url: url, 90 | }; 91 | } 92 | 93 | export function thematicBreak(): ThematicBreak { 94 | return { 95 | type: 'thematicBreak', 96 | }; 97 | } 98 | 99 | export function heading( 100 | depth: 1 | 2 | 3 | 4 | 5 | 6, 101 | ...children: PhrasingContent[] 102 | ): Heading { 103 | return { 104 | type: 'heading', 105 | depth: depth, 106 | children: children, 107 | }; 108 | } 109 | 110 | export function code(value: string, lang: string | undefined): Code { 111 | return { 112 | type: 'code', 113 | lang: lang, 114 | value: value, 115 | }; 116 | } 117 | 118 | export function math(value: string): Math { 119 | return { 120 | type: 'math', 121 | value, 122 | }; 123 | } 124 | 125 | export function blockquote(...children: FlowContent[]): Blockquote { 126 | return { 127 | type: 'blockquote', 128 | children: children, 129 | }; 130 | } 131 | 132 | export function listItem(...children: FlowContent[]): ListItem { 133 | return { 134 | type: 'listitem', 135 | children: children, 136 | }; 137 | } 138 | 139 | export function checkedListItem( 140 | checked: boolean, 141 | ...children: FlowContent[] 142 | ): ListItem { 143 | return { 144 | type: 'listitem', 145 | checked: checked, 146 | children: children, 147 | }; 148 | } 149 | 150 | export function unorderedList(...children: ListContent[]): List { 151 | return { 152 | type: 'list', 153 | children: children, 154 | ordered: false, 155 | }; 156 | } 157 | 158 | export function orderedList(...children: ListContent[]): List { 159 | return { 160 | type: 'list', 161 | children: children, 162 | start: 0, 163 | ordered: true, 164 | }; 165 | } 166 | 167 | export function strikethrough(...children: PhrasingContent[]): Delete { 168 | return { 169 | type: 'delete', 170 | children: children, 171 | }; 172 | } 173 | 174 | export function table(...children: TableContent[]): Table { 175 | return { 176 | type: 'table', 177 | children: children, 178 | }; 179 | } 180 | 181 | export function tableRow(...children: RowContent[]): TableContent { 182 | return { 183 | type: 'tableRow', 184 | children: children, 185 | }; 186 | } 187 | 188 | export function tableCell(...children: PhrasingContent[]): RowContent { 189 | return { 190 | type: 'tableCell', 191 | children: children, 192 | }; 193 | } 194 | -------------------------------------------------------------------------------- /src/markdown/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ast'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /src/markdown/types.ts: -------------------------------------------------------------------------------- 1 | // From https://github.com/syntax-tree/mdast 2 | 3 | import type {Node} from 'unist'; 4 | 5 | export interface Parent { 6 | children: MdastContent[]; 7 | } 8 | 9 | export interface Literal { 10 | value: string; 11 | } 12 | 13 | export interface Root extends Parent { 14 | type: 'root'; 15 | children: FlowContent[]; 16 | } 17 | 18 | export interface Paragraph extends Parent { 19 | type: 'paragraph'; 20 | children: PhrasingContent[]; 21 | } 22 | 23 | export interface Heading extends Parent { 24 | type: 'heading'; 25 | depth: 1 | 2 | 3 | 4 | 5 | 6; 26 | children: PhrasingContent[]; 27 | } 28 | 29 | export interface ThematicBreak extends Node { 30 | type: 'thematicBreak'; 31 | } 32 | 33 | export interface Blockquote extends Parent { 34 | type: 'blockquote'; 35 | children: FlowContent[]; 36 | } 37 | 38 | export interface List extends Parent { 39 | type: 'list'; 40 | ordered?: boolean; 41 | start?: number; 42 | spread?: boolean; 43 | children: ListContent[]; 44 | } 45 | 46 | export interface ListItem extends Parent { 47 | type: 'listitem'; 48 | checked?: boolean | undefined | null; 49 | spread?: boolean; 50 | children: FlowContent[]; 51 | } 52 | 53 | export interface HTML extends Literal { 54 | type: 'html'; 55 | } 56 | 57 | export interface Code extends Literal { 58 | type: 'code'; 59 | lang?: string; 60 | meta?: string; 61 | } 62 | 63 | export interface Math extends Literal { 64 | type: 'math'; 65 | } 66 | 67 | export interface Definition extends Node { 68 | type: 'definition'; 69 | } 70 | 71 | export interface Text extends Literal { 72 | type: 'text'; 73 | } 74 | 75 | export interface Emphasis extends Parent { 76 | type: 'emphasis'; 77 | children: PhrasingContent[]; 78 | } 79 | 80 | export interface Strong extends Parent { 81 | type: 'strong'; 82 | children: PhrasingContent[]; 83 | } 84 | 85 | export interface Delete extends Parent { 86 | type: 'delete'; 87 | children: PhrasingContent[]; 88 | } 89 | 90 | export interface InlineCode extends Literal { 91 | type: 'inlineCode'; 92 | } 93 | 94 | export interface InlineMath extends Literal { 95 | type: 'inlineMath'; 96 | } 97 | 98 | export interface Break extends Node { 99 | type: 'break'; 100 | } 101 | 102 | export interface Link extends Parent, Resource { 103 | type: 'link'; 104 | children: StaticPhrasingContent[]; 105 | } 106 | 107 | export interface Image extends Resource { 108 | type: 'image'; 109 | } 110 | 111 | export interface LinkReference extends Parent { 112 | type: 'linkReference'; 113 | children: StaticPhrasingContent[]; 114 | } 115 | 116 | export interface ImageReference extends Node { 117 | type: 'imageReference'; 118 | } 119 | 120 | export interface Resource { 121 | url: string; 122 | title?: string; 123 | } 124 | 125 | export interface Table extends Parent { 126 | type: 'table'; 127 | align?: ('left' | 'right' | 'center')[]; 128 | children: TableContent[]; 129 | } 130 | 131 | export interface TableRow extends Parent { 132 | type: 'tableRow'; 133 | children: RowContent[]; 134 | } 135 | 136 | export interface TableCell extends Parent { 137 | type: 'tableCell'; 138 | children: PhrasingContent[]; 139 | } 140 | 141 | export type MdastContent = 142 | | FlowContent 143 | | ListContent 144 | | PhrasingContent 145 | | TableContent 146 | | RowContent; 147 | 148 | export type FlowContent = 149 | | Blockquote 150 | | Code 151 | | Heading 152 | | HTML 153 | | List 154 | | Image 155 | | ImageReference 156 | | ThematicBreak 157 | | Content 158 | | Table 159 | | Math; 160 | 161 | export type Content = Definition | Paragraph; 162 | 163 | export type ListContent = ListItem; 164 | 165 | export type PhrasingContent = Link | LinkReference | StaticPhrasingContent; 166 | 167 | export type StaticPhrasingContent = 168 | | Image 169 | | Break 170 | | Emphasis 171 | | HTML 172 | | ImageReference 173 | | InlineCode 174 | | Strong 175 | | Text 176 | | Delete 177 | | InlineMath; 178 | 179 | export type TableContent = TableRow; 180 | 181 | export type RowContent = TableCell; 182 | -------------------------------------------------------------------------------- /src/notion/blocks.ts: -------------------------------------------------------------------------------- 1 | import {richText, supportedCodeLang, TableRowBlock} from './common'; 2 | import {AppendBlockChildrenParameters} from '@notionhq/client/build/src/api-endpoints'; 3 | 4 | export type Block = AppendBlockChildrenParameters['children'][number]; 5 | export type BlockWithoutChildren = Exclude< 6 | (Block & { 7 | type: 'paragraph'; 8 | })['paragraph']['children'], 9 | undefined 10 | >[number]; 11 | export type RichText = (Block & { 12 | type: 'paragraph'; 13 | })['paragraph']['rich_text'][number]; 14 | export type EmojiRequest = ((Block & { 15 | object: 'block'; 16 | type: 'callout'; 17 | })['callout']['icon'] & {type: 'emoji'})['emoji']; 18 | export type ApiColor = Exclude< 19 | (Block & { 20 | object: 'block'; 21 | type: 'callout'; 22 | })['callout']['color'], 23 | undefined 24 | >; 25 | 26 | export function divider(): Block { 27 | return { 28 | object: 'block', 29 | type: 'divider', 30 | divider: {}, 31 | }; 32 | } 33 | 34 | export function paragraph(text: RichText[]): Block { 35 | return { 36 | object: 'block', 37 | type: 'paragraph', 38 | paragraph: { 39 | rich_text: text, 40 | }, 41 | }; 42 | } 43 | 44 | export function code( 45 | text: RichText[], 46 | lang: supportedCodeLang = 'plain text', 47 | ): Block { 48 | return { 49 | object: 'block', 50 | type: 'code', 51 | code: { 52 | rich_text: text, 53 | language: lang, 54 | }, 55 | }; 56 | } 57 | 58 | export function blockquote( 59 | text: RichText[] = [], 60 | children: Block[] = [], 61 | ): Block { 62 | return { 63 | object: 'block', 64 | type: 'quote', 65 | quote: { 66 | // By setting an empty rich text we prevent the "Empty quote" line from showing up at all 67 | rich_text: text.length ? text : [richText('')], 68 | // @ts-expect-error Typings are not perfect 69 | children, 70 | }, 71 | }; 72 | } 73 | 74 | export function image(url: string): Block { 75 | return { 76 | object: 'block', 77 | type: 'image', 78 | image: { 79 | type: 'external', 80 | external: { 81 | url: url, 82 | }, 83 | }, 84 | }; 85 | } 86 | 87 | export function table_of_contents(): Block { 88 | return { 89 | object: 'block', 90 | type: 'table_of_contents', 91 | table_of_contents: {}, 92 | }; 93 | } 94 | 95 | export function headingOne(text: RichText[]): Block { 96 | return { 97 | object: 'block', 98 | type: 'heading_1', 99 | heading_1: { 100 | rich_text: text, 101 | }, 102 | }; 103 | } 104 | 105 | export function headingTwo(text: RichText[]): Block { 106 | return { 107 | object: 'block', 108 | type: 'heading_2', 109 | heading_2: { 110 | rich_text: text, 111 | }, 112 | }; 113 | } 114 | 115 | export function headingThree(text: RichText[]): Block { 116 | return { 117 | object: 'block', 118 | type: 'heading_3', 119 | heading_3: { 120 | rich_text: text, 121 | }, 122 | }; 123 | } 124 | 125 | export function bulletedListItem( 126 | text: RichText[], 127 | children: BlockWithoutChildren[] = [], 128 | ): Block { 129 | return { 130 | object: 'block', 131 | type: 'bulleted_list_item', 132 | bulleted_list_item: { 133 | rich_text: text, 134 | children: children.length ? children : undefined, 135 | }, 136 | }; 137 | } 138 | 139 | export function numberedListItem( 140 | text: RichText[], 141 | children: BlockWithoutChildren[] = [], 142 | ): Block { 143 | return { 144 | object: 'block', 145 | type: 'numbered_list_item', 146 | numbered_list_item: { 147 | rich_text: text, 148 | children: children.length ? children : undefined, 149 | }, 150 | }; 151 | } 152 | 153 | export function toDo( 154 | checked: boolean, 155 | text: RichText[], 156 | children: BlockWithoutChildren[] = [], 157 | ): Block { 158 | return { 159 | object: 'block', 160 | type: 'to_do', 161 | to_do: { 162 | rich_text: text, 163 | checked: checked, 164 | children: children.length ? children : undefined, 165 | }, 166 | }; 167 | } 168 | 169 | export function table(children: TableRowBlock[], tableWidth: number): Block { 170 | return { 171 | object: 'block', 172 | type: 'table', 173 | table: { 174 | table_width: tableWidth, 175 | has_column_header: true, 176 | children: children?.length ? children : [], 177 | }, 178 | }; 179 | } 180 | 181 | export function tableRow(cells: RichText[][] = []): TableRowBlock { 182 | return { 183 | object: 'block', 184 | type: 'table_row', 185 | table_row: { 186 | cells: cells.length ? cells : [], 187 | }, 188 | }; 189 | } 190 | 191 | export function equation(value: string): Block { 192 | return { 193 | type: 'equation', 194 | equation: { 195 | expression: value, 196 | }, 197 | }; 198 | } 199 | 200 | export function callout( 201 | text: RichText[] = [], 202 | emoji: EmojiRequest = '👍', 203 | color: ApiColor = 'default', 204 | children: Block[] = [], 205 | ): Block { 206 | return { 207 | object: 'block', 208 | type: 'callout', 209 | callout: { 210 | rich_text: text.length ? text : [richText('')], 211 | icon: { 212 | type: 'emoji', 213 | emoji, 214 | }, 215 | // @ts-expect-error See https://github.com/makenotion/notion-sdk-js/issues/575 216 | children, 217 | color, 218 | }, 219 | }; 220 | } 221 | -------------------------------------------------------------------------------- /src/notion/common.ts: -------------------------------------------------------------------------------- 1 | import type {RichText, EmojiRequest, ApiColor} from './blocks'; 2 | 3 | /** 4 | * The limits that the Notion API uses for property values. 5 | * @see https://developers.notion.com/reference/request-limits#limits-for-property-values 6 | */ 7 | export const LIMITS = { 8 | PAYLOAD_BLOCKS: 1000, 9 | RICH_TEXT_ARRAYS: 100, 10 | RICH_TEXT: { 11 | TEXT_CONTENT: 2000, 12 | LINK_URL: 1000, 13 | EQUATION_EXPRESSION: 1000, 14 | }, 15 | }; 16 | 17 | export interface RichTextOptions { 18 | type?: 'text' | 'equation'; // 'mention' is not supported 19 | annotations?: { 20 | bold?: boolean; 21 | italic?: boolean; 22 | strikethrough?: boolean; 23 | underline?: boolean; 24 | code?: boolean; 25 | color?: string; 26 | }; 27 | url?: string; 28 | } 29 | 30 | function isValidURL(url: string | undefined): boolean { 31 | if (!url || url === '') { 32 | return false; 33 | } 34 | 35 | const urlRegex = /^https?:\/\/.+/i; 36 | return urlRegex.test(url); 37 | } 38 | 39 | export function richText( 40 | content: string, 41 | options: RichTextOptions = {}, 42 | ): RichText { 43 | const annotations: RichText['annotations'] = { 44 | bold: false, 45 | strikethrough: false, 46 | underline: false, 47 | italic: false, 48 | code: false, 49 | color: 'default' as const, 50 | ...((options.annotations as RichText['annotations']) || {}), 51 | }; 52 | 53 | if (options.type === 'equation') 54 | return { 55 | type: 'equation', 56 | annotations, 57 | equation: { 58 | expression: content, 59 | }, 60 | }; 61 | else 62 | return { 63 | type: 'text', 64 | annotations, 65 | text: { 66 | content: content, 67 | link: isValidURL(options.url) 68 | ? { 69 | type: 'url', 70 | url: options.url, 71 | } 72 | : undefined, 73 | }, 74 | } as RichText; 75 | } 76 | 77 | export const SUPPORTED_CODE_BLOCK_LANGUAGES = [ 78 | 'abap', 79 | 'arduino', 80 | 'bash', 81 | 'basic', 82 | 'c', 83 | 'clojure', 84 | 'coffeescript', 85 | 'c++', 86 | 'c#', 87 | 'css', 88 | 'dart', 89 | 'diff', 90 | 'docker', 91 | 'elixir', 92 | 'elm', 93 | 'erlang', 94 | 'flow', 95 | 'fortran', 96 | 'f#', 97 | 'gherkin', 98 | 'glsl', 99 | 'go', 100 | 'graphql', 101 | 'groovy', 102 | 'haskell', 103 | 'html', 104 | 'java', 105 | 'javascript', 106 | 'json', 107 | 'julia', 108 | 'kotlin', 109 | 'latex', 110 | 'less', 111 | 'lisp', 112 | 'livescript', 113 | 'lua', 114 | 'makefile', 115 | 'markdown', 116 | 'markup', 117 | 'matlab', 118 | 'mermaid', 119 | 'nix', 120 | 'objective-c', 121 | 'ocaml', 122 | 'pascal', 123 | 'perl', 124 | 'php', 125 | 'plain text', 126 | 'powershell', 127 | 'prolog', 128 | 'protobuf', 129 | 'python', 130 | 'r', 131 | 'reason', 132 | 'ruby', 133 | 'rust', 134 | 'sass', 135 | 'scala', 136 | 'scheme', 137 | 'scss', 138 | 'shell', 139 | 'sql', 140 | 'swift', 141 | 'typescript', 142 | 'vb.net', 143 | 'verilog', 144 | 'vhdl', 145 | 'visual basic', 146 | 'webassembly', 147 | 'xml', 148 | 'yaml', 149 | 'java/c/c++/c#', 150 | ] as const; 151 | 152 | export type supportedCodeLang = (typeof SUPPORTED_CODE_BLOCK_LANGUAGES)[number]; 153 | 154 | export function isSupportedCodeLang(lang: string): lang is supportedCodeLang { 155 | return (SUPPORTED_CODE_BLOCK_LANGUAGES as readonly string[]).includes(lang); 156 | } 157 | 158 | export interface TableRowBlock { 159 | type: 'table_row'; 160 | table_row: { 161 | cells: Array>; 162 | }; 163 | object?: 'block'; 164 | } 165 | 166 | export const SUPPORTED_GFM_ALERT_TYPES = [ 167 | 'NOTE', 168 | 'TIP', 169 | 'IMPORTANT', 170 | 'WARNING', 171 | 'CAUTION', 172 | ] as const; 173 | 174 | export type GfmAlertType = (typeof SUPPORTED_GFM_ALERT_TYPES)[number]; 175 | 176 | export function isGfmAlertType(type: string): type is GfmAlertType { 177 | return (SUPPORTED_GFM_ALERT_TYPES as readonly string[]).includes(type); 178 | } 179 | 180 | export const GFM_ALERT_MAP: Record< 181 | GfmAlertType, 182 | { 183 | emoji: EmojiRequest; 184 | color: ApiColor; 185 | } 186 | > = { 187 | NOTE: {emoji: '📘', color: 'blue_background'}, 188 | TIP: {emoji: '💡', color: 'green_background'}, 189 | IMPORTANT: {emoji: '☝️', color: 'purple_background'}, 190 | WARNING: {emoji: '⚠️', color: 'yellow_background'}, 191 | CAUTION: {emoji: '❗', color: 'red_background'}, 192 | } as const; 193 | 194 | export const SUPPORTED_EMOJI_COLOR_MAP: Partial< 195 | Record 196 | > = { 197 | '👍': 'green_background', 198 | '📘': 'blue_background', 199 | '🚧': 'yellow_background', 200 | '❗': 'red_background', 201 | }; 202 | -------------------------------------------------------------------------------- /src/notion/index.ts: -------------------------------------------------------------------------------- 1 | import {supportedCodeLang, SUPPORTED_EMOJI_COLOR_MAP} from './common'; 2 | import type {EmojiRequest, ApiColor} from './blocks'; 3 | import lm from './languageMap.json'; 4 | 5 | export * from './blocks'; 6 | export * from './common'; 7 | 8 | export function parseCodeLanguage( 9 | lang?: string, 10 | ): supportedCodeLang | undefined { 11 | return lang 12 | ? (lm as Record)[lang.toLowerCase()] 13 | : undefined; 14 | } 15 | 16 | /** 17 | * Parses text to find a leading emoji and determines its corresponding Notion callout color 18 | * Uses Unicode 15.0 emoji pattern to detect emoji at start of text 19 | * @returns Emoji and color data if text starts with an emoji, null otherwise 20 | */ 21 | export function parseCalloutEmoji( 22 | text: string, 23 | ): {emoji: EmojiRequest; color: ApiColor} | null { 24 | if (!text) return null; 25 | 26 | // Get the first line of text 27 | const firstLine = text.split('\n')[0]; 28 | 29 | // Match text that starts with an emoji (with optional variation selector) 30 | const match = firstLine.match( 31 | /^([\p{Emoji_Presentation}\p{Extended_Pictographic}][\u{FE0F}\u{FE0E}]?).*$/u, 32 | ); 33 | 34 | if (!match) return null; 35 | 36 | const emoji = match[1]; 37 | 38 | return { 39 | emoji: emoji as EmojiRequest, 40 | color: SUPPORTED_EMOJI_COLOR_MAP[emoji as EmojiRequest] || 'default', 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/notion/languageMap.json: -------------------------------------------------------------------------------- 1 | { 2 | "abap": "abap", 3 | "sh": "shell", 4 | "shell-script": "shell", 5 | "bash": "shell", 6 | "zsh": "shell", 7 | "text": "vb.net", 8 | "c_cpp": "c++", 9 | "clojure": "clojure", 10 | "coffee": "coffeescript", 11 | "coffee-script": "coffeescript", 12 | "cpp": "c++", 13 | "csharp": "c#", 14 | "cake": "c#", 15 | "cakescript": "c#", 16 | "css": "css", 17 | "dart": "dart", 18 | "diff": "diff", 19 | "udiff": "diff", 20 | "dockerfile": "docker", 21 | "elixir": "elixir", 22 | "elm": "elm", 23 | "erlang": "erlang", 24 | "fsharp": "f#", 25 | "cucumber": "gherkin", 26 | "glsl": "glsl", 27 | "golang": "go", 28 | "groovy": "groovy", 29 | "haskell": "haskell", 30 | "html": "html", 31 | "xhtml": "html", 32 | "java": "java/c/c++/c#", 33 | "javascript": "javascript", 34 | "js": "javascript", 35 | "node": "javascript", 36 | "json": "json", 37 | "julia": "julia", 38 | "tex": "latex", 39 | "latex": "latex", 40 | "less": "less", 41 | "lisp": "webassembly", 42 | "livescript": "livescript", 43 | "live-script": "livescript", 44 | "ls": "livescript", 45 | "lua": "lua", 46 | "makefile": "makefile", 47 | "bsdmake": "makefile", 48 | "make": "makefile", 49 | "mf": "makefile", 50 | "markdown": "markdown", 51 | "pandoc": "markdown", 52 | "matlab": "matlab", 53 | "octave": "matlab", 54 | "nix": "nix", 55 | "nixos": "nix", 56 | "objectivec": "objective-c", 57 | "obj-c": "objective-c", 58 | "objc": "objective-c", 59 | "ocaml": "ocaml", 60 | "pascal": "pascal", 61 | "delphi": "pascal", 62 | "objectpascal": "pascal", 63 | "perl": "perl", 64 | "cperl": "perl", 65 | "php": "php", 66 | "inc": "php", 67 | "powershell": "powershell", 68 | "posh": "powershell", 69 | "pwsh": "powershell", 70 | "prolog": "prolog", 71 | "protobuf": "protobuf", 72 | "Protocol Buffers": "protobuf", 73 | "python": "python", 74 | "python3": "python", 75 | "rusthon": "python", 76 | "r": "r", 77 | "R": "r", 78 | "Rscript": "r", 79 | "splus": "r", 80 | "rust": "rust", 81 | "ruby": "ruby", 82 | "jruby": "ruby", 83 | "macruby": "ruby", 84 | "rake": "ruby", 85 | "rb": "ruby", 86 | "rbx": "ruby", 87 | "rs": "rust", 88 | "sass": "sass", 89 | "scala": "scala", 90 | "scheme": "scheme", 91 | "scss": "scss", 92 | "sql": "sql", 93 | "typescript": "typescript", 94 | "ts": "typescript", 95 | "visual basic": "vb.net", 96 | "vbnet": "vb.net", 97 | "vb .net": "vb.net", 98 | "vb.net": "vb.net", 99 | "verilog": "verilog", 100 | "vhdl": "vhdl", 101 | "wast": "webassembly", 102 | "wasm": "webassembly", 103 | "xml": "xml", 104 | "rss": "xml", 105 | "xsd": "xml", 106 | "wsdl": "xml", 107 | "yaml": "yaml", 108 | "yml": "yaml" 109 | } -------------------------------------------------------------------------------- /src/parser/internal.ts: -------------------------------------------------------------------------------- 1 | import * as md from '../markdown'; 2 | import * as notion from '../notion'; 3 | import path from 'path'; 4 | import {URL} from 'url'; 5 | import {isSupportedCodeLang, LIMITS} from '../notion'; 6 | 7 | function ensureLength(text: string, copy?: object) { 8 | const chunks = text.match(/[^]{1,2000}/g) || []; 9 | return chunks.flatMap((item: string) => notion.richText(item, copy)); 10 | } 11 | 12 | function ensureCodeBlockLanguage(lang?: string) { 13 | if (lang) { 14 | lang = lang.toLowerCase(); 15 | return isSupportedCodeLang(lang) ? lang : notion.parseCodeLanguage(lang); 16 | } 17 | 18 | return undefined; 19 | } 20 | 21 | function parseInline( 22 | element: md.PhrasingContent, 23 | options?: notion.RichTextOptions, 24 | ): notion.RichText[] { 25 | const copy = { 26 | annotations: { 27 | ...(options?.annotations ?? {}), 28 | }, 29 | url: options?.url, 30 | }; 31 | 32 | switch (element.type) { 33 | case 'text': 34 | return ensureLength(element.value, copy); 35 | 36 | case 'delete': 37 | copy.annotations.strikethrough = true; 38 | return element.children.flatMap(child => parseInline(child, copy)); 39 | 40 | case 'emphasis': 41 | copy.annotations.italic = true; 42 | return element.children.flatMap(child => parseInline(child, copy)); 43 | 44 | case 'strong': 45 | copy.annotations.bold = true; 46 | return element.children.flatMap(child => parseInline(child, copy)); 47 | 48 | case 'link': 49 | copy.url = element.url; 50 | return element.children.flatMap(child => parseInline(child, copy)); 51 | 52 | case 'inlineCode': 53 | copy.annotations.code = true; 54 | return [notion.richText(element.value, copy)]; 55 | 56 | case 'inlineMath': 57 | return [notion.richText(element.value, {...copy, type: 'equation'})]; 58 | 59 | default: 60 | return []; 61 | } 62 | } 63 | 64 | function parseImage(image: md.Image, options: BlocksOptions): notion.Block { 65 | // https://developers.notion.com/reference/block#image-blocks 66 | const allowedTypes = [ 67 | '.png', 68 | '.jpg', 69 | '.jpeg', 70 | '.gif', 71 | '.tif', 72 | '.tiff', 73 | '.bmp', 74 | '.svg', 75 | '.heic', 76 | '.webp', 77 | ]; 78 | 79 | function dealWithError() { 80 | return notion.paragraph([notion.richText(image.url)]); 81 | } 82 | 83 | try { 84 | if (options.strictImageUrls ?? true) { 85 | const parsedUrl = new URL(image.url); 86 | const fileType = path.extname(parsedUrl.pathname); 87 | if (allowedTypes.includes(fileType)) { 88 | return notion.image(image.url); 89 | } else { 90 | return dealWithError(); 91 | } 92 | } else { 93 | return notion.image(image.url); 94 | } 95 | } catch (error: unknown) { 96 | return dealWithError(); 97 | } 98 | } 99 | 100 | function parseParagraph( 101 | element: md.Paragraph, 102 | options: BlocksOptions, 103 | ): notion.Block[] { 104 | // Paragraphs can also be legacy 'TOC' from some markdown, so we check first 105 | const mightBeToc = 106 | element.children.length > 2 && 107 | element.children[0].type === 'text' && 108 | element.children[0].value === '[[' && 109 | element.children[1].type === 'emphasis'; 110 | if (mightBeToc) { 111 | const emphasisItem = element.children[1] as md.Emphasis; 112 | const emphasisTextItem = emphasisItem.children[0] as md.Text; 113 | if (emphasisTextItem.value === 'TOC') { 114 | return [notion.table_of_contents()]; 115 | } 116 | } 117 | 118 | // Notion doesn't deal with inline images, so we need to parse them all out 119 | // of the paragraph into individual blocks 120 | const images: notion.Block[] = []; 121 | const paragraphs: Array = []; 122 | element.children.forEach(item => { 123 | if (item.type === 'image') { 124 | images.push(parseImage(item, options)); 125 | } else { 126 | const richText = parseInline(item) as notion.RichText[]; 127 | if (richText.length) { 128 | paragraphs.push(richText); 129 | } 130 | } 131 | }); 132 | 133 | if (paragraphs.length) { 134 | return [notion.paragraph(paragraphs.flat()), ...images]; 135 | } else { 136 | return images; 137 | } 138 | } 139 | 140 | function parseBlockquote( 141 | element: md.Blockquote, 142 | options: BlocksOptions, 143 | ): notion.Block { 144 | const firstChild = element.children[0]; 145 | const firstTextNode = 146 | firstChild?.type === 'paragraph' 147 | ? (firstChild as md.Paragraph).children[0] 148 | : null; 149 | 150 | if (firstTextNode?.type === 'text') { 151 | // Helper to parse subsequent blocks 152 | const parseSubsequentBlocks = () => 153 | element.children.length > 1 154 | ? element.children.slice(1).flatMap(child => parseNode(child, options)) 155 | : []; 156 | 157 | // Check for GFM alert syntax first (both escaped and unescaped) 158 | const firstLine = firstTextNode.value.split('\n')[0]; 159 | const gfmMatch = firstLine.match( 160 | /^(?:\\\[|\[)!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/, 161 | ); 162 | 163 | if (gfmMatch && notion.isGfmAlertType(gfmMatch[1])) { 164 | const alertType = gfmMatch[1]; 165 | const alertConfig = notion.GFM_ALERT_MAP[alertType]; 166 | const displayType = 167 | alertType.charAt(0).toUpperCase() + alertType.slice(1).toLowerCase(); 168 | 169 | const children = []; 170 | const contentLines = firstTextNode.value.split('\n').slice(1); 171 | 172 | if (contentLines.length > 0) { 173 | children.push( 174 | notion.paragraph( 175 | parseInline({ 176 | type: 'text', 177 | value: contentLines.join('\n'), 178 | }), 179 | ), 180 | ); 181 | } 182 | 183 | children.push(...parseSubsequentBlocks()); 184 | 185 | return notion.callout( 186 | [notion.richText(displayType)], 187 | alertConfig.emoji, 188 | alertConfig.color, 189 | children, 190 | ); 191 | } 192 | 193 | // Check for emoji syntax if enabled 194 | if (options.enableEmojiCallouts) { 195 | const emojiData = notion.parseCalloutEmoji(firstTextNode.value); 196 | if (emojiData) { 197 | const paragraph = firstChild as md.Paragraph; 198 | const textWithoutEmoji = firstTextNode.value 199 | .slice(emojiData.emoji.length) 200 | .trimStart(); 201 | 202 | // Process inline content from first paragraph 203 | const richText = paragraph.children.flatMap(child => 204 | child === firstTextNode 205 | ? textWithoutEmoji 206 | ? parseInline({type: 'text', value: textWithoutEmoji}) 207 | : [] 208 | : parseInline(child), 209 | ); 210 | 211 | return notion.callout( 212 | richText, 213 | emojiData.emoji, 214 | emojiData.color, 215 | parseSubsequentBlocks(), 216 | ); 217 | } 218 | } 219 | } 220 | 221 | const children = element.children.flatMap(child => parseNode(child, options)); 222 | return notion.blockquote([], children); 223 | } 224 | 225 | function parseHeading(element: md.Heading): notion.Block { 226 | const text = element.children.flatMap(child => parseInline(child)); 227 | 228 | switch (element.depth) { 229 | case 1: 230 | return notion.headingOne(text); 231 | 232 | case 2: 233 | return notion.headingTwo(text); 234 | 235 | default: 236 | return notion.headingThree(text); 237 | } 238 | } 239 | 240 | function parseCode(element: md.Code): notion.Block { 241 | const text = ensureLength(element.value); 242 | const lang = ensureCodeBlockLanguage(element.lang); 243 | return notion.code(text, lang); 244 | } 245 | 246 | function parseList(element: md.List, options: BlocksOptions): notion.Block[] { 247 | return element.children.flatMap(item => { 248 | const paragraph = item.children.shift(); 249 | if (paragraph === undefined || paragraph.type !== 'paragraph') { 250 | return [] as notion.Block[]; 251 | } 252 | 253 | const text = paragraph.children.flatMap(child => parseInline(child)); 254 | 255 | // Now process any of the children 256 | const parsedChildren: notion.BlockWithoutChildren[] = item.children.flatMap( 257 | child => 258 | parseNode(child, options) as unknown as notion.BlockWithoutChildren, 259 | ); 260 | 261 | if (element.start !== null && element.start !== undefined) { 262 | return [notion.numberedListItem(text, parsedChildren)]; 263 | } else if (item.checked !== null && item.checked !== undefined) { 264 | return [notion.toDo(item.checked, text, parsedChildren)]; 265 | } else { 266 | return [notion.bulletedListItem(text, parsedChildren)]; 267 | } 268 | }); 269 | } 270 | 271 | function parseTableCell(node: md.TableCell): notion.RichText[] { 272 | return node.children.flatMap(child => parseInline(child)); 273 | } 274 | 275 | function parseTableRow(node: md.TableRow): notion.TableRowBlock { 276 | const cells = node.children.map(child => parseTableCell(child)); 277 | return notion.tableRow(cells); 278 | } 279 | 280 | function parseTable(node: md.Table): notion.Block[] { 281 | // The width of the table is the amount of cells in the first row, as all rows must have the same number of cells 282 | const tableWidth = node.children?.length 283 | ? node.children[0].children.length 284 | : 0; 285 | 286 | const tableRows = node.children.map(child => parseTableRow(child)); 287 | return [notion.table(tableRows, tableWidth)]; 288 | } 289 | 290 | function parseMath(node: md.Math): notion.Block { 291 | const textWithKatexNewlines = node.value.split('\n').join('\\\\\n'); 292 | return notion.equation(textWithKatexNewlines); 293 | } 294 | 295 | function parseNode( 296 | node: md.FlowContent, 297 | options: BlocksOptions, 298 | ): notion.Block[] { 299 | switch (node.type) { 300 | case 'heading': 301 | return [parseHeading(node)]; 302 | 303 | case 'paragraph': 304 | return parseParagraph(node, options); 305 | 306 | case 'code': 307 | return [parseCode(node)]; 308 | 309 | case 'blockquote': 310 | return [parseBlockquote(node, options)]; 311 | 312 | case 'list': 313 | return parseList(node, options); 314 | 315 | case 'table': 316 | return parseTable(node); 317 | 318 | case 'math': 319 | return [parseMath(node)]; 320 | 321 | case 'thematicBreak': 322 | return [notion.divider()]; 323 | 324 | default: 325 | return []; 326 | } 327 | } 328 | 329 | /** Options common to all methods. */ 330 | export interface CommonOptions { 331 | /** 332 | * Define how to behave when an item exceeds the Notion's request limits. 333 | * @see https://developers.notion.com/reference/request-limits#limits-for-property-values 334 | */ 335 | notionLimits?: { 336 | /** 337 | * Whether the excess items or characters should be automatically truncated where possible. 338 | * If set to `false`, the resulting item will not be compliant with Notion's limits. 339 | * Please note that text will be truncated only if the parser is not able to resolve 340 | * the issue in any other way. 341 | */ 342 | truncate?: boolean; 343 | /** The callback for when an item exceeds Notion's limits. */ 344 | onError?: (err: Error) => void; 345 | }; 346 | } 347 | 348 | export interface BlocksOptions extends CommonOptions { 349 | /** Whether to render invalid images as text */ 350 | strictImageUrls?: boolean; 351 | enableEmojiCallouts?: boolean; 352 | } 353 | 354 | export function parseBlocks( 355 | root: md.Root, 356 | options?: BlocksOptions, 357 | ): notion.Block[] { 358 | const parsed = root.children.flatMap(item => parseNode(item, options || {})); 359 | 360 | const truncate = !!(options?.notionLimits?.truncate ?? true), 361 | limitCallback = options?.notionLimits?.onError ?? (() => {}); 362 | 363 | if (parsed.length > LIMITS.PAYLOAD_BLOCKS) 364 | limitCallback( 365 | new Error( 366 | `Resulting blocks array exceeds Notion limit (${LIMITS.PAYLOAD_BLOCKS})`, 367 | ), 368 | ); 369 | 370 | return truncate ? parsed.slice(0, LIMITS.PAYLOAD_BLOCKS) : parsed; 371 | } 372 | 373 | export interface RichTextOptions extends CommonOptions { 374 | /** 375 | * How to behave when a non-inline element is detected: 376 | * - `ignore` (default): skip to the next element 377 | * - `throw`: throw an error 378 | */ 379 | nonInline?: 'ignore' | 'throw'; 380 | } 381 | 382 | export function parseRichText( 383 | root: md.Root, 384 | options?: RichTextOptions, 385 | ): notion.RichText[] { 386 | const richTexts: notion.RichText[] = []; 387 | 388 | root.children.forEach(child => { 389 | if (child.type === 'paragraph') 390 | child.children.forEach(child => richTexts.push(...parseInline(child))); 391 | else if (options?.nonInline === 'throw') 392 | throw new Error(`Unsupported markdown element: ${JSON.stringify(child)}`); 393 | }); 394 | 395 | const truncate = !!(options?.notionLimits?.truncate ?? true), 396 | limitCallback = options?.notionLimits?.onError ?? (() => {}); 397 | 398 | if (richTexts.length > LIMITS.RICH_TEXT_ARRAYS) 399 | limitCallback( 400 | new Error( 401 | `Resulting richTexts array exceeds Notion limit (${LIMITS.RICH_TEXT_ARRAYS})`, 402 | ), 403 | ); 404 | 405 | return ( 406 | truncate ? richTexts.slice(0, LIMITS.RICH_TEXT_ARRAYS) : richTexts 407 | ).map(rt => { 408 | if (rt.type !== 'text') return rt; 409 | 410 | if (rt.text.content.length > LIMITS.RICH_TEXT.TEXT_CONTENT) { 411 | limitCallback( 412 | new Error( 413 | `Resulting text content exceeds Notion limit (${LIMITS.RICH_TEXT.TEXT_CONTENT})`, 414 | ), 415 | ); 416 | if (truncate) 417 | rt.text.content = 418 | rt.text.content.slice(0, LIMITS.RICH_TEXT.TEXT_CONTENT - 3) + '...'; 419 | } 420 | 421 | if ( 422 | rt.text.link?.url && 423 | rt.text.link.url.length > LIMITS.RICH_TEXT.LINK_URL 424 | ) 425 | // There's no point in truncating URLs 426 | limitCallback( 427 | new Error( 428 | `Resulting text URL exceeds Notion limit (${LIMITS.RICH_TEXT.LINK_URL})`, 429 | ), 430 | ); 431 | 432 | // Notion equations are not supported by this library, since they don't exist in Markdown 433 | 434 | return rt; 435 | }); 436 | } 437 | -------------------------------------------------------------------------------- /test/fixtures/complex-items.md: -------------------------------------------------------------------------------- 1 | # Images 2 | 3 | This is a paragraph! 4 | 5 | > Quote 6 | 7 | Paragraph 8 | 9 | ![title](https://url.com/image.jpg) 10 | 11 | [[_TOC_]] 12 | -------------------------------------------------------------------------------- /test/fixtures/divider.md: -------------------------------------------------------------------------------- 1 | Thematic Break 2 | 3 | *** 4 | 5 | Divider 6 | 7 | --- 8 | 9 | END -------------------------------------------------------------------------------- /test/fixtures/images.md: -------------------------------------------------------------------------------- 1 | # Images 2 | 3 | This is an image in a paragraph ![image-test](https://image.com/url.jpg), which isnt supported in Notion. 4 | 5 | ![image-paragraph](https://image.com/paragraph.jpg) 6 | 7 | ![image-invalid](https://image.com/blah) 8 | -------------------------------------------------------------------------------- /test/fixtures/large-item.md: -------------------------------------------------------------------------------- 1 | # Large item 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam volutpat risus eget accumsan finibus. Sed finibus dictum metus eget efficitur. Morbi condimentum, dui eget mollis vehicula, velit magna condimentum ipsum, nec consequat risus nisl et urna. Maecenas maximus vehicula dui, eu mattis magna viverra a. Nunc et sapien a magna lobortis dapibus in sed quam. Morbi quis eleifend ex. Proin sodales enim imperdiet placerat pretium. Nulla aliquam vehicula arcu sed mollis. Curabitur iaculis, tortor facilisis tempus porta, sapien quam pretium turpis, id fermentum velit metus ut ipsum. Morbi euismod eros ac libero suscipit, vel luctus enim suscipit. Morbi pretium justo aliquam, volutpat diam sed, condimentum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam nisi nulla, feugiat ac lacinia eget, tincidunt vel nulla. Nulla finibus egestas eleifend. Vestibulum pretium, justo id imperdiet egestas, lorem turpis condimentum eros, sit amet sodales massa ex et orci. Morbi et mauris elementum nunc consequat maximus. Donec vulputate interdum massa nec posuere. Donec molestie magna porta, porttitor sapien eu, luctus dui. Aliquam fermentum nunc magna, id congue ex aliquam vitae. Phasellus imperdiet dignissim venenatis. Sed ligula neque, vulputate vitae molestie id, lacinia ac ipsum. Nunc non convallis quam. Curabitur sit amet eros blandit, condimentum massa et, dignissim nisl. Nulla facilisi. Mauris neque massa, convallis ut nibh sit amet, hendrerit rutrum nulla. Suspendisse ornare sodales sem, sed aliquam erat lacinia accumsan. Fusce pellentesque, risus et congue lobortis, massa enim gravida odio, nec vulputate orci lorem dictum justo. Integer metus nulla, varius sit amet gravida sed, commodo at lectus. Vivamus suscipit est turpis, nec cursus nulla ultrices eget. Nulla facilisi. Praesent quis posuere quam, sit amet sodales tellus. Integer porttitor orci augue, vel ullamcorper purus rutrum id. Nulla facilisi. Morbi aliquam tincidunt. 4 | -------------------------------------------------------------------------------- /test/fixtures/list.md: -------------------------------------------------------------------------------- 1 | # List 2 | 3 | - Item 1 4 | - Sub Item 1 5 | - Item 2 -------------------------------------------------------------------------------- /test/fixtures/math.md: -------------------------------------------------------------------------------- 1 | Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following 2 | equation. 3 | 4 | $$ 5 | L = \frac{1}{2} \rho v^2 S C_L 6 | test 7 | $$ 8 | -------------------------------------------------------------------------------- /test/fixtures/table.md: -------------------------------------------------------------------------------- 1 | # Table 2 | 3 | | First Header | Second Header | 4 | | ------------- | ------------- | 5 | | Content Cell | Content Cell | 6 | | Content Cell | Content Cell | -------------------------------------------------------------------------------- /test/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import {markdownToBlocks, markdownToRichText} from '../src'; 2 | import * as notion from '../src/notion'; 3 | import fs from 'fs'; 4 | import {LIMITS} from '../src/notion'; 5 | 6 | describe('markdown converter', () => { 7 | describe('markdownToBlocks', () => { 8 | it('should convert markdown to blocks', () => { 9 | const text = ` 10 | hello _world_ 11 | *** 12 | ## heading2 13 | * [x] todo 14 | `; 15 | const actual = markdownToBlocks(text); 16 | 17 | const expected = [ 18 | notion.paragraph([ 19 | notion.richText('hello '), 20 | notion.richText('world', {annotations: {italic: true}}), 21 | ]), 22 | notion.divider(), 23 | notion.headingTwo([notion.richText('heading2')]), 24 | notion.toDo(true, [notion.richText('todo')]), 25 | ]; 26 | 27 | expect(actual).toStrictEqual(expected); 28 | }); 29 | 30 | it('should deal with code - use plain text by default', () => { 31 | const text = ` 32 | ## Code 33 | \`\`\` 34 | const hello = "hello"; 35 | \`\`\` 36 | `; 37 | const actual = markdownToBlocks(text); 38 | 39 | const expected = [ 40 | notion.headingTwo([notion.richText('Code')]), 41 | notion.code([notion.richText('const hello = "hello";')], 'plain text'), 42 | ]; 43 | 44 | expect(actual).toStrictEqual(expected); 45 | }); 46 | 47 | it('should deal with code - handle Notion highlight keys', () => { 48 | const text = ` 49 | ## Code 50 | \`\`\` webassembly 51 | const hello = "hello"; 52 | \`\`\` 53 | `; 54 | const actual = markdownToBlocks(text); 55 | 56 | const expected = [ 57 | notion.headingTwo([notion.richText('Code')]), 58 | notion.code([notion.richText('const hello = "hello";')], 'webassembly'), 59 | ]; 60 | 61 | expect(actual).toStrictEqual(expected); 62 | }); 63 | 64 | it('should deal with code - handle Linguist highlight keys', () => { 65 | const text = ` 66 | ## Code 67 | \`\`\` ts 68 | const hello = "hello"; 69 | \`\`\` 70 | `; 71 | const actual = markdownToBlocks(text); 72 | 73 | const expected = [ 74 | notion.headingTwo([notion.richText('Code')]), 75 | notion.code([notion.richText('const hello = "hello";')], 'typescript'), 76 | ]; 77 | 78 | expect(actual).toStrictEqual(expected); 79 | }); 80 | 81 | it('should deal with complex items', () => { 82 | const text = fs.readFileSync('test/fixtures/complex-items.md').toString(); 83 | const actual = markdownToBlocks(text); 84 | 85 | const expected = [ 86 | notion.headingOne([notion.richText('Images')]), 87 | notion.paragraph([notion.richText('This is a paragraph!')]), 88 | notion.blockquote([], [notion.paragraph([notion.richText('Quote')])]), 89 | notion.paragraph([notion.richText('Paragraph')]), 90 | notion.image('https://url.com/image.jpg'), 91 | notion.table_of_contents(), 92 | ]; 93 | 94 | expect(actual).toStrictEqual(expected); 95 | }); 96 | 97 | it('should deal with divider', () => { 98 | const text = fs.readFileSync('test/fixtures/divider.md').toString(); 99 | const actual = markdownToBlocks(text); 100 | 101 | const expected = [ 102 | notion.paragraph([notion.richText('Thematic Break')]), 103 | notion.divider(), 104 | notion.paragraph([notion.richText('Divider')]), 105 | notion.divider(), 106 | notion.paragraph([notion.richText('END')]), 107 | ]; 108 | 109 | expect(actual).toStrictEqual(expected); 110 | }); 111 | 112 | it('should break up large elements', () => { 113 | const text = fs.readFileSync('test/fixtures/large-item.md').toString(); 114 | const actual = markdownToBlocks(text); 115 | 116 | const textArray = 117 | actual[1].type === 'paragraph' 118 | ? actual[1].paragraph.rich_text 119 | : {length: -1}; 120 | 121 | expect(textArray.length).toStrictEqual(9); 122 | }); 123 | 124 | it('should deal with lists', () => { 125 | const text = fs.readFileSync('test/fixtures/list.md').toString(); 126 | const actual = markdownToBlocks(text); 127 | 128 | const expected = [ 129 | notion.headingOne([notion.richText('List')]), 130 | notion.bulletedListItem( 131 | [notion.richText('Item 1')], 132 | // @ts-expect-error This problem is being addressed in issue #15 (https://github.com/tryfabric/martian/issues/15) 133 | [notion.bulletedListItem([notion.richText('Sub Item 1')])], 134 | ), 135 | notion.bulletedListItem([notion.richText('Item 2')]), 136 | ]; 137 | 138 | expect(actual).toStrictEqual(expected); 139 | }); 140 | 141 | it('should deal with tables', () => { 142 | const text = fs.readFileSync('test/fixtures/table.md').toString(); 143 | const actual = markdownToBlocks(text); 144 | const expected = [ 145 | notion.headingOne([notion.richText('Table')]), 146 | notion.table( 147 | [ 148 | notion.tableRow([ 149 | [notion.richText('First Header')], 150 | [notion.richText('Second Header')], 151 | ]), 152 | notion.tableRow([ 153 | [notion.richText('Content Cell')], 154 | [notion.richText('Content Cell')], 155 | ]), 156 | notion.tableRow([ 157 | [notion.richText('Content Cell')], 158 | [notion.richText('Content Cell')], 159 | ]), 160 | ], 161 | 2, 162 | ), 163 | ]; 164 | 165 | expect(actual).toStrictEqual(expected); 166 | }); 167 | 168 | it('should convert markdown to blocks - deal with images - strict mode', () => { 169 | const text = fs.readFileSync('test/fixtures/images.md').toString(); 170 | const actual = markdownToBlocks(text, {strictImageUrls: true}); 171 | 172 | const expected = [ 173 | notion.headingOne([notion.richText('Images')]), 174 | notion.paragraph([ 175 | notion.richText('This is an image in a paragraph '), 176 | notion.richText(', which isnt supported in Notion.'), 177 | ]), 178 | notion.image('https://image.com/url.jpg'), 179 | notion.image('https://image.com/paragraph.jpg'), 180 | notion.paragraph([notion.richText('https://image.com/blah')]), 181 | ]; 182 | 183 | expect(actual).toStrictEqual(expected); 184 | }); 185 | 186 | it('should convert markdown to blocks - deal with images - not strict mode', () => { 187 | const text = fs.readFileSync('test/fixtures/images.md').toString(); 188 | const actual = markdownToBlocks(text, {strictImageUrls: false}); 189 | 190 | const expected = [ 191 | notion.headingOne([notion.richText('Images')]), 192 | notion.paragraph([ 193 | notion.richText('This is an image in a paragraph '), 194 | notion.richText(', which isnt supported in Notion.'), 195 | ]), 196 | notion.image('https://image.com/url.jpg'), 197 | notion.image('https://image.com/paragraph.jpg'), 198 | notion.image('https://image.com/blah'), 199 | ]; 200 | 201 | expect(actual).toStrictEqual(expected); 202 | }); 203 | 204 | it('should parse math', () => { 205 | const text = fs.readFileSync('test/fixtures/math.md').toString(); 206 | const actual = markdownToBlocks(text); 207 | 208 | const expected = [ 209 | notion.paragraph([ 210 | notion.richText('Lift('), 211 | notion.richText('L', {type: 'equation'}), 212 | notion.richText(') can be determined by Lift Coefficient ('), 213 | notion.richText('C_L', {type: 'equation'}), 214 | notion.richText(') like the following\nequation.'), 215 | ]), 216 | notion.equation('L = \\frac{1}{2} \\rho v^2 S C_L\\\\\ntest'), 217 | ]; 218 | 219 | expect(actual).toStrictEqual(expected); 220 | }); 221 | }); 222 | 223 | describe('markdownToRichText', () => { 224 | it('should convert markdown to rich text', () => { 225 | const text = 'hello [_url_](https://example.com)'; 226 | const actual = markdownToRichText(text); 227 | 228 | const expected = [ 229 | notion.richText('hello '), 230 | notion.richText('url', { 231 | annotations: {italic: true}, 232 | url: 'https://example.com', 233 | }), 234 | ]; 235 | 236 | expect(actual).toStrictEqual(expected); 237 | }); 238 | 239 | it('should convert markdown with invalid link like "#title2" to rich text without link', () => { 240 | const text = 'hello [url](#head)'; 241 | const actual = markdownToRichText(text); 242 | 243 | const expected = [notion.richText('hello '), notion.richText('url')]; 244 | expect(actual).toStrictEqual(expected); 245 | }); 246 | 247 | it('should convert markdown with multiple newlines to rich text', () => { 248 | const text = 'hello\n\n[url](http://google.com)'; 249 | const actual = markdownToRichText(text); 250 | 251 | const expected = [ 252 | notion.richText('hello'), 253 | notion.richText('url', { 254 | url: 'http://google.com', 255 | }), 256 | ]; 257 | 258 | expect(actual).toStrictEqual(expected); 259 | }); 260 | 261 | it('should truncate items when options.notionLimits.truncate = true', () => { 262 | const text = Array(LIMITS.RICH_TEXT_ARRAYS + 10) 263 | .fill('a *a* ') 264 | .join(''); 265 | 266 | const actual = { 267 | default: markdownToRichText(text), 268 | explicit: markdownToRichText(text, {notionLimits: {truncate: true}}), 269 | }; 270 | 271 | expect(actual.default.length).toBe(LIMITS.RICH_TEXT_ARRAYS); 272 | expect(actual.explicit.length).toBe(LIMITS.RICH_TEXT_ARRAYS); 273 | }); 274 | 275 | it('should not truncate items when options.notionLimits.truncate = false', () => { 276 | const text = Array(LIMITS.RICH_TEXT_ARRAYS + 10) 277 | .fill('a *a* ') 278 | .join(''); 279 | 280 | const actual = markdownToRichText(text, { 281 | notionLimits: {truncate: false}, 282 | }); 283 | 284 | expect(actual.length).toBeGreaterThan(LIMITS.RICH_TEXT_ARRAYS); 285 | }); 286 | 287 | it('should call the callback when options.notionLimits.onError is defined', () => { 288 | const text = Array(LIMITS.RICH_TEXT_ARRAYS + 10) 289 | .fill('a *a* ') 290 | .join(''); 291 | const spy = jest.fn(); 292 | 293 | markdownToRichText(text, { 294 | notionLimits: {onError: spy}, 295 | }); 296 | 297 | expect(spy).toBeCalledTimes(1); 298 | expect(spy).toHaveBeenCalledWith(expect.any(Error)); 299 | }); 300 | 301 | it('should ignore unsupported elements by default', () => { 302 | const text1 = '# Header first\nOther text', 303 | text2 = 'Other text\n# Header second'; 304 | 305 | const actual1 = markdownToRichText(text1), 306 | actual2 = markdownToRichText(text2); 307 | 308 | const expected = [notion.richText('Other text')]; 309 | 310 | expect(actual1).toStrictEqual(expected); 311 | expect(actual2).toStrictEqual(expected); 312 | }); 313 | 314 | it("should ignore unsupported elements when nonInline = 'ignore'", () => { 315 | const text = '# Header first\nOther text'; 316 | 317 | const actual = markdownToRichText(text, {nonInline: 'ignore'}); 318 | 319 | const expected = [notion.richText('Other text')]; 320 | 321 | expect(actual).toStrictEqual(expected); 322 | }); 323 | 324 | it("should throw when there's an unsupported element and nonInline = 'throw'", () => { 325 | const text = '# Header first\nOther text'; 326 | 327 | expect(() => markdownToRichText(text, {nonInline: 'throw'})).toThrow(); 328 | expect(() => 329 | markdownToRichText(text, {nonInline: 'ignore'}), 330 | ).not.toThrow(); 331 | }); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /test/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import * as md from '../src/markdown'; 2 | import {text} from '../src/markdown'; 3 | import * as notion from '../src/notion'; 4 | import {parseBlocks, parseRichText} from '../src/parser/internal'; 5 | 6 | describe('gfm parser', () => { 7 | const options = {allowUnsupportedObjectType: false, strictImageUrls: true}; 8 | it('should parse paragraph with nested annotations', () => { 9 | const ast = md.root( 10 | md.paragraph( 11 | md.text('Hello '), 12 | md.emphasis(md.text('world '), md.strong(md.text('foo'))), 13 | md.text('! '), 14 | md.inlineCode('code'), 15 | ), 16 | ); 17 | 18 | const actual = parseBlocks(ast, options); 19 | 20 | const expected = [ 21 | notion.paragraph([ 22 | notion.richText('Hello '), 23 | notion.richText('world ', { 24 | annotations: {italic: true}, 25 | }), 26 | notion.richText('foo', { 27 | annotations: {italic: true, bold: true}, 28 | }), 29 | notion.richText('! '), 30 | notion.richText('code', { 31 | annotations: {code: true}, 32 | }), 33 | ]), 34 | ]; 35 | 36 | expect(actual).toStrictEqual(expected); 37 | }); 38 | 39 | it('should parse text with hrefs and annotations', () => { 40 | const ast = md.root( 41 | md.paragraph( 42 | md.text('hello world '), 43 | md.link( 44 | 'https://example.com', 45 | md.text('this is a '), 46 | md.emphasis(md.text('url')), 47 | ), 48 | md.text(' end'), 49 | ), 50 | ); 51 | 52 | const actual = parseBlocks(ast, options); 53 | 54 | const expected = [ 55 | notion.paragraph([ 56 | notion.richText('hello world '), 57 | notion.richText('this is a ', { 58 | url: 'https://example.com', 59 | }), 60 | notion.richText('url', { 61 | annotations: {italic: true}, 62 | url: 'https://example.com', 63 | }), 64 | notion.richText(' end'), 65 | ]), 66 | ]; 67 | 68 | expect(actual).toStrictEqual(expected); 69 | }); 70 | 71 | it('should parse thematic breaks', () => { 72 | const ast = md.root( 73 | md.paragraph(md.text('hello')), 74 | md.thematicBreak(), 75 | md.paragraph(md.text('world')), 76 | ); 77 | 78 | const actual = parseBlocks(ast, options); 79 | 80 | const expected = [ 81 | notion.paragraph([notion.richText('hello')]), 82 | notion.divider(), 83 | notion.paragraph([notion.richText('world')]), 84 | ]; 85 | 86 | expect(actual).toStrictEqual(expected); 87 | }); 88 | 89 | it('should parse headings', () => { 90 | const ast = md.root( 91 | md.heading(1, md.text('heading1')), 92 | md.heading(2, md.text('heading2')), 93 | md.heading(3, md.text('heading3')), 94 | md.heading(4, md.text('heading4')), 95 | ); 96 | 97 | const actual = parseBlocks(ast, options); 98 | 99 | const expected = [ 100 | notion.headingOne([notion.richText('heading1')]), 101 | notion.headingTwo([notion.richText('heading2')]), 102 | notion.headingThree([notion.richText('heading3')]), 103 | notion.headingThree([notion.richText('heading4')]), 104 | ]; 105 | 106 | expect(actual).toStrictEqual(expected); 107 | }); 108 | 109 | it('should parse code block and set the language to plain text if none is provided', () => { 110 | const ast = md.root( 111 | md.paragraph(md.text('hello')), 112 | md.code('const foo = () => {}', undefined), 113 | ); 114 | 115 | const actual = parseBlocks(ast); 116 | 117 | const expected = [ 118 | notion.paragraph([notion.richText('hello')]), 119 | notion.code([notion.richText('const foo = () => {}')], 'plain text'), 120 | ]; 121 | expect(actual).toStrictEqual(expected); 122 | }); 123 | 124 | it('should parse code block and set the proper language', () => { 125 | const ast = md.root( 126 | md.paragraph(md.text('hello')), 127 | md.code('public class Foo {}', 'java'), 128 | ); 129 | 130 | const actual = parseBlocks(ast, options); 131 | 132 | const expected = [ 133 | notion.paragraph([notion.richText('hello')]), 134 | notion.code([notion.richText('public class Foo {}')], 'java'), 135 | ]; 136 | 137 | expect(actual).toStrictEqual(expected); 138 | }); 139 | 140 | it('should parse code block and set the language to plain text if it is not supported by Notion', () => { 141 | const ast = md.root( 142 | md.paragraph(md.text('hello')), 143 | md.code('const foo = () => {}', 'not-supported'), 144 | ); 145 | 146 | const actual = parseBlocks(ast); 147 | 148 | const expected = [ 149 | notion.paragraph([notion.richText('hello')]), 150 | notion.code([notion.richText('const foo = () => {}')], 'plain text'), 151 | ]; 152 | 153 | expect(actual).toStrictEqual(expected); 154 | }); 155 | 156 | it('should parse block quote', () => { 157 | const ast = md.root( 158 | md.blockquote( 159 | md.heading(1, md.text('hello'), md.emphasis(md.text('world'))), 160 | ), 161 | ); 162 | 163 | const actual = parseBlocks(ast, options); 164 | 165 | const expected = [ 166 | notion.blockquote( 167 | [], 168 | [ 169 | notion.headingOne([ 170 | notion.richText('hello'), 171 | notion.richText('world', { 172 | annotations: {italic: true}, 173 | }), 174 | ]), 175 | ], 176 | ), 177 | ]; 178 | 179 | expect(actual).toStrictEqual(expected); 180 | }); 181 | 182 | it('should parse callout with emoji and formatting', () => { 183 | const ast = md.root( 184 | md.blockquote( 185 | md.paragraph( 186 | md.text('📘 '), 187 | md.strong(md.text('Note:')), 188 | md.text(' Important '), 189 | md.emphasis(md.text('information')), 190 | ), 191 | ), 192 | ); 193 | 194 | const actual = parseBlocks(ast, { 195 | ...options, 196 | enableEmojiCallouts: true, 197 | }); 198 | 199 | const expected = [ 200 | notion.callout( 201 | [ 202 | notion.richText('Note:', {annotations: {bold: true}}), 203 | notion.richText(' Important '), 204 | notion.richText('information', {annotations: {italic: true}}), 205 | ], 206 | '📘', 207 | 'blue_background', 208 | [], 209 | ), 210 | ]; 211 | 212 | expect(actual).toStrictEqual(expected); 213 | }); 214 | 215 | it('should parse callout with children blocks', () => { 216 | const ast = md.root( 217 | md.blockquote( 218 | md.paragraph(md.text('🚧 Under Construction')), 219 | md.paragraph(md.text('More details:')), 220 | md.unorderedList( 221 | md.listItem(md.paragraph(md.text('Work in progress'))), 222 | ), 223 | ), 224 | ); 225 | 226 | const actual = parseBlocks(ast, { 227 | ...options, 228 | enableEmojiCallouts: true, 229 | }); 230 | 231 | const expected = [ 232 | notion.callout( 233 | [notion.richText('Under Construction')], 234 | '🚧', 235 | 'yellow_background', 236 | [ 237 | notion.paragraph([notion.richText('More details:')]), 238 | notion.bulletedListItem([notion.richText('Work in progress')], []), 239 | ], 240 | ), 241 | ]; 242 | 243 | expect(actual).toStrictEqual(expected); 244 | }); 245 | 246 | it('should parse list', () => { 247 | const ast = md.root( 248 | md.paragraph(md.text('hello')), 249 | md.unorderedList( 250 | md.listItem(md.paragraph(md.text('a'))), 251 | md.listItem(md.paragraph(md.emphasis(md.text('b')))), 252 | md.listItem(md.paragraph(md.strong(md.text('c')))), 253 | ), 254 | md.orderedList(md.listItem(md.paragraph(md.text('d')))), 255 | ); 256 | 257 | const actual = parseBlocks(ast, options); 258 | 259 | const expected = [ 260 | notion.paragraph([notion.richText('hello')]), 261 | notion.bulletedListItem([notion.richText('a')]), 262 | notion.bulletedListItem([ 263 | notion.richText('b', {annotations: {italic: true}}), 264 | ]), 265 | notion.bulletedListItem([ 266 | notion.richText('c', {annotations: {bold: true}}), 267 | ]), 268 | notion.numberedListItem([notion.richText('d')]), 269 | ]; 270 | 271 | expect(actual).toStrictEqual(expected); 272 | }); 273 | 274 | it('should parse github extensions', () => { 275 | const ast = md.root( 276 | md.paragraph( 277 | md.link('https://example.com', md.text('https://example.com')), 278 | ), 279 | md.paragraph(md.strikethrough(md.text('strikethrough content'))), 280 | md.table( 281 | md.tableRow( 282 | md.tableCell(md.text('a')), 283 | md.tableCell(md.text('b')), 284 | md.tableCell(md.text('c')), 285 | md.tableCell(md.text('d')), 286 | ), 287 | ), 288 | md.unorderedList( 289 | md.checkedListItem(false, md.paragraph(md.text('to do'))), 290 | md.checkedListItem(true, md.paragraph(md.text('done'))), 291 | ), 292 | ); 293 | 294 | const actual = parseBlocks(ast, options); 295 | 296 | const expected = [ 297 | notion.paragraph([ 298 | notion.richText('https://example.com', { 299 | url: 'https://example.com', 300 | }), 301 | ]), 302 | notion.paragraph([ 303 | notion.richText('strikethrough content', { 304 | annotations: {strikethrough: true}, 305 | }), 306 | ]), 307 | notion.table( 308 | [ 309 | notion.tableRow([ 310 | [notion.richText('a')], 311 | [notion.richText('b')], 312 | [notion.richText('c')], 313 | [notion.richText('d')], 314 | ]), 315 | ], 316 | 4, 317 | ), 318 | notion.toDo(false, [notion.richText('to do')]), 319 | notion.toDo(true, [notion.richText('done')]), 320 | ]; 321 | 322 | expect(actual).toStrictEqual(expected); 323 | }); 324 | 325 | it('should parse rich text', () => { 326 | const ast = md.root( 327 | md.paragraph( 328 | md.text('a'), 329 | md.strong(md.emphasis(md.text('b')), md.text('c')), 330 | md.link('https://example.com', text('d')), 331 | ), 332 | ); 333 | 334 | const actual = parseRichText(ast); 335 | 336 | const expected = [ 337 | notion.richText('a'), 338 | notion.richText('b', {annotations: {italic: true, bold: true}}), 339 | notion.richText('c', {annotations: {bold: true}}), 340 | notion.richText('d', {url: 'https://example.com'}), 341 | ]; 342 | 343 | expect(actual).toStrictEqual(expected); 344 | }); 345 | 346 | it('should parse basic GFM alert', () => { 347 | const ast = md.root( 348 | md.blockquote( 349 | md.paragraph(md.text('[!NOTE]')), 350 | md.paragraph(md.text('Important information')), 351 | ), 352 | ); 353 | 354 | const actual = parseBlocks(ast, options); 355 | 356 | const expected = [ 357 | notion.callout([notion.richText('Note')], '📘', 'blue_background', [ 358 | notion.paragraph([notion.richText('Important information')]), 359 | ]), 360 | ]; 361 | 362 | expect(actual).toStrictEqual(expected); 363 | }); 364 | 365 | it('should parse GFM alert with formatted content', () => { 366 | const ast = md.root( 367 | md.blockquote( 368 | md.paragraph(md.text('[!TIP]')), 369 | md.paragraph(md.text('This is a tip with '), md.inlineCode('code')), 370 | ), 371 | ); 372 | 373 | const actual = parseBlocks(ast, options); 374 | 375 | const expected = [ 376 | notion.callout([notion.richText('Tip')], '💡', 'green_background', [ 377 | notion.paragraph([ 378 | notion.richText('This is a tip with '), 379 | notion.richText('code', {annotations: {code: true}}), 380 | ]), 381 | ]), 382 | ]; 383 | 384 | expect(actual).toStrictEqual(expected); 385 | }); 386 | 387 | it('should parse GFM alert with multiple paragraphs and lists', () => { 388 | const ast = md.root( 389 | md.blockquote( 390 | md.paragraph(md.text('[!IMPORTANT]')), 391 | md.paragraph( 392 | md.strong(md.text('Note:')), 393 | md.text(' Important '), 394 | md.emphasis(md.text('information')), 395 | ), 396 | md.paragraph(md.text('Additional details')), 397 | md.unorderedList( 398 | md.listItem(md.paragraph(md.text('Work in progress'))), 399 | ), 400 | ), 401 | ); 402 | 403 | const actual = parseBlocks(ast, options); 404 | 405 | const expected = [ 406 | notion.callout( 407 | [notion.richText('Important')], 408 | '☝️', 409 | 'purple_background', 410 | [ 411 | notion.paragraph([ 412 | notion.richText('Note:', {annotations: {bold: true}}), 413 | notion.richText(' Important '), 414 | notion.richText('information', {annotations: {italic: true}}), 415 | ]), 416 | notion.paragraph([notion.richText('Additional details')]), 417 | notion.bulletedListItem([notion.richText('Work in progress')], []), 418 | ], 419 | ), 420 | ]; 421 | 422 | expect(actual).toStrictEqual(expected); 423 | }); 424 | }); 425 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "es2019" 6 | ], 7 | "rootDir": ".", 8 | "outDir": "build", 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "declaration": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "test/**/*.ts", 18 | "scripts/**/*.ts", 19 | "src/**/*.json" 20 | ] 21 | } 22 | --------------------------------------------------------------------------------