FujitsuLaboratories/cattaz

View on GitHub
src/WikiParser.js

Summary

Maintainability
C
1 day
Test Coverage
import React from 'react';
import { Link } from 'react-router-dom';

import remark from 'remark';
import remarkGfm from 'remark-gfm';
import toHast from 'mdast-util-to-hast';
import toH from 'hast-to-hyperscript';

import clone from 'lodash/clone';
import repeat from 'lodash/repeat';

import Apps from './apps';
import AppContainer from './AppContainer';

const internalLink = /^[./]/;

export default class WikiParser {
  /**
   * Checks if position is inside region.
   * @param {!object} position line number and column number of the position. Both of them start from 1.
   * @param {!object} region position property of Unist.
   * @return {boolean} true if position is inside region.
   */
  static isInside(position, region) {
    if (position.line < region.start.line || region.end.line < position.line) return false;
    if (position.line === region.start.line && position.column < region.start.column) return false;
    if (position.line === region.end.line && position.column > region.end.column) return false;
    return true;
  }

  /**
   * Parse Markdown string to hast
   * @param {!string} markdown
   * @returns {object} Hast object
   */
  static parseToHast(markdown) {
    const mdast = remark().use(remarkGfm).parse(markdown);
    const hast = toHast(mdast);
    return hast;
  }

  /**
   * Convert fenced code block in hast to application
   * @param {!object} hast Hast object
   * @return {object} Hast object
   */
  static convertToCustomHast(hast) {
    if (hast.type === 'text') return hast;
    if (hast.tagName === 'pre') {
      const codeNode = hast.children[0];
      const htmlClasses = codeNode.properties.className;
      const language = (htmlClasses && htmlClasses[0].substring(9 /* length of 'language-' */)) || null;
      if (language in Apps) {
        const codeText = codeNode.children[0].value;
        return {
          type: 'element',
          tagName: `app:${language}`,
          properties: {
            // Original Hast posirion will be lost in hyperscript.
            // It must be a string?
            position: JSON.stringify(hast.position),
          },
          // Hast position
          position: hast.position,
          children: [{
            type: 'text',
            value: codeText,
          }],
        };
      }
    }
    const cloned = clone(hast);
    cloned.children = hast.children.map(WikiParser.convertToCustomHast);
    cloned.properties = (cloned.properties && clone(cloned.properties)) || {};
    // Original Hast posirion will be lost in hyperscript.
    cloned.properties.position = JSON.stringify(hast.position);
    return cloned;
  }

  /**
   * Render custom Hast
   * @param {object} customHast
   * @param {object} ctx
   * @returns {React.Node}
   */
  static renderCustomHast(customHast, ctx = {}) {
    function h(name, properties, children) {
      if (name.indexOf('app:') === 0) {
        const appName = name.substring(4);
        const appComponent = Apps[appName];
        const position = JSON.parse(properties.position);
        let active = false;
        if (ctx.cursorPosition && WikiParser.isInside(ctx.cursorPosition, position)) {
          active = true;
        }
        if (appComponent) {
          const app = React.createElement(appComponent, {
            data: children[0],
            onEdit: ctx.onEdit,
            appContext: {
              language: appName,
              position,
            },
          });
          return React.createElement(AppContainer, { active }, app);
        }
        throw new Error('unknown app');
      }
      if (name === 'a' && internalLink.test(properties.href)) {
        const propsForLink = clone(properties);
        propsForLink.to = propsForLink.href;
        propsForLink.className = propsForLink.className ? `${propsForLink.className} md` : 'md';
        delete propsForLink.href;
        delete propsForLink.position;
        return React.createElement(Link, propsForLink, children);
      }
      const propsForElem = clone(properties);
      if (propsForElem) {
        propsForElem.className = propsForElem.className ? `${propsForElem.className} md` : 'md';
        const deeperMatch = children && children.find && children.find((c) => {
          if (!c.props) return false;
          if (!c.props.className) return false;
          return c.props.className.indexOf('active-') >= 0;
        });
        if (deeperMatch) {
          propsForElem.className += ' active-outer'; // must have 'md' class
        } else if (propsForElem.position) {
          const position = JSON.parse(properties.position);
          if (ctx.cursorPosition && WikiParser.isInside(ctx.cursorPosition, position)) {
            propsForElem.className += ' active-inner'; // must have 'md' class
          }
        }
        delete propsForElem.position;
      }
      return React.createElement(name, propsForElem, children);
    }

    let rootNode = customHast;
    if (rootNode.type === 'root') {
      rootNode = clone(rootNode);
      rootNode.type = 'element';
      rootNode.tagName = 'div';
    }
    return toH(h, rootNode);
  }

  /**
   * @param {!string} appText
   * @returns {string}
   */
  static removeLastNewLine(appText) {
    if (appText.length === 0) return appText;
    if (appText[appText.length - 1] !== '\n') return appText;
    return appText.substring(0, appText.length - 1);
  }

  /**
   * @param {!object} originalAppLocation The location (https://github.com/wooorm/unist#location) of fenced code block
   * @param {!string} appText
   * @returns {string}
   */
  static indentAppCode(originalAppLocation, appText) {
    if (originalAppLocation.start.column <= 1) return appText;
    const indent = repeat(' ', originalAppLocation.start.column - 1);
    return appText.split('\n').map((l) => `${indent}${l}`).join('\n');
  }

  /**
   * @param {!string} startFencingStr
   * @param {!string} appText
   * @returns {string[]} Pair of extra fencing chars and original fencing chars
   */
  static getExtraFencingChars(startFencingStr, appText) {
    const originalFencing = startFencingStr.match(/[`~]{3,}/)[0];
    const fencingChar = originalFencing[0];
    const symbolCounter = new RegExp(`^\x20*(${fencingChar}{${originalFencing.length},})`);
    const maxSymbols = Math.max(...appText.split('\n').map((l) => {
      const match = l.match(symbolCounter);
      if (match) return match[1].length;
      return 0;
    }));
    const diffFencingLen = Math.max((maxSymbols - originalFencing.length) + 1, 0);
    return [repeat(fencingChar, diffFencingLen), originalFencing];
  }

  /**
   * @param {!string} originalText
   * @param {!object} originalAppLocation The location (https://github.com/wooorm/unist#location) of fenced code block
   * @param {!string} appLanguage
   * @param {!string} newAppText
   * @returns {string}
   */
  static replaceAppCode(originalText, originalAppLocation, appLanguage, newAppText) {
    const textBefore = originalText.substring(0, originalAppLocation.start.offset);
    const textAfter = originalText.substring(originalAppLocation.end.offset);
    const fencedText = originalText.substring(originalAppLocation.start.offset, originalAppLocation.end.offset);
    const startFencedStr = fencedText.substring(0, fencedText.search(/\r\n|\r|\n/));
    const [backticks, originalBackticks] = WikiParser.getExtraFencingChars(startFencedStr, newAppText);
    const endMarkIndentation = originalAppLocation.start.column - 1;
    const text = `${textBefore}${originalBackticks}${backticks}${appLanguage}
${WikiParser.indentAppCode(originalAppLocation, newAppText)}
${repeat(' ', endMarkIndentation)}${originalBackticks}${backticks}${textAfter}`;
    return text;
  }
}