`);
}
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('
');
});
});
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 WorldIs 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
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.ORDERED_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 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
This is a item with background
\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
This is a item with color
\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
This is a test
\n
This is a test too
\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
Hello World and Sun
\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
`.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(
`
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__/*"]
}