├── .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 | [](https://github.com/tryfabric/martian/actions/workflows/ci.yml)
6 | [](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('');
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('', {
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 | 
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 , which isnt supported in Notion.
4 |
5 | 
6 |
7 | 
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 |
--------------------------------------------------------------------------------