Repository: asnunes/notion-page-to-html Branch: main Commit: 0bd3d1d0c9ff Files: 138 Total size: 203.2 KB Directory structure: gitextract_8y8r4zl3/ ├── .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 ├── jest.config.js ├── package.json ├── src/ │ ├── __tests__/ │ │ └── mocks/ │ │ ├── blocks.ts │ │ ├── html.ts │ │ ├── img/ │ │ │ └── base64.ts │ │ └── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ node_modules dist coverage ./data requirements .vscode *.jpeg ================================================ FILE: .eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2020, sourceType: 'module', }, extends: [ '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. ], rules: {}, }; ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish on: push: branches: [main] workflow_dispatch: jobs: publish: runs-on: ubuntu-20.04 strategy: matrix: node-version: [16.x] steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 - name: Dependencies installation run: npm install - name: Test run run: npm test - name: Build run: npm run build - name: Publish uses: JS-DevTools/npm-publish@v1 with: token: ${{ secrets.NPM_TOKEN }} access: public ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: pull_request: branches: [main] jobs: build: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - name: Build test compose run: | make test ================================================ FILE: .gitignore ================================================ /coverage /node_modules .vscode .idea /dist script.js test.html ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npm run lint:staged ================================================ FILE: .husky/pre-push ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npm run test:ci ================================================ FILE: .lintstagedrc.json ================================================ { "*.ts": ["eslint 'src/**' --fix", "npm run test:staged"] } ================================================ FILE: .npmignore ================================================ __tests__ coverage node_modules src docs jest.config.js tsconfig.json .eslintignore .eslintrc.js .gitignore .huskyrc.json .nvmrc .lintstagedrc.json .prettierrc.js .tool-versions .vscode script.js test.html docker-compose* Dockerfile* Makefile .husky .github ================================================ FILE: .nvmrc ================================================ 12.19.0 ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: true, trailingComma: 'all', singleQuote: true, printWidth: 120, tabWidth: 2, }; ================================================ FILE: Dockerfile.test ================================================ FROM node:14.17 WORKDIR /usr/src/notion-page-to-html RUN npm -g i npm COPY ./package*.json ./ RUN npm install COPY . . ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Alexandre Nunes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # TEST test_compose = docker-compose -f docker-compose.test.yml .PRONY: test-build test-build: $(test_compose) build .PRONY: test test: make test-build && $(test_compose) run notion-page-to-html-test && make test-down .PRONY: test-down test-down: $(test_compose) down ================================================ FILE: README.md ================================================ ![Cover image](docs/cover.png) # Notion Page To HTML NodeJS tool to convert public notion pages to HTML. Also available as public API: [https://notion-page-to-html-api.vercel.app/](https://notion-page-to-html-api.vercel.app/) ## Supported features Most of the native Notion blocks are currently supported: - Headings - Text With Decorations - Quote - Image - YouTube Videos - Code - Math Equations - To-do - Checkbox - Bulleted Lists - Numbered Lists - Toggle Lists - Divider - Callout - Nested blocks Embeds and tables are not supported yet. ## Why notion-page-to-html? It's perfect as content manager system - This tool can get any public page from Notion and convert it to html. This is perfect 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. It's fully customizable - 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. ## Basic Usage Install it in a NodeJS project using npm ```bash npm install notion-page-to-html ``` Then, just import it and paste a public Notion page url ```jsx const NotionPageToHtml = require('notion-page-to-html'); // using async/await async function getPage() { const { title, icon, cover, html } = await NotionPageToHtml.convert("https://www.notion.so/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f"); console.log(title, icon, cover, html); } getPage(); ``` `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. ```jsx NotionPageToHtml.convert( 'https://www.notion.so/asnunes/Simple-Page-Text-4d64bbc0634d4758befa85c5a3a6c22f', options, ); ``` `options` is an object with the following keys | Key | Default value | If true | | ----------------------- | ------------- | ------------------------------------------------------ | | `excludeCSS` | false | returns html without style tag | | `excludeMetadata` | false | returns html without metatags | | `excludeScripts` | false | returns html without script tags | | `excludeHeaderFromBody` | false | returns html without title, cover and icon inside body | | `excludeTitleFromHead` | false | returns html without title tag in head | | `bodyContentOnly` | false | returns html body tag content only | --- ## Development and testing 1. Clone this application 2. Make sure you have node v14 or higher and then install all dependencies ```` npm i ```` Running tests: ```` npm test ```` Installing locally in another project: ```` npm run build npm pack ```` Inside your project: ```` npm i /path/to/tar/gz ```` Docker approach for testing 1. Make sure you have Docker and Docker Compose installed and then run: ```` make test ```` ## Contributing We love your feedback! Feel free to: - Report a bug - Discuss the current state of the code - Submit a fix - Propose new features - Become a maintainer Just create a GitHub issue or a PR ;) ================================================ FILE: docker-compose.test.yml ================================================ version: '3.8' services: notion-page-to-html-test: build: context: . dockerfile: Dockerfile.test container_name: notion-page-to-html-test logging: driver: 'json-file' options: max-size: '10m' max-file: '5' command: | npm test ================================================ FILE: jest.config.js ================================================ module.exports = { preset: 'ts-jest', testEnvironment: 'node', moduleDirectories: ['node_modules'], transform: { '.+\\.ts$': 'ts-jest', }, testMatch: ['/src/**/*.(test|spec).ts'], moduleNameMapper: { '@/(.*)': '/src/$1', }, collectCoverageFrom: ['src/**/*.ts', '!src/migrations/*.ts', '!src/server.ts', '!src/protocols/*.ts'], coverageProvider: 'babel', coverageDirectory: 'coverage', restoreMocks: true, }; ================================================ FILE: package.json ================================================ { "name": "notion-page-to-html", "version": "1.2.0", "description": "It converts public notion pages to html from url", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "prebuild": "rm -rf ./dist", "build": "tsc -p tsconfig.build.json", "lint": "eslint '*/**/*.{js,ts}' --quiet --fix", "lint:staged": "lint-staged", "test": "jest --passWithNoTests --silent --noStackTrace --runInBand", "test:watch": "npm test -- --watch", "test:verbose": "jest --passWithNoTests --runInBand", "test:staged": "npm test -- --findRelatedTests", "test:ci": "npm test -- --coverage" }, "repository": { "type": "git", "url": "git+https://github.com/asnunes/notion-page-to-html.git" }, "keywords": [ "notion", "page", "html" ], "author": "Alexandre Nunes", "license": "MIT", "bugs": { "url": "https://github.com/asnunes/notion-page-to-html/issues" }, "homepage": "https://github.com/asnunes/notion-page-to-html#readme", "devDependencies": { "@types/jest": "^27.0.2", "@types/nock": "^11.1.0", "@typescript-eslint/eslint-plugin": "^4.31.2", "@typescript-eslint/parser": "^4.31.2", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "git-commit-msg-linter": "^3.2.8", "husky": "^7.0.2", "jest": "^27.2.2", "lint-staged": "^11.1.2", "nock": "^13.1.3", "prettier": "^2.4.1", "ts-jest": "^27.0.5", "typescript": "^4.4.3" } } ================================================ FILE: src/__tests__/mocks/blocks.ts ================================================ import { Block, DecorableText, DecorationType, Decoration } from '../../data/protocols/blocks'; export const NO_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const SINGLE_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ]; export const SINGLE_TEXT_WITH_CHILDREN = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f41214426', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b1212114426', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child too', decorations: [], }, ], }, ] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ]; export const SINGLE_TEXT_WITH_BOLD = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'bold' as DecorationType, }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_ITALIC = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC_SEPARATED = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [{ type: 'bold' }], }, { text: 'World', decorations: [{ type: 'italic' }], }, ], }, ]; export const SINGLE_TEXT_WITH_UNDERLINE = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'underline' as DecorationType, }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_STRIKETHROUGH = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'strikethrough' as DecorationType, }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_CODE_DECORATION = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'myVar', decorations: [ { type: 'code' as DecorationType, }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_LINK = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'link' as DecorationType, value: 'https://www.google.com', }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: { block_color: 'red_background' }, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'link' as DecorationType, value: 'https://www.google.com', }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_EQUATION_DECORATION = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World ', decorations: [], }, { text: '⁍', decorations: [ { type: 'equation' as DecorationType, value: '2x', }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_COLOR = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello', decorations: [ { type: 'color' as DecorationType, value: 'purple', }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_COLOR_BACKGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello', decorations: [ { type: 'color' as DecorationType, value: 'yellow_background', }, ], }, ], }, ]; export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const TEXT_WITH_DECORATION = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const MULTILINE_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World\nIs everything alright?\nYes, Dude!', decorations: [], }, ], }, ]; export const TEXT_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: { block_color: 'red_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a text with red background', decorations: [], }, ], }, ]; export const TEXT_WITH_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: { block_color: 'purple', }, children: [] as Block[], decorableTexts: [ { text: 'This is a text with purple color', decorations: [], }, ], }, ]; export const H1_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a h1 title', decorations: [], }, ], }, ]; export const H1_TEXT_WITH_DECORATIONS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const H1_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'header', properties: {}, format: { block_color: 'green_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h1 with red background', decorations: [], }, ], }, ]; export const H1_WITH_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'header', properties: {}, format: { block_color: 'yellow', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h1 with yellow color', decorations: [], }, ], }, ]; export const H2_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a h2 title', decorations: [], }, ], }, ]; export const H2_TEXT_WITH_DECORATIONS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const H2_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_header', properties: {}, format: { block_color: 'yellow_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h2 with yellow background', decorations: [], }, ], }, ]; export const H2_WITH_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_header', properties: {}, format: { block_color: 'gray', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h2 with gray color', decorations: [], }, ], }, ]; export const H3_TEXT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_sub_header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a h3 title', decorations: [], }, ], }, ]; export const H3_TEXT_WITH_DECORATIONS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_sub_header', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const H3_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_sub_header', properties: {}, format: { block_color: 'orange_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h3 with orange background', decorations: [], }, ], }, ]; export const H3_WITH_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'sub_sub_header', properties: {}, format: { block_color: 'brown', }, children: [] as Block[], decorableTexts: [ { text: 'This is a h3 with brown color', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_SINGLE_ITEM = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_CHILDREN = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f41214426', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b1212114426', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child too', decorations: [], }, ], }, ] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: { block_color: 'brown_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a item with background', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: { block_color: 'orange', }, children: [] as Block[], decorableTexts: [ { text: 'This is a item with color', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_TWO_ITEMS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d752133', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test too', decorations: [], }, ], }, ]; export const UNORDERED_LIST_WITH_DECORATED_ITEMS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'bulleted_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const ORDERED_LIST_WITH_SINGLE_ITEM = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, ]; export const ORDERED_LIST_WITH_CHILDREN = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f41214426', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b1212114426', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child too', decorations: [], }, ], }, ] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ]; export const ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: { block_color: 'gray_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a item with background', decorations: [], }, ], }, ]; export const ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: { block_color: 'green', }, children: [] as Block[], decorableTexts: [ { text: 'This is a item with color', decorations: [], }, ], }, ]; export const ORDERED_LIST_WITH_TWO_ITEMS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d752133', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test too', decorations: [], }, ], }, ]; export const ORDERED_LIST_WITH_DECORATED_ITEMS = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'numbered_list', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const TODO = [ { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'to_do', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, ]; export const TODO_WITH_CHILDREN = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'to_do', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f41214426', type: 'to_do', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child', decorations: [], }, ], }, { id: '80d0fc46-5511-4d1d-a4ec-8b1212114426', type: 'to_do', properties: { checked: 'Yes' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a child too', decorations: [], }, ], }, ] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ]; export const TODO_WITH_FORMAT = [ { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'to_do', properties: {}, format: { block_color: 'blue_background', }, children: [] as Block[], decorableTexts: [ { text: 'This is a todo with style', decorations: [], }, ], }, ]; export const TODO_WITH_FORMAT_FOREGROUND = [ { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'to_do', properties: {}, format: { block_color: 'blue', }, children: [] as Block[], decorableTexts: [ { text: 'This is a todo with style', decorations: [], }, ], }, ]; export const CHECKED_TODO = [ { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'to_do', properties: { checked: 'Yes' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, ]; export const UNCHECKED_AND_CHECKED_TODOS = [ { id: 'd1e33c43-5079-4e66-961a-df032d2332', type: 'to_do', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test', decorations: [], }, ], }, { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'to_do', properties: { checked: 'Yes' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a test too', decorations: [], }, ], }, ]; export const CODE = [ { id: '479c7b34-6c22-4f2d-b947-8f47d02b48d6', type: 'code', properties: { language: 'JavaScript' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'function test() {\n\tvar isTesting = true;\n\treturn isTesting;\n}', decorations: [], }, ], }, ]; export const CODE_WITH_DECORATION = [ { id: '479c7b34-6c22-4f2d-b947-8f47d02b48d6', type: 'code', properties: { language: 'JavaScript' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'function test() {\n\tvar isTesting = true;\n\treturn ', decorations: [], }, { text: 'isTesting', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ';\n}', decorations: [], }, ], }, ]; export const QUOTE = [ { id: 'e0a0cfa3-1f64-438b-ac79-95e5c7ad4565', type: 'quote', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This a quote', decorations: [] as Decoration[], }, ], }, ]; export const QUOTE_WITH_FORMAT = [ { id: 'e0a0cfa3-1f64-438b-ac79-95e5c7ad4565', type: 'quote', properties: {}, format: { block_color: 'purple_background', }, children: [] as Block[], decorableTexts: [ { text: 'This a quote with background', decorations: [] as Decoration[], }, ], }, ]; export const QUOTE_WITH_FORMAT_FOREGROUND = [ { id: 'e0a0cfa3-1f64-438b-ac79-95e5c7ad4565', type: 'quote', properties: {}, format: { block_color: 'pink', }, children: [] as Block[], decorableTexts: [ { text: 'This a quote with color', decorations: [] as Decoration[], }, ], }, ]; export const QUOTE_WITH_DECORATION = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'quote', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ]; export const TEXT_BETWEEN_DIVIDER = [ { id: 'e0a0cfa3-438b-ac79-95e5c7ad4565', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This a text', decorations: [], }, ], }, { id: 'e0a0cfa3-1f64-438b-ac79-95e5c7ad4565', type: 'divider', properties: {}, format: {}, children: [] as Block[], decorableTexts: [], }, { id: 'e0a0cfa3-438b-95e5c7ad4565', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This a text too', decorations: [], }, ], }, ]; export const EMPTY_EQUATION = [ { id: '9b01339a-9de6-4eb1-bd7a-4c6d537590c7', type: 'equation', properties: {}, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const EQUATION = [ { id: '9b01339a-9de6-4eb1-bd7a-4c6d537590c7', type: 'equation', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: '\\int 2xdx = x^2 + C', decorations: [], }, ], }, ]; export const NO_YOUTUBE_VIDEO = [ { id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', type: 'video', properties: { source: 'https://www.example.com/watch?v=8G80nuEyDN4', }, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const YOUTUBE_VIDEO = [ { id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', type: 'video', properties: { source: 'https://www.youtube.com/watch?v=xBFqxBfLJWc', }, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const TEXT_WITH_YOUTUBE_VIDEO = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Test', decorations: [] }], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [] as Decoration[], }, ], }, { id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', type: 'video', properties: { source: 'https://www.youtube.com/watch?v=xBFqxBfLJWc', }, format: {}, children: [] as Block[], decorableTexts: [] as DecorableText[], }, ], }, ]; export const PAGE_WITH_YOUTUBE_VIDEO = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Test', decorations: [] }], children: [ { id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', type: 'video', properties: { source: 'https://www.youtube.com/watch?v=xBFqxBfLJWc', }, format: {}, children: [] as Block[], decorableTexts: [] as DecorableText[], }, ], }, ]; export const IMAGE = [ { id: 'ec3b36fd-f77d-46b4-8592-5966488612b1', type: 'image', properties: { source: 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', }, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const IMAGE_WITH_CAPTION = [ { id: 'ec3b36fd-f77d-46b4-8592-5966488612b1', type: 'image', properties: { source: 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', caption: 'It is a caption', }, format: {}, children: [] as Block[], decorableTexts: [], }, ]; export const IMAGE_WITH_CUSTOM_SIZE = [ { id: 'ec3b36fd-f77d-46b4-8592-5966488612b1', type: 'image', properties: { source: 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', }, format: { block_width: 240 }, children: [] as Block[], decorableTexts: [], }, ]; export const CALLOUT = [ { id: '16431c64-3bf0-481f-a29f-d544780d84f3', type: 'callout', properties: { page_icon: '💡' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a callout', decorations: [], }, ], }, ]; export const CALLOUT_WITH_IMAGE = [ { id: '16431c64-3bf0-481f-a29f-d544780d84f3', type: 'callout', properties: { page_icon: 'https://example.com/image.png' }, format: {}, children: [] as Block[], decorableTexts: [ { text: 'This is a callout', decorations: [], }, ], }, ]; export const CALLOUT_WITH_BACKGROUND = [ { id: '16431c64-3bf0-481f-a29f-d544780d84f3', type: 'callout', properties: { page_icon: '💡' }, format: { block_color: 'red_background' }, children: [] as Block[], decorableTexts: [ { text: 'This is a callout', decorations: [], }, ], }, ]; export const DETAILS_WITH_DECORATION = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Test', decorations: [] }], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'toggle', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ], decorableTexts: [ { text: 'Hello ', decorations: [], }, { text: 'World ', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, { text: 'and', decorations: [ { type: 'bold' as DecorationType, }, ], }, { text: ' Sun', decorations: [ { type: 'bold' as DecorationType, }, { type: 'italic' as DecorationType, }, ], }, ], }, ], }, ]; export const DETAILS = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Test', decorations: [] }], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'toggle', properties: {}, format: {}, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ], decorableTexts: [ { text: 'This is a detail', decorations: [] as Decoration[], }, ], }, ], }, ]; export const DETAILS_WITH_BG = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Test', decorations: [] }], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'toggle', properties: {}, format: { block_color: 'red_background', }, children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ], decorableTexts: [ { text: 'This is a detail', decorations: [] as Decoration[], }, ], }, ], }, ]; export const UNKNOWN = [ { id: 'd1e33c43-5079-4e66-961a-df032d38d532', type: 'headdfafdafader', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'What?!', decorations: [], }, ], }, ]; export const PAGE_WITH_TITLE = { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [{ text: 'Simple Page Title', decorations: [] }], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ], }; export const PAGE_WITHOUT_TITLE = { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: {}, format: {}, decorableTexts: [], children: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: {}, format: {}, children: [] as Block[], decorableTexts: [ { text: 'Hello World', decorations: [], }, ], }, ], }; export const PAGE_WITH_TITLE_AND_COVER_IMAGE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_cover: '/images/page-cover/solid_blue.png' }, format: { page_cover_position: 0.6 }, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; export const PAGE_WITH_TITLE_AND_ICON = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_icon: '🤴' }, format: {}, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; export const PAGE_WITH_TITLE_AND_COVER_IMAGE_NOT_FROM_NOTION = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_cover: 'https://www.example.com/some_image.png' }, format: { page_cover_position: 0.6 }, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; export const PAGE_WITH_TITLE_AND_INVALID_COVER_IMAGE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_cover: 'https://www.example.com/s.html' }, format: { page_cover_position: 0.6 }, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; export const PAGE_WITH_TITLE_AND_EMOJI_ICON = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_icon: '🤴' }, format: {}, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; export const PAGE_WITH_TITLE_AND_IMAGE_ICON = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { page_icon: 'https://www.example.com/some_image.png' }, format: {}, decorableTexts: [{ text: 'Page Title', decorations: [] }], children: [], }, ]; ================================================ FILE: src/__tests__/mocks/html.ts ================================================ import base64 from './img/base64'; const STYLE_TAG = `\ `; const HEADER = `\
🤴

Simple Page Test

`; const CONTENT_WITH_HEADER = `\ ${HEADER}

Hello World

`; const CONTENT_WITHOUT_HEADER = `\

Hello World

`; export const FULL_DOCUMENT = ` ${STYLE_TAG} Simple Page Test ${CONTENT_WITH_HEADER} `; export const DOCUMENT_WITHOUT_TITLE = ` ${STYLE_TAG} ${CONTENT_WITH_HEADER} `; export const DOCUMENT_WITHOUT_CSS = ` Simple Page Test ${CONTENT_WITH_HEADER} `; export const DOCUMENT_METADATA = ` ${STYLE_TAG} Simple Page Test ${CONTENT_WITH_HEADER} `; export const DOCUMENT_WITHOUT_SCRIPTS = ` ${STYLE_TAG} Simple Page Test ${CONTENT_WITH_HEADER} `; export const FULL_DOCUMENT_WITHOUT_HEADER_IN_BODY = ` ${STYLE_TAG} Simple Page Test ${CONTENT_WITHOUT_HEADER} `; export const BODY_ONLY = CONTENT_WITHOUT_HEADER; export const HEADER_WITH_TITLE_ONLY = `\

This is a title

`; export const HEADER_WITH_TITLE_AND_COVER_IMAGE = `\

This is a title

`; export const HEADER_WITH_TITLE_AND_COVER_IMAGE_WITHOUT_POSITION = `\

This is a title

`; export const HEADER_WITH_TITLE_COVER_IMAGE_AND_IMAGE_ICON = `\

This is a title

`; export const HEADER_WITH_TITLE_AND_IMAGE_ICON = `\

This is a title

`; export const HEADER_WITH_TITLE_AND_EMOJI_ICON = `\
🤴

This is a title

`; ================================================ FILE: src/__tests__/mocks/img/base64.ts ================================================ 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=='; ================================================ FILE: src/__tests__/mocks/notion-api-responses.ts ================================================ export const SUCCESSFUL_PAGE_CHUCK = { recordMap: { block: { '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { role: 'reader', value: { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', version: 31, type: 'page', properties: { title: [['Simple Page Test']] }, format: { page_cover: 'https://www.example.com/image.png', page_cover_position: 0.6, page_icon: '🤴' }, content: ['80d0fc46-5511-4d1d-a4ec-8b2f43d75226'], permissions: [{ role: 'reader', type: 'public_permission', allow_duplicate: false }], created_time: 1595516162445, last_edited_time: 1595520360000, parent_id: '8370825e-eb4c-483c-ace0-cc06e7dfc556', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, '80d0fc46-5511-4d1d-a4ec-8b2f43d75226': { role: 'reader', value: { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', version: 33, type: 'text', properties: { title: [['Hello World']] }, created_time: 1595516160000, last_edited_time: 1595516160000, parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, }, notion_user: { '408c862f-f07b-4036-b414-1ae5c5ce57b3': { role: 'reader', value: { id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', version: 5, email: 'user@example.com', given_name: 'User', family_name: 'Name', onboarding_completed: true, mobile_onboarding_completed: true, }, }, }, space: {}, }, cursor: { stack: [] }, }; export const SUCCESSFUL_RECORDS = { results: [ { role: 'reader', value: { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', version: 31, type: 'page', properties: { title: [['Simple Page Test']] }, content: ['80d0fc46-5511-4d1d-a4ec-8b2f43d75226'], permissions: [{ role: 'reader', type: 'public_permission', allow_duplicate: false }], }, }, ], }; export const SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN = { recordMap: { block: { '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { role: 'editor', value: { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', version: 262, type: 'page', properties: { title: [['Simple Page Text 2']] }, content: ['f8cf7a08-bf80-4f8b-a842-3db49df95e4d'], permissions: [ { role: 'editor', type: 'user_permission', user_id: 'user_id' }, { role: 'reader', type: 'public_permission' }, ], created_time: 1595516162445, last_edited_time: 1602527580000, parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', parent_table: 'space', alive: true, created_by_table: 'notion_user', created_by_id: 'user_id', last_edited_by_table: 'notion_user', last_edited_by_id: 'user_id', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d': { role: 'editor', value: { id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', version: 49, type: 'bulleted_list', properties: { title: [['Estou testando']] }, content: ['0b73eab8-8c01-4140-ab4d-cd6a0886cd76'], created_time: 1602511500000, last_edited_time: 1602527580000, parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, '0b73eab8-8c01-4140-ab4d-cd6a0886cd76': { role: 'editor', value: { id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', version: 12, type: 'bulleted_list', properties: { title: [['isso daqui']] }, content: ['6bebe374-1569-4836-9de5-847c91ecb3f8'], created_time: 1602527580000, last_edited_time: 1602527580000, parent_id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, '6bebe374-1569-4836-9de5-847c91ecb3f8': { role: 'editor', value: { id: '6bebe374-1569-4836-9de5-847c91ecb3f8', version: 32, type: 'bulleted_list', properties: { title: [['vamos ver se funciona']] }, created_time: 1602527580000, last_edited_time: 1602527580000, parent_id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, }, space: { '159177ec-0fb0-469e-a900-1a662b145a04': {}, }, }, cursor: { stack: [] }, }; export const SUCCESSFUL_PAGE_CHUCK_WITH_CHILDREN_NOT_IN_CHUNK = { recordMap: { block: { '4d64bbc0-634d-4758-befa-85c5a3a6c22f': { role: 'editor', value: { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', version: 356, type: 'page', properties: { title: [['Simple Page Text 2']] }, content: ['9dfff855-7aed-4a99-8e60-2e1384399200'], format: {}, permissions: [ { role: 'editor', type: 'user_permission', user_id: 'user_id' }, { role: 'reader', type: 'public_permission' }, ], created_time: 1595516162445, last_edited_time: 1602862020000, parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', parent_table: 'space', alive: true, created_by_table: 'notion_user', created_by_id: 'user_id', last_edited_by_table: 'notion_user', last_edited_by_id: 'user_id', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, '9dfff855-7aed-4a99-8e60-2e1384399200': { role: 'editor', value: { id: '9dfff855-7aed-4a99-8e60-2e1384399200', version: 40, type: 'toggle', properties: { title: [['Isso é um detail']] }, content: ['12cfada5-6686-4561-9c4f-177b3be4b422'], created_time: 1602689700000, last_edited_time: 1602861180000, parent_id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: 'use_id', last_edited_by_table: 'notion_user', last_edited_by_id: 'use_id', shard_id: 285188, space_id: '159177ec-0fb0-469e-a900-1a662b145a04', }, }, }, space: { '159177ec-0fb0-469e-a900-1a662b145a04': { role: 'editor', value: { id: '159177ec-0fb0-469e-a900-1a662b145a04', version: 173, name: 'workspace_name', domain: 'domais', permissions: [{ role: 'editor', type: 'user_permission', user_id: 'user_id' }], icon: 'https://lh3.googleusercontent.com/a-/AOh14GjqdgJy-51RgCHKPgIMrNaIX0y4QUAL6Mz9OFS4=s100', beta_enabled: false, pages: [ 'b0e76ef7-ec48-4aeb-9877-3b7907f4335c', '8cb60e50-7dec-4a8c-9032-e6c30a8ec642', 'c1665d8e-8d20-4b32-9a44-320428580441', '98c36805-bd5f-448f-b199-7fbff24d9963', 'a8624141-f0bd-4e41-9f02-83bbc7fc2b4a', 'b2115542-dfaa-4599-8ba9-77d467a33f94', '739c26a3-770d-44e7-b652-70b3a5ae0220', 'c215286e-fd31-4d74-9266-e832603a3e8e', '5ca7e1ba-65f4-422d-a274-529f8f8ec664', '4d64bbc0-634d-4758-befa-85c5a3a6c22f', 'f9466b44-33b3-4cb2-9083-b820ead2c4fd', ], created_time: 1591708116643, last_edited_time: 1602476700000, created_by_table: 'notion_user', created_by_id: 'user_id', last_edited_by_table: 'notion_user', last_edited_by_id: 'user_id', shard_id: 285188, plan_type: 'personal', invite_link_code: '09cb83f87702b6ce2be323132a369b69e23085b3', invite_link_enabled: true, }, }, }, }, cursor: { stack: [] }, }; export const SUCCESSFUL_SYNC_RECORD_VALUE = { recordMap: { block: { '12cfada5-6686-4561-9c4f-177b3be4b422': { role: 'editor', value: { id: '12cfada5-6686-4561-9c4f-177b3be4b422', version: 27, type: 'text', properties: { title: [['Lide com isso!']] }, created_time: 1602861180000, last_edited_time: 1602861180000, parent_id: '9dfff855-7aed-4a99-8e60-2e1384399200', parent_table: 'block', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', }, }, }, }, }; export const SUCCESSFUL_RECORDS_WITH_CHILDREN = { results: [ { role: 'reader', value: { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', version: 262, type: 'page', properties: { title: [['Simple Page Text 2']] }, content: ['f8cf7a08-bf80-4f8b-a842-3db49df95e4d'], permissions: [ { role: 'editor', type: 'user_permission', user_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3' }, { role: 'reader', type: 'public_permission' }, ], created_time: 1595516162445, last_edited_time: 1602527580000, parent_id: '159177ec-0fb0-469e-a900-1a662b145a04', parent_table: 'space', alive: true, created_by_table: 'notion_user', created_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', last_edited_by_table: 'notion_user', last_edited_by_id: '408c862f-f07b-4036-b414-1ae5c5ce57b3', }, }, ], }; export const NO_PAGE_ACCESS_RECORDS = { results: [{ role: 'none' }] }; export const MISSING_CONTENT_RECORDS = { results: [ { role: 'reader', value: { id: '9a75a541-277f-4a64-80e7-5581f36672ba', version: 22, type: 'page', permissions: [{ role: 'reader', type: 'public_permission' }], created_time: 1595551283696, last_edited_time: 1595551260000, alive: true, }, }, ], }; export const SINGLE_PAGE_WITH_COVER_IMAGE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', format: { page_cover: '/images/page-cover/solid_blue.png', page_cover_position: 0.6 }, properties: { title: [['Page Title']], }, contents: [], }, ]; export const SINGLE_PAGE_WITH_ICON = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', format: { page_icon: '🤴' }, properties: { title: [['Page Title']], }, contents: [], }, ]; export const SINGLE_TEXT_AND_TITLE_NOTION_API_CONTENT_RESPONSE = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello World']] }, contents: [], }, ]; export const SINGLE_TEXT_WITH_BOLD = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello '], ['World', [['b']]]], }, contents: [], }, ]; export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC_TOGETHER = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello '], ['World', [['b'], ['i']]]], }, contents: [], }, ]; export const SINGLE_TEXT_WITH_BOLD_AND_ITALIC = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [ ['Hello ', [['b']]], ['World', [['i']]], ], }, contents: [], }, ]; export const SINGLE_TEXT_WITH_COLOR = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello', [['h', 'purple']]]], }, contents: [], }, ]; export const SINGLE_TEXT_WITH_EQUATION = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello World '], ['⁍', [['e', '2x']]]], }, contents: [], }, ]; export const SINGLE_TEXT_WITH_LINK = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello '], ['World', [['a', 'https://www.google.com']]]] }, contents: [], }, ]; export const SINGLE_TEXT_WITH_FORMAT = [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', format: { block_color: 'red_background' }, properties: { title: [['Hello '], ['World', [['a', 'https://www.google.com']]]] }, contents: [], }, ]; export const CALLOUT_WITH_PAGE_ICON = [ { id: '16431c64-3bf0-481f-a29f-d544780d84f3', type: 'callout', properties: { title: [['This is a callout']] }, format: { page_icon: '💡' }, contents: [], }, ]; export const IMAGE_WITH_CUSTOM_SIZE = [ { id: 'ec3b36fd-f77d-46b4-8592-5966488612b1', type: 'image', properties: { source: [ [ 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', ], ], }, format: { block_width: 240, block_height: 50, display_source: 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg', block_full_width: false, block_page_width: false, block_aspect_ratio: 1, block_preserve_scale: true, }, contents: [], }, ]; export const TEXT_NOTION_API_CONTENT_RESPONSE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { title: [['Simple Page Test']] }, format: { page_cover: 'https://www.example.com/image.png', page_cover_position: 0.6, page_icon: '🤴' }, contents: [ { id: '80d0fc46-5511-4d1d-a4ec-8b2f43d75226', type: 'text', properties: { title: [['Hello World']] }, contents: [], }, ], }, ]; export const VIDEO_NOTION_API_CONTENT_RESPONSE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { title: [['Simple Page Test']] }, contents: [ { id: 'dcde43cb-7131-4687-8f22-c9789fa75f46', type: 'video', properties: { source: [['https://www.youtube.com/watch?v=xBFqxBfLJWc']] }, contents: [], }, ], }, ]; export const LIST_WITH_CHILDREN_RESPONSE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { title: [['Simple Page Text 2']] }, contents: [ { id: 'f8cf7a08-bf80-4f8b-a842-3db49df95e4d', type: 'bulleted_list', properties: { title: [['Estou testando']] }, contents: [ { id: '0b73eab8-8c01-4140-ab4d-cd6a0886cd76', type: 'bulleted_list', properties: { title: [['isso daqui']] }, contents: [ { id: '6bebe374-1569-4836-9de5-847c91ecb3f8', type: 'bulleted_list', properties: { title: [['vamos ver se funciona']] }, contents: [], }, ], }, ], }, ], }, ]; export const DETAILS_RESPONSE = [ { id: '4d64bbc0-634d-4758-befa-85c5a3a6c22f', type: 'page', properties: { title: [['Simple Page Text 2']] }, format: {}, contents: [ { id: '9dfff855-7aed-4a99-8e60-2e1384399200', type: 'toggle', properties: { title: [['Isso é um detail']] }, contents: [ { id: '12cfada5-6686-4561-9c4f-177b3be4b422', type: 'text', properties: { title: [['Lide com isso!']] }, contents: [], }, ], }, ], }, ]; ================================================ FILE: src/data/helpers/block-to-inner-html.ts ================================================ import { Block, DecorableText } from '../protocols/blocks'; import { Decorator } from '../use-cases/blocks-to-html-converter/block-parsers/decorations/decorator'; import { replaceLineBreakByBrTag } from './replace-line-break-to-br-tag'; export const blockToInnerHtml = async (block: Block): Promise => { const decorator = new Decorator(block.decorableTexts || ([] as DecorableText[])); const decoratedText = await decorator.decorate(); return Promise.resolve(replaceLineBreakByBrTag(decoratedText)); }; ================================================ FILE: src/data/helpers/block-to-inner-text.ts ================================================ import { Block } from '../../data/protocols/blocks'; export const blockToInnerText = (block: Block): string => { const decorableTexts = block.decorableTexts; return decorableTexts ? decorableTexts.map((dt) => dt.text).join('') : ''; }; ================================================ FILE: src/data/helpers/blocks-to-html.ts ================================================ import { Block } from '../protocols/blocks'; import { ListBlocksWrapper, BlockDispatcher, BlocksToHTML } from '../use-cases/blocks-to-html-converter'; export const blocksToHtml = async (blocks: Block[]): Promise => { const dispatcher = new BlockDispatcher(); const listWrapper = new ListBlocksWrapper(); const blocksToHtmlConverter = new BlocksToHTML(blocks, dispatcher, listWrapper); const html = await blocksToHtmlConverter.convert(); return Promise.resolve(html); }; export const indentBlocksToHtml = async (blocks: Block[]): Promise => { if (blocks.length === 0) return Promise.resolve(''); const html = await blocksToHtml(blocks); return Promise.resolve(html); }; ================================================ FILE: src/data/helpers/color-to-hex.ts ================================================ export const backgroundColorToHex = (color: string): string => { return backgroundColorsToHex[color] || '#FFFFFF'; }; export const foregroundColorToHex = (color: string): string => { return foregroundColorTextToHEX[color] || '#37352F'; }; const foregroundColorTextToHEX: Record = { purple: '#6940A5', yellow: '#E9AB01', gray: '#9B9A97', brown: '#64473A', orange: '#D9730D', green: '#0F7B6C', blue: '#0B6E99', pink: '#AD1A72', red: '#E03E3E', none: '#37352F', }; const backgroundColorsToHex: Record = { gray_background: '#B4AEAE', brown_background: '#E9E5E3', orange_background: '#FAEBDD', yellow_background: '#FBF3DB', green_background: '#DDEDEA', blue_background: '#DDEBF1', purple_background: '#EAE4F2', pink_background: '#F4DFEB', red_background: '#FBE4E4', }; ================================================ FILE: src/data/helpers/replace-line-break-to-br-tag.ts ================================================ export const replaceLineBreakByBrTag = (str: string): string => str.replace(/[\r\n]/g, '
'); ================================================ FILE: src/data/protocols/blocks/block.ts ================================================ import { DecorableText } from './decorable-text'; import { Format } from './format'; export type Block = { id: string; type: string; children: Block[]; properties: Record; format: Format; decorableTexts: DecorableText[]; }; ================================================ FILE: src/data/protocols/blocks/decorable-text.ts ================================================ import { Decoration } from './decoration'; export type DecorableText = { text: string; decorations: Decoration[]; }; ================================================ FILE: src/data/protocols/blocks/decoration.ts ================================================ export type Decoration = { type: DecorationType; value?: string; }; export type DecorationType = | 'plain' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'link' | 'code' | 'color' | 'equation'; ================================================ FILE: src/data/protocols/blocks/format.ts ================================================ export type Format = { block_color?: string; block_width?: number; page_icon?: string; page_cover?: string; page_cover_position?: number; }; ================================================ FILE: src/data/protocols/blocks/index.ts ================================================ export * from './block'; export * from './decorable-text'; export * from './decoration'; export * from './list-blocks-wrapper'; ================================================ FILE: src/data/protocols/blocks/list-blocks-wrapper.ts ================================================ import { Block } from './block'; export interface ListBlocksWrapper { wrapLists(blocks: Block[]): Block[]; } ================================================ FILE: src/data/protocols/html-options/html-options.ts ================================================ export type HtmlOptions = { excludeTitleFromHead?: boolean; excludeCSS?: boolean; excludeMetadata?: boolean; excludeScripts?: boolean; excludeHeaderFromBody?: boolean; bodyContentOnly?: boolean; }; ================================================ FILE: src/data/protocols/http-request/http-get-client.ts ================================================ import { HttpResponse } from './http-response'; export interface HttpGetClient { get(url: string): Promise; } ================================================ FILE: src/data/protocols/http-request/http-post-client.ts ================================================ import { HttpResponse } from './http-response'; export interface HttpPostClient { post(url: string, body: Record): Promise; } ================================================ FILE: src/data/protocols/http-request/http-response.ts ================================================ export type HttpResponse = { status: number; data: Record | string; headers?: Record; }; ================================================ FILE: src/data/protocols/http-request/index.ts ================================================ export * from './http-post-client'; export * from './http-get-client'; export * from './http-response'; ================================================ FILE: src/data/protocols/page-props/image-cover.ts ================================================ export type ImageCover = { base64: string; position: number; }; ================================================ FILE: src/data/protocols/page-props/index.ts ================================================ export * from './page-props'; export * from './image-cover'; ================================================ FILE: src/data/protocols/page-props/page-props.ts ================================================ export type PageProps = { title: string; coverImageSrc?: string; coverImagePosition?: number; icon?: string; }; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-dispatcher.ts ================================================ import { Block } from '../../protocols/blocks'; import { ToHtml, ToHtmlClass } from '../../../domain/use-cases/to-html'; import * as blockParsers from './block-parsers'; export class BlockDispatcher { dispatch(block: Block): ToHtml { const ToHtmlConverter = fromBlockToHtmlConverter[block.type] || blockParsers.UnknownBlockToHtml; return new ToHtmlConverter(block); } } const fromBlockToHtmlConverter: Record = { text: blockParsers.TextBlockToHtml, header: blockParsers.HeaderBlockToHtml, sub_header: blockParsers.SubHeaderBlockParser, sub_sub_header: blockParsers.SubSubHeaderBlockParser, to_do: blockParsers.ToDoBlockToHtml, code: blockParsers.CodeBlockToHtml, equation: blockParsers.EquationBlockToHtml, quote: blockParsers.QuoteBlockToHtml, divider: blockParsers.DividerBlockToHtml, list: blockParsers.ListBlockToHtml, video: blockParsers.YouTubeVideoBlockToHtml, image: blockParsers.ImageBlockToHtml, callout: blockParsers.CalloutBlockToHtml, toggle: blockParsers.ToggleBlockToHtml, page: blockParsers.PageBlockToHtml, }; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/callout.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; import { Base64Converter } from '../../../../utils/base-64-converter'; export class CalloutBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); const iconHtml = await new IconToHtml(this._block.properties.page_icon, this._block.id).toHtml(); return Promise.resolve(`
${iconHtml}

${await blockToInnerHtml(this._block)}

`); } } class IconToHtml { private readonly _icon: string | undefined; private readonly _id: string; constructor(icon: string | undefined, id: string) { this._icon = icon; this._id = id; } async toHtml(): Promise { if (!this._icon) return `
💡
`; if (!this._icon.startsWith('http')) return `
${this._icon}
`; const url = `https://www.notion.so/image/${encodeURIComponent(this._icon)}?table=block&id=${this._id}`; const imageSource = await Base64Converter.convert(url); const caption = 'callout icon'; return `
${caption}
`; } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/code.ts ================================================ import { Block } from '../../../../data/protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { blockToInnerText } from '../../../helpers/block-to-inner-text'; export class CodeBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const languageClass = this._language ? `class="language-${this._language}"` : ''; return Promise.resolve( `
${blockToInnerText(this._block).replace(/(\s{4}|\t)/g, '  ')}
`, ); } private get _language(): string { return this._block.properties?.language?.toLowerCase().replace(/ /g, ''); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-dispatcher.ts ================================================ import { Decoration } from '../../../../../data/protocols/blocks/decoration'; import { ToHtml, ToHtmlClass } from '../../../../../domain/use-cases/to-html'; import * as DecorationParsers from './decoration-parsers'; export class DecoratorDispatcher { dispatch(text: string, decoration: Decoration): ToHtml { const ToHtmlConverter = fromDecorationTypeToParsers[decoration.type] || DecorationParsers.UnknownDecorationToHtml; return new ToHtmlConverter(text, decoration); } } const fromDecorationTypeToParsers: Record = { bold: DecorationParsers.BoldDecorationToHtml, italic: DecorationParsers.ItalicDecorationToHtml, strikethrough: DecorationParsers.StrikeThroughDecorationToHtml, code: DecorationParsers.CodeDecorationToHtml, underline: DecorationParsers.UnderlineDecorationToHtml, equation: DecorationParsers.EquationDecorationToHtml, link: DecorationParsers.LinkDecorationToHtml, color: DecorationParsers.ColorDecorationToHtml, }; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/bold.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class BoldDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(`${this._text}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/code.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class CodeDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(`${this._text}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/color.test.ts ================================================ import { ColorDecorationToHtml } from './color'; import { Decoration } from '../../../../../protocols/blocks'; describe('#convert', () => { describe('When color is given as foreground color', () => { it('styles using css color property', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'purple' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toMatch('style="color:'); }); }); describe('When color is given as background color', () => { it('styles using css background-color property', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'purple_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toMatch('style="background-color:'); }); it('do not preserves color value on style', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'purple_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).not.toMatch('purple_background'); }); }); describe('When purple color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'purple' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When yellow color is given as color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'yellow' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When gray color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'gray' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When brown color is given as color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'brown' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When orange color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'orange' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When green color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'green' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When pink color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'pink' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When red color is given as foreground color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'red' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When unknown color is given as foreground color', () => { it('converts to equivalent default foreground color hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'refafad' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When no color value is given as foreground color', () => { it('converts to equivalent default foreground color hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When gray color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'gray_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When brown color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'brown_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When orange color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'orange_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When yellow color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'yellow_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When green color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'green_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When blue color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'blue_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When purple color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'purple_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When pink color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'pink_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When red color is given as background color', () => { it('converts to equivalent hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'red_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); describe('When unknown color is given as background color', () => { it('converts to equivalent default background color hex code and apply style to html', async () => { const text = 'Text with color'; const decoration: Decoration = { type: 'color', value: 'refafad_background' }; const result = await new ColorDecorationToHtml(text, decoration).convert(); expect(result).toBe('Text with color'); }); }); }); ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/color.ts ================================================ import { Decoration } from '../../../../../../data/protocols/blocks'; import { ToHtml } from '../../../../../../domain/use-cases/to-html'; import { foregroundColorToHex, backgroundColorToHex } from '../../../../../helpers/color-to-hex'; export class ColorDecorationToHtml implements ToHtml { private readonly _text: string; private readonly _decoration: Decoration; constructor(text: string, decoration: Decoration) { this._text = text; this._decoration = decoration; } async convert(): Promise { return Promise.resolve(`${this._text}`); } private _isBackground(): boolean { return !!this._decoration.value?.includes('_background'); } private get _style() { const textColor = this._decoration.value || 'none'; if (this._isBackground()) return `background-color: ${backgroundColorToHex(textColor)};`; return `color: ${foregroundColorToHex(textColor)};`; } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/equation.ts ================================================ import { Decoration } from '../../../../../../data/protocols/blocks'; import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class EquationDecorationToHtml implements ToHtml { private readonly _text: string; private readonly _decoration: Decoration; constructor(text: string, decoration: Decoration) { this._text = text; this._decoration = decoration; } async convert(): Promise { const equation = this._decoration.value; return Promise.resolve(equation ? `$${equation}$` : ''); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/index.ts ================================================ export * from './bold'; export * from './code'; export * from './color'; export * from './equation'; export * from './italic'; export * from './link'; export * from './strikethrough'; export * from './underline'; export * from './unknown'; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/italic.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class ItalicDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(`${this._text}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/link.ts ================================================ import { Decoration } from '../../../../../../data/protocols/blocks'; import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class LinkDecorationToHtml implements ToHtml { private readonly _text: string; private readonly _decoration: Decoration; constructor(text: string, decoration: Decoration) { this._text = text; this._decoration = decoration; } async convert(): Promise { return Promise.resolve(`${this._text}`); } private get _link(): string { return this._decoration.value || '#'; } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/strikethrough.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class StrikeThroughDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(`${this._text}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/underline.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class UnderlineDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(`${this._text}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decoration-parsers/unknown.ts ================================================ import { ToHtml } from '../../../../../../domain/use-cases/to-html'; export class UnknownDecorationToHtml implements ToHtml { private readonly _text: string; constructor(text: string) { this._text = text; } async convert(): Promise { return Promise.resolve(this._text); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/decorations/decorator.ts ================================================ import { DecorableText } from '../../../../../data/protocols/blocks/decorable-text'; import { DecoratorDispatcher } from './decoration-dispatcher'; export class Decorator { private readonly _decorableTexts: DecorableText[]; constructor(decorableTexts: DecorableText[]) { this._decorableTexts = decorableTexts; } async decorate(): Promise { const decorableTextsByDecorators = await Promise.all( this._decorableTexts.map(await this._decorateByDecorableText.bind(this)), ); return Promise.resolve(decorableTextsByDecorators.join('')); } async _decorateByDecorableText(decorableText: DecorableText): Promise { let html = decorableText.text; for (const decoration of decorableText.decorations) { const decorator = new DecoratorDispatcher().dispatch(html, decoration); html = await decorator.convert(); } return Promise.resolve(html); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/divider.ts ================================================ import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; export class DividerBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { return Promise.resolve(`
`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/equation.ts ================================================ import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { blockToInnerText } from '../../../helpers/block-to-inner-text'; export class EquationBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const { decorableTexts } = this._block; if (decorableTexts.length === 0) return Promise.resolve(''); return Promise.resolve(decorableTexts ? `
$$${blockToInnerText(this._block)}$$
` : ''); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/header.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; export class HeaderBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); return Promise.resolve(`${await blockToInnerHtml(this._block)}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/image.ts ================================================ import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { Base64Converter } from '../../../../utils/base-64-converter'; import { FormatToStyle } from '../../format-to-style'; export class ImageBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { if (!this._rawSrc) return ''; const imageSource = await Base64Converter.convert(this._rawSrc); const caption = this._caption; const style = new FormatToStyle(this._block.format).toStyle(); return `
${caption} ${caption !== '' ? `
${caption}
` : ''}
`; } private get _rawSrc() { const url = this._block.properties.source; if (!url) return; return `https://www.notion.so/image/${encodeURIComponent(url)}?table=block&id=${this._block.id}`; } private get _caption() { return this._block.properties.caption || ''; } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/index.ts ================================================ export * from './image'; export * from './list'; export * from './callout'; export * from './code'; export * from './divider'; export * from './equation'; export * from './header'; export * from './quote'; export * from './sub-header'; export * from './sub-sub-header'; export * from './text'; export * from './to-do'; export * from './toggle'; export * from './page'; export * from './unknown'; export * from './youtube-video'; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/list/index.ts ================================================ export * from './list'; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/list/list-item.ts ================================================ import { blockToInnerHtml } from '../../../../helpers/block-to-inner-html'; import { Block } from '../../../../protocols/blocks'; import { ToHtml } from '../../../../../domain/use-cases/to-html'; import { indentBlocksToHtml } from '../../../../helpers/blocks-to-html'; export class ListItemToHtml implements ToHtml { private _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const childrenHtml = await indentBlocksToHtml(this._block.children); return Promise.resolve(`
  • ${await blockToInnerHtml(this._block)}${childrenHtml}
  • `); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/list/list.ts ================================================ import { Block } from '../../../../protocols/blocks'; import { ToHtml } from '../../../../../domain/use-cases/to-html'; import { ListItemToHtml } from './list-item'; import { FormatToStyle } from '../../../format-to-style'; export class ListBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const tag: string = fromTypeToTag[this._block.children[0].type] || fromTypeToTag.bulleted_list; const style = new FormatToStyle(this._block.format).toStyle(); const innerHtml = await this._itemsHtml(); return Promise.resolve(`<${tag}${style}>\n${innerHtml}\n`); } private async _itemsHtml(): Promise { const items = await Promise.all(this._block.children.map(async (c) => new ListItemToHtml(c).convert())); return Promise.resolve(items.join('\n')); } } const fromTypeToTag: Record = { bulleted_list: 'ul', numbered_list: 'ol', }; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/page.ts ================================================ import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { blocksToHtml } from '../../../helpers/blocks-to-html'; export class PageBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { return blocksToHtml(this._block.children); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/quote.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; export class QuoteBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); return Promise.resolve(`${await blockToInnerHtml(this._block)}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/sub-header.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; export class SubHeaderBlockParser implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); return Promise.resolve(`${await blockToInnerHtml(this._block)}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/sub-sub-header.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; export class SubSubHeaderBlockParser implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); return Promise.resolve(`${await blockToInnerHtml(this._block)}`); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/block-parsers/text.ts ================================================ import { blockToInnerHtml } from '../../../helpers/block-to-inner-html'; import { Block } from '../../../protocols/blocks'; import { ToHtml } from '../../../../domain/use-cases/to-html'; import { FormatToStyle } from '../../format-to-style'; import { indentBlocksToHtml } from '../../../helpers/blocks-to-html'; export class TextBlockToHtml implements ToHtml { private readonly _block: Block; constructor(block: Block) { this._block = block; } async convert(): Promise { const style = new FormatToStyle(this._block.format).toStyle(); const childrenHtml = await indentBlocksToHtml(this._block.children); return Promise.resolve(`${await blockToInnerHtml(this._block)}${childrenHtml}

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

    '); }); }); describe('When single text block is given', () => { it('returns html with p tag', async () => { const html = await makeSut(BlockMocks.SINGLE_TEXT).convert(); expect(html).toBe('

    Hello World

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

    Hello World

    This is a child

    This is a child too

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

    Hello World

    '); }); }); describe('When single line text with italic part', () => { it('returns html with single p paragraph with strong tag nested', async () => { const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_ITALIC).convert(); expect(html).toBe('

    Hello World

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

    Hello World

    '); }); }); describe('When single line text with strikethrough part', () => { it('returns html with single p paragraph with del tag inside', async () => { const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_STRIKETHROUGH).convert(); expect(html).toBe('

    Hello World

    '); }); }); describe('When single line text with code part', () => { it('returns html with single p paragraph with code tag inside', async () => { const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_CODE_DECORATION).convert(); expect(html).toBe('

    Hello myVar

    '); }); }); describe('When single line text with link part', () => { it('returns html with single p paragraph with a tag with given link', async () => { const html = await makeSut(BlockMocks.SINGLE_TEXT_WITH_LINK).convert(); expect(html).toBe('

    Hello World

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

    Hello World $2x$

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

    Hello

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

    Hello

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

    Hello World

    '); }); }); describe('When single line text with bold and italic parts apart', () => { it('returns html with single p paragraph with strong and em tags nested', async () => { const html = await makeSut(BlockMocks.TEXT_WITH_DECORATION).convert(); expect(html).toBe( '

    Hello World and Sun

    ', ); }); }); describe('When multiline text block is given', () => { it('returns html with two p tags', async () => { const html = await makeSut(BlockMocks.MULTILINE_TEXT).convert(); expect(html).toBe('

    Hello World
    Is everything alright?
    Yes, Dude!

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

    This is a text with red background

    '); }); }); describe('When text block has foreground color', () => { it('returns html p tag with style and color prop', async () => { const html = await makeSut(BlockMocks.TEXT_WITH_FORMAT_FOREGROUND).convert(); expect(html).toBe('

    This is a text with purple color

    '); }); }); }); describe('When only a h1 title block is given', () => { describe('When single block is given', () => { it('returns html with h1 tag', async () => { const html = await makeSut(BlockMocks.H1_TEXT).convert(); expect(html).toBe('

    This is a h1 title

    '); }); }); describe('When single line header with decoration', () => { it('returns html with single h1 with decoration tags inside', async () => { const html = await makeSut(BlockMocks.H1_TEXT_WITH_DECORATIONS).convert(); expect(html).toBe( '

    Hello World and Sun

    ', ); }); }); describe('When header block has background color', () => { it('returns html h1 tag with style and background-color prop', async () => { const html = await makeSut(BlockMocks.H1_WITH_FORMAT).convert(); expect(html).toBe('

    This is a h1 with red background

    '); }); }); describe('When header block has foreground color', () => { it('returns html h1 tag with style and color prop', async () => { const html = await makeSut(BlockMocks.H1_WITH_FORMAT_FOREGROUND).convert(); expect(html).toBe('

    This is a h1 with yellow color

    '); }); }); }); describe('When only a h2 title block is given', () => { describe('When single block is given', () => { it('returns html with h2 tag', async () => { const html = await makeSut(BlockMocks.H2_TEXT).convert(); expect(html).toBe('

    This is a h2 title

    '); }); }); describe('When single line h2 with decoration', () => { it('returns html with single h1 with decoration tags inside', async () => { const html = await makeSut(BlockMocks.H2_TEXT_WITH_DECORATIONS).convert(); expect(html).toBe( '

    Hello World and Sun

    ', ); }); }); describe('When sub header block has background color', () => { it('returns html h2 tag with style and background-color prop', async () => { const html = await makeSut(BlockMocks.H2_WITH_FORMAT).convert(); expect(html).toBe('

    This is a h2 with yellow background

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

    This is a h2 with gray color

    '); }); }); }); describe('When only a h3 title block is given', () => { describe('When single block is given', () => { it('returns html with h3 tag', async () => { const html = await makeSut(BlockMocks.H3_TEXT).convert(); expect(html).toBe('

    This is a h3 title

    '); }); }); describe('When single line h3 with decoration', () => { it('returns html with single h1 with decoration tags inside', async () => { const html = await makeSut(BlockMocks.H3_TEXT_WITH_DECORATIONS).convert(); expect(html).toBe( '

    Hello World and Sun

    ', ); }); }); describe('When sub header block has background color', () => { it('returns html h3 tag with style and background-color prop', async () => { const html = await makeSut(BlockMocks.H3_WITH_FORMAT).convert(); expect(html).toBe('

    This is a h3 with orange background

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

    This is a h3 with brown color

    '); }); }); }); describe('When only an unordered list block is given', () => { describe('When single block is given', () => { it('returns html with ul tag with li tag inside', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM).convert(); expect(html).toBe('
      \n
    • This is a test
    • \n
    '); }); }); describe('When block has children', () => { it('returns html with ul and li tags and children blocks inside', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_CHILDREN).convert(); expect(html.replace(/\s/g, '')).toBe( `
    • Hello World
      • This is a child
      • This is a child too
    `.replace(/\s/g, ''), ); }); }); describe('When single block is given with background color', () => { it('returns html with ul tag with li tag inside and background', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT).convert(); expect(html).toBe('
      \n
    • This is a item with background
    • \n
    '); }); }); describe('When single block is given with foreground color', () => { it('returns html with ul tag with li tag inside and foreground', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND).convert(); expect(html).toBe('
      \n
    • This is a item with color
    • \n
    '); }); }); describe('When list block with two items is given', () => { it('returns html with ul tag with li tag inside', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_TWO_ITEMS).convert(); expect(html).toBe('
      \n
    • This is a test
    • \n
    • This is a test too
    • \n
    '); }); }); describe('When single line unordered list with decoration', () => { it('returns html with ul tag with li tag and decorations tags inside', async () => { const html = await makeSut(BlockMocks.UNORDERED_LIST_WITH_DECORATED_ITEMS).convert(); expect(html).toBe( '
      \n
    • Hello World and Sun
    • \n
    ', ); }); }); }); describe('When only an ordered list block is given', () => { describe('When single block is given', () => { it('returns html with ol tag with li tag inside', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM).convert(); expect(html).toBe('
      \n
    1. This is a test
    2. \n
    '); }); }); describe('When block has children', () => { it('returns html with ul and li tags and children blocks inside', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_CHILDREN).convert(); expect(html.replace(/\s/g, '')).toBe( `
    1. Hello World
      1. This is a child
      2. This is a child too
    `.replace(/\s/g, ''), ); }); }); describe('When single block is given with background color', () => { it('returns html with ol tag with li tag inside and background', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT).convert(); expect(html).toBe('
      \n
    1. This is a item with background
    2. \n
    '); }); }); describe('When single block is given with foreground color', () => { it('returns html with ol tag with li tag inside and foreground', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_SINGLE_ITEM_AND_FORMAT_FOREGROUND).convert(); expect(html).toBe('
      \n
    1. This is a item with color
    2. \n
    '); }); }); describe('When list block with two items is given', () => { it('returns html with ol tag with li tag inside', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_TWO_ITEMS).convert(); expect(html).toBe('
      \n
    1. This is a test
    2. \n
    3. This is a test too
    4. \n
    '); }); }); describe('When single line ordered list with decoration', () => { it('returns html with ol tag with li tag and decorations tags inside', async () => { const html = await makeSut(BlockMocks.ORDERED_LIST_WITH_DECORATED_ITEMS).convert(); expect(html).toBe( '
      \n
    1. Hello World and Sun
    2. \n
    ', ); }); }); }); describe('When only a to do list block is given', () => { describe('When single unchecked block is given', () => { it('returns html with a div and unchecked checkbox and label inside', async () => { const html = await makeSut(BlockMocks.TODO).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • This is a test\
    • \
    \ `.replace(/\s/g, ''), ); }); }); describe('When block has children', () => { it('returns html with todo and children blocks inside', async () => { const html = await makeSut(BlockMocks.TODO_WITH_CHILDREN).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • Hello World\
      • This is a child\
      • \
      \
      • This is a child too\
      • \
      \
    • \
    \ `.replace(/\s/g, ''), ); }); }); describe('When single unchecked block with background color is given', () => { it('returns html with a div and unchecked checkbox and label inside with style on div', async () => { const html = await makeSut(BlockMocks.TODO_WITH_FORMAT).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • This is a todo with style\
    • \
    \ `.replace(/\s/g, ''), ); }); }); describe('When single unchecked block with foreground color is given', () => { it('returns html with a div and unchecked checkbox and label inside with style on div', async () => { const html = await makeSut(BlockMocks.TODO_WITH_FORMAT_FOREGROUND).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • This is a todo with style\
    • \
    \ `.replace(/\s/g, ''), ); }); }); describe('When single checked block is given', () => { it('returns html with a div and checked checkbox and label inside', async () => { const html = await makeSut(BlockMocks.CHECKED_TODO).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • This is a test\
    • \
    \ `.replace(/\s/g, ''), ); }); }); describe('When to-do block with two items is given', () => { it('returns html with two divs and checkbox and label inside', async () => { const html = await makeSut(BlockMocks.UNCHECKED_AND_CHECKED_TODOS).convert(); expect(html.replace(/\s/g, '')).toBe( `\
    • This is a test\
    • \
    \
    • This is a test too\
    • \
    \ `.replace(/\s/g, ''), ); }); }); }); describe('When single code block is given', () => { describe('When there is no style on code block', () => { it('returns html with pre tag and code tag inside', async () => { const html = await makeSut(BlockMocks.CODE).convert(); expect(html).toBe( `
    function test() {\n  var isTesting = true;\n  return isTesting;\n}
    `, ); }); }); describe('When there is style on code block', () => { it('ignores styles and returns html with pre tag and code tag inside', async () => { const html = await makeSut(BlockMocks.CODE_WITH_DECORATION).convert(); expect(html).toBe( `
    function test() {\n  var isTesting = true;\n  return isTesting;\n}
    `, ); }); }); }); describe('When single quote block is given', () => { describe('When there is no style on quote block', () => { it('returns html with blockquote tag', async () => { const html = await makeSut(BlockMocks.QUOTE).convert(); expect(html).toBe('
    This a quote
    '); }); }); describe('When there is style on quote block', () => { it('returns html with blockquote tag and decorations inside', async () => { const html = await makeSut(BlockMocks.QUOTE_WITH_DECORATION).convert(); expect(html).toBe( `
    Hello World and Sun
    `, ); }); }); describe('When there is background color on quote', () => { it('returns html with style with background color prop', async () => { const html = await makeSut(BlockMocks.QUOTE_WITH_FORMAT).convert(); expect(html).toBe('
    This a quote with background
    '); }); }); describe('When there is background color on quote', () => { it('returns html with style with background color prop', async () => { const html = await makeSut(BlockMocks.QUOTE_WITH_FORMAT_FOREGROUND).convert(); expect(html).toBe('
    This a quote with color
    '); }); }); }); describe('When divider block is given', () => { it('returns html with hr tag', async () => { const html = await makeSut(BlockMocks.TEXT_BETWEEN_DIVIDER).convert(); expect(html).toBe(`

    This a text

    \n
    \n

    This a text too

    `); }); }); describe('When equation block is given', () => { describe('When there is no equation content', () => { it('returns empty string', async () => { const html = await makeSut(BlockMocks.EMPTY_EQUATION).convert(); expect(html).toBe(''); }); }); describe('When there is no equation content', () => { it('returns html with div tag and equation class with equation inside', async () => { const html = await makeSut(BlockMocks.EQUATION).convert(); expect(html).toBe(`
    $$\\int 2xdx = x^2 + C$$
    `); }); }); }); describe('When video block is given', () => { describe('When it is not a youtube video', () => { it('returns empty string', async () => { const html = await makeSut(BlockMocks.NO_YOUTUBE_VIDEO).convert(); expect(html).toBe(''); }); }); describe('When it is a youtube video', () => { it('returns html with iframe tag and embed id', async () => { const html = await makeSut(BlockMocks.YOUTUBE_VIDEO).convert(); expect(html.replace(/\s/g, '')).toBe( ``.replace(/\s/g, ''), ); }); }); }); describe('When image block is given', () => { beforeEach(() => { const imageSource = 'https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bcedd078-56cd-4137-a28a-af16b5746874/767-50x50.jpg'; const blockId = 'ec3b36fd-f77d-46b4-8592-5966488612b1'; nock('https://www.notion.so') .get(`/image/${encodeURIComponent(imageSource)}?table=block&id=${blockId}`) .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 'content-type': 'image/jpeg', }); }); describe('When image has no caption', () => { it('returns html with img tag with src as base64', async () => { const html = await makeSut(BlockMocks.IMAGE).convert(); expect(html.replace(/\s/g, '')).toBe( `
    `.replace(/\s/g, ''), ); }); }); describe('When image has caption', () => { it('returns html with img tag with src as base64 and alt attr with given caption', async () => { const html = await makeSut(BlockMocks.IMAGE_WITH_CAPTION).convert(); expect(html.replace(/\s/g, '')).toBe( `
    It is a caption
    It is a caption
    `.replace(/\s/g, ''), ); }); }); describe('When image has width', () => { it('returns html with img tag with width in style', async () => { const html = await makeSut(BlockMocks.IMAGE_WITH_CUSTOM_SIZE).convert(); expect(html.replace(/\s/g, '')).toBe( `
    `.replace(/\s/g, ''), ); }); }); describe('When detail block is given', () => { describe('When there are no style on block', () => { it('returns empty string', async () => { const html = await makeSut(BlockMocks.DETAILS).convert(); expect(html.replace(/\s/g, '')).toBe( `
    This is a detail

    Hello World

    `.replace(/\s/g, ''), ); }); }); describe('When there is style block', () => { it('returns html with blockquote tag and decorations inside', async () => { const html = await makeSut(BlockMocks.DETAILS_WITH_DECORATION).convert(); expect(html.replace(/\s/g, '')).toBe( `
    Hello World and Sun

    Hello World

    `.replace(/\s/g, ''), ); }); }); describe('When there is background color', () => { it('returns html with background color for the intire block', async () => { const html = await makeSut(BlockMocks.DETAILS_WITH_BG).convert(); expect(html.replace(/\s/g, '')).toBe( `
    This is a detail

    Hello World

    `.replace(/\s/g, ''), ); }); }); }); describe('When image must have a table and block id attached to url', () => { it('it should attach block id to it', async () => { const base64ConverterSpy = jest.spyOn(Base64Converter, 'convert'); const source = BlockMocks.IMAGE_WITH_CAPTION[0].properties.source; const id = BlockMocks.IMAGE_WITH_CAPTION[0].id; await makeSut(BlockMocks.IMAGE_WITH_CAPTION).convert(); const expectedImageUrl = `https://www.notion.so/image/${encodeURIComponent(source)}?table=block&id=${id}`; expect(base64ConverterSpy).toBeCalledWith(expectedImageUrl); }); }); }); describe('When callout block is given', () => { describe('with default background and emoji icon', () => { it('converts to callout html', async () => { const html = await makeSut(BlockMocks.CALLOUT).convert(); expect(html.replace(/\s/g, '')).toBe( `
    💡

    This is a callout

    `.replace(/\s/g, ''), ); }); }); describe('with default background and image icon', () => { beforeEach(() => { const imageSource = 'https://example.com/image.png'; const blockId = '16431c64-3bf0-481f-a29f-d544780d84f3'; nock('https://www.notion.so') .get(`/image/${encodeURIComponent(imageSource)}?table=block&id=${blockId}`) .replyWithFile(200, resolve('src/__tests__/mocks/img/baseImage.jpeg'), { 'content-type': 'image/jpeg', }); }); it('converts to callout html and image to base64', async () => { const html = await makeSut(BlockMocks.CALLOUT_WITH_IMAGE).convert(); expect(html.replace(/\s/g, '')).toBe( `
    callout icon

    This is a callout

    `.replace(/\s/g, ''), ); }); it('it should attach block id to it', async () => { const base64ConverterSpy = jest.spyOn(Base64Converter, 'convert'); const blocks = BlockMocks.CALLOUT_WITH_IMAGE; const source = blocks[0].properties.page_icon; const id = blocks[0].id; await makeSut(blocks).convert(); const expectedImageUrl = `https://www.notion.so/image/${encodeURIComponent(source)}?table=block&id=${id}`; expect(base64ConverterSpy).toBeCalledWith(expectedImageUrl); }); }); describe('with given background and emoji icon', () => { it('converts to callout html', async () => { const html = await makeSut(BlockMocks.CALLOUT_WITH_BACKGROUND).convert(); expect(html.replace(/\s/g, '')).toBe( `
    💡

    This is a callout

    `.replace(/\s/g, ''), ); }); }); }); describe('When unknown block is given', () => { it('returns empty string', async () => { const html = await makeSut(BlockMocks.UNKNOWN).convert(); expect(html).toBe(''); }); }); }); ================================================ FILE: src/data/use-cases/blocks-to-html-converter/blocks-to-html-converter.ts ================================================ import { ToHtml } from '../../../domain/use-cases/to-html'; import { Block } from '../../protocols/blocks'; import { BlockDispatcher } from './block-dispatcher'; import { ListBlocksWrapper } from './list-blocks-wrapper'; export class BlocksToHTML implements ToHtml { private _blocks: Block[]; private _dispatcher: BlockDispatcher; private _listBlocksWrapper: ListBlocksWrapper; constructor(blocks: Block[], dispatcher: BlockDispatcher, listBlocksWrapper: ListBlocksWrapper) { this._dispatcher = dispatcher; this._listBlocksWrapper = listBlocksWrapper; this._blocks = this._wrapLists(blocks); } async convert(): Promise { const htmlPromises: Promise = Promise.all(this._blocks.map(this._convertBlock.bind(this))); const html = (await htmlPromises).join('\n'); return new Promise((resolve) => resolve(html)); } private async _convertBlock(block: Block): Promise { const blockToHtmlConverter = this._dispatch(block); const htmlBlock = await blockToHtmlConverter.convert(); return new Promise((resolve) => resolve(htmlBlock)); } private _wrapLists(blocks: Block[]): Block[] { return this._listBlocksWrapper.wrapLists(blocks); } private _dispatch(block: Block): ToHtml { return this._dispatcher.dispatch(block); } } ================================================ FILE: src/data/use-cases/blocks-to-html-converter/index.ts ================================================ export * from './blocks-to-html-converter'; export * from './block-dispatcher'; export * from './list-blocks-wrapper'; ================================================ FILE: src/data/use-cases/blocks-to-html-converter/list-blocks-wrapper.ts ================================================ import { Block } from '../../protocols/blocks'; export class ListBlocksWrapper { wrapLists(blocks: Block[]): Block[] { return blocks.reduce((blocks, b) => { if (!this._isList(b)) return [...blocks, b]; if (this._isFirstItemOfAList(blocks, b)) return [...blocks, this._generateListBlock(b)]; const lastContent = blocks[blocks.length - 1]; lastContent.children.push(b); return blocks; }, [] as Block[]); } private _isList(block: Block): boolean { return block && block.type.includes('list'); } private _isFirstItemOfAList(blocks: Block[], currentBlock: Block): boolean { const lastContent = blocks[blocks.length - 1]; return ( (!this._isList(lastContent) || (lastContent && lastContent.children[0].type !== currentBlock.type)) && this._isList(currentBlock) ); } private _generateListBlock(childBlock: Block): Block { return { id: `${childBlock.id}-parent`, type: 'list', properties: childBlock.properties, format: childBlock.format, children: [childBlock], decorableTexts: [], }; } } ================================================ FILE: src/data/use-cases/format-to-style/format-to-style.ts ================================================ import { Format } from 'data/protocols/blocks/format'; import { foregroundColorToHex, backgroundColorToHex } from '../../helpers/color-to-hex'; export class FormatToStyle { private readonly _format: Format; constructor(format: Format) { this._format = format; } toStyle(): string { const styleProps = []; const blockColor = this._format.block_color; if (blockColor) styleProps.push(new BlockColorToProp(blockColor).toStyle()); const blockWidth = this._format.block_width; if (blockWidth) styleProps.push(new BlockWidthToProp(blockWidth).toStyle()); if (styleProps.length === 0) return ''; return ` style="${styleProps.join('')}"`; } } class BlockColorToProp { private readonly _blockColor: string; constructor(blockColor: string) { this._blockColor = blockColor; } toStyle(): string { if (this._isBackground()) return `background-color: ${backgroundColorToHex(this._blockColor)}; `; return `color: ${foregroundColorToHex(this._blockColor)}; `; } private _isBackground(): boolean { return !!this._blockColor?.includes('background'); } } class BlockWidthToProp { private readonly _blockWidth: number; constructor(blockWidth: number) { this._blockWidth = blockWidth; } toStyle(): string { return `width: ${this._blockWidth}px; `; } } ================================================ FILE: src/data/use-cases/format-to-style/index.ts ================================================ export * from './format-to-style'; ================================================ FILE: src/data/use-cases/html-wrapper/header-from-template.test.ts ================================================ import { HeaderFromTemplate } from './header-from-template'; import * as html from '../../../__tests__/mocks/html'; describe('#toHeader', () => { describe('when page has title only', () => { it('returns html with header and h1', () => { const pageProps = { title: 'This is a title' }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_ONLY.replace(/\s/g, '')); }); }); describe('when page has title and cover', () => { describe('when coverImagePosition is given on pageProp', () => { it('returns html with header and h1 and image position on image style', () => { const pageProps = { title: 'This is a title', coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', coverImagePosition: 15, }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_COVER_IMAGE.replace(/\s/g, '')); }); }); describe('when coverImagePosition is not given on pageProp', () => { it('returns html with header and h1 and image position as 0% on image style', () => { const pageProps = { title: 'This is a title', coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual( html.HEADER_WITH_TITLE_AND_COVER_IMAGE_WITHOUT_POSITION.replace(/\s/g, ''), ); }); }); }); describe('when page has title cover and icon', () => { describe('when icon is an image', () => { it('returns html with header with h1, image cover and image icon with page-header-icon-with-cover class', () => { const pageProps = { title: 'This is a title', coverImageSrc: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', icon: 'data:image/jpeg;base64,/4QDeRXhpZgAASUkqAAgAAAAGABIBAwA', }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_COVER_IMAGE_AND_IMAGE_ICON.replace(/\s/g, '')); }); }); }); describe('when page has title and icon', () => { describe('when icon is an image', () => { it('returns html with header with h1 and image icon', () => { const pageProps = { title: 'This is a title', icon: 'data:image/jpeg;base64,/4QDeRXhpZgAASUkqAAgAAAAGABIBAwA', }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_IMAGE_ICON.replace(/\s/g, '')); }); }); describe('when icon is an emoji', () => { it('retruns html with header with h1 and emoji in a div', () => { const pageProps = { title: 'This is a title', icon: '🤴', }; const result = new HeaderFromTemplate(pageProps).toHeader(); expect(result.replace(/\s/g, '')).toEqual(html.HEADER_WITH_TITLE_AND_EMOJI_ICON.replace(/\s/g, '')); }); }); }); }); ================================================ FILE: src/data/use-cases/html-wrapper/header-from-template.ts ================================================ import { PageProps } from '../../protocols/page-props/page-props'; export class HeaderFromTemplate { private readonly _pageProps: PageProps; constructor(pageProps: PageProps) { this._pageProps = pageProps; } toHeader(): string { return `\
    ${this._coverImageHtml} ${this._iconHtml} ${this._titleHtml}
    \ `; } private get _coverImageHtml(): string { const { coverImageSrc, coverImagePosition } = this._pageProps; return coverImageSrc ? `` : ''; } private get _iconHtml(): string { const { coverImageSrc, icon } = this._pageProps; if (!icon) return ''; const imageCoverSrcClassName = coverImageSrc ? 'page-header-icon-with-cover' : ''; if (!icon.startsWith('data:image/')) return `
    ${icon}
    `; return `
    `; } private get _titleHtml(): string { const { title } = this._pageProps; return `

    ${title}

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