alvis/gatsby-source-notion

View on GitHub
source/markdown.ts

Summary

Maintainability
C
7 hrs
Test Coverage
A
100%
/*
 *                            *** MIT LICENSE ***
 * -------------------------------------------------------------------------
 * This code may be modified and distributed under the MIT license.
 * See the LICENSE file for details.
 * -------------------------------------------------------------------------
 *
 * @summary   Collection of block to markdown convertors
 *
 * @author    Alvis HT Tang <alvis@hilbert.space>
 * @license   MIT
 * @copyright Copyright (c) 2021 - All Rights Reserved.
 * -------------------------------------------------------------------------
 */

import type { Block, NotionAPIBlock, NotionAPIRichText } from '#types';

/* eslint-disable @typescript-eslint/naming-convention */

/**
 * annotate a text as bold
 * @param block a RichText block to be annotated
 * @returns an annotated RichText block
 */
export function bold(block: NotionAPIRichText): NotionAPIRichText {
  return block.annotations.bold
    ? {
        ...block,
        annotations: { ...block.annotations, bold: false },
        plain_text: `**${block.plain_text}**`,
      }
    : block;
}

/**
 * annotate a text as italic
 * @param block a RichText block to be annotated
 * @returns an annotated RichText block
 */
export function italic(block: NotionAPIRichText): NotionAPIRichText {
  return block.annotations.italic
    ? {
        ...block,
        annotations: { ...block.annotations, italic: false },
        plain_text: `_${block.plain_text}_`,
      }
    : block;
}

/**
 * annotate a text as strike-through
 * @param block a RichText block to be annotated
 * @returns an annotated RichText block
 */
export function strikethrough(block: NotionAPIRichText): NotionAPIRichText {
  return block.annotations.strikethrough
    ? {
        ...block,
        annotations: { ...block.annotations, strikethrough: false },
        plain_text: `~~${block.plain_text}~~`,
      }
    : block;
}

/**
 * annotate a text as an inline code
 * @param block a RichText block to be annotated
 * @returns an annotated RichText block
 */
export function code(block: NotionAPIRichText): NotionAPIRichText {
  return block.annotations.code
    ? {
        ...block,
        annotations: { ...block.annotations, code: false },
        plain_text: `\`${block.plain_text}\``,
      }
    : block;
}

/**
 * annotate a text as an inline code
 * @param block a RichText block to be annotated
 * @returns an annotated RichText block
 */
export function math(block: NotionAPIRichText): NotionAPIRichText {
  return block.type === 'equation'
    ? {
        ...block,
        type: 'text',
        plain_text: `$${block.equation.expression}$`,
        text: { content: `$${block.equation.expression}$`, link: null },
      }
    : block;
}

/* eslint-enable */

/**
 * get the url of a file property
 * @param image a file property returned from Notion API
 * @returns its url
 */
export function image(
  image: Extract<NotionAPIBlock, { type: 'image' }>['image'],
): string {
  const caption = texts(image.caption);
  const url = image.type === 'external' ? image.external.url : image.file.url;

  return `![${caption}](${url})`;
}

/**
 * convert a RichText block to markdown format
 * @param block a RichText block to be parsed
 * @returns text in markdown format
 */
export function text(block: NotionAPIRichText): string {
  const plain = strikethrough(italic(bold(code(math(block))))).plain_text;

  return block.href ? `[${plain}](${block.href})` : plain;
}

/**
 * convert RichText blocks to markdown format
 * @param blocks RichText blocks to be parsed
 * @param indent space to be prefixed to the content per line
 * @returns text in markdown format
 */
export function texts(blocks: NotionAPIRichText[], indent = ''): string {
  return `${indent}${blocks.map(text).join('')}`;
}

/**
 * add children content to the parent text if present
 * @param parent first part of the content
 * @param block the content block which may contain children
 * @param indent space to be prefixed to the content per line
 * @returns content with children content if present
 */
function appendChildren(parent: string, block: Block, indent: string): string {
  const supportedChildren = block.has_children
    ? block.children.filter((child) => child.type !== 'unsupported')
    : [];

  if (supportedChildren.length) {
    const content = markdown(supportedChildren, indent);

    // no extra line for list-like items
    const glue = [
      'bulleted_list_item',
      'numbered_list_item',
      'to_do',
      undefined,
    ].includes(supportedChildren[0].type)
      ? ''
      : '\n';

    // the ending \n will be attached to the parent block
    // so removing it from the children content to prevent extra lines
    return parent + '\n' + glue + content.trimRight();
  } else {
    return parent;
  }
}

/**
 * convert a Block to markdown format
 * @param block a Block to be parsed
 * @param indent space to be prefixed to the content per line
 * @returns text in markdown format
 */
// eslint-disable-next-line max-lines-per-function
export function parse(block: Block, indent = ''): string | null {
  const append = (text: string): string =>
    appendChildren(text, block, `${indent}  `);

  switch (block.type) {
    case 'heading_1':
      return `# ${texts(block.heading_1.rich_text)}\n`;
    case 'heading_2':
      return `## ${texts(block.heading_2.rich_text)}\n`;
    case 'heading_3':
      return `### ${texts(block.heading_3.rich_text)}\n`;
    case 'paragraph':
      return `${append(texts(block.paragraph.rich_text))}\n`;
    case 'bulleted_list_item':
      return indent + append(`* ${texts(block.bulleted_list_item.rich_text)}`);
    case 'numbered_list_item':
      return indent + append(`1. ${texts(block.numbered_list_item.rich_text)}`);
    case 'to_do': {
      const checked = block.to_do.checked ? 'x' : ' ';

      return indent + append(`- [${checked}] ${texts(block.to_do.rich_text)}`);
    }
    case 'toggle':
      return `${append(texts(block.toggle.rich_text))}\n`;
    case 'child_page':
      return `${append(block.child_page.title)}\n`;
    case 'image':
      return `${append(image(block.image))}\n`;
    case 'unsupported':
    default:
      return null;
  }
}

/**
 * convert Blocks to markdown format
 * @param blocks Blocks to be parsed
 * @param indent space to be prefixed to the content per line
 * @returns text in markdown format
 */
export function markdown(blocks: Block[], indent = ''): string {
  return blocks
    .map((block) => parse(block, indent))
    .filter((text): text is string => text !== null)
    .join('\n');
}