├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .npmignore ├── .nvmrc ├── .prettierrc.js ├── Dockerfile.test ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.test.yml ├── docs └── cover.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── mocks │ │ ├── blocks.ts │ │ ├── html.ts │ │ ├── img │ │ ├── base64.ts │ │ └── baseImage.jpeg │ │ └── notion-api-responses.ts ├── data │ ├── helpers │ │ ├── block-to-inner-html.ts │ │ ├── block-to-inner-text.ts │ │ ├── blocks-to-html.ts │ │ ├── color-to-hex.ts │ │ └── replace-line-break-to-br-tag.ts │ ├── protocols │ │ ├── blocks │ │ │ ├── block.ts │ │ │ ├── decorable-text.ts │ │ │ ├── decoration.ts │ │ │ ├── format.ts │ │ │ ├── index.ts │ │ │ └── list-blocks-wrapper.ts │ │ ├── html-options │ │ │ └── html-options.ts │ │ ├── http-request │ │ │ ├── http-get-client.ts │ │ │ ├── http-post-client.ts │ │ │ ├── http-response.ts │ │ │ └── index.ts │ │ └── page-props │ │ │ ├── image-cover.ts │ │ │ ├── index.ts │ │ │ └── page-props.ts │ └── use-cases │ │ ├── blocks-to-html-converter │ │ ├── block-dispatcher.ts │ │ ├── block-parsers │ │ │ ├── callout.ts │ │ │ ├── code.ts │ │ │ ├── decorations │ │ │ │ ├── decoration-dispatcher.ts │ │ │ │ ├── decoration-parsers │ │ │ │ │ ├── bold.ts │ │ │ │ │ ├── code.ts │ │ │ │ │ ├── color.test.ts │ │ │ │ │ ├── color.ts │ │ │ │ │ ├── equation.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── italic.ts │ │ │ │ │ ├── link.ts │ │ │ │ │ ├── strikethrough.ts │ │ │ │ │ ├── underline.ts │ │ │ │ │ └── unknown.ts │ │ │ │ └── decorator.ts │ │ │ ├── divider.ts │ │ │ ├── equation.ts │ │ │ ├── header.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── list │ │ │ │ ├── index.ts │ │ │ │ ├── list-item.ts │ │ │ │ └── list.ts │ │ │ ├── page.ts │ │ │ ├── quote.ts │ │ │ ├── sub-header.ts │ │ │ ├── sub-sub-header.ts │ │ │ ├── text.ts │ │ │ ├── to-do.ts │ │ │ ├── toggle.ts │ │ │ ├── unknown.ts │ │ │ └── youtube-video.ts │ │ ├── blocks-to-html-converter.test.ts │ │ ├── blocks-to-html-converter.ts │ │ ├── index.ts │ │ └── list-blocks-wrapper.ts │ │ ├── format-to-style │ │ ├── format-to-style.ts │ │ └── index.ts │ │ ├── html-wrapper │ │ ├── header-from-template.test.ts │ │ ├── header-from-template.ts │ │ ├── options-html-wrapper.ts │ │ ├── scripts.ts │ │ └── styles.ts │ │ └── page-block-to-page-props │ │ ├── index.ts │ │ ├── page-block-to-cover-image-block.ts │ │ ├── page-block-to-icon.ts │ │ ├── page-block-to-page-props.test.ts │ │ ├── page-block-to-page-props.ts │ │ └── page-block-to-title.ts ├── domain │ └── use-cases │ │ ├── html-wrapper.ts │ │ └── to-html.ts ├── index.ts ├── infra │ ├── errors │ │ ├── index.ts │ │ ├── invalid-page-url.ts │ │ ├── missing-content.ts │ │ ├── missing-page-id.ts │ │ ├── notion-page-access.ts │ │ └── notion-page-not-found.ts │ ├── protocols │ │ ├── notion-api-content-response.ts │ │ └── validation.ts │ └── use-cases │ │ ├── http-post │ │ └── node-http-post-client.ts │ │ ├── to-blocks │ │ ├── decoration-array-to-decorations.ts │ │ ├── format-filter.ts │ │ ├── notion-api-content-response-to-blocks.test.ts │ │ ├── notion-api-content-response-to-blocks.ts │ │ ├── prop-title-to-decorable-texts.ts │ │ └── properties-parser.ts │ │ ├── to-notion-api-content-responses │ │ ├── notion-api-page-fetcher.test.ts │ │ ├── notion-api-page-fetcher.ts │ │ └── services │ │ │ ├── index.ts │ │ │ ├── notion-page-id-validation.service.ts │ │ │ ├── page-chunk-validation.service.test.ts │ │ │ ├── page-chunk-validation.service.ts │ │ │ └── page-record-validation.service.ts │ │ └── to-page-id │ │ ├── index.ts │ │ ├── notion-url-to-page-id.test.ts │ │ ├── notion-url-to-page-id.ts │ │ └── services │ │ ├── id-normalizer.ts │ │ ├── index.ts │ │ └── url-validator.ts ├── main │ ├── factories │ │ ├── blocks-to-html.factory.ts │ │ ├── index.ts │ │ ├── notion-api-page-fetcher.factory.ts │ │ └── notion-url-to-page-id.factory.ts │ ├── protocols │ │ └── notion-page.ts │ └── use-cases │ │ └── notion-api-to-html │ │ ├── index.ts │ │ ├── notion-page-to-html.test.ts │ │ └── notion-page-to-html.ts └── utils │ ├── base-64-converter.ts │ ├── either.ts │ ├── errors │ ├── forbidden-error.ts │ ├── image-not-found-error.ts │ └── index.ts │ └── use-cases │ └── http-get │ └── node-http-get.ts ├── tsconfig.build.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | ./data 5 | requirements 6 | .vscode 7 | *.jpeg -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: 'module', 6 | }, 7 | extends: [ 8 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 9 | ], 10 | rules: {}, 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: [main] 5 | workflow_dispatch: 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | matrix: 11 | node-version: [16.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | - name: Dependencies installation 16 | run: npm install 17 | - name: Test run 18 | run: npm test 19 | - name: Build 20 | run: npm run build 21 | - name: Publish 22 | uses: JS-DevTools/npm-publish@v1 23 | with: 24 | token: ${{ secrets.NPM_TOKEN }} 25 | access: public 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: [main] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Build test compose 11 | run: | 12 | make test 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /node_modules 3 | .vscode 4 | .idea 5 | /dist 6 | script.js 7 | test.html -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint:staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test:ci 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": ["eslint 'src/**' --fix", "npm run test:staged"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | coverage 3 | node_modules 4 | src 5 | docs 6 | jest.config.js 7 | tsconfig.json 8 | .eslintignore 9 | .eslintrc.js 10 | .gitignore 11 | .huskyrc.json 12 | .nvmrc 13 | .lintstagedrc.json 14 | .prettierrc.js 15 | .tool-versions 16 | .vscode 17 | script.js 18 | test.html 19 | docker-compose* 20 | Dockerfile* 21 | Makefile 22 | .husky 23 | .github -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.19.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM node:14.17 2 | WORKDIR /usr/src/notion-page-to-html 3 | RUN npm -g i npm 4 | COPY ./package*.json ./ 5 | RUN npm install 6 | COPY . . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexandre Nunes 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # TEST 2 | test_compose = docker-compose -f docker-compose.test.yml 3 | 4 | .PRONY: test-build 5 | test-build: 6 | $(test_compose) build 7 | 8 | .PRONY: test 9 | test: 10 | make test-build && $(test_compose) run notion-page-to-html-test && make test-down 11 | 12 | .PRONY: test-down 13 | test-down: 14 | $(test_compose) down -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Cover image](docs/cover.png) 2 | 3 | # Notion Page To HTML 4 | 5 | NodeJS tool to convert public notion pages to HTML. 6 | 7 | Also available as public API: 8 | 9 | [https://notion-page-to-html-api.vercel.app/](https://notion-page-to-html-api.vercel.app/) 10 | 11 | ## Supported features 12 | 13 | Most of the native Notion blocks are currently supported: 14 | 15 | - Headings 16 | - Text With Decorations 17 | - Quote 18 | - Image 19 | - YouTube Videos 20 | - Code 21 | - Math Equations 22 | - To-do 23 | - Checkbox 24 | - Bulleted Lists 25 | - Numbered Lists 26 | - Toggle Lists 27 | - Divider 28 | - Callout 29 | - Nested blocks 30 | 31 | Embeds and tables are not supported yet. 32 | 33 | ## Why notion-page-to-html? 34 | 35 | It's perfect as content manager system 36 | 37 | - This tool can get any public page from Notion and convert it to html. This is perfect 38 | for the ones who want to use Notion as CMS. Once it gets page content from Notion, it becomes completely independent (images are converted to base64 so you do not have to call Notion again to get content). You can convert a page and then make it private again. 39 | 40 | It's fully customizable 41 | 42 | - You can choose how you want to get page content. Do you want title, cover, and icon in html body? You can do that! Do you want they apart of html so you can choose where place it? You have it. Do want html without style? Without Equation and Code Highlighting scripts? Do you want body content only? You have those options too. 43 | 44 | ## Basic Usage 45 | 46 | Install it in a NodeJS project using npm 47 | 48 | ```bash 49 | npm install notion-page-to-html 50 | ``` 51 | 52 | Then, just import it and paste a public Notion page url 53 | 54 | ```jsx 55 | const NotionPageToHtml = require('notion-page-to-html'); 56 | 57 | // using async/await 58 | async function getPage() { 59 | const { title, icon, cover, html } = await NotionPageToHtml.convert("https://www.notion.so/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f"); 60 | console.log(title, icon, cover, html); 61 | } 62 | 63 | getPage(); 64 | ``` 65 | 66 | `cover` is a base64 string from original page cover image. `icon` can be an emoji or base64 image based on original page icon. `html` is a full html document by default. It has style, body, MathJax and PrismJS CDN scripts by default. You can pass some options to handle html content. 67 | 68 | ```jsx 69 | NotionPageToHtml.convert( 70 | 'https://www.notion.so/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f', 71 | options, 72 | ); 73 | ``` 74 | 75 | `options` is an object with the following keys 76 | 77 | | Key | Default value | If true | 78 | | ----------------------- | ------------- | ------------------------------------------------------ | 79 | | `excludeCSS` | false | returns html without style tag | 80 | | `excludeMetadata` | false | returns html without metatags | 81 | | `excludeScripts` | false | returns html without script tags | 82 | | `excludeHeaderFromBody` | false | returns html without title, cover and icon inside body | 83 | | `excludeTitleFromHead` | false | returns html without title tag in head | 84 | | `bodyContentOnly` | false | returns html body tag content only | 85 | 86 | --- 87 | 88 | ## Development and testing 89 | 90 | 1. Clone this application 91 | 92 | 2. Make sure you have node v14 or higher and then install all dependencies 93 | 94 | ```` 95 | npm i 96 | ```` 97 | Running tests: 98 | 99 | ```` 100 | npm test 101 | ```` 102 | 103 | Installing locally in another project: 104 | ```` 105 | npm run build 106 | npm pack 107 | ```` 108 | Inside your project: 109 | ```` 110 | npm i /path/to/tar/gz 111 | ```` 112 | 113 | Docker approach for testing 114 | 115 | 1. Make sure you have Docker and Docker Compose installed and then run: 116 | ```` 117 | make test 118 | ```` 119 | 120 | ## Contributing 121 | 122 | We love your feedback! Feel free to: 123 | 124 | - Report a bug 125 | - Discuss the current state of the code 126 | - Submit a fix 127 | - Propose new features 128 | - Become a maintainer 129 | 130 | Just create a GitHub issue or a PR ;) 131 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | notion-page-to-html-test: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.test 7 | container_name: notion-page-to-html-test 8 | logging: 9 | driver: 'json-file' 10 | options: 11 | max-size: '10m' 12 | max-file: '5' 13 | command: | 14 | npm test 15 | -------------------------------------------------------------------------------- /docs/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asnunes/notion-page-to-html/0bd3d1d0c9ff1f11188d64152b093aa9f9bd1be8/docs/cover.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleDirectories: ['node_modules'], 5 | transform: { 6 | '.+\\.ts$': 'ts-jest', 7 | }, 8 | testMatch: ['/src/**/*.(test|spec).ts'], 9 | moduleNameMapper: { 10 | '@/(.*)': '/src/$1', 11 | }, 12 | collectCoverageFrom: ['src/**/*.ts', '!src/migrations/*.ts', '!src/server.ts', '!src/protocols/*.ts'], 13 | coverageProvider: 'babel', 14 | coverageDirectory: 'coverage', 15 | restoreMocks: true, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-page-to-html", 3 | "version": "1.2.0", 4 | "description": "It converts public notion pages to html from url", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "prebuild": "rm -rf ./dist", 9 | "build": "tsc -p tsconfig.build.json", 10 | "lint": "eslint '*/**/*.{js,ts}' --quiet --fix", 11 | "lint:staged": "lint-staged", 12 | "test": "jest --passWithNoTests --silent --noStackTrace --runInBand", 13 | "test:watch": "npm test -- --watch", 14 | "test:verbose": "jest --passWithNoTests --runInBand", 15 | "test:staged": "npm test -- --findRelatedTests", 16 | "test:ci": "npm test -- --coverage" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/asnunes/notion-page-to-html.git" 21 | }, 22 | "keywords": [ 23 | "notion", 24 | "page", 25 | "html" 26 | ], 27 | "author": "Alexandre Nunes", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/asnunes/notion-page-to-html/issues" 31 | }, 32 | "homepage": "https://github.com/asnunes/notion-page-to-html#readme", 33 | "devDependencies": { 34 | "@types/jest": "^27.0.2", 35 | "@types/nock": "^11.1.0", 36 | "@typescript-eslint/eslint-plugin": "^4.31.2", 37 | "@typescript-eslint/parser": "^4.31.2", 38 | "eslint": "^7.32.0", 39 | "eslint-config-prettier": "^8.3.0", 40 | "eslint-plugin-prettier": "^4.0.0", 41 | "git-commit-msg-linter": "^3.2.8", 42 | "husky": "^7.0.2", 43 | "jest": "^27.2.2", 44 | "lint-staged": "^11.1.2", 45 | "nock": "^13.1.3", 46 | "prettier": "^2.4.1", 47 | "ts-jest": "^27.0.5", 48 | "typescript": "^4.4.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/__tests__/mocks/html.ts: -------------------------------------------------------------------------------- 1 | import base64 from './img/base64'; 2 | 3 | const STYLE_TAG = `\ 4 | 256 | `; 257 | 258 | const HEADER = `\ 259 |
260 | 261 |
262 | 🤴 263 |
264 |

Simple Page Test

265 |
266 | `; 267 | 268 | const CONTENT_WITH_HEADER = `\ 269 | ${HEADER} 270 |

Hello World

271 | `; 272 | 273 | const CONTENT_WITHOUT_HEADER = `\ 274 |

Hello World

275 | `; 276 | 277 | export const FULL_DOCUMENT = ` 278 | 279 | 280 | 281 | 282 | 283 | ${STYLE_TAG} 284 | Simple Page Test 285 | 286 | 287 | 288 | ${CONTENT_WITH_HEADER} 289 | 290 | 291 | 298 | 301 | 302 | 303 | `; 304 | 305 | export const DOCUMENT_WITHOUT_TITLE = ` 306 | 307 | 308 | 309 | 310 | 311 | ${STYLE_TAG} 312 | 313 | 314 | 315 | ${CONTENT_WITH_HEADER} 316 | 317 | 318 | 325 | 328 | 329 | 330 | `; 331 | 332 | export const DOCUMENT_WITHOUT_CSS = ` 333 | 334 | 335 | 336 | 337 | 338 | Simple Page Test 339 | 340 | 341 | 342 | ${CONTENT_WITH_HEADER} 343 | 344 | 345 | 352 | 355 | 356 | 357 | `; 358 | 359 | export const DOCUMENT_METADATA = ` 360 | 361 | 362 | 363 | ${STYLE_TAG} 364 | Simple Page Test 365 | 366 | 367 | 368 | ${CONTENT_WITH_HEADER} 369 | 370 | 371 | 378 | 381 | 382 | 383 | `; 384 | 385 | export const DOCUMENT_WITHOUT_SCRIPTS = ` 386 | 387 | 388 | 389 | 390 | 391 | ${STYLE_TAG} 392 | Simple Page Test 393 | 394 | 395 | ${CONTENT_WITH_HEADER} 396 | 397 | 398 | `; 399 | 400 | export const FULL_DOCUMENT_WITHOUT_HEADER_IN_BODY = ` 401 | 402 | 403 | 404 | 405 | 406 | ${STYLE_TAG} 407 | Simple Page Test 408 | 409 | 410 | 411 | ${CONTENT_WITHOUT_HEADER} 412 | 413 | 414 | 421 | 424 | 425 | 426 | `; 427 | 428 | export const BODY_ONLY = CONTENT_WITHOUT_HEADER; 429 | 430 | export const HEADER_WITH_TITLE_ONLY = `\ 431 |
432 |

This is a title

433 |
434 | `; 435 | 436 | export const HEADER_WITH_TITLE_AND_COVER_IMAGE = `\ 437 |
438 | 439 |

This is a title

440 |
441 | `; 442 | 443 | export const HEADER_WITH_TITLE_AND_COVER_IMAGE_WITHOUT_POSITION = `\ 444 |
445 | 446 |

This is a title

447 |
448 | `; 449 | 450 | export const HEADER_WITH_TITLE_COVER_IMAGE_AND_IMAGE_ICON = `\ 451 |
452 | 453 |
454 | 455 |
456 |

This is a title

457 |
458 | `; 459 | 460 | export const HEADER_WITH_TITLE_AND_IMAGE_ICON = `\ 461 |
462 |
463 | 464 |
465 |

This is a title

466 |
467 | `; 468 | 469 | export const HEADER_WITH_TITLE_AND_EMOJI_ICON = `\ 470 |
471 |
472 | 🤴 473 |
474 |

This is a title

475 |
476 | `; 477 | -------------------------------------------------------------------------------- /src/__tests__/mocks/img/base64.ts: -------------------------------------------------------------------------------- 1 | export default 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QDeRXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAAABwAAkAcABAAAADAyMTABkQcABAAAAAECAwCGkgcAFgAAAMAAAAAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAADIAAAADoAQAAQAAADIAAAAAAAAAQVNDSUkAAABQaWNzdW0gSUQ6IDc2N//bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/CABEIADIAMgMBIgACEQEDEQH/xAAaAAACAwEBAAAAAAAAAAAAAAAABAIDBQEG/8QAFgEBAQEAAAAAAAAAAAAAAAAAAQIA/9oADAMBAAIQAxAAAAHGlKUujtef0ZMgiKuMiVWdaFVp1gcFl5kOj4nmqc1lvTuUjUvotSFQQDKzC5dpDbloDSAn/8QAIBAAAgICAwADAQAAAAAAAAAAAQIAAwQREhMxECFBIv/aAAgBAQABBQITUoUc87mk1Ll0+zB8VTMclz632Sp2PBB/LZNnJxudRIGCWHE6SlmCY9e3xaNMqqi6MpyF6e6qqdrWFNkgts1MRrTbsgiNOzQW2CwKbuFo+BB40/B7+//EABkRAAIDAQAAAAAAAAAAAAAAAAERABAgAv/aAAgBAwEBPwHAis9E04xn/8QAGxEAAgMAAwAAAAAAAAAAAAAAAAEQERICIGH/2gAIAQIBAT8BhRaPTSNIXFKKMdf/xAArEAACAAQCCAcBAAAAAAAAAAAAAQIRITEQMgMSIkFRgZGhIDBhYnFyscH/2gAIAQEABj8CwX8JLSOTEUqvB6knO2EzL2OeEyj3YO9hRatxHD5NrSLkbGk61El1GvbF+kH1W82YZsqJKrZlNaC/ArOCIz9saXLlyZm8j//EACEQAAMAAgIBBQEAAAAAAAAAAAABESExUWFBIHGBkbHw/9oACAEBAAE/IUFkKXb3K2SXGUa2PJHn2MbyF8ksV4MF42SjRVNQLVSSPWULgyrJYv0rYDUOHRXCGL0R+CO03ZMHWzv6bFswS/2SdJvkvFE5G06e4FMhPoH4VpTCJjM2eZMILvyOB4Hv5kH+Fo36ESKEqPwiGst8htBOkTwiaEHRSNOjGKpwXa36G0Ws9Gbt9+lq/IdxljSrB//aAAwDAQACAAMAAAAQ6lbzVmrzq95M89+9/8QAHREAAgEEAwAAAAAAAAAAAAAAAAERECAhMUFRYf/aAAgBAwEBPxCCMnFEb0MPG6YTSJOh4W//xAAZEQEBAQEBAQAAAAAAAAAAAAABABEgMVH/2gAIAQIBAT8Q2HBJNs/a9hwSJfbHP//EACIQAQADAAICAgIDAAAAAAAAAAEAESExQVFhcYGRwRDR8P/aAAgBAQABPxDB9zPbVv4lSFaqovqv97j5MIDp9vH1+4eaR4WPmFfA7CYIa89xTk4Vcvgq6cfEtQOi8mNCB7S+oxpYqnyVz09dRQSVzyMujQNyr0Z9Ex9dvSKxbLW14gsPRvfEqbZBfRF1rE7SpyXthIcgZFjQ9oKKY00aOkuRkCjc8qIKuIOwcS9BCgmHzKdSCxOH1DNo+map33zUMtES5Tl5+2IZWE/E9QV9tZQ+42SC8wH1KVAAe2Wy4itFTLsLeqj2e50L9cwBRowx/UXV54iCnDrzGXA6DSfcuEpxYv7h5VEaFj+YJbY7afaogQVDkkf8WXPLGjrHN4j7tHd9QmDl6n//2Q=='; 2 | -------------------------------------------------------------------------------- /src/__tests__/mocks/img/baseImage.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asnunes/notion-page-to-html/0bd3d1d0c9ff1f11188d64152b093aa9f9bd1be8/src/__tests__/mocks/img/baseImage.jpeg -------------------------------------------------------------------------------- /src/__tests__/mocks/notion-api-responses.ts: -------------------------------------------------------------------------------- 1 | export const SUCCESSFUL_PAGE_CHUCK = { 2 | recordMap: { 3 | block: { 4 | '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { 5 | role: 'reader', 6 | value: { 7 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 8 | version: 31, 9 | type: 'page', 10 | properties: { title: [['Simple Page Test']] }, 11 | format: { page_cover: 'https://www.example.com/image.png', page_cover_position: 0.6, page_icon: '🤴' }, 12 | content: ['80d0fc46-5511-4d1d-a4ec-8b2f43d75226'], 13 | permissions: [{ role: 'reader', type: 'public_permission', allow_duplicate: false }], 14 | created_time: 1595516162445, 15 | last_edited_time: 1595520360000, 16 | parent_id: '8370825e-eb4c-483c-ace0-cc06e7dfc556', 17 | parent_table: 'block', 18 | alive: true, 19 | created_by_table: 'notion_user', 20 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 21 | last_edited_by_table: 'notion_user', 22 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 23 | shard_id: 285188, 24 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 25 | }, 26 | }, 27 | '80d0fc46-5511-4d1d-a4ec-8b2f43d75226': { 28 | role: 'reader', 29 | value: { 30 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 31 | version: 33, 32 | type: 'text', 33 | properties: { title: [['Hello World']] }, 34 | created_time: 1595516160000, 35 | last_edited_time: 1595516160000, 36 | parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 37 | parent_table: 'block', 38 | alive: true, 39 | created_by_table: 'notion_user', 40 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 41 | last_edited_by_table: 'notion_user', 42 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 43 | shard_id: 285188, 44 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 45 | }, 46 | }, 47 | }, 48 | notion_user: { 49 | '408c862f-f07b-4036-b414-1ae5c5ce57b3': { 50 | role: 'reader', 51 | value: { 52 | id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 53 | version: 5, 54 | email: 'user@example.com', 55 | given_name: 'User', 56 | family_name: 'Name', 57 | onboarding_completed: true, 58 | mobile_onboarding_completed: true, 59 | }, 60 | }, 61 | }, 62 | space: {}, 63 | }, 64 | cursor: { stack: [] }, 65 | }; 66 | 67 | export const SUCCESSFUL_RECORDS = { 68 | results: [ 69 | { 70 | role: 'reader', 71 | value: { 72 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 73 | version: 31, 74 | type: 'page', 75 | properties: { title: [['Simple Page Test']] }, 76 | content: ['80d0fc46-5511-4d1d-a4ec-8b2f43d75226'], 77 | permissions: [{ role: 'reader', type: 'public_permission', allow_duplicate: false }], 78 | }, 79 | }, 80 | ], 81 | }; 82 | 83 | export const SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN = { 84 | recordMap: { 85 | block: { 86 | '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { 87 | role: 'editor', 88 | value: { 89 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 90 | version: 262, 91 | type: 'page', 92 | properties: { title: [['Simple Page Text 2']] }, 93 | content: ['f8cf7a08-bf80-4f8b-a842-3db49df95e4d'], 94 | permissions: [ 95 | { role: 'editor', type: 'user_permission', user_id: 'user_id' }, 96 | { role: 'reader', type: 'public_permission' }, 97 | ], 98 | created_time: 1595516162445, 99 | last_edited_time: 1602527580000, 100 | parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', 101 | parent_table: 'space', 102 | alive: true, 103 | created_by_table: 'notion_user', 104 | created_by_id: 'user_id', 105 | last_edited_by_table: 'notion_user', 106 | last_edited_by_id: 'user_id', 107 | shard_id: 285188, 108 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 109 | }, 110 | }, 111 | 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d': { 112 | role: 'editor', 113 | value: { 114 | id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', 115 | version: 49, 116 | type: 'bulleted_list', 117 | properties: { title: [['Estou testando']] }, 118 | content: ['0b73eab8-8c01-4140-ab4d-cd6a0886cd76'], 119 | created_time: 1602511500000, 120 | last_edited_time: 1602527580000, 121 | parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 122 | parent_table: 'block', 123 | alive: true, 124 | created_by_table: 'notion_user', 125 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 126 | last_edited_by_table: 'notion_user', 127 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 128 | shard_id: 285188, 129 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 130 | }, 131 | }, 132 | '0b73eab8-8c01-4140-ab4d-cd6a0886cd76': { 133 | role: 'editor', 134 | value: { 135 | id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', 136 | version: 12, 137 | type: 'bulleted_list', 138 | properties: { title: [['isso daqui']] }, 139 | content: ['6bebe374-1569-4836-9de5-847c91ecb3f8'], 140 | created_time: 1602527580000, 141 | last_edited_time: 1602527580000, 142 | parent_id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', 143 | parent_table: 'block', 144 | alive: true, 145 | created_by_table: 'notion_user', 146 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 147 | last_edited_by_table: 'notion_user', 148 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 149 | shard_id: 285188, 150 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 151 | }, 152 | }, 153 | '6bebe374-1569-4836-9de5-847c91ecb3f8': { 154 | role: 'editor', 155 | value: { 156 | id: '6bebe374-1569-4836-9de5-847c91ecb3f8', 157 | version: 32, 158 | type: 'bulleted_list', 159 | properties: { title: [['vamos ver se funciona']] }, 160 | created_time: 1602527580000, 161 | last_edited_time: 1602527580000, 162 | parent_id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', 163 | parent_table: 'block', 164 | alive: true, 165 | created_by_table: 'notion_user', 166 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 167 | last_edited_by_table: 'notion_user', 168 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 169 | shard_id: 285188, 170 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 171 | }, 172 | }, 173 | }, 174 | space: { 175 | '159177ec-0fb0-469e-a900-1a662b145a04': {}, 176 | }, 177 | }, 178 | cursor: { stack: [] }, 179 | }; 180 | 181 | export const SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN_NOT_IN_CHUNK = { 182 | recordMap: { 183 | block: { 184 | '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { 185 | role: 'editor', 186 | value: { 187 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 188 | version: 356, 189 | type: 'page', 190 | properties: { title: [['Simple Page Text 2']] }, 191 | content: ['9dfff855-7aed-4a99-8e60-2e1384399200'], 192 | format: {}, 193 | permissions: [ 194 | { role: 'editor', type: 'user_permission', user_id: 'user_id' }, 195 | { role: 'reader', type: 'public_permission' }, 196 | ], 197 | created_time: 1595516162445, 198 | last_edited_time: 1602862020000, 199 | parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', 200 | parent_table: 'space', 201 | alive: true, 202 | created_by_table: 'notion_user', 203 | created_by_id: 'user_id', 204 | last_edited_by_table: 'notion_user', 205 | last_edited_by_id: 'user_id', 206 | shard_id: 285188, 207 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 208 | }, 209 | }, 210 | '9dfff855-7aed-4a99-8e60-2e1384399200': { 211 | role: 'editor', 212 | value: { 213 | id: '9dfff855-7aed-4a99-8e60-2e1384399200', 214 | version: 40, 215 | type: 'toggle', 216 | properties: { title: [['Isso é um detail']] }, 217 | content: ['12cfada5-6686-4561-9c4f-177b3be4b422'], 218 | created_time: 1602689700000, 219 | last_edited_time: 1602861180000, 220 | parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 221 | parent_table: 'block', 222 | alive: true, 223 | created_by_table: 'notion_user', 224 | created_by_id: 'use_id', 225 | last_edited_by_table: 'notion_user', 226 | last_edited_by_id: 'use_id', 227 | shard_id: 285188, 228 | space_id: '159177ec-0fb0-469e-a900-1a662b145a04', 229 | }, 230 | }, 231 | }, 232 | space: { 233 | '159177ec-0fb0-469e-a900-1a662b145a04': { 234 | role: 'editor', 235 | value: { 236 | id: '159177ec-0fb0-469e-a900-1a662b145a04', 237 | version: 173, 238 | name: 'workspace_name', 239 | domain: 'domais', 240 | permissions: [{ role: 'editor', type: 'user_permission', user_id: 'user_id' }], 241 | icon: 'https://lh3.googleusercontent.com/a-/AOh14GjqdgJy-51RgCHKPgIMrNaIX0y4QUAL6Mz9OFS4=s100', 242 | beta_enabled: false, 243 | pages: [ 244 | 'b0e76ef7-ec48-4aeb-9877-3b7907f4335c', 245 | '8cb60e50-7dec-4a8c-9032-e6c30a8ec642', 246 | 'c1665d8e-8d20-4b32-9a44-320428580441', 247 | '98c36805-bd5f-448f-b199-7fbff24d9963', 248 | 'a8624141-f0bd-4e41-9f02-83bbc7fc2b4a', 249 | 'b2115542-dfaa-4599-8ba9-77d467a33f94', 250 | '739c26a3-770d-44e7-b652-70b3a5ae0220', 251 | 'c215286e-fd31-4d74-9266-e832603a3e8e', 252 | '5ca7e1ba-65f4-422d-a274-529f8f8ec664', 253 | '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 254 | 'f9466b44-33b3-4cb2-9083-b820ead2c4fd', 255 | ], 256 | created_time: 1591708116643, 257 | last_edited_time: 1602476700000, 258 | created_by_table: 'notion_user', 259 | created_by_id: 'user_id', 260 | last_edited_by_table: 'notion_user', 261 | last_edited_by_id: 'user_id', 262 | shard_id: 285188, 263 | plan_type: 'personal', 264 | invite_link_code: '09cb83f87702b6ce2be323132a369b69e23085b3', 265 | invite_link_enabled: true, 266 | }, 267 | }, 268 | }, 269 | }, 270 | cursor: { stack: [] }, 271 | }; 272 | 273 | export const SUCCESSFUL_SYNC_RECORD_VALUE = { 274 | recordMap: { 275 | block: { 276 | '12cfada5-6686-4561-9c4f-177b3be4b422': { 277 | role: 'editor', 278 | value: { 279 | id: '12cfada5-6686-4561-9c4f-177b3be4b422', 280 | version: 27, 281 | type: 'text', 282 | properties: { title: [['Lide com isso!']] }, 283 | created_time: 1602861180000, 284 | last_edited_time: 1602861180000, 285 | parent_id: '9dfff855-7aed-4a99-8e60-2e1384399200', 286 | parent_table: 'block', 287 | alive: true, 288 | created_by_table: 'notion_user', 289 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 290 | last_edited_by_table: 'notion_user', 291 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 292 | }, 293 | }, 294 | }, 295 | }, 296 | }; 297 | 298 | export const SUCCESSFUL_RECORDS_WITH_CHILDREN = { 299 | results: [ 300 | { 301 | role: 'reader', 302 | value: { 303 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 304 | version: 262, 305 | type: 'page', 306 | properties: { title: [['Simple Page Text 2']] }, 307 | content: ['f8cf7a08-bf80-4f8b-a842-3db49df95e4d'], 308 | permissions: [ 309 | { role: 'editor', type: 'user_permission', user_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3' }, 310 | { role: 'reader', type: 'public_permission' }, 311 | ], 312 | created_time: 1595516162445, 313 | last_edited_time: 1602527580000, 314 | parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', 315 | parent_table: 'space', 316 | alive: true, 317 | created_by_table: 'notion_user', 318 | created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 319 | last_edited_by_table: 'notion_user', 320 | last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', 321 | }, 322 | }, 323 | ], 324 | }; 325 | 326 | export const NO_PAGE_ACCESS_RECORDS = { results: [{ role: 'none' }] }; 327 | 328 | export const MISSING_CONTENT_RECORDS = { 329 | results: [ 330 | { 331 | role: 'reader', 332 | value: { 333 | id: '9a75a541-277f-4a64-80e7-5581f36672ba', 334 | version: 22, 335 | type: 'page', 336 | permissions: [{ role: 'reader', type: 'public_permission' }], 337 | created_time: 1595551283696, 338 | last_edited_time: 1595551260000, 339 | alive: true, 340 | }, 341 | }, 342 | ], 343 | }; 344 | 345 | export const SINGLE_PAGE_WITH_COVER_IMAGE = [ 346 | { 347 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 348 | type: 'page', 349 | format: { page_cover: '/images/page-cover/solid_blue.png', page_cover_position: 0.6 }, 350 | properties: { 351 | title: [['Page Title']], 352 | }, 353 | contents: [], 354 | }, 355 | ]; 356 | 357 | export const SINGLE_PAGE_WITH_ICON = [ 358 | { 359 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 360 | type: 'page', 361 | format: { page_icon: '🤴' }, 362 | properties: { 363 | title: [['Page Title']], 364 | }, 365 | contents: [], 366 | }, 367 | ]; 368 | 369 | export const SINGLE_TEXT_AND_TITLE_NOTION_API_CONTENT_RESPONSE = [ 370 | { 371 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 372 | type: 'text', 373 | properties: { title: [['Hello World']] }, 374 | contents: [], 375 | }, 376 | ]; 377 | 378 | export const SINGLE_TEXT_WITH_BOLD = [ 379 | { 380 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 381 | type: 'text', 382 | properties: { 383 | title: [['Hello '], ['World', [['b']]]], 384 | }, 385 | contents: [], 386 | }, 387 | ]; 388 | 389 | export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC_TOGETHER = [ 390 | { 391 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 392 | type: 'text', 393 | properties: { 394 | title: [['Hello '], ['World', [['b'], ['i']]]], 395 | }, 396 | contents: [], 397 | }, 398 | ]; 399 | 400 | export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC = [ 401 | { 402 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 403 | type: 'text', 404 | properties: { 405 | title: [ 406 | ['Hello ', [['b']]], 407 | ['World', [['i']]], 408 | ], 409 | }, 410 | contents: [], 411 | }, 412 | ]; 413 | 414 | export const SINGLE_TEXT_WITH_COLOR = [ 415 | { 416 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 417 | type: 'text', 418 | properties: { 419 | title: [['Hello', [['h', 'purple']]]], 420 | }, 421 | contents: [], 422 | }, 423 | ]; 424 | 425 | export const SINGLE_TEXT_WITH_EQUATION = [ 426 | { 427 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 428 | type: 'text', 429 | properties: { 430 | title: [['Hello World '], ['⁍', [['e', '2x']]]], 431 | }, 432 | contents: [], 433 | }, 434 | ]; 435 | 436 | export const SINGLE_TEXT_WITH_LINK = [ 437 | { 438 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 439 | type: 'text', 440 | properties: { title: [['Hello '], ['World', [['a', 'https://www.google.com']]]] }, 441 | contents: [], 442 | }, 443 | ]; 444 | 445 | export const SINGLE_TEXT_WITH_FORMAT = [ 446 | { 447 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 448 | type: 'text', 449 | format: { block_color: 'red_background' }, 450 | properties: { title: [['Hello '], ['World', [['a', 'https://www.google.com']]]] }, 451 | contents: [], 452 | }, 453 | ]; 454 | 455 | export const CALLOUT_WITH_PAGE_ICON = [ 456 | { 457 | id: '16431c64-3bf0-481f-a29f-d544780d84f3', 458 | type: 'callout', 459 | properties: { title: [['This is a callout']] }, 460 | format: { page_icon: '💡' }, 461 | contents: [], 462 | }, 463 | ]; 464 | 465 | export const IMAGE_WITH_CUSTOM_SIZE = [ 466 | { 467 | id: 'ec3b36fd-f77d-46b4-8592-5966488612b1', 468 | type: 'image', 469 | properties: { 470 | source: [ 471 | [ 472 | 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', 473 | ], 474 | ], 475 | }, 476 | format: { 477 | block_width: 240, 478 | block_height: 50, 479 | display_source: 480 | 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', 481 | block_full_width: false, 482 | block_page_width: false, 483 | block_aspect_ratio: 1, 484 | block_preserve_scale: true, 485 | }, 486 | contents: [], 487 | }, 488 | ]; 489 | 490 | export const TEXT_NOTION_API_CONTENT_RESPONSE = [ 491 | { 492 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 493 | type: 'page', 494 | properties: { title: [['Simple Page Test']] }, 495 | format: { page_cover: 'https://www.example.com/image.png', page_cover_position: 0.6, page_icon: '🤴' }, 496 | contents: [ 497 | { 498 | id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', 499 | type: 'text', 500 | properties: { title: [['Hello World']] }, 501 | contents: [], 502 | }, 503 | ], 504 | }, 505 | ]; 506 | 507 | export const VIDEO_NOTION_API_CONTENT_RESPONSE = [ 508 | { 509 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 510 | type: 'page', 511 | properties: { title: [['Simple Page Test']] }, 512 | contents: [ 513 | { 514 | id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', 515 | type: 'video', 516 | properties: { source: [['https://www.youtube.com/watch?v=xBFqxBfLJWc']] }, 517 | contents: [], 518 | }, 519 | ], 520 | }, 521 | ]; 522 | 523 | export const LIST_WITH_CHILDREN_RESPONSE = [ 524 | { 525 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 526 | type: 'page', 527 | properties: { title: [['Simple Page Text 2']] }, 528 | contents: [ 529 | { 530 | id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', 531 | type: 'bulleted_list', 532 | properties: { title: [['Estou testando']] }, 533 | contents: [ 534 | { 535 | id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', 536 | type: 'bulleted_list', 537 | properties: { title: [['isso daqui']] }, 538 | contents: [ 539 | { 540 | id: '6bebe374-1569-4836-9de5-847c91ecb3f8', 541 | type: 'bulleted_list', 542 | properties: { title: [['vamos ver se funciona']] }, 543 | contents: [], 544 | }, 545 | ], 546 | }, 547 | ], 548 | }, 549 | ], 550 | }, 551 | ]; 552 | 553 | export const DETAILS_RESPONSE = [ 554 | { 555 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 556 | type: 'page', 557 | properties: { title: [['Simple Page Text 2']] }, 558 | format: {}, 559 | contents: [ 560 | { 561 | id: '9dfff855-7aed-4a99-8e60-2e1384399200', 562 | type: 'toggle', 563 | properties: { title: [['Isso é um detail']] }, 564 | contents: [ 565 | { 566 | id: '12cfada5-6686-4561-9c4f-177b3be4b422', 567 | type: 'text', 568 | properties: { title: [['Lide com isso!']] }, 569 | contents: [], 570 | }, 571 | ], 572 | }, 573 | ], 574 | }, 575 | ]; 576 | -------------------------------------------------------------------------------- /src/data/helpers/block-to-inner-html.ts: -------------------------------------------------------------------------------- 1 | import { Block, DecorableText } from '../protocols/blocks'; 2 | import { Decorator } from '../use-cases/blocks-to-html-converter/block-parsers/decorations/decorator'; 3 | import { replaceLineBreakByBrTag } from './replace-line-break-to-br-tag'; 4 | 5 | export const blockToInnerHtml = async (block: Block): Promise => { 6 | const decorator = new Decorator(block.decorableTexts || ([] as DecorableText[])); 7 | const decoratedText = await decorator.decorate(); 8 | return Promise.resolve(replaceLineBreakByBrTag(decoratedText)); 9 | }; 10 | -------------------------------------------------------------------------------- /src/data/helpers/block-to-inner-text.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../data/protocols/blocks'; 2 | 3 | export const blockToInnerText = (block: Block): string => { 4 | const decorableTexts = block.decorableTexts; 5 | return decorableTexts ? decorableTexts.map((dt) => dt.text).join('') : ''; 6 | }; 7 | -------------------------------------------------------------------------------- /src/data/helpers/blocks-to-html.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../protocols/blocks'; 2 | import { ListBlocksWrapper, BlockDispatcher, BlocksToHTML } from '../use-cases/blocks-to-html-converter'; 3 | 4 | export const blocksToHtml = async (blocks: Block[]): Promise => { 5 | const dispatcher = new BlockDispatcher(); 6 | const listWrapper = new ListBlocksWrapper(); 7 | const blocksToHtmlConverter = new BlocksToHTML(blocks, dispatcher, listWrapper); 8 | const html = await blocksToHtmlConverter.convert(); 9 | return Promise.resolve(html); 10 | }; 11 | 12 | export const indentBlocksToHtml = async (blocks: Block[]): Promise => { 13 | if (blocks.length === 0) return Promise.resolve(''); 14 | 15 | const html = await blocksToHtml(blocks); 16 | return Promise.resolve(html); 17 | }; 18 | -------------------------------------------------------------------------------- /src/data/helpers/color-to-hex.ts: -------------------------------------------------------------------------------- 1 | export const backgroundColorToHex = (color: string): string => { 2 | return backgroundColorsToHex[color] || '#FFFFFF'; 3 | }; 4 | 5 | export const foregroundColorToHex = (color: string): string => { 6 | return foregroundColorTextToHEX[color] || '#37352F'; 7 | }; 8 | 9 | const foregroundColorTextToHEX: Record = { 10 | purple: '#6940A5', 11 | yellow: '#E9AB01', 12 | gray: '#9B9A97', 13 | brown: '#64473A', 14 | orange: '#D9730D', 15 | green: '#0F7B6C', 16 | blue: '#0B6E99', 17 | pink: '#AD1A72', 18 | red: '#E03E3E', 19 | none: '#37352F', 20 | }; 21 | 22 | const backgroundColorsToHex: Record = { 23 | gray_background: '#B4AEAE', 24 | brown_background: '#E9E5E3', 25 | orange_background: '#FAEBDD', 26 | yellow_background: '#FBF3DB', 27 | green_background: '#DDEDEA', 28 | blue_background: '#DDEBF1', 29 | purple_background: '#EAE4F2', 30 | pink_background: '#F4DFEB', 31 | red_background: '#FBE4E4', 32 | }; 33 | -------------------------------------------------------------------------------- /src/data/helpers/replace-line-break-to-br-tag.ts: -------------------------------------------------------------------------------- 1 | export const replaceLineBreakByBrTag = (str: string): string => str.replace(/[\r\n]/g, '
'); 2 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/block.ts: -------------------------------------------------------------------------------- 1 | import { DecorableText } from './decorable-text'; 2 | import { Format } from './format'; 3 | 4 | export type Block = { 5 | id: string; 6 | type: string; 7 | children: Block[]; 8 | properties: Record; 9 | format: Format; 10 | decorableTexts: DecorableText[]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/decorable-text.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from './decoration'; 2 | 3 | export type DecorableText = { 4 | text: string; 5 | decorations: Decoration[]; 6 | }; 7 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/decoration.ts: -------------------------------------------------------------------------------- 1 | export type Decoration = { 2 | type: DecorationType; 3 | value?: string; 4 | }; 5 | 6 | export type DecorationType = 7 | | 'plain' 8 | | 'bold' 9 | | 'italic' 10 | | 'underline' 11 | | 'strikethrough' 12 | | 'link' 13 | | 'code' 14 | | 'color' 15 | | 'equation'; 16 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/format.ts: -------------------------------------------------------------------------------- 1 | export type Format = { 2 | block_color?: string; 3 | block_width?: number; 4 | page_icon?: string; 5 | page_cover?: string; 6 | page_cover_position?: number; 7 | }; 8 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './block'; 2 | export * from './decorable-text'; 3 | export * from './decoration'; 4 | export * from './list-blocks-wrapper'; 5 | -------------------------------------------------------------------------------- /src/data/protocols/blocks/list-blocks-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Block } from './block'; 2 | 3 | export interface ListBlocksWrapper { 4 | wrapLists(blocks: Block[]): Block[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/protocols/html-options/html-options.ts: -------------------------------------------------------------------------------- 1 | export type HtmlOptions = { 2 | excludeTitleFromHead?: boolean; 3 | excludeCSS?: boolean; 4 | excludeMetadata?: boolean; 5 | excludeScripts?: boolean; 6 | excludeHeaderFromBody?: boolean; 7 | bodyContentOnly?: boolean; 8 | }; 9 | -------------------------------------------------------------------------------- /src/data/protocols/http-request/http-get-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from './http-response'; 2 | 3 | export interface HttpGetClient { 4 | get(url: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/protocols/http-request/http-post-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from './http-response'; 2 | 3 | export interface HttpPostClient { 4 | post(url: string, body: Record): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/protocols/http-request/http-response.ts: -------------------------------------------------------------------------------- 1 | export type HttpResponse = { 2 | status: number; 3 | data: Record | string; 4 | headers?: Record; 5 | }; 6 | -------------------------------------------------------------------------------- /src/data/protocols/http-request/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-post-client'; 2 | export * from './http-get-client'; 3 | export * from './http-response'; 4 | -------------------------------------------------------------------------------- /src/data/protocols/page-props/image-cover.ts: -------------------------------------------------------------------------------- 1 | export type ImageCover = { 2 | base64: string; 3 | position: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/data/protocols/page-props/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page-props'; 2 | export * from './image-cover'; 3 | -------------------------------------------------------------------------------- /src/data/protocols/page-props/page-props.ts: -------------------------------------------------------------------------------- 1 | export type PageProps = { 2 | title: string; 3 | coverImageSrc?: string; 4 | coverImagePosition?: number; 5 | icon?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | import { ToHtml, ToHtmlClass } from '../../../domain/use-cases/to-html'; 3 | import * as blockParsers from './block-parsers'; 4 | 5 | export class BlockDispatcher { 6 | dispatch(block: Block): ToHtml { 7 | const ToHtmlConverter = fromBlockToHtmlConverter[block.type] || blockParsers.UnknownBlockToHtml; 8 | return new ToHtmlConverter(block); 9 | } 10 | } 11 | 12 | const fromBlockToHtmlConverter: Record = { 13 | text: blockParsers.TextBlockToHtml, 14 | header: blockParsers.HeaderBlockToHtml, 15 | sub_header: blockParsers.SubHeaderBlockParser, 16 | sub_sub_header: blockParsers.SubSubHeaderBlockParser, 17 | to_do: blockParsers.ToDoBlockToHtml, 18 | code: blockParsers.CodeBlockToHtml, 19 | equation: blockParsers.EquationBlockToHtml, 20 | quote: blockParsers.QuoteBlockToHtml, 21 | divider: blockParsers.DividerBlockToHtml, 22 | list: blockParsers.ListBlockToHtml, 23 | video: blockParsers.YouTubeVideoBlockToHtml, 24 | image: blockParsers.ImageBlockToHtml, 25 | callout: blockParsers.CalloutBlockToHtml, 26 | toggle: blockParsers.ToggleBlockToHtml, 27 | page: blockParsers.PageBlockToHtml, 28 | }; 29 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/callout.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | import { Base64Converter } from '../../../../utils/base-64-converter'; 6 | 7 | export class CalloutBlockToHtml implements ToHtml { 8 | private readonly _block: Block; 9 | 10 | constructor(block: Block) { 11 | this._block = block; 12 | } 13 | 14 | async convert(): Promise { 15 | const style = new FormatToStyle(this._block.format).toStyle(); 16 | const iconHtml = await new IconToHtml(this._block.properties.page_icon, this._block.id).toHtml(); 17 | 18 | return Promise.resolve(` 19 |
20 | ${iconHtml} 21 |

${await blockToInnerHtml(this._block)}

22 |
23 | `); 24 | } 25 | } 26 | 27 | class IconToHtml { 28 | private readonly _icon: string | undefined; 29 | private readonly _id: string; 30 | 31 | constructor(icon: string | undefined, id: string) { 32 | this._icon = icon; 33 | this._id = id; 34 | } 35 | 36 | async toHtml(): Promise { 37 | if (!this._icon) return `
💡
`; 38 | if (!this._icon.startsWith('http')) return `
${this._icon}
`; 39 | 40 | const url = `https://www.notion.so/image/${encodeURIComponent(this._icon)}?table=block&id=${this._id}`; 41 | const imageSource = await Base64Converter.convert(url); 42 | const caption = 'callout icon'; 43 | return `
${caption}
`; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/code.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../../data/protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | import { blockToInnerText } from '../../../helpers/block-to-inner-text'; 4 | 5 | export class CodeBlockToHtml implements ToHtml { 6 | private readonly _block: Block; 7 | 8 | constructor(block: Block) { 9 | this._block = block; 10 | } 11 | 12 | async convert(): Promise { 13 | const languageClass = this._language ? `class="language-${this._language}"` : ''; 14 | 15 | return Promise.resolve( 16 | `
${blockToInnerText(this._block).replace(/(\s{4}|\t)/g, '  ')}
`, 17 | ); 18 | } 19 | 20 | private get _language(): string { 21 | return this._block.properties?.language?.toLowerCase().replace(/ /g, ''); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../../../../../data/protocols/blocks/decoration'; 2 | import { ToHtml, ToHtmlClass } from '../../../../../domain/use-cases/to-html'; 3 | import * as DecorationParsers from './decoration-parsers'; 4 | 5 | export class DecoratorDispatcher { 6 | dispatch(text: string, decoration: Decoration): ToHtml { 7 | const ToHtmlConverter = fromDecorationTypeToParsers[decoration.type] || DecorationParsers.UnknownDecorationToHtml; 8 | return new ToHtmlConverter(text, decoration); 9 | } 10 | } 11 | 12 | const fromDecorationTypeToParsers: Record = { 13 | bold: DecorationParsers.BoldDecorationToHtml, 14 | italic: DecorationParsers.ItalicDecorationToHtml, 15 | strikethrough: DecorationParsers.StrikeThroughDecorationToHtml, 16 | code: DecorationParsers.CodeDecorationToHtml, 17 | underline: DecorationParsers.UnderlineDecorationToHtml, 18 | equation: DecorationParsers.EquationDecorationToHtml, 19 | link: DecorationParsers.LinkDecorationToHtml, 20 | color: DecorationParsers.ColorDecorationToHtml, 21 | }; 22 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/bold.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | 3 | export class BoldDecorationToHtml implements ToHtml { 4 | private readonly _text: string; 5 | 6 | constructor(text: string) { 7 | this._text = text; 8 | } 9 | 10 | async convert(): Promise { 11 | return Promise.resolve(`${this._text}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/code.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | 3 | export class CodeDecorationToHtml implements ToHtml { 4 | private readonly _text: string; 5 | 6 | constructor(text: string) { 7 | this._text = text; 8 | } 9 | 10 | async convert(): Promise { 11 | return Promise.resolve(`${this._text}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/color.test.ts: -------------------------------------------------------------------------------- 1 | import { ColorDecorationToHtml } from './color'; 2 | import { Decoration } from '../../../../../protocols/blocks'; 3 | 4 | describe('#convert', () => { 5 | describe('When color is given as foreground color', () => { 6 | it('styles using css color property', async () => { 7 | const text = 'Text with color'; 8 | const decoration: Decoration = { type: 'color', value: 'purple' }; 9 | 10 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 11 | 12 | expect(result).toMatch('style="color:'); 13 | }); 14 | }); 15 | 16 | describe('When color is given as background color', () => { 17 | it('styles using css background-color property', async () => { 18 | const text = 'Text with color'; 19 | const decoration: Decoration = { type: 'color', value: 'purple_background' }; 20 | 21 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 22 | 23 | expect(result).toMatch('style="background-color:'); 24 | }); 25 | 26 | it('do not preserves color value on style', async () => { 27 | const text = 'Text with color'; 28 | const decoration: Decoration = { type: 'color', value: 'purple_background' }; 29 | 30 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 31 | 32 | expect(result).not.toMatch('purple_background'); 33 | }); 34 | }); 35 | 36 | describe('When purple color is given as foreground color', () => { 37 | it('converts to equivalent hex code and apply style to html', async () => { 38 | const text = 'Text with color'; 39 | const decoration: Decoration = { type: 'color', value: 'purple' }; 40 | 41 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 42 | 43 | expect(result).toBe('Text with color'); 44 | }); 45 | }); 46 | 47 | describe('When yellow color is given as color', () => { 48 | it('converts to equivalent hex code and apply style to html', async () => { 49 | const text = 'Text with color'; 50 | const decoration: Decoration = { type: 'color', value: 'yellow' }; 51 | 52 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 53 | 54 | expect(result).toBe('Text with color'); 55 | }); 56 | }); 57 | 58 | describe('When gray color is given as foreground color', () => { 59 | it('converts to equivalent hex code and apply style to html', async () => { 60 | const text = 'Text with color'; 61 | const decoration: Decoration = { type: 'color', value: 'gray' }; 62 | 63 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 64 | 65 | expect(result).toBe('Text with color'); 66 | }); 67 | }); 68 | 69 | describe('When brown color is given as color', () => { 70 | it('converts to equivalent hex code and apply style to html', async () => { 71 | const text = 'Text with color'; 72 | const decoration: Decoration = { type: 'color', value: 'brown' }; 73 | 74 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 75 | 76 | expect(result).toBe('Text with color'); 77 | }); 78 | }); 79 | 80 | describe('When orange color is given as foreground color', () => { 81 | it('converts to equivalent hex code and apply style to html', async () => { 82 | const text = 'Text with color'; 83 | const decoration: Decoration = { type: 'color', value: 'orange' }; 84 | 85 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 86 | 87 | expect(result).toBe('Text with color'); 88 | }); 89 | }); 90 | 91 | describe('When green color is given as foreground color', () => { 92 | it('converts to equivalent hex code and apply style to html', async () => { 93 | const text = 'Text with color'; 94 | const decoration: Decoration = { type: 'color', value: 'green' }; 95 | 96 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 97 | 98 | expect(result).toBe('Text with color'); 99 | }); 100 | }); 101 | 102 | describe('When pink color is given as foreground color', () => { 103 | it('converts to equivalent hex code and apply style to html', async () => { 104 | const text = 'Text with color'; 105 | const decoration: Decoration = { type: 'color', value: 'pink' }; 106 | 107 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 108 | 109 | expect(result).toBe('Text with color'); 110 | }); 111 | }); 112 | 113 | describe('When red color is given as foreground color', () => { 114 | it('converts to equivalent hex code and apply style to html', async () => { 115 | const text = 'Text with color'; 116 | const decoration: Decoration = { type: 'color', value: 'red' }; 117 | 118 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 119 | 120 | expect(result).toBe('Text with color'); 121 | }); 122 | }); 123 | 124 | describe('When unknown color is given as foreground color', () => { 125 | it('converts to equivalent default foreground color hex code and apply style to html', async () => { 126 | const text = 'Text with color'; 127 | const decoration: Decoration = { type: 'color', value: 'refafad' }; 128 | 129 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 130 | 131 | expect(result).toBe('Text with color'); 132 | }); 133 | }); 134 | 135 | describe('When no color value is given as foreground color', () => { 136 | it('converts to equivalent default foreground color hex code and apply style to html', async () => { 137 | const text = 'Text with color'; 138 | const decoration: Decoration = { type: 'color' }; 139 | 140 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 141 | 142 | expect(result).toBe('Text with color'); 143 | }); 144 | }); 145 | 146 | describe('When gray color is given as background color', () => { 147 | it('converts to equivalent hex code and apply style to html', async () => { 148 | const text = 'Text with color'; 149 | const decoration: Decoration = { type: 'color', value: 'gray_background' }; 150 | 151 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 152 | 153 | expect(result).toBe('Text with color'); 154 | }); 155 | }); 156 | 157 | describe('When brown color is given as background color', () => { 158 | it('converts to equivalent hex code and apply style to html', async () => { 159 | const text = 'Text with color'; 160 | const decoration: Decoration = { type: 'color', value: 'brown_background' }; 161 | 162 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 163 | 164 | expect(result).toBe('Text with color'); 165 | }); 166 | }); 167 | 168 | describe('When orange color is given as background color', () => { 169 | it('converts to equivalent hex code and apply style to html', async () => { 170 | const text = 'Text with color'; 171 | const decoration: Decoration = { type: 'color', value: 'orange_background' }; 172 | 173 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 174 | 175 | expect(result).toBe('Text with color'); 176 | }); 177 | }); 178 | 179 | describe('When yellow color is given as background color', () => { 180 | it('converts to equivalent hex code and apply style to html', async () => { 181 | const text = 'Text with color'; 182 | const decoration: Decoration = { type: 'color', value: 'yellow_background' }; 183 | 184 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 185 | 186 | expect(result).toBe('Text with color'); 187 | }); 188 | }); 189 | 190 | describe('When green color is given as background color', () => { 191 | it('converts to equivalent hex code and apply style to html', async () => { 192 | const text = 'Text with color'; 193 | const decoration: Decoration = { type: 'color', value: 'green_background' }; 194 | 195 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 196 | 197 | expect(result).toBe('Text with color'); 198 | }); 199 | }); 200 | 201 | describe('When blue color is given as background color', () => { 202 | it('converts to equivalent hex code and apply style to html', async () => { 203 | const text = 'Text with color'; 204 | const decoration: Decoration = { type: 'color', value: 'blue_background' }; 205 | 206 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 207 | 208 | expect(result).toBe('Text with color'); 209 | }); 210 | }); 211 | 212 | describe('When purple color is given as background color', () => { 213 | it('converts to equivalent hex code and apply style to html', async () => { 214 | const text = 'Text with color'; 215 | const decoration: Decoration = { type: 'color', value: 'purple_background' }; 216 | 217 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 218 | 219 | expect(result).toBe('Text with color'); 220 | }); 221 | }); 222 | 223 | describe('When pink color is given as background color', () => { 224 | it('converts to equivalent hex code and apply style to html', async () => { 225 | const text = 'Text with color'; 226 | const decoration: Decoration = { type: 'color', value: 'pink_background' }; 227 | 228 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 229 | 230 | expect(result).toBe('Text with color'); 231 | }); 232 | }); 233 | 234 | describe('When red color is given as background color', () => { 235 | it('converts to equivalent hex code and apply style to html', async () => { 236 | const text = 'Text with color'; 237 | const decoration: Decoration = { type: 'color', value: 'red_background' }; 238 | 239 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 240 | 241 | expect(result).toBe('Text with color'); 242 | }); 243 | }); 244 | 245 | describe('When unknown color is given as background color', () => { 246 | it('converts to equivalent default background color hex code and apply style to html', async () => { 247 | const text = 'Text with color'; 248 | const decoration: Decoration = { type: 'color', value: 'refafad_background' }; 249 | 250 | const result = await new ColorDecorationToHtml(text, decoration).convert(); 251 | 252 | expect(result).toBe('Text with color'); 253 | }); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/color.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../../../../../../data/protocols/blocks'; 2 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 3 | import { foregroundColorToHex, backgroundColorToHex } from '../../../../../helpers/color-to-hex'; 4 | 5 | export class ColorDecorationToHtml implements ToHtml { 6 | private readonly _text: string; 7 | private readonly _decoration: Decoration; 8 | 9 | constructor(text: string, decoration: Decoration) { 10 | this._text = text; 11 | this._decoration = decoration; 12 | } 13 | 14 | async convert(): Promise { 15 | return Promise.resolve(`${this._text}`); 16 | } 17 | 18 | private _isBackground(): boolean { 19 | return !!this._decoration.value?.includes('_background'); 20 | } 21 | 22 | private get _style() { 23 | const textColor = this._decoration.value || 'none'; 24 | 25 | if (this._isBackground()) return `background-color: ${backgroundColorToHex(textColor)};`; 26 | return `color: ${foregroundColorToHex(textColor)};`; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/equation.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../../../../../../data/protocols/blocks'; 2 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 3 | 4 | export class EquationDecorationToHtml implements ToHtml { 5 | private readonly _text: string; 6 | private readonly _decoration: Decoration; 7 | 8 | constructor(text: string, decoration: Decoration) { 9 | this._text = text; 10 | this._decoration = decoration; 11 | } 12 | 13 | async convert(): Promise { 14 | const equation = this._decoration.value; 15 | return Promise.resolve(equation ? `$${equation}$` : ''); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bold'; 2 | export * from './code'; 3 | export * from './color'; 4 | export * from './equation'; 5 | export * from './italic'; 6 | export * from './link'; 7 | export * from './strikethrough'; 8 | export * from './underline'; 9 | export * from './unknown'; 10 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/italic.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | 3 | export class ItalicDecorationToHtml implements ToHtml { 4 | private readonly _text: string; 5 | 6 | constructor(text: string) { 7 | this._text = text; 8 | } 9 | 10 | async convert(): Promise { 11 | return Promise.resolve(`${this._text}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/link.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../../../../../../data/protocols/blocks'; 2 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 3 | 4 | export class LinkDecorationToHtml implements ToHtml { 5 | private readonly _text: string; 6 | private readonly _decoration: Decoration; 7 | 8 | constructor(text: string, decoration: Decoration) { 9 | this._text = text; 10 | this._decoration = decoration; 11 | } 12 | 13 | async convert(): Promise { 14 | return Promise.resolve(`${this._text}`); 15 | } 16 | 17 | private get _link(): string { 18 | return this._decoration.value || '#'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/strikethrough.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | 3 | export class StrikeThroughDecorationToHtml implements ToHtml { 4 | private readonly _text: string; 5 | 6 | constructor(text: string) { 7 | this._text = text; 8 | } 9 | 10 | async convert(): Promise { 11 | return Promise.resolve(`${this._text}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/underline.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | export class UnderlineDecorationToHtml implements ToHtml { 3 | private readonly _text: string; 4 | 5 | constructor(text: string) { 6 | this._text = text; 7 | } 8 | 9 | async convert(): Promise { 10 | return Promise.resolve(`${this._text}`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/unknown.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../../../domain/use-cases/to-html'; 2 | 3 | export class UnknownDecorationToHtml implements ToHtml { 4 | private readonly _text: string; 5 | 6 | constructor(text: string) { 7 | this._text = text; 8 | } 9 | 10 | async convert(): Promise { 11 | return Promise.resolve(this._text); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decorator.ts: -------------------------------------------------------------------------------- 1 | import { DecorableText } from '../../../../../data/protocols/blocks/decorable-text'; 2 | import { DecoratorDispatcher } from './decoration-dispatcher'; 3 | 4 | export class Decorator { 5 | private readonly _decorableTexts: DecorableText[]; 6 | 7 | constructor(decorableTexts: DecorableText[]) { 8 | this._decorableTexts = decorableTexts; 9 | } 10 | 11 | async decorate(): Promise { 12 | const decorableTextsByDecorators = await Promise.all( 13 | this._decorableTexts.map(await this._decorateByDecorableText.bind(this)), 14 | ); 15 | return Promise.resolve(decorableTextsByDecorators.join('')); 16 | } 17 | 18 | async _decorateByDecorableText(decorableText: DecorableText): Promise { 19 | let html = decorableText.text; 20 | for (const decoration of decorableText.decorations) { 21 | const decorator = new DecoratorDispatcher().dispatch(html, decoration); 22 | html = await decorator.convert(); 23 | } 24 | 25 | return Promise.resolve(html); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/divider.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | 4 | export class DividerBlockToHtml implements ToHtml { 5 | private readonly _block: Block; 6 | 7 | constructor(block: Block) { 8 | this._block = block; 9 | } 10 | 11 | async convert(): Promise { 12 | return Promise.resolve(`
`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/equation.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | import { blockToInnerText } from '../../../helpers/block-to-inner-text'; 4 | 5 | export class EquationBlockToHtml implements ToHtml { 6 | private readonly _block: Block; 7 | 8 | constructor(block: Block) { 9 | this._block = block; 10 | } 11 | 12 | async convert(): Promise { 13 | const { decorableTexts } = this._block; 14 | if (decorableTexts.length === 0) return Promise.resolve(''); 15 | 16 | return Promise.resolve(decorableTexts ? `
$$${blockToInnerText(this._block)}$$
` : ''); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/header.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | 6 | export class HeaderBlockToHtml implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const style = new FormatToStyle(this._block.format).toStyle(); 15 | return Promise.resolve(`${await blockToInnerHtml(this._block)}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/image.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | import { Base64Converter } from '../../../../utils/base-64-converter'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | 6 | export class ImageBlockToHtml implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | if (!this._rawSrc) return ''; 15 | 16 | const imageSource = await Base64Converter.convert(this._rawSrc); 17 | const caption = this._caption; 18 | const style = new FormatToStyle(this._block.format).toStyle(); 19 | 20 | return ` 21 |
22 | ${caption} 23 | ${caption !== '' ? `
${caption}
` : ''} 24 |
25 | `; 26 | } 27 | 28 | private get _rawSrc() { 29 | const url = this._block.properties.source; 30 | if (!url) return; 31 | 32 | return `https://www.notion.so/image/${encodeURIComponent(url)}?table=block&id=${this._block.id}`; 33 | } 34 | 35 | private get _caption() { 36 | return this._block.properties.caption || ''; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './image'; 2 | export * from './list'; 3 | export * from './callout'; 4 | export * from './code'; 5 | export * from './divider'; 6 | export * from './equation'; 7 | export * from './header'; 8 | export * from './quote'; 9 | export * from './sub-header'; 10 | export * from './sub-sub-header'; 11 | export * from './text'; 12 | export * from './to-do'; 13 | export * from './toggle'; 14 | export * from './page'; 15 | export * from './unknown'; 16 | export * from './youtube-video'; 17 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './list'; 2 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/list/list-item.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../../domain/use-cases/to-html'; 4 | import { indentBlocksToHtml } from '../../../../helpers/blocks-to-html'; 5 | 6 | export class ListItemToHtml implements ToHtml { 7 | private _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const childrenHtml = await indentBlocksToHtml(this._block.children); 15 | 16 | return Promise.resolve(`
  • ${await blockToInnerHtml(this._block)}${childrenHtml}
  • `); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/list/list.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../../domain/use-cases/to-html'; 3 | import { ListItemToHtml } from './list-item'; 4 | import { FormatToStyle } from '../../../format-to-style'; 5 | 6 | export class ListBlockToHtml implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const tag: string = fromTypeToTag[this._block.children[0].type] || fromTypeToTag.bulleted_list; 15 | const style = new FormatToStyle(this._block.format).toStyle(); 16 | 17 | const innerHtml = await this._itemsHtml(); 18 | 19 | return Promise.resolve(`<${tag}${style}>\n${innerHtml}\n`); 20 | } 21 | 22 | private async _itemsHtml(): Promise { 23 | const items = await Promise.all(this._block.children.map(async (c) => new ListItemToHtml(c).convert())); 24 | return Promise.resolve(items.join('\n')); 25 | } 26 | } 27 | 28 | const fromTypeToTag: Record = { 29 | bulleted_list: 'ul', 30 | numbered_list: 'ol', 31 | }; 32 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/page.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | import { blocksToHtml } from '../../../helpers/blocks-to-html'; 4 | 5 | export class PageBlockToHtml implements ToHtml { 6 | private readonly _block: Block; 7 | 8 | constructor(block: Block) { 9 | this._block = block; 10 | } 11 | 12 | async convert(): Promise { 13 | return blocksToHtml(this._block.children); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/quote.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | 6 | export class QuoteBlockToHtml implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const style = new FormatToStyle(this._block.format).toStyle(); 15 | return Promise.resolve(`${await blockToInnerHtml(this._block)}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/sub-header.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | 6 | export class SubHeaderBlockParser implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const style = new FormatToStyle(this._block.format).toStyle(); 15 | return Promise.resolve(`${await blockToInnerHtml(this._block)}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/sub-sub-header.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | 6 | export class SubSubHeaderBlockParser implements ToHtml { 7 | private readonly _block: Block; 8 | 9 | constructor(block: Block) { 10 | this._block = block; 11 | } 12 | 13 | async convert(): Promise { 14 | const style = new FormatToStyle(this._block.format).toStyle(); 15 | return Promise.resolve(`${await blockToInnerHtml(this._block)}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/text.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | import { indentBlocksToHtml } from '../../../helpers/blocks-to-html'; 6 | 7 | export class TextBlockToHtml implements ToHtml { 8 | private readonly _block: Block; 9 | 10 | constructor(block: Block) { 11 | this._block = block; 12 | } 13 | 14 | async convert(): Promise { 15 | const style = new FormatToStyle(this._block.format).toStyle(); 16 | const childrenHtml = await indentBlocksToHtml(this._block.children); 17 | 18 | return Promise.resolve(`${await blockToInnerHtml(this._block)}${childrenHtml}

    `); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/to-do.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | import { indentBlocksToHtml } from '../../../helpers/blocks-to-html'; 6 | 7 | export class ToDoBlockToHtml implements ToHtml { 8 | private readonly _block: Block; 9 | 10 | constructor(block: Block) { 11 | this._block = block; 12 | } 13 | 14 | async convert(): Promise { 15 | const style = new FormatToStyle(this._block.format).toStyle(); 16 | const childrenHtml = await indentBlocksToHtml(this._block.children); 17 | 18 | return Promise.resolve(`\ 19 |
      20 |
    • 21 |
      22 | ${await blockToInnerHtml( 23 | this._block, 24 | )}${childrenHtml} 25 |
    • 26 |
    `); 27 | } 28 | 29 | private _isChecked(): boolean { 30 | return !!this._block.properties.checked; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/toggle.ts: -------------------------------------------------------------------------------- 1 | import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; 2 | import { Block } from '../../../protocols/blocks'; 3 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 4 | import { FormatToStyle } from '../../format-to-style'; 5 | import { indentBlocksToHtml } from '../../../helpers/blocks-to-html'; 6 | 7 | export class ToggleBlockToHtml implements ToHtml { 8 | private readonly _block: Block; 9 | 10 | constructor(block: Block) { 11 | this._block = block; 12 | } 13 | 14 | async convert(): Promise { 15 | const style = new FormatToStyle(this._block.format).toStyle(); 16 | const childrenHtml = await indentBlocksToHtml(this._block.children); 17 | 18 | return Promise.resolve(` 19 |
    20 | ${await blockToInnerHtml(this._block)} 21 | ${childrenHtml} 22 |
    `); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/unknown.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 2 | 3 | export class UnknownBlockToHtml implements ToHtml { 4 | async convert(): Promise { 5 | return Promise.resolve(''); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/block-parsers/youtube-video.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../protocols/blocks'; 2 | import { ToHtml } from '../../../../domain/use-cases/to-html'; 3 | 4 | export class YouTubeVideoBlockToHtml implements ToHtml { 5 | private readonly _block: Block; 6 | 7 | constructor(block: Block) { 8 | this._block = block; 9 | } 10 | 11 | async convert(): Promise { 12 | const id = this._youtubeId; 13 | if (!id) return ''; 14 | return ``; 22 | } 23 | 24 | private get _youtubeId(): string | void { 25 | const youtubeIdMatcher = 26 | /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/gi; 27 | return youtubeIdMatcher.exec(this._src)?.[1]; 28 | } 29 | 30 | private get _src() { 31 | return this._block.properties?.source; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/blocks-to-html-converter.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { resolve } from 'path'; 3 | 4 | import { Block } from '../../protocols/blocks'; 5 | import * as BlockMocks from '../../../__tests__/mocks/blocks'; 6 | import { BlocksToHTML, BlockDispatcher, ListBlocksWrapper } from './index'; 7 | import { ToHtml } from '../../../domain/use-cases/to-html'; 8 | import { Base64Converter } from '../../../utils/base-64-converter'; 9 | import base64Img from '../../../__tests__/mocks/img/base64'; 10 | 11 | describe('#convert', () => { 12 | const makeSut = (blocks: Block[]): ToHtml => { 13 | const blockDispatcher = new BlockDispatcher(); 14 | const listBlocksWrapper = new ListBlocksWrapper(); 15 | return new BlocksToHTML(blocks, blockDispatcher, listBlocksWrapper); 16 | }; 17 | 18 | describe('When only a text block is given', () => { 19 | describe('When empty text block is given', () => { 20 | it('returns empty p tag', async () => { 21 | const html = await makeSut(BlockMocks.NO_TEXT).convert(); 22 | 23 | expect(html).toBe('

    '); 24 | }); 25 | }); 26 | 27 | describe('When single text block is given', () => { 28 | it('returns html with p tag', async () => { 29 | const html = await makeSut(BlockMocks.SINGLE_TEXT).convert(); 30 | 31 | expect(html).toBe('

    Hello World

    '); 32 | }); 33 | }); 34 | 35 | describe('When single text block has children', () => { 36 | it('returns html with p tag and children blocks inside', async () => { 37 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_CHILDREN).convert(); 38 | 39 | expect(html.replace(/\s/g, '')).toBe( 40 | `

    Hello World 41 |

    This is a child

    42 |

    This is a child too

    43 |

    `.replace(/\s/g, ''), 44 | ); 45 | }); 46 | }); 47 | 48 | describe('When single line text with bold part', () => { 49 | it('returns html with single p paragraph with strong tag nested', async () => { 50 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_BOLD).convert(); 51 | 52 | expect(html).toBe('

    Hello World

    '); 53 | }); 54 | }); 55 | 56 | describe('When single line text with italic part', () => { 57 | it('returns html with single p paragraph with strong tag nested', async () => { 58 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_ITALIC).convert(); 59 | 60 | expect(html).toBe('

    Hello World

    '); 61 | }); 62 | }); 63 | 64 | describe('When single line text with underline part', () => { 65 | it('returns html with single p paragraph with span tag and underline style nested', async () => { 66 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_UNDERLINE).convert(); 67 | 68 | expect(html).toBe('

    Hello World

    '); 69 | }); 70 | }); 71 | 72 | describe('When single line text with strikethrough part', () => { 73 | it('returns html with single p paragraph with del tag inside', async () => { 74 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_STRIKETHROUGH).convert(); 75 | 76 | expect(html).toBe('

    Hello World

    '); 77 | }); 78 | }); 79 | 80 | describe('When single line text with code part', () => { 81 | it('returns html with single p paragraph with code tag inside', async () => { 82 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_CODE_DECORATION).convert(); 83 | 84 | expect(html).toBe('

    Hello myVar

    '); 85 | }); 86 | }); 87 | 88 | describe('When single line text with link part', () => { 89 | it('returns html with single p paragraph with a tag with given link', async () => { 90 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_LINK).convert(); 91 | 92 | expect(html).toBe('

    Hello World

    '); 93 | }); 94 | }); 95 | 96 | describe('When single line text with inline equation part', () => { 97 | it('returns html with single p paragraph equation wrapped inside $$', async () => { 98 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_EQUATION_DECORATION).convert(); 99 | 100 | expect(html).toBe('

    Hello World $2x$

    '); 101 | }); 102 | }); 103 | 104 | describe('When single line text with color part', () => { 105 | it('returns html with single p paragraph with span tag and color style inside', async () => { 106 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_COLOR).convert(); 107 | 108 | expect(html).toBe('

    Hello

    '); 109 | }); 110 | }); 111 | 112 | describe('When single line text with color background part', () => { 113 | it('returns html with single p paragraph with span tag and background color style inside', async () => { 114 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_COLOR_BACKGROUND).convert(); 115 | 116 | expect(html).toBe('

    Hello

    '); 117 | }); 118 | }); 119 | 120 | describe('When single line text with bold and italic parts together', () => { 121 | it('returns html with single p paragraph with strong and em tags nested', async () => { 122 | const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_BOLD_AND_ITALIC).convert(); 123 | 124 | expect(html).toBe('

    Hello World

    '); 125 | }); 126 | }); 127 | 128 | describe('When single line text with bold and italic parts apart', () => { 129 | it('returns html with single p paragraph with strong and em tags nested', async () => { 130 | const html = await makeSut(BlockMocks.TEXT_WITH_DECORATION).convert(); 131 | 132 | expect(html).toBe( 133 | '

    Hello World and Sun

    ', 134 | ); 135 | }); 136 | }); 137 | 138 | describe('When multiline text block is given', () => { 139 | it('returns html with two p tags', async () => { 140 | const html = await makeSut(BlockMocks.MULTILINE_TEXT).convert(); 141 | 142 | expect(html).toBe('

    Hello World
    Is everything alright?
    Yes, Dude!

    '); 143 | }); 144 | }); 145 | 146 | describe('When text block has background color', () => { 147 | it('returns html p tag with style and background-color prop', async () => { 148 | const html = await makeSut(BlockMocks.TEXT_WITH_FORMAT).convert(); 149 | 150 | expect(html).toBe('

    This is a text with red background

    '); 151 | }); 152 | }); 153 | 154 | describe('When text block has foreground color', () => { 155 | it('returns html p tag with style and color prop', async () => { 156 | const html = await makeSut(BlockMocks.TEXT_WITH_FORMAT_FOREGROUND).convert(); 157 | 158 | expect(html).toBe('

    This is a text with purple color

    '); 159 | }); 160 | }); 161 | }); 162 | 163 | describe('When only a h1 title block is given', () => { 164 | describe('When single block is given', () => { 165 | it('returns html with h1 tag', async () => { 166 | const html = await makeSut(BlockMocks.H1_TEXT).convert(); 167 | 168 | expect(html).toBe('

    This is a h1 title

    '); 169 | }); 170 | }); 171 | 172 | describe('When single line header with decoration', () => { 173 | it('returns html with single h1 with decoration tags inside', async () => { 174 | const html = await makeSut(BlockMocks.H1_TEXT_WITH_DECORATIONS).convert(); 175 | 176 | expect(html).toBe( 177 | '

    Hello World and Sun

    ', 178 | ); 179 | }); 180 | }); 181 | 182 | describe('When header block has background color', () => { 183 | it('returns html h1 tag with style and background-color prop', async () => { 184 | const html = await makeSut(BlockMocks.H1_WITH_FORMAT).convert(); 185 | 186 | expect(html).toBe('

    This is a h1 with red background

    '); 187 | }); 188 | }); 189 | 190 | describe('When header block has foreground color', () => { 191 | it('returns html h1 tag with style and color prop', async () => { 192 | const html = await makeSut(BlockMocks.H1_WITH_FORMAT_FOREGROUND).convert(); 193 | 194 | expect(html).toBe('

    This is a h1 with yellow color

    '); 195 | }); 196 | }); 197 | }); 198 | 199 | describe('When only a h2 title block is given', () => { 200 | describe('When single block is given', () => { 201 | it('returns html with h2 tag', async () => { 202 | const html = await makeSut(BlockMocks.H2_TEXT).convert(); 203 | 204 | expect(html).toBe('

    This is a h2 title

    '); 205 | }); 206 | }); 207 | 208 | describe('When single line h2 with decoration', () => { 209 | it('returns html with single h1 with decoration tags inside', async () => { 210 | const html = await makeSut(BlockMocks.H2_TEXT_WITH_DECORATIONS).convert(); 211 | 212 | expect(html).toBe( 213 | '

    Hello World and Sun

    ', 214 | ); 215 | }); 216 | }); 217 | 218 | describe('When sub header block has background color', () => { 219 | it('returns html h2 tag with style and background-color prop', async () => { 220 | const html = await makeSut(BlockMocks.H2_WITH_FORMAT).convert(); 221 | 222 | expect(html).toBe('

    This is a h2 with yellow background

    '); 223 | }); 224 | }); 225 | 226 | describe('When sub header block has foreground color', () => { 227 | it('returns html h2 tag with style and color prop', async () => { 228 | const html = await makeSut(BlockMocks.H2_WITH_FORMAT_FOREGROUND).convert(); 229 | 230 | expect(html).toBe('

    This is a h2 with gray color

    '); 231 | }); 232 | }); 233 | }); 234 | 235 | describe('When only a h3 title block is given', () => { 236 | describe('When single block is given', () => { 237 | it('returns html with h3 tag', async () => { 238 | const html = await makeSut(BlockMocks.H3_TEXT).convert(); 239 | 240 | expect(html).toBe('

    This is a h3 title

    '); 241 | }); 242 | }); 243 | 244 | describe('When single line h3 with decoration', () => { 245 | it('returns html with single h1 with decoration tags inside', async () => { 246 | const html = await makeSut(BlockMocks.H3_TEXT_WITH_DECORATIONS).convert(); 247 | 248 | expect(html).toBe( 249 | '

    Hello World and Sun

    ', 250 | ); 251 | }); 252 | }); 253 | 254 | describe('When sub header block has background color', () => { 255 | it('returns html h3 tag with style and background-color prop', async () => { 256 | const html = await makeSut(BlockMocks.H3_WITH_FORMAT).convert(); 257 | 258 | expect(html).toBe('

    This is a h3 with orange background

    '); 259 | }); 260 | }); 261 | 262 | describe('When sub sub header block has foreground color', () => { 263 | it('returns html h3 tag with style and color prop', async () => { 264 | const html = await makeSut(BlockMocks.H3_WITH_FORMAT_FOREGROUND).convert(); 265 | 266 | expect(html).toBe('

    This is a h3 with brown color

    '); 267 | }); 268 | }); 269 | }); 270 | 271 | describe('When only an unordered list block is given', () => { 272 | describe('When single block is given', () => { 273 | it('returns html with ul tag with li tag inside', async () => { 274 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM).convert(); 275 | 276 | expect(html).toBe('
      \n
    • This is a test
    • \n
    '); 277 | }); 278 | }); 279 | 280 | describe('When block has children', () => { 281 | it('returns html with ul and li tags and children blocks inside', async () => { 282 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_CHILDREN).convert(); 283 | 284 | expect(html.replace(/\s/g, '')).toBe( 285 | `
      286 |
    • Hello World 287 |
        288 |
      • This is a child
      • 289 |
      • This is a child too
      • 290 |
      291 |
    • 292 |
    `.replace(/\s/g, ''), 293 | ); 294 | }); 295 | }); 296 | 297 | describe('When single block is given with background color', () => { 298 | it('returns html with ul tag with li tag inside and background', async () => { 299 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT).convert(); 300 | 301 | expect(html).toBe('
      \n
    • This is a item with background
    • \n
    '); 302 | }); 303 | }); 304 | 305 | describe('When single block is given with foreground color', () => { 306 | it('returns html with ul tag with li tag inside and foreground', async () => { 307 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND).convert(); 308 | 309 | expect(html).toBe('
      \n
    • This is a item with color
    • \n
    '); 310 | }); 311 | }); 312 | 313 | describe('When list block with two items is given', () => { 314 | it('returns html with ul tag with li tag inside', async () => { 315 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_TWO_ITEMS).convert(); 316 | 317 | expect(html).toBe('
      \n
    • This is a test
    • \n
    • This is a test too
    • \n
    '); 318 | }); 319 | }); 320 | 321 | describe('When single line unordered list with decoration', () => { 322 | it('returns html with ul tag with li tag and decorations tags inside', async () => { 323 | const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_DECORATED_ITEMS).convert(); 324 | 325 | expect(html).toBe( 326 | '
      \n
    • Hello World and Sun
    • \n
    ', 327 | ); 328 | }); 329 | }); 330 | }); 331 | 332 | describe('When only an ordered list block is given', () => { 333 | describe('When single block is given', () => { 334 | it('returns html with ol tag with li tag inside', async () => { 335 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM).convert(); 336 | 337 | expect(html).toBe('
      \n
    1. This is a test
    2. \n
    '); 338 | }); 339 | }); 340 | 341 | describe('When block has children', () => { 342 | it('returns html with ul and li tags and children blocks inside', async () => { 343 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_CHILDREN).convert(); 344 | 345 | expect(html.replace(/\s/g, '')).toBe( 346 | `
      347 |
    1. Hello World 348 |
        349 |
      1. This is a child
      2. 350 |
      3. This is a child too
      4. 351 |
      352 |
    2. 353 |
    `.replace(/\s/g, ''), 354 | ); 355 | }); 356 | }); 357 | 358 | describe('When single block is given with background color', () => { 359 | it('returns html with ol tag with li tag inside and background', async () => { 360 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT).convert(); 361 | 362 | expect(html).toBe('
      \n
    1. This is a item with background
    2. \n
    '); 363 | }); 364 | }); 365 | 366 | describe('When single block is given with foreground color', () => { 367 | it('returns html with ol tag with li tag inside and foreground', async () => { 368 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND).convert(); 369 | 370 | expect(html).toBe('
      \n
    1. This is a item with color
    2. \n
    '); 371 | }); 372 | }); 373 | 374 | describe('When list block with two items is given', () => { 375 | it('returns html with ol tag with li tag inside', async () => { 376 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_TWO_ITEMS).convert(); 377 | 378 | expect(html).toBe('
      \n
    1. This is a test
    2. \n
    3. This is a test too
    4. \n
    '); 379 | }); 380 | }); 381 | 382 | describe('When single line ordered list with decoration', () => { 383 | it('returns html with ol tag with li tag and decorations tags inside', async () => { 384 | const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_DECORATED_ITEMS).convert(); 385 | 386 | expect(html).toBe( 387 | '
      \n
    1. Hello World and Sun
    2. \n
    ', 388 | ); 389 | }); 390 | }); 391 | }); 392 | 393 | describe('When only a to do list block is given', () => { 394 | describe('When single unchecked block is given', () => { 395 | it('returns html with a div and unchecked checkbox and label inside', async () => { 396 | const html = await makeSut(BlockMocks.TODO).convert(); 397 | 398 | expect(html.replace(/\s/g, '')).toBe( 399 | `\ 400 |
      401 |
    • 402 |
      403 | This is a test\ 404 |
    • \ 405 |
    \ 406 | `.replace(/\s/g, ''), 407 | ); 408 | }); 409 | }); 410 | 411 | describe('When block has children', () => { 412 | it('returns html with todo and children blocks inside', async () => { 413 | const html = await makeSut(BlockMocks.TODO_WITH_CHILDREN).convert(); 414 | 415 | expect(html.replace(/\s/g, '')).toBe( 416 | `\ 417 |
      418 |
    • 419 |
      420 | Hello World\ 421 |
        422 |
      • 423 |
        424 | This is a child\ 425 |
      • \ 426 |
      \ 427 |
        428 |
      • 429 |
        430 | This is a child too\ 431 |
      • \ 432 |
      \ 433 |
    • \ 434 |
    \ 435 | `.replace(/\s/g, ''), 436 | ); 437 | }); 438 | }); 439 | 440 | describe('When single unchecked block with background color is given', () => { 441 | it('returns html with a div and unchecked checkbox and label inside with style on div', async () => { 442 | const html = await makeSut(BlockMocks.TODO_WITH_FORMAT).convert(); 443 | 444 | expect(html.replace(/\s/g, '')).toBe( 445 | `\ 446 |
      447 |
    • 448 |
      449 | This is a todo with style\ 450 |
    • \ 451 |
    \ 452 | `.replace(/\s/g, ''), 453 | ); 454 | }); 455 | }); 456 | 457 | describe('When single unchecked block with foreground color is given', () => { 458 | it('returns html with a div and unchecked checkbox and label inside with style on div', async () => { 459 | const html = await makeSut(BlockMocks.TODO_WITH_FORMAT_FOREGROUND).convert(); 460 | 461 | expect(html.replace(/\s/g, '')).toBe( 462 | `\ 463 |
      464 |
    • 465 |
      466 | This is a todo with style\ 467 |
    • \ 468 |
    \ 469 | `.replace(/\s/g, ''), 470 | ); 471 | }); 472 | }); 473 | 474 | describe('When single checked block is given', () => { 475 | it('returns html with a div and checked checkbox and label inside', async () => { 476 | const html = await makeSut(BlockMocks.CHECKED_TODO).convert(); 477 | 478 | expect(html.replace(/\s/g, '')).toBe( 479 | `\ 480 |
      481 |
    • 482 |
      483 | This is a test\ 484 |
    • \ 485 |
    \ 486 | `.replace(/\s/g, ''), 487 | ); 488 | }); 489 | }); 490 | 491 | describe('When to-do block with two items is given', () => { 492 | it('returns html with two divs and checkbox and label inside', async () => { 493 | const html = await makeSut(BlockMocks.UNCHECKED_AND_CHECKED_TODOS).convert(); 494 | 495 | expect(html.replace(/\s/g, '')).toBe( 496 | `\ 497 |
      498 |
    • 499 |
      500 | This is a test\ 501 |
    • \ 502 |
    \ 503 |
      504 |
    • 505 |
      506 | This is a test too\ 507 |
    • \ 508 |
    \ 509 | `.replace(/\s/g, ''), 510 | ); 511 | }); 512 | }); 513 | }); 514 | 515 | describe('When single code block is given', () => { 516 | describe('When there is no style on code block', () => { 517 | it('returns html with pre tag and code tag inside', async () => { 518 | const html = await makeSut(BlockMocks.CODE).convert(); 519 | 520 | expect(html).toBe( 521 | `
    function test() {\n  var isTesting = true;\n  return isTesting;\n}
    `, 522 | ); 523 | }); 524 | }); 525 | 526 | describe('When there is style on code block', () => { 527 | it('ignores styles and returns html with pre tag and code tag inside', async () => { 528 | const html = await makeSut(BlockMocks.CODE_WITH_DECORATION).convert(); 529 | 530 | expect(html).toBe( 531 | `
    function test() {\n  var isTesting = true;\n  return isTesting;\n}
    `, 532 | ); 533 | }); 534 | }); 535 | }); 536 | 537 | describe('When single quote block is given', () => { 538 | describe('When there is no style on quote block', () => { 539 | it('returns html with blockquote tag', async () => { 540 | const html = await makeSut(BlockMocks.QUOTE).convert(); 541 | 542 | expect(html).toBe('
    This a quote
    '); 543 | }); 544 | }); 545 | 546 | describe('When there is style on quote block', () => { 547 | it('returns html with blockquote tag and decorations inside', async () => { 548 | const html = await makeSut(BlockMocks.QUOTE_WITH_DECORATION).convert(); 549 | 550 | expect(html).toBe( 551 | `
    Hello World and Sun
    `, 552 | ); 553 | }); 554 | }); 555 | 556 | describe('When there is background color on quote', () => { 557 | it('returns html with style with background color prop', async () => { 558 | const html = await makeSut(BlockMocks.QUOTE_WITH_FORMAT).convert(); 559 | 560 | expect(html).toBe('
    This a quote with background
    '); 561 | }); 562 | }); 563 | 564 | describe('When there is background color on quote', () => { 565 | it('returns html with style with background color prop', async () => { 566 | const html = await makeSut(BlockMocks.QUOTE_WITH_FORMAT_FOREGROUND).convert(); 567 | 568 | expect(html).toBe('
    This a quote with color
    '); 569 | }); 570 | }); 571 | }); 572 | 573 | describe('When divider block is given', () => { 574 | it('returns html with hr tag', async () => { 575 | const html = await makeSut(BlockMocks.TEXT_BETWEEN_DIVIDER).convert(); 576 | 577 | expect(html).toBe(`

    This a text

    \n
    \n

    This a text too

    `); 578 | }); 579 | }); 580 | 581 | describe('When equation block is given', () => { 582 | describe('When there is no equation content', () => { 583 | it('returns empty string', async () => { 584 | const html = await makeSut(BlockMocks.EMPTY_EQUATION).convert(); 585 | 586 | expect(html).toBe(''); 587 | }); 588 | }); 589 | 590 | describe('When there is no equation content', () => { 591 | it('returns html with div tag and equation class with equation inside', async () => { 592 | const html = await makeSut(BlockMocks.EQUATION).convert(); 593 | 594 | expect(html).toBe(`
    $$\\int 2xdx = x^2 + C$$
    `); 595 | }); 596 | }); 597 | }); 598 | 599 | describe('When video block is given', () => { 600 | describe('When it is not a youtube video', () => { 601 | it('returns empty string', async () => { 602 | const html = await makeSut(BlockMocks.NO_YOUTUBE_VIDEO).convert(); 603 | 604 | expect(html).toBe(''); 605 | }); 606 | }); 607 | 608 | describe('When it is a youtube video', () => { 609 | it('returns html with iframe tag and embed id', async () => { 610 | const html = await makeSut(BlockMocks.YOUTUBE_VIDEO).convert(); 611 | 612 | expect(html.replace(/\s/g, '')).toBe( 613 | ``.replace(/\s/g, ''), 621 | ); 622 | }); 623 | }); 624 | }); 625 | 626 | describe('When image block is given', () => { 627 | beforeEach(() => { 628 | const imageSource = 629 | 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg'; 630 | const blockId = 'ec3b36fd-f77d-46b4-8592-5966488612b1'; 631 | 632 | nock('https://www.notion.so') 633 | .get(`/image/${encodeURIComponent(imageSource)}?table=block&id=${blockId}`) 634 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 635 | 'content-type': 'image/jpeg', 636 | }); 637 | }); 638 | 639 | describe('When image has no caption', () => { 640 | it('returns html with img tag with src as base64', async () => { 641 | const html = await makeSut(BlockMocks.IMAGE).convert(); 642 | 643 | expect(html.replace(/\s/g, '')).toBe( 644 | ` 645 |
    646 | 647 |
    648 | `.replace(/\s/g, ''), 649 | ); 650 | }); 651 | }); 652 | 653 | describe('When image has caption', () => { 654 | it('returns html with img tag with src as base64 and alt attr with given caption', async () => { 655 | const html = await makeSut(BlockMocks.IMAGE_WITH_CAPTION).convert(); 656 | 657 | expect(html.replace(/\s/g, '')).toBe( 658 | ` 659 |
    660 | It is a caption 661 |
    It is a caption
    662 |
    663 | `.replace(/\s/g, ''), 664 | ); 665 | }); 666 | }); 667 | 668 | describe('When image has width', () => { 669 | it('returns html with img tag with width in style', async () => { 670 | const html = await makeSut(BlockMocks.IMAGE_WITH_CUSTOM_SIZE).convert(); 671 | 672 | expect(html.replace(/\s/g, '')).toBe( 673 | ` 674 |
    675 | 676 |
    677 | `.replace(/\s/g, ''), 678 | ); 679 | }); 680 | }); 681 | 682 | describe('When detail block is given', () => { 683 | describe('When there are no style on block', () => { 684 | it('returns empty string', async () => { 685 | const html = await makeSut(BlockMocks.DETAILS).convert(); 686 | 687 | expect(html.replace(/\s/g, '')).toBe( 688 | ` 689 |
    690 | This is a detail 691 |

    692 | Hello World 693 |

    694 |
    695 | `.replace(/\s/g, ''), 696 | ); 697 | }); 698 | }); 699 | 700 | describe('When there is style block', () => { 701 | it('returns html with blockquote tag and decorations inside', async () => { 702 | const html = await makeSut(BlockMocks.DETAILS_WITH_DECORATION).convert(); 703 | 704 | expect(html.replace(/\s/g, '')).toBe( 705 | ` 706 |
    707 | Hello World and Sun 708 |

    709 | Hello World 710 |

    711 |
    712 | `.replace(/\s/g, ''), 713 | ); 714 | }); 715 | }); 716 | 717 | describe('When there is background color', () => { 718 | it('returns html with background color for the intire block', async () => { 719 | const html = await makeSut(BlockMocks.DETAILS_WITH_BG).convert(); 720 | 721 | expect(html.replace(/\s/g, '')).toBe( 722 | ` 723 |
    724 | This is a detail 725 |

    726 | Hello World 727 |

    728 |
    729 | `.replace(/\s/g, ''), 730 | ); 731 | }); 732 | }); 733 | }); 734 | 735 | describe('When image must have a table and block id attached to url', () => { 736 | it('it should attach block id to it', async () => { 737 | const base64ConverterSpy = jest.spyOn(Base64Converter, 'convert'); 738 | const source = BlockMocks.IMAGE_WITH_CAPTION[0].properties.source; 739 | const id = BlockMocks.IMAGE_WITH_CAPTION[0].id; 740 | 741 | await makeSut(BlockMocks.IMAGE_WITH_CAPTION).convert(); 742 | 743 | const expectedImageUrl = `https://www.notion.so/image/${encodeURIComponent(source)}?table=block&id=${id}`; 744 | expect(base64ConverterSpy).toBeCalledWith(expectedImageUrl); 745 | }); 746 | }); 747 | }); 748 | 749 | describe('When callout block is given', () => { 750 | describe('with default background and emoji icon', () => { 751 | it('converts to callout html', async () => { 752 | const html = await makeSut(BlockMocks.CALLOUT).convert(); 753 | 754 | expect(html.replace(/\s/g, '')).toBe( 755 | `
    756 |
    💡
    757 |

    This is a callout

    758 |
    `.replace(/\s/g, ''), 759 | ); 760 | }); 761 | }); 762 | 763 | describe('with default background and image icon', () => { 764 | beforeEach(() => { 765 | const imageSource = 'https://example.com/image.png'; 766 | const blockId = '16431c64-3bf0-481f-a29f-d544780d84f3'; 767 | 768 | nock('https://www.notion.so') 769 | .get(`/image/${encodeURIComponent(imageSource)}?table=block&id=${blockId}`) 770 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 771 | 'content-type': 'image/jpeg', 772 | }); 773 | }); 774 | 775 | it('converts to callout html and image to base64', async () => { 776 | const html = await makeSut(BlockMocks.CALLOUT_WITH_IMAGE).convert(); 777 | 778 | expect(html.replace(/\s/g, '')).toBe( 779 | `
    780 |
    callout icon
    781 |

    This is a callout

    782 |
    `.replace(/\s/g, ''), 783 | ); 784 | }); 785 | 786 | it('it should attach block id to it', async () => { 787 | const base64ConverterSpy = jest.spyOn(Base64Converter, 'convert'); 788 | const blocks = BlockMocks.CALLOUT_WITH_IMAGE; 789 | const source = blocks[0].properties.page_icon; 790 | const id = blocks[0].id; 791 | 792 | await makeSut(blocks).convert(); 793 | 794 | const expectedImageUrl = `https://www.notion.so/image/${encodeURIComponent(source)}?table=block&id=${id}`; 795 | expect(base64ConverterSpy).toBeCalledWith(expectedImageUrl); 796 | }); 797 | }); 798 | 799 | describe('with given background and emoji icon', () => { 800 | it('converts to callout html', async () => { 801 | const html = await makeSut(BlockMocks.CALLOUT_WITH_BACKGROUND).convert(); 802 | 803 | expect(html.replace(/\s/g, '')).toBe( 804 | `
    805 |
    💡
    806 |

    This is a callout

    807 |
    `.replace(/\s/g, ''), 808 | ); 809 | }); 810 | }); 811 | }); 812 | 813 | describe('When unknown block is given', () => { 814 | it('returns empty string', async () => { 815 | const html = await makeSut(BlockMocks.UNKNOWN).convert(); 816 | 817 | expect(html).toBe(''); 818 | }); 819 | }); 820 | }); 821 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/blocks-to-html-converter.ts: -------------------------------------------------------------------------------- 1 | import { ToHtml } from '../../../domain/use-cases/to-html'; 2 | import { Block } from '../../protocols/blocks'; 3 | import { BlockDispatcher } from './block-dispatcher'; 4 | import { ListBlocksWrapper } from './list-blocks-wrapper'; 5 | 6 | export class BlocksToHTML implements ToHtml { 7 | private _blocks: Block[]; 8 | private _dispatcher: BlockDispatcher; 9 | private _listBlocksWrapper: ListBlocksWrapper; 10 | 11 | constructor(blocks: Block[], dispatcher: BlockDispatcher, listBlocksWrapper: ListBlocksWrapper) { 12 | this._dispatcher = dispatcher; 13 | this._listBlocksWrapper = listBlocksWrapper; 14 | this._blocks = this._wrapLists(blocks); 15 | } 16 | 17 | async convert(): Promise { 18 | const htmlPromises: Promise = Promise.all(this._blocks.map(this._convertBlock.bind(this))); 19 | const html = (await htmlPromises).join('\n'); 20 | return new Promise((resolve) => resolve(html)); 21 | } 22 | 23 | private async _convertBlock(block: Block): Promise { 24 | const blockToHtmlConverter = this._dispatch(block); 25 | const htmlBlock = await blockToHtmlConverter.convert(); 26 | return new Promise((resolve) => resolve(htmlBlock)); 27 | } 28 | 29 | private _wrapLists(blocks: Block[]): Block[] { 30 | return this._listBlocksWrapper.wrapLists(blocks); 31 | } 32 | 33 | private _dispatch(block: Block): ToHtml { 34 | return this._dispatcher.dispatch(block); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './blocks-to-html-converter'; 2 | export * from './block-dispatcher'; 3 | export * from './list-blocks-wrapper'; 4 | -------------------------------------------------------------------------------- /src/data/use-cases/blocks-to-html-converter/list-blocks-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | 3 | export class ListBlocksWrapper { 4 | wrapLists(blocks: Block[]): Block[] { 5 | return blocks.reduce((blocks, b) => { 6 | if (!this._isList(b)) return [...blocks, b]; 7 | 8 | if (this._isFirstItemOfAList(blocks, b)) return [...blocks, this._generateListBlock(b)]; 9 | 10 | const lastContent = blocks[blocks.length - 1]; 11 | lastContent.children.push(b); 12 | return blocks; 13 | }, [] as Block[]); 14 | } 15 | 16 | private _isList(block: Block): boolean { 17 | return block && block.type.includes('list'); 18 | } 19 | 20 | private _isFirstItemOfAList(blocks: Block[], currentBlock: Block): boolean { 21 | const lastContent = blocks[blocks.length - 1]; 22 | 23 | return ( 24 | (!this._isList(lastContent) || (lastContent && lastContent.children[0].type !== currentBlock.type)) && 25 | this._isList(currentBlock) 26 | ); 27 | } 28 | 29 | private _generateListBlock(childBlock: Block): Block { 30 | return { 31 | id: `${childBlock.id}-parent`, 32 | type: 'list', 33 | properties: childBlock.properties, 34 | format: childBlock.format, 35 | children: [childBlock], 36 | decorableTexts: [], 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/data/use-cases/format-to-style/format-to-style.ts: -------------------------------------------------------------------------------- 1 | import { Format } from 'data/protocols/blocks/format'; 2 | import { foregroundColorToHex, backgroundColorToHex } from '../../helpers/color-to-hex'; 3 | 4 | export class FormatToStyle { 5 | private readonly _format: Format; 6 | 7 | constructor(format: Format) { 8 | this._format = format; 9 | } 10 | 11 | toStyle(): string { 12 | const styleProps = []; 13 | 14 | const blockColor = this._format.block_color; 15 | if (blockColor) styleProps.push(new BlockColorToProp(blockColor).toStyle()); 16 | 17 | const blockWidth = this._format.block_width; 18 | if (blockWidth) styleProps.push(new BlockWidthToProp(blockWidth).toStyle()); 19 | 20 | if (styleProps.length === 0) return ''; 21 | return ` style="${styleProps.join('')}"`; 22 | } 23 | } 24 | 25 | class BlockColorToProp { 26 | private readonly _blockColor: string; 27 | 28 | constructor(blockColor: string) { 29 | this._blockColor = blockColor; 30 | } 31 | 32 | toStyle(): string { 33 | if (this._isBackground()) return `background-color: ${backgroundColorToHex(this._blockColor)}; `; 34 | return `color: ${foregroundColorToHex(this._blockColor)}; `; 35 | } 36 | 37 | private _isBackground(): boolean { 38 | return !!this._blockColor?.includes('background'); 39 | } 40 | } 41 | 42 | class BlockWidthToProp { 43 | private readonly _blockWidth: number; 44 | 45 | constructor(blockWidth: number) { 46 | this._blockWidth = blockWidth; 47 | } 48 | 49 | toStyle(): string { 50 | return `width: ${this._blockWidth}px; `; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/data/use-cases/format-to-style/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format-to-style'; 2 | -------------------------------------------------------------------------------- /src/data/use-cases/html-wrapper/header-from-template.test.ts: -------------------------------------------------------------------------------- 1 | import { HeaderFromTemplate } from './header-from-template'; 2 | import * as html from '../../../__tests__/mocks/html'; 3 | 4 | describe('#toHeader', () => { 5 | describe('when page has title only', () => { 6 | it('returns html with header and h1', () => { 7 | const pageProps = { title: 'This is a title' }; 8 | 9 | const result = new HeaderFromTemplate(pageProps).toHeader(); 10 | 11 | expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_ONLY.replace(/\s/g, '')); 12 | }); 13 | }); 14 | 15 | describe('when page has title and cover', () => { 16 | describe('when coverImagePosition is given on pageProp', () => { 17 | it('returns html with header and h1 and image position on image style', () => { 18 | const pageProps = { 19 | title: 'This is a title', 20 | coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', 21 | coverImagePosition: 15, 22 | }; 23 | 24 | const result = new HeaderFromTemplate(pageProps).toHeader(); 25 | 26 | expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_COVER_IMAGE.replace(/\s/g, '')); 27 | }); 28 | }); 29 | 30 | describe('when coverImagePosition is not given on pageProp', () => { 31 | it('returns html with header and h1 and image position as 0% on image style', () => { 32 | const pageProps = { 33 | title: 'This is a title', 34 | coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', 35 | }; 36 | 37 | const result = new HeaderFromTemplate(pageProps).toHeader(); 38 | 39 | expect(result.replace(/\s/g, '')).toEqual( 40 | html.HEADER_WITH_TITLE_AND_COVER_IMAGE_WITHOUT_POSITION.replace(/\s/g, ''), 41 | ); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('when page has title cover and icon', () => { 47 | describe('when icon is an image', () => { 48 | it('returns html with header with h1, image cover and image icon with page-header-icon-with-cover class', () => { 49 | const pageProps = { 50 | title: 'This is a title', 51 | coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', 52 | icon: 'data:image/jpeg;base64,/4QDeRXhpZgAASUkqAAgAAAAGABIBAwA', 53 | }; 54 | 55 | const result = new HeaderFromTemplate(pageProps).toHeader(); 56 | 57 | expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_COVER_IMAGE_AND_IMAGE_ICON.replace(/\s/g, '')); 58 | }); 59 | }); 60 | }); 61 | 62 | describe('when page has title and icon', () => { 63 | describe('when icon is an image', () => { 64 | it('returns html with header with h1 and image icon', () => { 65 | const pageProps = { 66 | title: 'This is a title', 67 | icon: 'data:image/jpeg;base64,/4QDeRXhpZgAASUkqAAgAAAAGABIBAwA', 68 | }; 69 | 70 | const result = new HeaderFromTemplate(pageProps).toHeader(); 71 | 72 | expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_IMAGE_ICON.replace(/\s/g, '')); 73 | }); 74 | }); 75 | 76 | describe('when icon is an emoji', () => { 77 | it('retruns html with header with h1 and emoji in a div', () => { 78 | const pageProps = { 79 | title: 'This is a title', 80 | icon: '🤴', 81 | }; 82 | 83 | const result = new HeaderFromTemplate(pageProps).toHeader(); 84 | 85 | expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_EMOJI_ICON.replace(/\s/g, '')); 86 | }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/data/use-cases/html-wrapper/header-from-template.ts: -------------------------------------------------------------------------------- 1 | import { PageProps } from '../../protocols/page-props/page-props'; 2 | 3 | export class HeaderFromTemplate { 4 | private readonly _pageProps: PageProps; 5 | 6 | constructor(pageProps: PageProps) { 7 | this._pageProps = pageProps; 8 | } 9 | 10 | toHeader(): string { 11 | return `\ 12 |
    13 | ${this._coverImageHtml} 14 | ${this._iconHtml} 15 | ${this._titleHtml} 16 |
    \ 17 | `; 18 | } 19 | 20 | private get _coverImageHtml(): string { 21 | const { coverImageSrc, coverImagePosition } = this._pageProps; 22 | 23 | return coverImageSrc 24 | ? `` 27 | : ''; 28 | } 29 | 30 | private get _iconHtml(): string { 31 | const { coverImageSrc, icon } = this._pageProps; 32 | if (!icon) return ''; 33 | 34 | const imageCoverSrcClassName = coverImageSrc ? 'page-header-icon-with-cover' : ''; 35 | 36 | if (!icon.startsWith('data:image/')) 37 | return `
    ${icon}
    `; 38 | return `
    `; 39 | } 40 | 41 | private get _titleHtml(): string { 42 | const { title } = this._pageProps; 43 | 44 | return `

    ${title}

    `; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/data/use-cases/html-wrapper/options-html-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { PageProps } from 'data/protocols/page-props'; 2 | import { HtmlWrapper } from '../../../domain/use-cases/html-wrapper'; 3 | import { HtmlOptions } from '../../protocols/html-options/html-options'; 4 | import { HeaderFromTemplate } from './header-from-template'; 5 | import { SCRIPTS } from './scripts'; 6 | import { STYLE } from './styles'; 7 | 8 | export class OptionsHtmlWrapper implements HtmlWrapper { 9 | private readonly _options: HtmlOptions; 10 | 11 | constructor(options: HtmlOptions) { 12 | this._options = options; 13 | } 14 | 15 | wrapHtml(pageProps: PageProps, html: string): string { 16 | if (this._options.bodyContentOnly) return html; 17 | 18 | const title = pageProps.title; 19 | 20 | return `\ 21 | 22 | 23 | ${this._headFromTemplate(title)} 24 | 25 | ${!this._options.excludeHeaderFromBody ? new HeaderFromTemplate(pageProps).toHeader() : ''} 26 | ${html} 27 | ${!this._options.excludeScripts ? SCRIPTS : ''} 28 | 29 | `; 30 | } 31 | 32 | private _headFromTemplate(title: string): string { 33 | return `\ 34 | 35 | ${!this._options.excludeMetadata ? '' : ''} 36 | ${!this._options.excludeMetadata ? '' : ''} 37 | ${!this._options.excludeCSS ? STYLE : ''} 38 | ${!this._options.excludeTitleFromHead ? `${title}` : ''} 39 | ${ 40 | !this._options.excludeScripts 41 | ? '' 42 | : '' 43 | } 44 | `; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/data/use-cases/html-wrapper/scripts.ts: -------------------------------------------------------------------------------- 1 | export const SCRIPTS = `\ 2 | 3 | 4 | 11 | \ 14 | `; 15 | -------------------------------------------------------------------------------- /src/data/use-cases/html-wrapper/styles.ts: -------------------------------------------------------------------------------- 1 | export const STYLE = `\ 2 | `; 255 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page-block-to-page-props'; 2 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/page-block-to-cover-image-block.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | import { Base64Converter } from '../../../utils/base-64-converter'; 3 | import { ImageCover } from '../../protocols/page-props'; 4 | 5 | export class PageBlockToCoverImageSource { 6 | private readonly _pageBlock: Block; 7 | 8 | constructor(pageBlock: Block) { 9 | this._pageBlock = pageBlock; 10 | } 11 | 12 | async toImageCover(): Promise { 13 | const pageCover = this._pageBlock.properties.page_cover; 14 | if (!pageCover || !this._isImageURL(pageCover)) return Promise.resolve(null); 15 | 16 | let head = ''; 17 | if (pageCover.startsWith('/')) head = 'https://www.notion.so'; 18 | 19 | const base64 = await Base64Converter.convert(this.getImageAuthenticatedSrc(head + pageCover)); 20 | const position = this._pageCoverPositionToPositionCenter(this._pageBlock.format.page_cover_position || 0.6); 21 | 22 | return { base64, position }; 23 | } 24 | 25 | private _isImageURL(url: string): boolean { 26 | return /(?:([^:\/?#]+):)?(?:\/\/([^/?#]*))?([^?#]*\.(?:jpg|gif|png|jpeg))(?:\?([^#]*))?(?:#(.*))?/gi.test(url); 27 | } 28 | 29 | private getImageAuthenticatedSrc(src: string): string { 30 | return `https://www.notion.so/image/${encodeURIComponent(src)}?table=block&id=${this._pageBlock.id}`; 31 | } 32 | 33 | private _pageCoverPositionToPositionCenter(coverPosition: number): number { 34 | return (1 - coverPosition) * 100; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/page-block-to-icon.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | import { Base64Converter } from '../../../utils/base-64-converter'; 3 | 4 | export class PageBlockToIcon { 5 | private readonly _pageBlock: Block; 6 | 7 | constructor(pageBlock: Block) { 8 | this._pageBlock = pageBlock; 9 | } 10 | 11 | async toIcon(): Promise { 12 | const icon = this._pageBlock.properties.page_icon; 13 | if (!icon) return Promise.resolve(null); 14 | if (!icon.startsWith('http')) return icon; 15 | 16 | const url = `https://www.notion.so/image/${encodeURIComponent(icon)}?table=block&id=${this._pageBlock.id}`; 17 | return Base64Converter.convert(url); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/page-block-to-page-props.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { resolve } from 'path'; 3 | import { PageBlockToPageProps } from './index'; 4 | import { Base64Converter } from '../../../utils/base-64-converter'; 5 | import * as Blocks from '../../../__tests__/mocks/blocks'; 6 | import base64Img from '../../../__tests__/mocks/img/base64'; 7 | 8 | describe('#toPageProps', () => { 9 | describe('when page was title only', () => { 10 | it('returns page prop with title only and correct value', async () => { 11 | const pageBlockToPageProps = new PageBlockToPageProps(Blocks.PAGE_WITH_TITLE); 12 | 13 | const result = await pageBlockToPageProps.toPageProps(); 14 | 15 | expect(result).toEqual({ title: 'Simple Page Title' }); 16 | }); 17 | }); 18 | 19 | describe('when page was no title', () => { 20 | it('returns page prop with title setted as an empty string', async () => { 21 | const pageBlockToPageProps = new PageBlockToPageProps(Blocks.PAGE_WITHOUT_TITLE); 22 | 23 | const result = await pageBlockToPageProps.toPageProps(); 24 | 25 | expect(result).toEqual({ title: '' }); 26 | }); 27 | }); 28 | 29 | describe('when page has title and cover image', () => { 30 | describe('when image is from notion', () => { 31 | it('returns base64 image in coverImageSrc prop', async () => { 32 | nock('https://www.notion.so') 33 | .get('/image/https%3A%2F%2Fwww.notion.so%2Fimages%2Fpage-cover%2Fsolid_blue.png') 34 | .query({ 35 | table: 'block', 36 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 37 | }) 38 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 39 | 'content-type': 'image/jpeg', 40 | }); 41 | const pageBlockToPageProps = new PageBlockToPageProps(Blocks.PAGE_WITH_TITLE_AND_COVER_IMAGE[0]); 42 | 43 | const result = await pageBlockToPageProps.toPageProps(); 44 | 45 | expect(result).toEqual({ title: 'Page Title', coverImageSrc: base64Img, coverImagePosition: 40 }); 46 | }); 47 | }); 48 | 49 | describe('when image is not from notion', () => { 50 | it('returns base64 image in coverImageSrc prop', async () => { 51 | nock('https://www.notion.so') 52 | .get('/image/https%3A%2F%2Fwww.example.com%2Fsome_image.png') 53 | .query({ 54 | table: 'block', 55 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 56 | }) 57 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 58 | 'content-type': 'image/jpeg', 59 | }); 60 | 61 | const pageBlockToPageProps = new PageBlockToPageProps( 62 | Blocks.PAGE_WITH_TITLE_AND_COVER_IMAGE_NOT_FROM_NOTION[0], 63 | ); 64 | 65 | const result = await pageBlockToPageProps.toPageProps(); 66 | 67 | expect(result).toEqual({ title: 'Page Title', coverImageSrc: base64Img, coverImagePosition: 40 }); 68 | }); 69 | }); 70 | 71 | describe('when image url is not valid', () => { 72 | it('returns base64 image in coverImageSrc prop', async () => { 73 | const pageBlockToPageProps = new PageBlockToPageProps(Blocks.PAGE_WITH_TITLE_AND_INVALID_COVER_IMAGE[0]); 74 | 75 | const result = await pageBlockToPageProps.toPageProps(); 76 | 77 | expect(result).toEqual({ title: 'Page Title' }); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('when page has title and icon', () => { 83 | describe('when icon is an utf-8 emoji', () => { 84 | it('returns emoji in page prop', async () => { 85 | const pageBlockToPageProps = new PageBlockToPageProps(Blocks.PAGE_WITH_TITLE_AND_EMOJI_ICON[0]); 86 | 87 | const result = await pageBlockToPageProps.toPageProps(); 88 | 89 | expect(result).toEqual({ title: 'Page Title', icon: '🤴' }); 90 | }); 91 | }); 92 | 93 | describe('when icon is an image url', () => { 94 | const block = Blocks.PAGE_WITH_TITLE_AND_IMAGE_ICON[0]; 95 | const imageSource = block.properties.page_icon; 96 | 97 | beforeEach(() => { 98 | nock('https://www.notion.so') 99 | .get(`/image/${encodeURIComponent(imageSource)}?table=block&id=${block.id}`) 100 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 101 | 'content-type': 'image/jpeg', 102 | }); 103 | }); 104 | 105 | it('returns image as base64 in page prop', async () => { 106 | const pageBlockToPageProps = new PageBlockToPageProps(block); 107 | 108 | const result = await pageBlockToPageProps.toPageProps(); 109 | 110 | expect(result).toEqual({ title: 'Page Title', icon: base64Img }); 111 | }); 112 | 113 | it('attaches block id to image url on base64 convertion', async () => { 114 | const base64ConverterSpy = jest.spyOn(Base64Converter, 'convert'); 115 | const pageBlockToPageProps = new PageBlockToPageProps(block); 116 | 117 | await pageBlockToPageProps.toPageProps(); 118 | 119 | const expectedImageUrl = `https://www.notion.so/image/${encodeURIComponent(imageSource)}?table=block&id=${ 120 | block.id 121 | }`; 122 | 123 | expect(base64ConverterSpy).toBeCalledWith(expectedImageUrl); 124 | }); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/page-block-to-page-props.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | import { PageProps } from '../../protocols/page-props'; 3 | import { PageBlockToTitle } from './page-block-to-title'; 4 | import { PageBlockToCoverImageSource } from './page-block-to-cover-image-block'; 5 | import { PageBlockToIcon } from './page-block-to-icon'; 6 | 7 | export class PageBlockToPageProps { 8 | private readonly _pageBlock: Block; 9 | 10 | constructor(pageBlock: Block) { 11 | this._pageBlock = pageBlock; 12 | } 13 | 14 | async toPageProps(): Promise { 15 | const title = new PageBlockToTitle(this._pageBlock).toTitle(); 16 | const coverImage = await new PageBlockToCoverImageSource(this._pageBlock).toImageCover(); 17 | const icon = await new PageBlockToIcon(this._pageBlock).toIcon(); 18 | 19 | return Promise.resolve({ 20 | title, 21 | ...(coverImage && { coverImageSrc: coverImage.base64, coverImagePosition: coverImage.position }), 22 | ...(icon && { icon }), 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/data/use-cases/page-block-to-page-props/page-block-to-title.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../protocols/blocks'; 2 | 3 | export class PageBlockToTitle { 4 | private readonly _pageBlock: Block; 5 | 6 | constructor(pageBlock: Block) { 7 | this._pageBlock = pageBlock; 8 | } 9 | 10 | toTitle(): string { 11 | return this._pageBlock.decorableTexts[0]?.text || ''; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/use-cases/html-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { PageProps } from '../../data/protocols/page-props'; 2 | 3 | export interface HtmlWrapper { 4 | wrapHtml(pageProps: PageProps, html: string): string; 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/use-cases/to-html.ts: -------------------------------------------------------------------------------- 1 | export interface ToHtml { 2 | convert(): Promise; 3 | } 4 | 5 | export interface ToHtmlClass { 6 | new (...args: any): ToHtml; 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { NotionPageToHtml } from './main/use-cases/notion-api-to-html'; 2 | 3 | export default NotionPageToHtml; 4 | module.exports = NotionPageToHtml; 5 | -------------------------------------------------------------------------------- /src/infra/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './missing-content'; 2 | export * from './missing-page-id'; 3 | export * from './notion-page-access'; 4 | export * from './invalid-page-url'; 5 | export * from './notion-page-not-found'; 6 | -------------------------------------------------------------------------------- /src/infra/errors/invalid-page-url.ts: -------------------------------------------------------------------------------- 1 | export class InvalidPageUrlError extends Error { 2 | constructor(url: string) { 3 | super(`Url "${url}" is not a valid notion page.`); 4 | this.name = 'InvalidPageUrlError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/errors/missing-content.ts: -------------------------------------------------------------------------------- 1 | export class MissingContentError extends Error { 2 | constructor(pageId: string) { 3 | super(`Can not find content on page ${pageId}. Is it empty?`); 4 | this.name = 'MissingContentError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/errors/missing-page-id.ts: -------------------------------------------------------------------------------- 1 | export class MissingPageIdError extends Error { 2 | constructor() { 3 | super('PageId is Missing'); 4 | this.name = 'MissingPageIdError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/errors/notion-page-access.ts: -------------------------------------------------------------------------------- 1 | export class NotionPageAccessError extends Error { 2 | constructor(pageId: string) { 3 | super(`Can not read Notion Page of id ${pageId}. Is it open for public reading?`); 4 | this.name = 'NotionPageAccessError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/errors/notion-page-not-found.ts: -------------------------------------------------------------------------------- 1 | export class NotionPageNotFound extends Error { 2 | constructor(pageId: string) { 3 | super( 4 | `Can not find Notion Page of id ${pageId}. Is the url correct? It is the original page or a redirect page (not supported)?`, 5 | ); 6 | this.name = 'NotionPageNotFound'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/infra/protocols/notion-api-content-response.ts: -------------------------------------------------------------------------------- 1 | export type NotionApiContentResponse = { 2 | id: string; 3 | type: string; 4 | properties: Record; 5 | format?: Record; 6 | contents: NotionApiContentResponse[]; 7 | }; 8 | -------------------------------------------------------------------------------- /src/infra/protocols/validation.ts: -------------------------------------------------------------------------------- 1 | export interface Validation = []> { 2 | validate(...args: Args): Error | null; 3 | } 4 | -------------------------------------------------------------------------------- /src/infra/use-cases/http-post/node-http-post-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpPostClient, HttpResponse } from '../../../data/protocols/http-request'; 2 | import https, { RequestOptions } from 'https'; 3 | import { URL } from 'url'; 4 | 5 | export class NodeHttpPostClient implements HttpPostClient { 6 | async post(url: string, body: Record): Promise { 7 | const urlHandler = new URL(url); 8 | const stringifiedBody = JSON.stringify(body); 9 | 10 | const options: RequestOptions = { 11 | hostname: urlHandler.hostname, 12 | path: urlHandler.pathname, 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | 'Content-Length': stringifiedBody.length, 17 | }, 18 | }; 19 | 20 | let status = 504; 21 | 22 | const requestAsPromised: Promise = new Promise((resolve, reject) => { 23 | const req = https 24 | .request(options, (res) => { 25 | status = res.statusCode || 504; 26 | 27 | const chunks = new Array(); 28 | 29 | res.on('data', (chunk) => { 30 | chunks.push(chunk); 31 | }); 32 | 33 | res.on('end', () => { 34 | const result = Buffer.concat(chunks).toString('utf8'); 35 | resolve({ status, data: JSON.parse(result) }); 36 | }); 37 | }) 38 | .on('error', (err) => reject(err.message)); 39 | 40 | req.write(stringifiedBody); 41 | req.end(); 42 | }); 43 | 44 | return requestAsPromised; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/decoration-array-to-decorations.ts: -------------------------------------------------------------------------------- 1 | import { Decoration, DecorationType } from '../../../data/protocols/blocks'; 2 | 3 | export class DecorationArrayToDecorations { 4 | private readonly _decorationsArray: Array; 5 | 6 | constructor(decorationsArray: Array) { 7 | this._decorationsArray = decorationsArray; 8 | } 9 | 10 | toDecorations(): Decoration[] { 11 | if (!this._decorationsArray) return [] as Decoration[]; 12 | 13 | return this._decorationsArray.map((decorations) => { 14 | const [type, value] = decorations; 15 | 16 | return { 17 | type: fromDecorationArrayTypeToDecorationType[type] || 'plain', 18 | ...(value && { value }), 19 | }; 20 | }); 21 | } 22 | } 23 | 24 | const fromDecorationArrayTypeToDecorationType: Record = { 25 | b: 'bold', 26 | i: 'italic', 27 | _: 'underline', 28 | s: 'strikethrough', 29 | c: 'code', 30 | a: 'link', 31 | e: 'equation', 32 | h: 'color', 33 | }; 34 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/format-filter.ts: -------------------------------------------------------------------------------- 1 | export class FormatFilter { 2 | private readonly _format: Record; 3 | 4 | constructor(format: Record | undefined) { 5 | this._format = format || {}; 6 | } 7 | 8 | filter(): Record { 9 | const presentAcceptableKeys = Object.keys(this._format).filter((k) => ACCEPTABLE_KEYS.includes(k)); 10 | return presentAcceptableKeys.reduce>((filteredFormat, key) => { 11 | return { 12 | ...filteredFormat, 13 | [key]: this._format[key], 14 | }; 15 | }, {} as Record); 16 | } 17 | } 18 | 19 | const ACCEPTABLE_KEYS: string[] = ['block_color', 'page_cover_position', 'block_width']; 20 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/notion-api-content-response-to-blocks.test.ts: -------------------------------------------------------------------------------- 1 | import * as NotionApiMocks from '../../../__tests__/mocks/notion-api-responses'; 2 | import * as BlockMocks from '../../../__tests__/mocks/blocks'; 3 | import { NotionApiContentResponsesToBlocks } from './notion-api-content-response-to-blocks'; 4 | 5 | describe('#toBlocks', () => { 6 | describe('when page with title and single text content is given', () => { 7 | it('converts to one single text block with given content', () => { 8 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_AND_TITLE_NOTION_API_CONTENT_RESPONSE; 9 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 10 | 11 | const result = notionApiContentResponsesToBlocks.toBlocks(); 12 | 13 | expect(result).toEqual(BlockMocks.SINGLE_TEXT); 14 | }); 15 | }); 16 | 17 | describe('when page with text content with bold content is given', () => { 18 | it('converts to one block with two decorations', () => { 19 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_BOLD; 20 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 21 | 22 | const result = notionApiContentResponsesToBlocks.toBlocks(); 23 | 24 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_BOLD); 25 | }); 26 | }); 27 | 28 | describe('when page with text content with bold and italic content is given', () => { 29 | describe('when they are together', () => { 30 | it('converts to one block with two decorations', () => { 31 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_BOLD_AND_ITALIC_TOGETHER; 32 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 33 | 34 | const result = notionApiContentResponsesToBlocks.toBlocks(); 35 | 36 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_BOLD_AND_ITALIC); 37 | }); 38 | }); 39 | 40 | describe('when they are not together', () => { 41 | it('converts to one block with two decorations', () => { 42 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_BOLD_AND_ITALIC; 43 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 44 | 45 | const result = notionApiContentResponsesToBlocks.toBlocks(); 46 | 47 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_BOLD_AND_ITALIC_SEPARATED); 48 | }); 49 | }); 50 | }); 51 | 52 | describe('when page with color text content is given', () => { 53 | it('converts to one block with decoration with value', () => { 54 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_COLOR; 55 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 56 | 57 | const result = notionApiContentResponsesToBlocks.toBlocks(); 58 | 59 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_COLOR); 60 | }); 61 | }); 62 | 63 | describe('when page with equation text content is given', () => { 64 | it('converts to one block with decoration with value', () => { 65 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_EQUATION; 66 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 67 | 68 | const result = notionApiContentResponsesToBlocks.toBlocks(); 69 | 70 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_EQUATION_DECORATION); 71 | }); 72 | }); 73 | 74 | describe('when page with link text content is given', () => { 75 | it('converts to one block with decoration with value', () => { 76 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_LINK; 77 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 78 | 79 | const result = notionApiContentResponsesToBlocks.toBlocks(); 80 | 81 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_LINK); 82 | }); 83 | }); 84 | 85 | describe('when page with format is given', () => { 86 | it('passes format prop to block', () => { 87 | const notionApiContentResponses = NotionApiMocks.SINGLE_TEXT_WITH_FORMAT; 88 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 89 | 90 | const result = notionApiContentResponsesToBlocks.toBlocks(); 91 | 92 | expect(result).toEqual(BlockMocks.SINGLE_TEXT_WITH_FORMAT); 93 | }); 94 | }); 95 | 96 | describe('when page with custom image size is given', () => { 97 | it('passes block_width to format', () => { 98 | const notionApiContentResponses = NotionApiMocks.IMAGE_WITH_CUSTOM_SIZE; 99 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 100 | 101 | const result = notionApiContentResponsesToBlocks.toBlocks(); 102 | 103 | expect(result).toEqual(BlockMocks.IMAGE_WITH_CUSTOM_SIZE); 104 | }); 105 | }); 106 | 107 | describe('when page with page_icon in format is given', () => { 108 | it('passes format prop to properties', () => { 109 | const notionApiContentResponses = NotionApiMocks.CALLOUT_WITH_PAGE_ICON; 110 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 111 | 112 | const result = notionApiContentResponsesToBlocks.toBlocks(); 113 | 114 | expect(result).toEqual(BlockMocks.CALLOUT); 115 | }); 116 | }); 117 | 118 | describe('when page with youtube link', () => { 119 | it('converts to one block with decoration with value', () => { 120 | const notionApiContentResponses = NotionApiMocks.VIDEO_NOTION_API_CONTENT_RESPONSE; 121 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 122 | 123 | const result = notionApiContentResponsesToBlocks.toBlocks(); 124 | 125 | expect(result).toEqual(BlockMocks.PAGE_WITH_YOUTUBE_VIDEO); 126 | }); 127 | }); 128 | 129 | describe('when page with page cover and page cover position is given', () => { 130 | it('converts to page block with page_cover and page_conver_position in format prop', () => { 131 | const notionApiContentResponses = NotionApiMocks.SINGLE_PAGE_WITH_COVER_IMAGE; 132 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 133 | 134 | const result = notionApiContentResponsesToBlocks.toBlocks(); 135 | 136 | expect(result).toEqual(BlockMocks.PAGE_WITH_TITLE_AND_COVER_IMAGE); 137 | }); 138 | }); 139 | 140 | describe('when page with page icon is given', () => { 141 | it('converts to page block with page_icon in format prop', () => { 142 | const notionApiContentResponses = NotionApiMocks.SINGLE_PAGE_WITH_ICON; 143 | const notionApiContentResponsesToBlocks = new NotionApiContentResponsesToBlocks(notionApiContentResponses); 144 | 145 | const result = notionApiContentResponsesToBlocks.toBlocks(); 146 | 147 | expect(result).toEqual(BlockMocks.PAGE_WITH_TITLE_AND_ICON); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/notion-api-content-response-to-blocks.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../data/protocols/blocks'; 2 | import { NotionApiContentResponse } from '../../protocols/notion-api-content-response'; 3 | import { PropTitleToDecorableTexts } from '../to-blocks/prop-title-to-decorable-texts'; 4 | import { FormatFilter } from './format-filter'; 5 | import { PropertiesParser } from './properties-parser'; 6 | 7 | export class NotionApiContentResponsesToBlocks { 8 | private readonly _notionApiContentResponses: NotionApiContentResponse[]; 9 | 10 | constructor(notionApiContentResponses: NotionApiContentResponse[]) { 11 | this._notionApiContentResponses = notionApiContentResponses; 12 | } 13 | 14 | toBlocks(): Block[] { 15 | if (!this._notionApiContentResponses) return []; 16 | 17 | return this._notionApiContentResponses.map((nacr) => ({ 18 | id: nacr.id, 19 | type: nacr.type, 20 | format: new FormatFilter(nacr.format).filter(), 21 | properties: new PropertiesParser(nacr.format, nacr.properties).parse(), 22 | children: new NotionApiContentResponsesToBlocks(nacr.contents).toBlocks(), 23 | decorableTexts: new PropTitleToDecorableTexts(nacr.properties?.title).toDecorableTexts(), 24 | })); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/prop-title-to-decorable-texts.ts: -------------------------------------------------------------------------------- 1 | import { DecorableText } from '../../../data/protocols/blocks'; 2 | import { DecorationArrayToDecorations } from './decoration-array-to-decorations'; 3 | 4 | export class PropTitleToDecorableTexts { 5 | private readonly _title: any[] | undefined; 6 | 7 | constructor(title: any[] | undefined) { 8 | this._title = title; 9 | } 10 | 11 | toDecorableTexts(): DecorableText[] { 12 | if (!this._title) return [] as DecorableText[]; 13 | 14 | return this._title.map((richText: any[]) => { 15 | const text = richText[0].toString(); 16 | const decorationsArray = richText[1]; 17 | 18 | return { 19 | text, 20 | decorations: new DecorationArrayToDecorations(decorationsArray).toDecorations(), 21 | }; 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-blocks/properties-parser.ts: -------------------------------------------------------------------------------- 1 | export class PropertiesParser { 2 | private readonly _format: Record; 3 | private readonly _properties: Record; 4 | 5 | constructor(format: Record | undefined, properties: Record | undefined) { 6 | this._format = format || {}; 7 | this._properties = properties || {}; 8 | } 9 | 10 | parse(): Record { 11 | const avaliableKeys = Object.keys({ ...this._format, ...this._properties }).filter((k) => 12 | KEYS_TO_PRESERVE.includes(k), 13 | ); 14 | 15 | return avaliableKeys.reduce>( 16 | (format, key) => ({ 17 | ...format, 18 | [key]: this._properties[key]?.[0]?.[0] || this._format[key], 19 | }), 20 | {}, 21 | ); 22 | } 23 | } 24 | 25 | const KEYS_TO_PRESERVE = ['source', 'caption', 'language', 'checked', 'page_icon', 'page_cover']; 26 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/notion-api-page-fetcher.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { NotionApiPageFetcher } from './notion-api-page-fetcher'; 3 | import { NotionPageIdValidator, PageRecordValidator, PageChunkValidator } from './services'; 4 | import { NodeHttpPostClient } from '../http-post/node-http-post-client'; 5 | import { MissingContentError, MissingPageIdError, NotionPageAccessError } from '../../errors'; 6 | import * as NotionApiMocks from '../../../__tests__/mocks/notion-api-responses'; 7 | 8 | describe('#getNotionPageContents', () => { 9 | afterEach(() => { 10 | nock.cleanAll(); 11 | }); 12 | 13 | const makeSut = (notionPageId: string): NotionApiPageFetcher => { 14 | const httpPostClient = new NodeHttpPostClient(); 15 | const notionPageIdValidator = new NotionPageIdValidator(); 16 | const pageRecordValidator = new PageRecordValidator(); 17 | const pageChunkValidator = new PageChunkValidator(); 18 | 19 | return new NotionApiPageFetcher( 20 | notionPageId, 21 | httpPostClient, 22 | notionPageIdValidator, 23 | pageRecordValidator, 24 | pageChunkValidator, 25 | ); 26 | }; 27 | 28 | describe('when notion page id is valid and page is public', () => { 29 | it('returns NotionApiContentResponse object with page content when page is valid', async () => { 30 | nock('https://www.notion.so').post('/api/v3/loadPageChunk').reply(200, NotionApiMocks.SUCCESSFUL_PAGE_CHUCK); 31 | nock('https://www.notion.so').post('/api/v3/getRecordValues').reply(200, NotionApiMocks.SUCCESSFUL_RECORDS); 32 | const notionPageId = '4d64bbc0-634d-4758-befa-85c5a3a6c22f'; 33 | const apiInterface = makeSut(notionPageId); 34 | 35 | const response = await apiInterface.getNotionPageContents(); 36 | 37 | expect(response).toEqual(NotionApiMocks.TEXT_NOTION_API_CONTENT_RESPONSE); 38 | }); 39 | 40 | it('passes its children when it is available', async () => { 41 | nock('https://www.notion.so') 42 | .post('/api/v3/loadPageChunk') 43 | .reply(200, NotionApiMocks.SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN); 44 | nock('https://www.notion.so') 45 | .post('/api/v3/getRecordValues') 46 | .reply(200, NotionApiMocks.SUCCESSFUL_RECORDS_WITH_CHILDREN); 47 | 48 | const notionPageId = '4d64bbc0-634d-4758-befa-85c5a3a6c22f'; 49 | const apiInterface = makeSut(notionPageId); 50 | 51 | const response = await apiInterface.getNotionPageContents(); 52 | 53 | expect(response).toEqual(NotionApiMocks.LIST_WITH_CHILDREN_RESPONSE); 54 | }); 55 | 56 | describe('when children is not available on page chunk but it is available by request', () => { 57 | it('get out block from new request and passes in content', async () => { 58 | nock('https://www.notion.so') 59 | .post('/api/v3/loadPageChunk') 60 | .reply(200, NotionApiMocks.SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN_NOT_IN_CHUNK); 61 | nock('https://www.notion.so') 62 | .post('/api/v3/syncRecordValues') 63 | .reply(200, NotionApiMocks.SUCCESSFUL_SYNC_RECORD_VALUE); 64 | nock('https://www.notion.so') 65 | .post('/api/v3/getRecordValues') 66 | .reply(200, NotionApiMocks.SUCCESSFUL_RECORDS_WITH_CHILDREN); 67 | 68 | const notionPageId = '4d64bbc0-634d-4758-befa-85c5a3a6c22f'; 69 | const apiInterface = makeSut(notionPageId); 70 | 71 | const response = await apiInterface.getNotionPageContents(); 72 | 73 | expect(response).toEqual(NotionApiMocks.DETAILS_RESPONSE); 74 | }); 75 | }); 76 | }); 77 | 78 | describe('when notion page id is missing', () => { 79 | it('throws MissingPageIdError', async () => { 80 | const response = () => makeSut('').getNotionPageContents(); 81 | 82 | await expect(response).toThrow(new MissingPageIdError()); 83 | }); 84 | }); 85 | 86 | describe('when notion page is not open for public reading', () => { 87 | it('throws NotionPageAccessError', async () => { 88 | nock('https://www.notion.so').post('/api/v3/getRecordValues').reply(200, NotionApiMocks.NO_PAGE_ACCESS_RECORDS); 89 | 90 | const notionPageId = 'b02b33d9-95cd-44cb-8e7f-01f1870c1ee8'; 91 | const apiInterface = makeSut(notionPageId); 92 | 93 | const response = () => apiInterface.getNotionPageContents(); 94 | 95 | await expect(response).rejects.toThrowError(new NotionPageAccessError(notionPageId)); 96 | }); 97 | }); 98 | 99 | describe('when notion page is empty', () => { 100 | it('throws MissingContentError', async () => { 101 | nock('https://www.notion.so').post('/api/v3/getRecordValues').reply(200, NotionApiMocks.MISSING_CONTENT_RECORDS); 102 | 103 | const notionPageId = '9a75a541-277f-4a64-80e7-5581f36672ba'; 104 | const apiInterface = makeSut(notionPageId); 105 | 106 | const response = () => apiInterface.getNotionPageContents(); 107 | 108 | await expect(response).rejects.toThrow(new MissingContentError(notionPageId)); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/notion-api-page-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { HttpPostClient, HttpResponse } from '../../../data/protocols/http-request'; 2 | import { NotionApiContentResponse } from '../../protocols/notion-api-content-response'; 3 | import { NotionPageIdValidator, PageRecordValidator, PageChunkValidator } from './services'; 4 | 5 | const NOTION_API_PATH = 'https://www.notion.so/api/v3/'; 6 | 7 | export class NotionApiPageFetcher { 8 | constructor( 9 | private readonly notionPageId: string, 10 | private readonly httpPostClient: HttpPostClient, 11 | private readonly notionPageIdValidator: NotionPageIdValidator, 12 | private readonly pageRecordValidator: PageRecordValidator, 13 | private readonly pageChunkValidator: PageChunkValidator, 14 | ) { 15 | const pageIdError = this.notionPageIdValidator.validate(this.notionPageId); 16 | if (pageIdError) throw pageIdError; 17 | } 18 | 19 | async getNotionPageContents(): Promise { 20 | const pageRecords = await this.fetchRecordValues(); 21 | const pageRecordError = this.pageRecordValidator.validate(this.notionPageId, pageRecords); 22 | if (pageRecordError) throw pageRecordError; 23 | 24 | const chunk = await this.fetchPageChunk(); 25 | const chunkError = await this.pageChunkValidator.validate(this.notionPageId, chunk.status); 26 | if (chunkError) throw chunkError; 27 | 28 | const pageData = pageRecords.data as Record; 29 | const chunkData = chunk.data as Record; 30 | 31 | const contentIds = [pageData.results[0].value.id]; 32 | return this.mapContentsIdToContent(contentIds, chunkData, pageData); 33 | } 34 | 35 | private async mapContentsIdToContent( 36 | contentIds: string[], 37 | chunkData: Record, 38 | pageData: Record, 39 | ): Promise { 40 | const contentsNotInChunk = await this.contentsNotInChunk(contentIds, chunkData, pageData); 41 | const contentsInChunk = await this.contentsInChunk(contentIds, chunkData, pageData); 42 | const unorderedContents = contentsInChunk.concat(contentsNotInChunk).filter((c) => !!contentIds.includes(c.id)); 43 | 44 | return unorderedContents.sort((a, b) => contentIds.indexOf(a.id) - contentIds.indexOf(b.id)); 45 | } 46 | 47 | private async contentsNotInChunk( 48 | contentIds: string[], 49 | chunkData: Record, 50 | pageData: Record, 51 | ): Promise { 52 | const contentsIdsNotInChunk = contentIds.filter((id: string) => !chunkData.recordMap?.block[id]); 53 | const contentsNotInChunkRecords = await this.fetchRecordValuesByContentIds(contentsIdsNotInChunk); 54 | const dataNotInChunk = contentsIdsNotInChunk 55 | .map((id) => { 56 | const data = contentsNotInChunkRecords.data as Record; 57 | return data.recordMap?.block[id].value; 58 | }) 59 | .filter((d) => !!d); 60 | 61 | return Promise.all( 62 | dataNotInChunk.map(async (c: Record) => { 63 | const format = c.format; 64 | 65 | return { 66 | id: c.id, 67 | type: c.type, 68 | properties: c.properties, 69 | ...(format && { format }), 70 | contents: await this.mapContentsIdToContent(c.content || [], chunkData, pageData), 71 | }; 72 | }), 73 | ); 74 | } 75 | 76 | private async contentsInChunk( 77 | contentIds: string[], 78 | chunkData: Record, 79 | pageData: Record, 80 | ): Promise { 81 | const dataInChunk = contentIds 82 | .filter((id: string) => !!chunkData.recordMap?.block[id]) 83 | .map((id: string) => chunkData.recordMap?.block[id].value); 84 | 85 | return Promise.all( 86 | dataInChunk.map(async (c: Record) => { 87 | const format = c.format; 88 | 89 | return { 90 | id: c.id, 91 | type: c.type, 92 | properties: c.properties, 93 | ...(format && { format }), 94 | contents: await this.mapContentsIdToContent(c.content || [], chunkData, pageData), 95 | }; 96 | }), 97 | ); 98 | } 99 | 100 | private async fetchRecordValues(): Promise { 101 | return this.httpPostClient.post(NOTION_API_PATH + 'getRecordValues', { 102 | requests: [ 103 | { 104 | id: this.notionPageId, 105 | table: 'block', 106 | }, 107 | ], 108 | }); 109 | } 110 | 111 | private fetchPageChunk(): Promise { 112 | return this.httpPostClient.post(NOTION_API_PATH + 'loadPageChunk', { 113 | pageId: this.notionPageId, 114 | limit: 999999, 115 | cursor: { 116 | stack: [], 117 | }, 118 | chunkNumber: 0, 119 | verticalColumns: false, 120 | }); 121 | } 122 | 123 | private fetchRecordValuesByContentIds(contentIds: string[]): Promise { 124 | if (contentIds.length === 0) 125 | return Promise.resolve({ 126 | status: 200, 127 | data: {}, 128 | }); 129 | 130 | return this.httpPostClient.post(NOTION_API_PATH + 'syncRecordValues', { 131 | requests: contentIds.map((id) => ({ 132 | id, 133 | table: 'block', 134 | version: -1, 135 | })), 136 | }); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page-record-validation.service'; 2 | export * from './notion-page-id-validation.service'; 3 | export * from './page-chunk-validation.service'; 4 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/services/notion-page-id-validation.service.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../../protocols/validation'; 2 | import { MissingPageIdError } from '../../../errors'; 3 | 4 | export class NotionPageIdValidator implements Validation<[string]> { 5 | validate(notionPageId: string): Error | null { 6 | if (!notionPageId || notionPageId == '') return new MissingPageIdError(); 7 | return null; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/services/page-chunk-validation.service.test.ts: -------------------------------------------------------------------------------- 1 | import { PageChunkValidator } from './index'; 2 | 3 | describe('PageChunkValidator', () => { 4 | const makeSut = () => new PageChunkValidator(); 5 | 6 | let sut: PageChunkValidator; 7 | beforeEach(() => { 8 | sut = makeSut(); 9 | }); 10 | 11 | it('should not return an error if status is 200', () => { 12 | const error = sut.validate('any_id', 200); 13 | 14 | expect(error).toBeNull(); 15 | }); 16 | 17 | it('should return NotionPageAccessError error if status is 401', () => { 18 | const error = sut.validate('any_id', 401); 19 | 20 | expect(error?.name).toBe('NotionPageAccessError'); 21 | }); 22 | 23 | it('should return NotionPageAccessError error if status is 403', () => { 24 | const error = sut.validate('any_id', 403); 25 | 26 | expect(error?.name).toBe('NotionPageAccessError'); 27 | }); 28 | 29 | it('should return NotionPageNotFound error if status is 404', () => { 30 | const error = sut.validate('any_id', 404); 31 | 32 | expect(error?.name).toBe('NotionPageNotFound'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/services/page-chunk-validation.service.ts: -------------------------------------------------------------------------------- 1 | import { NotionPageAccessError, NotionPageNotFound } from '../../../../infra/errors'; 2 | import { Validation } from '../../../protocols/validation'; 3 | 4 | export class PageChunkValidator implements Validation<[string, number]> { 5 | validate(notionPageId: string, pageChunkStatus: number): Error | null { 6 | if ([401, 403].includes(pageChunkStatus)) { 7 | return new NotionPageAccessError(notionPageId); 8 | } 9 | 10 | if (pageChunkStatus === 404) { 11 | return new NotionPageNotFound(notionPageId); 12 | } 13 | 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-notion-api-content-responses/services/page-record-validation.service.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../../protocols/validation'; 2 | 3 | import { NotionPageAccessError, MissingContentError } from '../../../errors'; 4 | import { HttpResponse } from 'data/protocols/http-request'; 5 | 6 | export class PageRecordValidator implements Validation<[string, HttpResponse]> { 7 | validate(notionPageId: string, pageRecord: HttpResponse): Error | null { 8 | const data = pageRecord.data as Record; 9 | 10 | if (pageRecord.status === 401 || !data.results?.[0]?.value) { 11 | return new NotionPageAccessError(notionPageId); 12 | } 13 | 14 | if (!data.results[0]?.value?.content) { 15 | return new MissingContentError(notionPageId); 16 | } 17 | 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notion-url-to-page-id'; 2 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/notion-url-to-page-id.test.ts: -------------------------------------------------------------------------------- 1 | import { NotionUrlToPageId } from './index'; 2 | import { InvalidPageUrlError } from '../../errors'; 3 | import { UrlValidator, IdNormalizer } from './services'; 4 | 5 | describe('#toPageId', () => { 6 | const makeSut = (url: string): NotionUrlToPageId => { 7 | const idNormalizer = new IdNormalizer(); 8 | const urlValidator = new UrlValidator(); 9 | return new NotionUrlToPageId(url, idNormalizer, urlValidator); 10 | }; 11 | 12 | describe('when invalid url is given', () => { 13 | describe('when it is from another domain', () => { 14 | it('throws InvalidPageUrlError', () => { 15 | const url = 'https://example.com/notion_page_id'; 16 | 17 | const result = () => makeSut(url).toPageId(); 18 | 19 | expect(result).toThrow(new InvalidPageUrlError(url)); 20 | }); 21 | }); 22 | 23 | describe('when it is from the same domain, but not a page path', () => { 24 | it('throws InvalidPageUrlError', () => { 25 | const url = 'https://www.notion.so/onboarding'; 26 | 27 | const result = () => makeSut(url).toPageId(); 28 | 29 | expect(result).toThrow(new InvalidPageUrlError(url)); 30 | }); 31 | }); 32 | }); 33 | 34 | describe('when valid url is given', () => { 35 | describe('when it has full page url with unnormalized page id', () => { 36 | it('returns normalized page id', () => { 37 | const url = 'https://www.notion.so/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f'; 38 | 39 | const result = makeSut(url).toPageId(); 40 | 41 | expect(result).toBe('4d64bbc0-634d-4758-befa-85c5a3a6c22f'); 42 | }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/notion-url-to-page-id.ts: -------------------------------------------------------------------------------- 1 | import { IdNormalizer, UrlValidator } from './services'; 2 | 3 | export class NotionUrlToPageId { 4 | constructor( 5 | private readonly url: string, 6 | private readonly idNormalizer: IdNormalizer, 7 | private readonly urlValidator: UrlValidator, 8 | ) {} 9 | 10 | toPageId(): string { 11 | const urlError = this.urlValidator.validate(this.url); 12 | if (urlError) throw urlError; 13 | 14 | return this.idNormalizer.normalizeId(this.ununormalizedPageId); 15 | } 16 | 17 | private get ununormalizedPageId(): string { 18 | const tail = this.url.split('/').reverse()[0]; 19 | if (tail.split('-').length === 0) return tail; 20 | 21 | return tail.split('-').reverse()[0]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/services/id-normalizer.ts: -------------------------------------------------------------------------------- 1 | export class IdNormalizer { 2 | normalizeId(id: string): string { 3 | const isItAlreadyNormalized = id.length === 36; 4 | return isItAlreadyNormalized 5 | ? id 6 | : `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(16, 4)}-${id.substr(20)}`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './id-normalizer'; 2 | export * from './url-validator'; 3 | -------------------------------------------------------------------------------- /src/infra/use-cases/to-page-id/services/url-validator.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../../protocols/validation'; 2 | import { InvalidPageUrlError } from '../../../errors'; 3 | 4 | export class UrlValidator implements Validation<[string]> { 5 | validate(url: string): Error | null { 6 | if (!this.isNotionPargeUrl(url)) return new InvalidPageUrlError(url); 7 | return null; 8 | } 9 | 10 | private isNotionPargeUrl(url: string): boolean { 11 | return /^http(s?):\/\/((w{3}.)?notion.so|[\w\-]*\.notion\.site)\/((\w)+?\/)?(\w|-){32,}/g.test(url); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/blocks-to-html.factory.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../data/protocols/blocks'; 2 | import { BlocksToHTML, BlockDispatcher, ListBlocksWrapper } from '../../data/use-cases/blocks-to-html-converter'; 3 | 4 | export const makeBlocksToHtml = (blocks: Block[]): BlocksToHTML => { 5 | const dispatcher = new BlockDispatcher(); 6 | const listBlocksWrapper = new ListBlocksWrapper(); 7 | return new BlocksToHTML(blocks, dispatcher, listBlocksWrapper); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notion-url-to-page-id.factory'; 2 | export * from './notion-api-page-fetcher.factory'; 3 | export * from './blocks-to-html.factory'; 4 | -------------------------------------------------------------------------------- /src/main/factories/notion-api-page-fetcher.factory.ts: -------------------------------------------------------------------------------- 1 | import { NotionApiPageFetcher } from '../../infra/use-cases/to-notion-api-content-responses/notion-api-page-fetcher'; 2 | import { NodeHttpPostClient } from '../../infra/use-cases/http-post/node-http-post-client'; 3 | import { 4 | NotionPageIdValidator, 5 | PageChunkValidator, 6 | PageRecordValidator, 7 | } from '../../infra/use-cases/to-notion-api-content-responses/services'; 8 | 9 | export const createNotionApiPageFetcher = async (pageId: string): Promise => { 10 | const httpPostClient = new NodeHttpPostClient(); 11 | 12 | const notionPageIdValidator = new NotionPageIdValidator(); 13 | const pageRecordValidator = new PageRecordValidator(); 14 | const pageChunkValidator = new PageChunkValidator(); 15 | 16 | return new NotionApiPageFetcher( 17 | pageId, 18 | httpPostClient, 19 | notionPageIdValidator, 20 | pageRecordValidator, 21 | pageChunkValidator, 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/main/factories/notion-url-to-page-id.factory.ts: -------------------------------------------------------------------------------- 1 | import { NotionUrlToPageId } from '../../infra/use-cases/to-page-id'; 2 | import { IdNormalizer, UrlValidator } from '../../infra/use-cases/to-page-id/services'; 3 | 4 | export const createNotionUrlToPageId = (url: string): NotionUrlToPageId => { 5 | const idNormalizer = new IdNormalizer(); 6 | const urlValidator = new UrlValidator(); 7 | return new NotionUrlToPageId(url, idNormalizer, urlValidator); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/protocols/notion-page.ts: -------------------------------------------------------------------------------- 1 | export type NotionPage = { 2 | html: string; 3 | title?: string; 4 | icon?: string; 5 | cover?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/main/use-cases/notion-api-to-html/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notion-page-to-html'; 2 | export * from '../../protocols/notion-page'; 3 | -------------------------------------------------------------------------------- /src/main/use-cases/notion-api-to-html/notion-page-to-html.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { resolve } from 'path'; 3 | import { NotionPageToHtml } from './index'; 4 | import { InvalidPageUrlError } from '../../../infra/errors'; 5 | import * as NotionApiMocks from '../../../__tests__/mocks/notion-api-responses'; 6 | import * as HTML_RESPONSES from '../../../__tests__/mocks/html'; 7 | import base64 from '../../../__tests__/mocks/img/base64'; 8 | 9 | describe('#convert', () => { 10 | describe('When page is valid', () => { 11 | const pageId = '4d64bbc0634d4758befa85c5a3a6c22f'; 12 | 13 | beforeEach(() => { 14 | nock('https://www.notion.so') 15 | .post('/api/v3/loadPageChunk', (body) => body.pageId.replace(/-/g, '') === pageId) 16 | .reply(200, NotionApiMocks.SUCCESSFUL_PAGE_CHUCK); 17 | 18 | nock('https://www.notion.so').post('/api/v3/getRecordValues').reply(200, NotionApiMocks.SUCCESSFUL_RECORDS); 19 | 20 | nock('https://www.notion.so') 21 | .get('/image/https%3A%2F%2Fwww.example.com%2Fimage.png') 22 | .query({ 23 | table: 'block', 24 | id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 25 | }) 26 | .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 27 | 'content-type': 'image/jpeg', 28 | }); 29 | }); 30 | 31 | describe('When no options is given', () => { 32 | it('returns full html when full url is given', async () => { 33 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 34 | 35 | const response = await NotionPageToHtml.convert(url); 36 | 37 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.FULL_DOCUMENT.replace(/\s/g, '')); 38 | }); 39 | 40 | it('returns full html when short url is given', async () => { 41 | const url = `https://www.notion.so/${pageId}`; 42 | 43 | const response = await NotionPageToHtml.convert(url); 44 | 45 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.FULL_DOCUMENT.replace(/\s/g, '')); 46 | }); 47 | 48 | it('returns page title in title prop', async () => { 49 | const url = `https://www.notion.so/${pageId}`; 50 | 51 | const response = await NotionPageToHtml.convert(url); 52 | 53 | expect(response.title).toEqual('Simple Page Test'); 54 | }); 55 | 56 | it('returns page cover in cover prop', async () => { 57 | const url = `https://www.notion.so/${pageId}`; 58 | 59 | const response = await NotionPageToHtml.convert(url); 60 | 61 | expect(response.cover).toEqual(base64); 62 | }); 63 | 64 | it('returns page icon in icon prop', async () => { 65 | const url = `https://www.notion.so/${pageId}`; 66 | 67 | const response = await NotionPageToHtml.convert(url); 68 | 69 | expect(response.icon).toEqual('🤴'); 70 | }); 71 | }); 72 | 73 | describe('When excludeTitleFromHead is given', () => { 74 | it('returns without title', async () => { 75 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 76 | 77 | const response = await NotionPageToHtml.convert(url, { 78 | excludeTitleFromHead: true, 79 | }); 80 | 81 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.DOCUMENT_WITHOUT_TITLE.replace(/\s/g, '')); 82 | }); 83 | }); 84 | 85 | describe('When excludeCSS is given', () => { 86 | it('returns without style tag', async () => { 87 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 88 | 89 | const response = await NotionPageToHtml.convert(url, { 90 | excludeCSS: true, 91 | }); 92 | 93 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.DOCUMENT_WITHOUT_CSS.replace(/\s/g, '')); 94 | }); 95 | }); 96 | 97 | describe('When excludeMetadata is given', () => { 98 | it('returns without metatags', async () => { 99 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 100 | 101 | const response = await NotionPageToHtml.convert(url, { 102 | excludeMetadata: true, 103 | }); 104 | 105 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.DOCUMENT_METADATA.replace(/\s/g, '')); 106 | }); 107 | }); 108 | 109 | describe('When excludeScripts is given', () => { 110 | it('returns without script tags', async () => { 111 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 112 | 113 | const response = await NotionPageToHtml.convert(url, { 114 | excludeScripts: true, 115 | }); 116 | 117 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.DOCUMENT_WITHOUT_SCRIPTS.replace(/\s/g, '')); 118 | }); 119 | }); 120 | 121 | describe('When excludeHeaderFromBody is given', () => { 122 | it('returns body content only without header', async () => { 123 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 124 | 125 | const response = await NotionPageToHtml.convert(url, { 126 | excludeHeaderFromBody: true, 127 | }); 128 | 129 | expect(response.html.replace(/\s/g, '')).toEqual( 130 | HTML_RESPONSES.FULL_DOCUMENT_WITHOUT_HEADER_IN_BODY.replace(/\s/g, ''), 131 | ); 132 | }); 133 | }); 134 | 135 | describe('When bodyContentOnly is given', () => { 136 | it('returns body content only', async () => { 137 | const url = `https://www.notion.so/asnunes/Simple-Page-Text-${pageId}`; 138 | 139 | const response = await NotionPageToHtml.convert(url, { 140 | bodyContentOnly: true, 141 | }); 142 | 143 | expect(response.html.replace(/\s/g, '')).toEqual(HTML_RESPONSES.BODY_ONLY.replace(/\s/g, '')); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('When wrong link is given', () => { 149 | it('throws invalid page url error', async () => { 150 | nock('https://www.notion.so').post('/api/v3/loadPageChunk').reply(200, NotionApiMocks.SUCCESSFUL_PAGE_CHUCK); 151 | 152 | nock('https://www.notion.so').post('/api/v3/getRecordValues').reply(200, NotionApiMocks.SUCCESSFUL_RECORDS); 153 | 154 | const response = () => 155 | NotionPageToHtml.convert('https://www.example.com/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f'); 156 | 157 | await expect(response).rejects.toThrow( 158 | new InvalidPageUrlError('https://www.example.com/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f'), 159 | ); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/main/use-cases/notion-api-to-html/notion-page-to-html.ts: -------------------------------------------------------------------------------- 1 | import { PageBlockToPageProps } from '../../../data/use-cases/page-block-to-page-props'; 2 | import { HtmlOptions } from '../../../data/protocols/html-options/html-options'; 3 | import { OptionsHtmlWrapper } from '../../../data/use-cases/html-wrapper/options-html-wrapper'; 4 | import { NotionApiContentResponsesToBlocks } from '../../../infra/use-cases/to-blocks/notion-api-content-response-to-blocks'; 5 | import { createNotionUrlToPageId, createNotionApiPageFetcher, makeBlocksToHtml } from '../../factories'; 6 | import { NotionPage } from '../../protocols/notion-page'; 7 | 8 | /** 9 | * @class NotionPageToHtml 10 | * @description This class converts a Notion page to HTML using the convert method. 11 | */ 12 | export class NotionPageToHtml { 13 | /** 14 | * @description It converts a Notion page to HTML. Page must be public before it can be converted. 15 | * It can be made private again after the conversion. 16 | * @param pageURL The URL of the page to convert. Can be notion.so or notion.site URL. 17 | * @param htmlOptions Options to customize the HTML output. It is an object with the following properties: 18 | * @param htmlOptions.excludeCSS If true, it will return html without style tag. It is false by default. 19 | * @param htmlOptions.excludeMetadata If true, it will return html without metatags. It is false by default. 20 | * @param htmlOptions.excludeScripts If true, it will return html without scripts. It is false by default. 21 | * @param htmlOptions.excludeHeaderFromBody If true, it will return html without title, cover and icon inside body. It is false by default. 22 | * @param htmlOptions.excludeTitleFromHead If true, it will return html without title tag in head. It is false by default. 23 | * @param htmlOptions.bodyContentOnly If true, it will return html body tag content only. It is false by default. 24 | * 25 | * @returns The converted Page. It is an object with the following properties: 26 | * - title: The title of the page. 27 | * - icon: The icon of the page. Can be an emoji or a base64 encoded image string. 28 | * - cover: The cover image of the page. It is a base64 encoded image string. 29 | * - html: The raw HTML string of the page. 30 | * @throws If the page is not public, it will throw an error. 31 | * @throws If the page is not found, it will throw an error. 32 | * @throws If the url is invalid, it will throw an error. 33 | */ 34 | static async convert(pageURL: string, htmlOptions: HtmlOptions = {}): Promise { 35 | const pageId = createNotionUrlToPageId(pageURL).toPageId(); 36 | const fetcher = await createNotionApiPageFetcher(pageId); 37 | const notionApiResponses = await fetcher.getNotionPageContents(); 38 | const blocks = new NotionApiContentResponsesToBlocks(notionApiResponses).toBlocks(); 39 | 40 | if (blocks.length === 0) return Promise.resolve({ html: '' }); 41 | 42 | const htmlBody = await makeBlocksToHtml(blocks).convert(); 43 | const pageProps = await new PageBlockToPageProps(blocks[0]).toPageProps(); 44 | 45 | return { 46 | title: pageProps.title, 47 | ...(pageProps.icon && { icon: pageProps.icon }), 48 | ...(pageProps.coverImageSrc && { cover: pageProps.coverImageSrc }), 49 | html: new OptionsHtmlWrapper(htmlOptions).wrapHtml(pageProps, htmlBody), 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/base-64-converter.ts: -------------------------------------------------------------------------------- 1 | import { NodeHttpGetClient } from './use-cases/http-get/node-http-get'; 2 | 3 | export class Base64Converter { 4 | private readonly _imageSource: string; 5 | 6 | constructor(imageURL: string) { 7 | this._imageSource = imageURL; 8 | } 9 | 10 | static async convert(imageURL: string): Promise { 11 | return Promise.resolve(new Base64Converter(imageURL)._convert()); 12 | } 13 | 14 | async _convert(): Promise { 15 | const response = await new NodeHttpGetClient().get(this._imageSource); 16 | return Promise.resolve(response.data.toString()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/either.ts: -------------------------------------------------------------------------------- 1 | export type Either = Success | Failure; 2 | 3 | export class Success { 4 | constructor(readonly value: S) {} 5 | 6 | isSuccess(): this is Success { 7 | return true; 8 | } 9 | 10 | isFailure(): this is Failure { 11 | return false; 12 | } 13 | } 14 | 15 | export class Failure { 16 | constructor(readonly value: F) {} 17 | 18 | isSuccess(): this is Success { 19 | return false; 20 | } 21 | 22 | isFailure(): this is Failure { 23 | return true; 24 | } 25 | } 26 | 27 | export function sendSuccess(value: S): Either { 28 | return new Success(value); 29 | } 30 | 31 | export function sendFailure(value: F): Either { 32 | return new Failure(value); 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/errors/forbidden-error.ts: -------------------------------------------------------------------------------- 1 | export class ForbiddenError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'ForbiddenError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/errors/image-not-found-error.ts: -------------------------------------------------------------------------------- 1 | export class ImageNotFoundError extends Error { 2 | constructor(path: string) { 3 | super(`Image on path ${path} could not be found`); 4 | this.name = 'ImageNotFoundError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './forbidden-error'; 2 | export * from './image-not-found-error'; 3 | -------------------------------------------------------------------------------- /src/utils/use-cases/http-get/node-http-get.ts: -------------------------------------------------------------------------------- 1 | import { HttpGetClient, HttpResponse } from '../../../data/protocols/http-request'; 2 | import https from 'https'; 3 | import { ForbiddenError } from '../../errors'; 4 | 5 | export class NodeHttpGetClient implements HttpGetClient { 6 | async get(url: string): Promise { 7 | const requestAsPromised: Promise = new Promise((resolve, reject) => { 8 | let stringData = ''; 9 | https 10 | .get(url, (res) => { 11 | res.setEncoding('base64'); 12 | 13 | res.on('data', (chunk) => { 14 | stringData += chunk; 15 | }); 16 | 17 | res.on('end', () => { 18 | const format = res.headers['content-type'] || 'image/jpeg'; 19 | 20 | if (res.statusCode === 403) throw new ForbiddenError('could not fetch data from url: ' + url); 21 | 22 | if (format.includes('image')) { 23 | return resolve({ 24 | status: res.statusCode || 200, 25 | headers: res.headers as Record, 26 | data: `data:${format};base64,${stringData}`, 27 | }); 28 | } 29 | 30 | return resolve({ 31 | status: res.statusCode || 200, 32 | headers: res.headers as Record, 33 | data: JSON.parse(stringData), 34 | }); 35 | }); 36 | }) 37 | .on('error', (err) => reject(err.message)); 38 | }); 39 | 40 | return requestAsPromised; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "**/*.d.ts", "**/*.spec.ts", "**/*.test.ts", "**/__tests__/**/*"] 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "outDir": "dist", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "target": "es5", 8 | "module": "commonjs", 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "rootDirs": ["src", 12 | "src/__tests__" 13 | ], 14 | "allowJs": true, 15 | "lib": ["es2019"] 16 | }, 17 | "include": ["src"], 18 | "exclude": ["node_modules", "**/__tests__/*"] 19 | } 20 | --------------------------------------------------------------------------------