UNPKG

react-markdown

Version:
477 lines (423 loc) 13.2 kB
'use strict' const React = require('react') const ReactIs = require('react-is') // @ts-ignore remove when typed const svg = require('property-information/svg') // @ts-ignore remove when typed const find = require('property-information/find') // @ts-ignore remove when typed const hastToReact = require('property-information/hast-to-react.json') // @ts-ignore remove when typed const spaces = require('space-separated-tokens') // @ts-ignore remove when typed const commas = require('comma-separated-tokens') const style = require('style-to-object') exports.hastToReact = toReact exports.hastChildrenToReact = childrenToReact /** * @typedef {JSX.IntrinsicElements} IntrinsicElements * @typedef {import('react').ReactNode} ReactNode * @typedef {import('unist').Position} Position * @typedef {import('hast').Element} Element * @typedef {import('hast').Root} Root * @typedef {import('hast').Text} Text * @typedef {import('hast').Comment} Comment * @typedef {import('hast').DocType} Doctype */ /** * @typedef {Object} Info * @property {string?} space * @property {string?} attribute * @property {string?} property * @property {boolean} boolean * @property {boolean} booleanish * @property {boolean} overloadedBoolean * @property {boolean} number * @property {boolean} commaSeparated * @property {boolean} spaceSeparated * @property {boolean} commaOrSpaceSeparated * @property {boolean} mustUseProperty * @property {boolean} defined * * @typedef {Object} Schema * @property {Object.<string, Info>} property * @property {Object.<string, string>} normal * @property {string?} space * * @typedef {Object} Raw * @property {'raw'} type * @property {string} value * * @typedef {Object} Context * @property {TransformOptions} options * @property {Schema} schema * @property {number} listDepth * * @callback TransformLink * @param {string} href * @param {Array.<Comment|Element|Text>} children * @param {string?} title * @returns {string} * * @callback TransformImage * @param {string} src * @param {string} alt * @param {string?} title * @returns {string} * * @callback TransformLinkTarget * @param {string} href * @param {Array.<Comment|Element|Text>} children * @param {string?} title * @returns {string} * * @typedef {keyof IntrinsicElements} ReactMarkdownNames * * @typedef {Object.<string, unknown>} ReactBaseProps * * To do: is `data-sourcepos` typeable? * * @typedef {Object} ReactMarkdownProps * @property {Element} node * @property {string} key * @property {ReactNode[]} children * @property {Position?} [sourcePosition] Passed when `options.rawSourcePos` is given * @property {number} [index] Passed when `options.includeElementIndex` is given * @property {number} [siblingCount] Passed when `options.includeElementIndex` is given * * @callback NormalComponent * @param {ReactBaseProps & ReactMarkdownProps} props * @returns {ReactNode} * * @callback CodeComponent * @param {ReactBaseProps & ReactMarkdownProps & {inline?: boolean}} props * @returns {ReactNode} * * @callback HeadingComponent * @param {ReactBaseProps & ReactMarkdownProps & {level: number}} props * @returns {ReactNode} * * @callback LiComponent * @param {ReactBaseProps & ReactMarkdownProps & {checked: boolean|null, index: number, ordered: boolean}} props * @returns {ReactNode} * * @callback OrderedListComponent * @param {ReactBaseProps & ReactMarkdownProps & {depth: number, ordered: true}} props * @returns {ReactNode} * * @callback TableCellComponent * @param {ReactBaseProps & ReactMarkdownProps & {style?: Object.<string, unknown>, isHeader: boolean}} props * @returns {ReactNode} * * @callback TableRowComponent * @param {ReactBaseProps & ReactMarkdownProps & {isHeader: boolean}} props * @returns {ReactNode} * * @callback UnorderedListComponent * @param {ReactBaseProps & ReactMarkdownProps & {depth: number, ordered: false}} props * @returns {ReactNode} * * @typedef {Object} SpecialComponents * @property {CodeComponent|ReactMarkdownNames} code * @property {HeadingComponent|ReactMarkdownNames} h1 * @property {HeadingComponent|ReactMarkdownNames} h2 * @property {HeadingComponent|ReactMarkdownNames} h3 * @property {HeadingComponent|ReactMarkdownNames} h4 * @property {HeadingComponent|ReactMarkdownNames} h5 * @property {HeadingComponent|ReactMarkdownNames} h6 * @property {LiComponent|ReactMarkdownNames} li * @property {OrderedListComponent|ReactMarkdownNames} ol * @property {TableCellComponent|ReactMarkdownNames} td * @property {TableCellComponent|ReactMarkdownNames} th * @property {TableRowComponent|ReactMarkdownNames} tr * @property {UnorderedListComponent|ReactMarkdownNames} ul * * @typedef {Record<Exclude<ReactMarkdownNames, keyof SpecialComponents>, NormalComponent|ReactMarkdownNames>} NormalComponents * @typedef {Partial<NormalComponents & SpecialComponents>} Components */ /** * @typedef {Object} TransformOptions * @property {boolean} [sourcePos=false] * @property {boolean} [rawSourcePos=false] * @property {boolean} [skipHtml=false] * @property {boolean} [includeElementIndex=false] * @property {false|TransformLink} [transformLinkUri] * @property {TransformImage} [transformImageUri] * @property {string|TransformLinkTarget} [linkTarget] * @property {Components} [components] */ const own = {}.hasOwnProperty /** * @param {Context} context * @param {Element|Root} node */ function childrenToReact(context, node) { /** @type {Array.<ReactNode>} */ const children = [] let childIndex = -1 /** @type {Comment|Doctype|Element|Raw|Text} */ let child while (++childIndex < node.children.length) { child = node.children[childIndex] if (child.type === 'element') { children.push(toReact(context, child, childIndex, node)) } else if (child.type === 'text') { children.push(child.value) } // @ts-ignore `raw` nodes are non-standard else if (child.type === 'raw' && !context.options.skipHtml) { // Default behavior is to show (encoded) HTML. // @ts-ignore `raw` nodes are non-standard children.push(child.value) } } return children } /** * @param {Context} context * @param {Element} node * @param {number} index * @param {Element|Root} parent */ function toReact(context, node, index, parent) { const options = context.options const parentSchema = context.schema /** @type {ReactMarkdownNames} */ // @ts-ignore assume a known HTML/SVG element. const name = node.tagName /** @type {Object.<string, unknown>} */ const properties = {} let schema = parentSchema /** @type {string} */ let property if (parentSchema.space === 'html' && name === 'svg') { schema = svg context.schema = schema } for (property in node.properties) { /* istanbul ignore else - prototype polution. */ if (own.call(node.properties, property)) { addProperty(properties, property, node.properties[property], context) } } if (name === 'ol' || name === 'ul') { context.listDepth++ } const children = childrenToReact(context, node) if (name === 'ol' || name === 'ul') { context.listDepth-- } // Restore parent schema. context.schema = parentSchema // Nodes created by plugins do not have positional info, in which case we use // an object that matches the positon interface. const position = node.position || { start: {line: null, column: null, offset: null}, end: {line: null, column: null, offset: null} } /** @type {NormalComponent|SpecialComponents[keyof SpecialComponents]|ReactMarkdownNames} */ const component = options.components && own.call(options.components, name) ? options.components[name] : name const basic = typeof component === 'string' || component === React.Fragment if (!ReactIs.isValidElementType(component)) { throw new TypeError( `Component for name \`${name}\` not defined or is not renderable` ) } properties.key = [ name, position.start.line, position.start.column, index ].join('-') if (name === 'a' && options.linkTarget) { properties.target = typeof options.linkTarget === 'function' ? // @ts-ignore assume `href` is a string options.linkTarget(properties.href, node.children, properties.title) : options.linkTarget } if (name === 'a' && options.transformLinkUri) { properties.href = options.transformLinkUri( // @ts-ignore assume `href` is a string properties.href, node.children, properties.title ) } if (!basic && name === 'code' && parent.tagName !== 'pre') { properties.inline = true } if ( !basic && (name === 'h1' || name === 'h2' || name === 'h3' || name === 'h4' || name === 'h5' || name === 'h6') ) { properties.level = parseInt(name.charAt(1), 10) } if (name === 'img' && options.transformImageUri) { properties.src = options.transformImageUri( // @ts-ignore assume `src` is a string properties.src, properties.alt, properties.title ) } if (!basic && name === 'li') { const input = getInputElement(node) properties.checked = input ? Boolean(input.properties.checked) : null properties.index = getElementsBeforeCount(parent, node) properties.ordered = parent.tagName === 'ol' } if (!basic && (name === 'ol' || name === 'ul')) { properties.ordered = name === 'ol' properties.depth = context.listDepth } if (name === 'td' || name === 'th') { if (properties.align) { if (!properties.style) properties.style = {} // @ts-ignore assume `style` is an object properties.style.textAlign = properties.align delete properties.align } if (!basic) { properties.isHeader = name === 'th' } } if (!basic && name === 'tr') { properties.isHeader = Boolean(parent.tagName === 'thead') } // If `sourcePos` is given, pass source information (line/column info from markdown source). if (options.sourcePos) { properties['data-sourcepos'] = flattenPosition(position) } if (!basic && options.rawSourcePos) { properties.sourcePosition = node.position } // If `includeElementIndex` is given, pass node index info to components. if (!basic && options.includeElementIndex) { properties.index = getElementsBeforeCount(parent, node) properties.siblingCount = getElementsBeforeCount(parent) } if (!basic) { properties.node = node } // Ensure no React warnings are emitted for void elements w/ children. return children.length > 0 ? React.createElement(component, properties, children) : React.createElement(component, properties) } /** * @param {Element|Root} node * @returns {Element?} */ function getInputElement(node) { let index = -1 while (++index < node.children.length) { const child = node.children[index] if (child.type === 'element' && child.tagName === 'input') { return child } } return null } /** * @param {Element|Root} parent * @param {Element} [node] * @returns {number} */ function getElementsBeforeCount(parent, node) { let index = -1 let count = 0 while (++index < parent.children.length) { if (parent.children[index] === node) break if (parent.children[index].type === 'element') count++ } return count } /** * @param {Object.<string, unknown>} props * @param {string} prop * @param {unknown} value * @param {Context} ctx */ function addProperty(props, prop, value, ctx) { /** @type {Info} */ const info = find(ctx.schema, prop) let result = value // Ignore nullish and `NaN` values. // eslint-disable-next-line no-self-compare if (result === null || result === undefined || result !== result) { return } // Accept `array`. // Most props are space-separated. if (result && typeof result === 'object' && 'length' in result) { // type-coverage:ignore-next-line remove when typed. result = (info.commaSeparated ? commas : spaces).stringify(result) } if (info.property === 'style' && typeof result === 'string') { result = parseStyle(result) } if (info.space) { props[ own.call(hastToReact, info.property) ? hastToReact[info.property] : info.property ] = result } else { props[info.attribute] = result } } /** * @param {string} value * @returns {Object.<string, string>} */ function parseStyle(value) { /** @type {Object.<string, string>} */ const result = {} try { style(value, iterator) } catch (/** @type {Error} */ _) { // Silent. } return result /** * @param {string} name * @param {string} v */ function iterator(name, v) { const k = name.slice(0, 4) === '-ms-' ? `ms-${name.slice(4)}` : name result[k.replace(/-([a-z])/g, styleReplacer)] = v } } /** * @param {unknown} _ * @param {string} $1 */ function styleReplacer(_, $1) { return $1.toUpperCase() } /** * @param {Position} pos * @returns {string} */ function flattenPosition(pos) { return [ pos.start.line, ':', pos.start.column, '-', pos.end.line, ':', pos.end.column ] .map((d) => String(d)) .join('') }