diff --git a/index.js b/index.js index 918d3ff5..6e83f36e 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ /** - * @typedef {import('./lib/react-markdown.js').ReactMarkdownOptions} Options + * @typedef {import('./lib/react-markdown.js').Options} Options * @typedef {import('./lib/ast-to-react.js').Components} Components */ diff --git a/lib/ast-to-react.js b/lib/ast-to-react.js index d32c06b0..9076b38e 100644 --- a/lib/ast-to-react.js +++ b/lib/ast-to-react.js @@ -1,103 +1,87 @@ +/// + /** - * @template T - * @typedef {import('react').ComponentType} ComponentType + * @typedef {import('react').ComponentPropsWithoutRef} ComponentPropsWithoutRef + * @template {import('react').ElementType} T */ /** - * @template {import('react').ElementType} T - * @typedef {import('react').ComponentPropsWithoutRef} ComponentPropsWithoutRef + * @typedef {import('react').ComponentType} ComponentType + * @template T */ /** - * @typedef {import('react').ReactNode} ReactNode - * @typedef {import('unist').Position} Position * @typedef {import('hast').Element} Element - * @typedef {import('hast').ElementContent} ElementContent + * @typedef {import('hast').Parents} Parents * @typedef {import('hast').Root} Root - * @typedef {import('hast').Text} Text - * @typedef {import('hast').Comment} Comment - * @typedef {import('hast').Doctype} Doctype - * @typedef {import('property-information').Info} Info + * * @typedef {import('property-information').Schema} Schema - * @typedef {import('./complex-types.js').ReactMarkdownProps} ReactMarkdownProps * - * @typedef Raw - * @property {'raw'} type - * @property {string} value + * @typedef {import('react').ReactNode} ReactNode + * + * @typedef {import('unist').Position} Position * - * @typedef Context - * @property {Options} options + * @typedef {import('./complex-types.js').ReactMarkdownProps} ReactMarkdownProps + * @typedef {import('./complex-types.js').NormalComponents} NormalComponents + * @typedef {import('./react-markdown.js').Options} Options + */ + +/** + * @typedef State + * Info passed around. + * @property {Readonly} options + * Configuration. * @property {Schema} schema + * Schema. * @property {number} listDepth - * - * @callback TransformLink - * @param {string} href - * @param {Array} children - * @param {string?} title - * @returns {string} - * - * @callback TransformImage - * @param {string} src - * @param {string} alt - * @param {string?} title - * @returns {string} - * - * @typedef {keyof JSX.IntrinsicElements} ReactMarkdownNames - * - * To do: is `data-sourcepos` typeable? + * Depth. * * @typedef {ComponentPropsWithoutRef<'code'> & ReactMarkdownProps & {inline?: boolean}} CodeProps + * Props passed to components for `code`. + * to do: always pass `inline`? * @typedef {ComponentPropsWithoutRef<'h1'> & ReactMarkdownProps & {level: number}} HeadingProps - * @typedef {ComponentPropsWithoutRef<'li'> & ReactMarkdownProps & {checked: boolean|null, index: number, ordered: boolean}} LiProps + * Props passed to components for `h1`, `h2`, etc. + * @typedef {ComponentPropsWithoutRef<'li'> & ReactMarkdownProps & {checked: boolean | null, index: number, ordered: boolean}} LiProps + * Props passed to components for `li`. + * to do: use `undefined`. * @typedef {ComponentPropsWithoutRef<'ol'> & ReactMarkdownProps & {depth: number, ordered: true}} OrderedListProps - * @typedef {ComponentPropsWithoutRef<'td'> & ReactMarkdownProps & {style?: Record, isHeader: false}} TableDataCellProps - * @typedef {ComponentPropsWithoutRef<'th'> & ReactMarkdownProps & {style?: Record, isHeader: true}} TableHeaderCellProps + * Props passed to components for `ol`. + * @typedef {ComponentPropsWithoutRef<'td'> & ReactMarkdownProps & {isHeader: false}} TableDataCellProps + * Props passed to components for `td`. + * @typedef {ComponentPropsWithoutRef<'th'> & ReactMarkdownProps & {isHeader: true}} TableHeaderCellProps + * Props passed to components for `th`. * @typedef {ComponentPropsWithoutRef<'tr'> & ReactMarkdownProps & {isHeader: boolean}} TableRowProps + * Props passed to components for `tr`. * @typedef {ComponentPropsWithoutRef<'ul'> & ReactMarkdownProps & {depth: number, ordered: false}} UnorderedListProps - * - * @typedef {ComponentType} CodeComponent - * @typedef {ComponentType} HeadingComponent - * @typedef {ComponentType} LiComponent - * @typedef {ComponentType} OrderedListComponent - * @typedef {ComponentType} TableDataCellComponent - * @typedef {ComponentType} TableHeaderCellComponent - * @typedef {ComponentType} TableRowComponent - * @typedef {ComponentType} UnorderedListComponent + * Props passed to components for `ul`. * * @typedef 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 {TableDataCellComponent|ReactMarkdownNames} td - * @property {TableHeaderCellComponent|ReactMarkdownNames} th - * @property {TableRowComponent|ReactMarkdownNames} tr - * @property {UnorderedListComponent|ReactMarkdownNames} ul + * @property {ComponentType | keyof JSX.IntrinsicElements} code + * @property {ComponentType | keyof JSX.IntrinsicElements} h1 + * @property {ComponentType | keyof JSX.IntrinsicElements} h2 + * @property {ComponentType | keyof JSX.IntrinsicElements} h3 + * @property {ComponentType | keyof JSX.IntrinsicElements} h4 + * @property {ComponentType | keyof JSX.IntrinsicElements} h5 + * @property {ComponentType | keyof JSX.IntrinsicElements} h6 + * @property {ComponentType | keyof JSX.IntrinsicElements} li + * @property {ComponentType | keyof JSX.IntrinsicElements} ol + * @property {ComponentType | keyof JSX.IntrinsicElements} td + * @property {ComponentType | keyof JSX.IntrinsicElements} th + * @property {ComponentType | keyof JSX.IntrinsicElements} tr + * @property {ComponentType | keyof JSX.IntrinsicElements} ul * - * @typedef {Partial & SpecialComponents>} Components - * - * @typedef Options - * @property {boolean} [sourcePos=false] - * @property {boolean} [rawSourcePos=false] - * @property {boolean} [skipHtml=false] - * @property {boolean} [includeElementIndex=false] - * @property {null|false|TransformLink} [transformLinkUri] - * @property {TransformImage} [transformImageUri] - * @property {Components} [components] + * @typedef {Partial & SpecialComponents>} Components + * Components. */ import React from 'react' import ReactIs from 'react-is' +import {stringify as commas} from 'comma-separated-tokens' import {whitespace} from 'hast-util-whitespace' -import {svg, find, hastToReact} from 'property-information' +import {find, hastToReact, svg} from 'property-information' import {stringify as spaces} from 'space-separated-tokens' -import {stringify as commas} from 'comma-separated-tokens' import style from 'style-to-object' +import {stringifyPosition} from 'unist-util-stringify-position' import {uriTransformer} from './uri-transformer.js' const own = {}.hasOwnProperty @@ -107,21 +91,23 @@ const own = {}.hasOwnProperty const tableElements = new Set(['table', 'thead', 'tbody', 'tfoot', 'tr']) /** - * @param {Context} context - * @param {Element|Root} node + * @param {State} state + * Info passed around. + * @param {Readonly} node + * Node to transform. + * @returns {Array} + * Nodes. */ -export function childrenToReact(context, node) { +export function childrenToReact(state, node) { /** @type {Array} */ const children = [] let childIndex = -1 - /** @type {Comment|Doctype|Element|Raw|Text} */ - let child while (++childIndex < node.children.length) { - child = node.children[childIndex] + const child = node.children[childIndex] if (child.type === 'element') { - children.push(toReact(context, child, childIndex, node)) + children.push(toReact(state, child, childIndex, node)) } else if (child.type === 'text') { // Currently, a warning is triggered by react for *any* white space in // tables. @@ -137,7 +123,7 @@ export function childrenToReact(context, node) { ) { children.push(child.value) } - } else if (child.type === 'raw' && !context.options.skipHtml) { + } else if (child.type === 'raw' && !state.options.skipHtml) { // Default behavior is to show (encoded) HTML. children.push(child.value) } @@ -147,21 +133,26 @@ export function childrenToReact(context, node) { } /** - * @param {Context} context - * @param {Element} node + * @param {State} state + * Info passed around. + * @param {Readonly} node + * Node to transform. * @param {number} index - * @param {Element|Root} parent + * Position of `node` in `parent`. + * @param {Readonly} parent + * Parent of `node`. + * @returns {ReactNode} + * Node. */ -function toReact(context, node, index, parent) { - const options = context.options +function toReact(state, node, index, parent) { + const options = state.options const transform = options.transformLinkUri === undefined ? uriTransformer : options.transformLinkUri - const parentSchema = context.schema - /** @type {ReactMarkdownNames} */ - // @ts-expect-error assume a known HTML/SVG element. - const name = node.tagName + const parentSchema = state.schema + // Assume a known HTML/SVG element. + const name = /** @type {keyof JSX.IntrinsicElements} */ (node.tagName) /** @type {Record} */ const properties = {} let schema = parentSchema @@ -170,36 +161,30 @@ function toReact(context, node, index, parent) { if (parentSchema.space === 'html' && name === 'svg') { schema = svg - context.schema = schema + state.schema = schema } if (node.properties) { for (property in node.properties) { if (own.call(node.properties, property)) { - addProperty(properties, property, node.properties[property], context) + addProperty(state, properties, property, node.properties[property]) } } } if (name === 'ol' || name === 'ul') { - context.listDepth++ + state.listDepth++ } - const children = childrenToReact(context, node) + const children = childrenToReact(state, node) if (name === 'ol' || name === 'ul') { - context.listDepth-- + state.listDepth-- } // Restore parent schema. - context.schema = parentSchema + state.schema = parentSchema - // Nodes created by plugins do not have positional info, in which case we use - // an object that matches the position interface. - const position = node.position || { - start: {line: null, column: null, offset: null}, - end: {line: null, column: null, offset: null} - } const component = options.components && own.call(options.components, name) ? options.components[name] @@ -218,6 +203,7 @@ function toReact(context, node, index, parent) { properties.href = transform( String(properties.href || ''), node.children, + // To do: pass `undefined`. typeof properties.title === 'string' ? properties.title : null ) } @@ -247,6 +233,7 @@ function toReact(context, node, index, parent) { properties.src = options.transformImageUri( String(properties.src || ''), String(properties.alt || ''), + // To do: pass `undefined`. typeof properties.title === 'string' ? properties.title : null ) } @@ -254,21 +241,30 @@ function toReact(context, node, index, parent) { if (!basic && name === 'li' && parent.type === 'element') { const input = getInputElement(node) properties.checked = - input && input.properties ? Boolean(input.properties.checked) : null + // To do: pass `undefined`. + 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 + properties.depth = state.listDepth } if (name === 'td' || name === 'th') { if (properties.align) { - if (!properties.style) properties.style = {} - // @ts-expect-error assume `style` is an object - properties.style.textAlign = properties.align + let style = /** @type {Record | undefined} */ ( + properties.style + ) + + if (!style) { + style = {} + properties.style = style + } + + style.textAlign = String(properties.align) + delete properties.align } @@ -283,7 +279,7 @@ function toReact(context, node, index, parent) { // If `sourcePos` is given, pass source information (line/column info from markdown source). if (options.sourcePos) { - properties['data-sourcepos'] = flattenPosition(position) + properties['data-sourcepos'] = stringifyPosition(node) } if (!basic && options.rawSourcePos) { @@ -307,8 +303,10 @@ function toReact(context, node, index, parent) { } /** - * @param {Element|Root} node - * @returns {Element?} + * @param {Readonly} node + * Node to check. + * @returns {Element | undefined} + * `input` element, if found. */ function getInputElement(node) { let index = -1 @@ -320,35 +318,43 @@ function getInputElement(node) { return child } } - - return null } /** - * @param {Element|Root} parent - * @param {Element} [node] + * @param {Readonly} parent + * Node. + * @param {Readonly} [node] + * Node in parent (optional). * @returns {number} + * Siblings before `node`. */ 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++ + const child = parent.children[index] + if (child === node) break + if (child.type === 'element') count++ } return count } /** + * @param {State} state + * Info passed around. * @param {Record} props + * Properties. * @param {string} prop + * Property. * @param {unknown} value - * @param {Context} ctx + * Value. + * @returns {undefined} + * Nothing. */ -function addProperty(props, prop, value, ctx) { - const info = find(ctx.schema, prop) +function addProperty(state, props, prop, value) { + const info = find(state.schema, prop) let result = value // Ignore nullish and `NaN` values. @@ -380,7 +386,9 @@ function addProperty(props, prop, value, ctx) { /** * @param {string} value + * Style. * @returns {Record} + * Style. */ function parseStyle(value) { /** @type {Record} */ @@ -396,7 +404,9 @@ function parseStyle(value) { /** * @param {string} name + * Name. * @param {string} v + * Value. */ function iterator(name, v) { const k = name.slice(0, 4) === '-ms-' ? `ms-${name.slice(4)}` : name @@ -406,26 +416,12 @@ function parseStyle(value) { /** * @param {unknown} _ + * Whole match. * @param {string} $1 + * Letter. + * @returns {string} + * Replacement. */ function styleReplacer(_, $1) { return $1.toUpperCase() } - -/** - * @param {Position|{start: {line: null, column: null, offset: null}, end: {line: null, column: null, offset: null}}} pos - * @returns {string} - */ -function flattenPosition(pos) { - return [ - pos.start.line, - ':', - pos.start.column, - '-', - pos.end.line, - ':', - pos.end.column - ] - .map(String) - .join('') -} diff --git a/lib/complex-types.d.ts b/lib/complex-types.d.ts index 5319a88b..b460319a 100644 --- a/lib/complex-types.d.ts +++ b/lib/complex-types.d.ts @@ -1,24 +1,32 @@ -import type {ReactNode, ComponentType, ComponentPropsWithoutRef} from 'react' -import type {Position} from 'unist' +// File for types which are not handled correctly in JSDoc mode. import type {Element} from 'hast' +import type {ComponentPropsWithoutRef, ComponentType, ReactNode} from 'react' +import type {Position} from 'unist' -/* File for types which are not handled correctly in JSDoc mode */ - +/** + * Props passed to components. + */ export type ReactMarkdownProps = { - node: Element - children: ReactNode[] /** - * Passed when `options.rawSourcePos` is given + * Passed when `options.sourcePos` is given. */ - sourcePosition?: Position + 'data-sourcepos': string | undefined /** * Passed when `options.includeElementIndex` is given */ index?: number + /** + * Original hast node. + */ + node: Element /** * Passed when `options.includeElementIndex` is given */ siblingCount?: number + /** + * Passed when `options.rawSourcePos` is given + */ + sourcePosition?: Position | undefined } export type NormalComponents = { diff --git a/lib/react-markdown.js b/lib/react-markdown.js index df97b321..2aeb1372 100644 --- a/lib/react-markdown.js +++ b/lib/react-markdown.js @@ -1,74 +1,151 @@ /** - * @typedef {import('react').ReactNode} ReactNode + * @typedef {import('hast').Element} Element + * @typedef {import('hast').ElementContent} ElementContent + * @typedef {import('hast').Parents} Parents + * @typedef {import('remark-rehype').Options} RemarkRehypeOptions * @typedef {import('react').ReactElement<{}>} ReactElement * @typedef {import('unified').PluggableList} PluggableList - * @typedef {import('hast').Root} Root - * @typedef {import('./rehype-filter.js').Options} FilterOptions - * @typedef {import('./ast-to-react.js').Options} TransformOptions - * - * @typedef CoreOptions - * @property {string} children - * - * @typedef PluginOptions - * @property {PluggableList} [remarkPlugins=[]] - * @property {PluggableList} [rehypePlugins=[]] - * @property {import('remark-rehype').Options | undefined} [remarkRehypeOptions={}] - * - * @typedef LayoutOptions - * @property {string} [className] - * - * @typedef {CoreOptions & PluginOptions & LayoutOptions & FilterOptions & TransformOptions} ReactMarkdownOptions + * @typedef {import('./ast-to-react.js').Components} Components + */ + +/** + * @callback AllowElement + * Decide if `element` should be allowed. + * @param {Readonly} element + * Element to check. + * @param {number} index + * Index of `element` in `parent`. + * @param {Readonly | undefined} parent + * Parent of `element`. + * @returns {boolean | null | undefined} + * Whether to allow `element` (default: `false`). * * @typedef Deprecation + * Deprecation. * @property {string} id + * ID in readme. * @property {string} [to] + * Field to use instead (optional). + * + * @typedef Options + * Configuration. + * @property {AllowElement | null | undefined} [allowElement] + * Function called to check if an element is allowed (when truthy) or not, + * `allowedElements` or `disallowedElements` is used first! + * @property {ReadonlyArray | null | undefined} [allowedElements] + * Tag names to allow (cannot combine w/ `disallowedElements`), all tag names + * are allowed by default. + * @property {string | null | undefined} [children] + * Markdown to parse. + * @property {string | null | undefined} [className] + * Wrap the markdown in a `div` with this class name. + * @property {Components | null | undefined} [components] + * Map tag names to React components. + * @property {ReadonlyArray | null | undefined} [disallowedElements] + * Tag names to disallow (cannot combine w/ `allowedElements`), all tag names + * are allowed by default. + * @property {boolean | null | undefined} [includeElementIndex=false] + * Pass the `index` (number of elements before it) and `siblingCount` (number + * of elements in parent) as props to all components (default: `false`). + * @property {boolean | null | undefined} [rawSourcePos=false] + * Pass a `sourcePosition` prop to all components with their position + * (default: `false`). + * @property {PluggableList | null | undefined} [rehypePlugins] + * List of rehype plugins to use. + * @property {PluggableList | null | undefined} [remarkPlugins] + * List of remark plugins to use. + * @property {Readonly | null | undefined} [remarkRehypeOptions] + * Options to pass through to `remark-rehype`. + * @property {boolean | null | undefined} [skipHtml=false] + * Ignore HTML in markdown completely (default: `false`). + * @property {boolean | null | undefined} [sourcePos=false] + * Pass a `data-sourcepos` prop to all components with a serialized position + * (default: `false`). + * @property {TransformLink | false | null | undefined} [transformLinkUri] + * Change URLs on images (default: `uriTransformer`); + * pass `false` to allow all URLs, which is unsafe + * @property {TransformImage | false | null | undefined} [transformImageUri] + * Change URLs on links (default: `uriTransformer`); + * pass `false` to allow all URLs, which is unsafe + * @property {boolean | null | undefined} [unwrapDisallowed=false] + * Extract (unwrap) the children of not allowed elements (default: `false`); + * normally when say `strong` is disallowed, it and it’s children are dropped, + * with `unwrapDisallowed` the element itself is replaced by its children. + * + * @callback TransformImage + * Transform URLs on images. + * @param {string} src + * URL to transform. + * @param {string} alt + * Alt text. + * @param {string | null} title + * Title. + * To do: pass `undefined`. + * @returns {string | null | undefined} + * Transformed URL (optional). + * + * @callback TransformLink + * Transform URLs on links. + * @param {string} href + * URL to transform. + * @param {ReadonlyArray} children + * Content. + * @param {string | null} title + * Title. + * To do: pass `undefined`. + * @returns {string} + * Transformed URL (optional). */ import React from 'react' -import {VFile} from 'vfile' -import {unified} from 'unified' -import remarkParse from 'remark-parse' -import remarkRehype from 'remark-rehype' import PropTypes from 'prop-types' import {html} from 'property-information' -import rehypeFilter from './rehype-filter.js' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import {unified} from 'unified' +import {VFile} from 'vfile' import {childrenToReact} from './ast-to-react.js' +import rehypeFilter from './rehype-filter.js' const own = {}.hasOwnProperty const changelog = 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md' -/** @type {Record} */ +// Mutable because we `delete` any time it’s used and a message is sent. +/** @type {Record>} */ const deprecated = { - plugins: {to: 'remarkPlugins', id: 'change-plugins-to-remarkplugins'}, - renderers: {to: 'components', id: 'change-renderers-to-components'}, astPlugins: {id: 'remove-buggy-html-in-markdown-parser'}, allowDangerousHtml: {id: 'remove-buggy-html-in-markdown-parser'}, - escapeHtml: {id: 'remove-buggy-html-in-markdown-parser'}, - source: {to: 'children', id: 'change-source-to-children'}, allowNode: { - to: 'allowElement', - id: 'replace-allownode-allowedtypes-and-disallowedtypes' + id: 'replace-allownode-allowedtypes-and-disallowedtypes', + to: 'allowElement' }, allowedTypes: { - to: 'allowedElements', - id: 'replace-allownode-allowedtypes-and-disallowedtypes' + id: 'replace-allownode-allowedtypes-and-disallowedtypes', + to: 'allowedElements' }, disallowedTypes: { - to: 'disallowedElements', - id: 'replace-allownode-allowedtypes-and-disallowedtypes' + id: 'replace-allownode-allowedtypes-and-disallowedtypes', + to: 'disallowedElements' }, + escapeHtml: {id: 'remove-buggy-html-in-markdown-parser'}, includeNodeIndex: { - to: 'includeElementIndex', - id: 'change-includenodeindex-to-includeelementindex' - } + id: 'change-includenodeindex-to-includeelementindex', + to: 'includeElementIndex' + }, + plugins: {id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'}, + renderers: {id: 'change-renderers-to-components', to: 'components'}, + source: {id: 'change-source-to-children', to: 'children'} } /** - * React component to render markdown. + * Component to render markdown. * - * @param {ReactMarkdownOptions} options + * @param {Readonly} options + * Configuration (required). + * Note: React types require that props are passed. * @returns {ReactElement} + * React element. */ export function ReactMarkdown(options) { for (const key in deprecated) { @@ -97,7 +174,7 @@ export function ReactMarkdown(options) { if (typeof options.children === 'string') { file.value = options.children - } else if (options.children !== undefined && options.children !== null) { + } else if (options.children !== null && options.children !== undefined) { console.warn( `[react-markdown] Warning: please pass a string as \`children\` (not: \`${options.children}\`)` ) diff --git a/lib/rehype-filter.js b/lib/rehype-filter.js index 3d6a00d8..9da34f48 100644 --- a/lib/rehype-filter.js +++ b/lib/rehype-filter.js @@ -1,42 +1,42 @@ -import {visit} from 'unist-util-visit' - /** - * @typedef {import('unist').Node} Node - * @typedef {import('hast').Root} Root * @typedef {import('hast').Element} Element - * - * @callback AllowElement - * @param {Element} element - * @param {number} index - * @param {Element|Root} parent - * @returns {boolean|undefined} - * - * @typedef Options - * @property {Array} [allowedElements] - * @property {Array} [disallowedElements=[]] - * @property {AllowElement} [allowElement] - * @property {boolean} [unwrapDisallowed=false] + * @typedef {import('hast').Root} Root + * @typedef {import('./react-markdown.js').Options} Options */ +import {visit} from 'unist-util-visit' + /** - * @type {import('unified').Plugin<[Options], Root>} + * Filter nodes. + * + * @param {Readonly} options + * Configuration (required). + * @returns + * Transform (optional). */ export default function rehypeFilter(options) { - if (options.allowedElements && options.disallowedElements) { - throw new TypeError( - 'Only one of `allowedElements` and `disallowedElements` should be defined' - ) - } - if ( + options.allowElement || options.allowedElements || - options.disallowedElements || - options.allowElement + options.disallowedElements ) { - return (tree) => { - visit(tree, 'element', (node, index, parent_) => { - const parent = /** @type {Element|Root} */ (parent_) - /** @type {boolean|undefined} */ + if (options.allowedElements && options.disallowedElements) { + throw new TypeError( + 'Only one of `allowedElements` and `disallowedElements` should be defined' + ) + } + + /** + * Transform. + * + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + visit(tree, 'element', function (node, index, parent) { + /** @type {boolean | undefined} */ let remove if (options.allowedElements) { @@ -49,7 +49,7 @@ export default function rehypeFilter(options) { remove = !options.allowElement(node, index, parent) } - if (remove && typeof index === 'number') { + if (remove && parent && typeof index === 'number') { if (options.unwrapDisallowed && node.children) { parent.children.splice(index, 1, ...node.children) } else { diff --git a/lib/uri-transformer.js b/lib/uri-transformer.js index 0bcfa5bb..eca407fb 100644 --- a/lib/uri-transformer.js +++ b/lib/uri-transformer.js @@ -1,11 +1,15 @@ const protocols = ['http', 'https', 'mailto', 'tel'] /** - * @param {string} uri + * Make a URL safe. + * + * @param {string} value + * URL. * @returns {string} + * Safe URL. */ -export function uriTransformer(uri) { - const url = (uri || '').trim() +export function uriTransformer(value) { + const url = (value || '').trim() const first = url.charAt(0) if (first === '#' || first === '/') { @@ -40,6 +44,7 @@ export function uriTransformer(uri) { return url } + // To do: is there an alternative? // eslint-disable-next-line no-script-url return 'javascript:void(0)' } diff --git a/package.json b/package.json index 6f0526c5..459523e5 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", + "mdast-util-to-hast": "^13.0.2", "prop-types": "^15.0.0", "property-information": "^6.0.0", "react-is": "^18.0.0", @@ -92,6 +93,7 @@ "space-separated-tokens": "^2.0.0", "style-to-object": "^0.4.0", "unified": "^11.0.0", + "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, @@ -106,7 +108,6 @@ "@types/react-is": "^18.0.0", "c8": "^8.0.0", "esbuild": "^0.19.0", - "eslint-config-xo-react": "^0.27.0", "eslint-plugin-es": "^4.0.0", "eslint-plugin-react": "^7.0.0", "eslint-plugin-react-hooks": "^4.0.0", @@ -172,7 +173,7 @@ "envs": [ "shared-node-browser" ], - "extends": "xo-react", + "extends": "plugin:react/jsx-runtime", "overrides": [ { "files": [ @@ -193,7 +194,7 @@ ], "rules": { "n/file-extension-in-import": "off", - "react/no-children-prop": "off" + "no-unused-vars": "off" } } ], diff --git a/readme.md b/readme.md index 30be3fdc..acb8265d 100644 --- a/readme.md +++ b/readme.md @@ -167,48 +167,48 @@ The default export is `ReactMarkdown`. ### `props` -* `children` (`string`, default: `''`)\ +* `allowElement` (`(element, index, parent) => boolean?`, optional)\ + function called to check if an element is allowed (when truthy) or not, + `allowedElements` or `disallowedElements` is used first! +* `allowedElements` (`Array`, optional)\ + tag names to allow (cannot combine w/ `disallowedElements`), all tag names + are allowed by default +* `children` (`string`, optional)\ markdown to parse -* `components` (`Record`, default: `{}`)\ - object mapping tag names to React components -* `remarkPlugins` (`Array`, default: `[]`)\ - list of [remark plugins][remark-plugins] to use -* `rehypePlugins` (`Array`, default: `[]`)\ - list of [rehype plugins][rehype-plugins] to use -* `remarkRehypeOptions` (`Object?`, default: `undefined`)\ - options to pass through to [`remark-rehype`][remark-rehype] * `className` (`string?`)\ wrap the markdown in a `div` with this class name -* `skipHtml` (`boolean`, default: `false`)\ - ignore HTML in markdown completely -* `sourcePos` (`boolean`, default: `false`)\ - pass a prop to all components with a serialized position - (`data-sourcepos="3:1-3:13"`) -* `rawSourcePos` (`boolean`, default: `false`)\ - pass a prop to all components with their [position][] - (`sourcePosition: {start: {line: 3, column: 1}, end:…}`) +* `components` (`Record`, optional)\ + map tag names to React components +* `disallowedElements` (`Array`, optional)\ + tag names to disallow (cannot combine w/ `allowedElements`), all tag names + are allowed by default * `includeElementIndex` (`boolean`, default: `false`)\ pass the `index` (number of elements before it) and `siblingCount` (number of elements in parent) as props to all components -* `allowedElements` (`Array`, default: `undefined`)\ - tag names to allow (can’t combine w/ `disallowedElements`), all tag names - are allowed by default -* `disallowedElements` (`Array`, default: `undefined`)\ - tag names to disallow (can’t combine w/ `allowedElements`), all tag names - are allowed by default -* `allowElement` (`(element, index, parent) => boolean?`, optional)\ - function called to check if an element is allowed (when truthy) or not, - `allowedElements` or `disallowedElements` is used first! -* `unwrapDisallowed` (`boolean`, default: `false`)\ - extract (unwrap) the children of not allowed elements, by default, when - `strong` is disallowed, it and it’s children are dropped, but with - `unwrapDisallowed` the element itself is replaced by its children -* `transformLinkUri` (`(href, children, title) => string`, default: - [`uriTransformer`][uri-transformer], optional)\ - change URLs on links, pass `null` to allow all URLs, see [security][] +* `rawSourcePos` (`boolean`, default: `false`)\ + pass a `sourcePosition` prop to all components with their [position][] +* `rehypePlugins` (`Array`, optional)\ + list of [rehype plugins][rehype-plugins] to use +* `remarkPlugins` (`Array`, optional)\ + list of [remark plugins][remark-plugins] to use +* `remarkRehypeOptions` (`Object?`, optional)\ + options to pass through to [`remark-rehype`][remark-rehype] +* `skipHtml` (`boolean`, default: `false`)\ + ignore HTML in markdown completely +* `sourcePos` (`boolean`, default: `false`)\ + pass a `data-sourcepos` prop to all components with a serialized position * `transformImageUri` (`(src, alt, title) => string`, default: - [`uriTransformer`][uri-transformer], optional)\ - change URLs on images, pass `null` to allow all URLs, see [security][] + [`uriTransformer`][uri-transformer])\ + change URLs on images; + pass `false` to allow all URLs, which is unsafe (see [security][]) +* `transformLinkUri` (`(href, children, title) => string`, default: + [`uriTransformer`][uri-transformer])\ + change URLs on links; + pass `false` to allow all URLs, which is unsafe (see [security][]) +* `unwrapDisallowed` (`boolean`, default: `false`)\ + extract (unwrap) the children of not allowed elements; + normally when say `strong` is disallowed, it and it’s children are dropped, + with `unwrapDisallowed` the element itself is replaced by its children ### `uriTransformer` @@ -348,18 +348,19 @@ ReactDom.render( ) : ( - + {children} ) @@ -549,11 +550,15 @@ You can also change the things that come from markdown: ```jsx + em(props) { + const {node, ...rest} = props + return + } }} /> ``` diff --git a/test/fixtures/runthrough.html b/test/fixtures/runthrough.html deleted file mode 100644 index 824af77d..00000000 --- a/test/fixtures/runthrough.html +++ /dev/null @@ -1,161 +0,0 @@ -

h1 Heading

-

h2 Heading

-

h3 Heading

-

h4 Heading

-
h5 Heading
-
h6 Heading
-

Horizontal Rules

-
-
-
-

Emphasis

-

This is bold text

-

This is bold text

-

This is italic text

-

This is italic text

-

Strikethrough

-

Blockquotes

-
-

Blockquotes can also be nested...

-
-

...by using additional greater-than signs right next to each other...

-
-

...or with spaces between arrows.

-
-
-
-

Lists

-

Unordered

-
    -
  • Create a list by starting a line with +, -, or *
  • -
  • Sub-lists are made by indenting 2 spaces: -
      -
    • Marker character change forces new list start: -
        -
      • Ac tristique libero volutpat at
      • -
      -
        -
      • Facilisis in pretium nisl aliquet
      • -
      -
        -
      • Nulla volutpat aliquam velit
      • -
      -
    • -
    -
  • -
  • Very easy!
  • -
-

Ordered

-
    -
  1. Lorem ipsum dolor sit amet
  2. -
  3. Consectetur adipiscing elit
  4. -
  5. Integer molestie lorem at massa
  6. -
-

Or:

-
    -
  1. You can use sequential numbers...
  2. -
  3. ...or keep all the numbers as 1.
  4. -
-

Start numbering with offset:

-
    -
  1. foo
  2. -
  3. bar
  4. -
-

Loose lists?

-
    -
  • -

    foo

    -
  • -
  • -

    bar

    -
  • -
-

Code

-

Inline code

-

Indented code

-
// Some comments
-line 1 of code
-line 2 of code
-line 3 of code
-
-

Block code "fences"

-
Sample text here...
-
-

Syntax highlighting

-
var foo = function (bar) {
-  return bar++;
-};
-
-console.log(foo(5));
-
-

Tables

- - - - - - - - - - - - - - - - - - - - - -
TagUse
pParagraph
tableTable
emEmphasis
-

Left/right aligned columns

- - - - - - - - - - - - - - - - - - - - - -
ProjectStars
React80 759
Vue.js73 322
sse-channel50
-

Links

-

Espen.Codes

-

Sanity

-

Autoconverted link https://github.com/remarkjs/react-markdown

-

Link references

-

Images

-

React Markdown -Mead

-

Like links, Images also have a footnote style syntax

-

Alt text

-

With a reference later in the document defining the URL location:

-

Hard breaks

-

Yeah, hard breaks
-can be useful too.

-

HTML entities

-

Some characters, like æ, & and similar should be handled properly.

-

HTML

-

Does anyone actually like the fact that you can embed HTML in markdown?

- -

We used to have a known bug where inline HTML wasn't handled well. You can do basic tags like -code, as long as it doesn't contain any attributes. If you -have weird ordering on your tags, it won't work either. It does support nested -tags, however. And with the rehype-raw plugin, it can now properly handle HTML! Which is pretty sweet.

-

-

Cool, eh?

\ No newline at end of file diff --git a/test/fixtures/runthrough.md b/test/fixtures/runthrough.md deleted file mode 100644 index 7fec387f..00000000 --- a/test/fixtures/runthrough.md +++ /dev/null @@ -1,171 +0,0 @@ -# h1 Heading -## h2 Heading -### h3 Heading -#### h4 Heading -##### h5 Heading -###### h6 Heading - - -## Horizontal Rules - -___ - ---- - -*** - - -## Emphasis - -**This is bold text** - -__This is bold text__ - -*This is italic text* - -_This is italic text_ - -~~Strikethrough~~ - - -## Blockquotes - - -> Blockquotes can also be nested... ->> ...by using additional greater-than signs right next to each other... -> > > ...or with spaces between arrows. - - -## Lists - -Unordered - -+ Create a list by starting a line with `+`, `-`, or `*` -+ Sub-lists are made by indenting 2 spaces: - - Marker character change forces new list start: - * Ac tristique libero volutpat at - + Facilisis in pretium nisl aliquet - - Nulla volutpat aliquam velit -+ Very easy! - -Ordered - -1. Lorem ipsum dolor sit amet -2. Consectetur adipiscing elit -3. Integer molestie lorem at massa - -Or: - -1. You can use sequential numbers... -1. ...or keep all the numbers as `1.` - -Start numbering with offset: - -57. foo -1. bar - -Loose lists? - -- foo - -- bar - - -## Code - -Inline `code` - -Indented code - - // Some comments - line 1 of code - line 2 of code - line 3 of code - - -Block code "fences" - -``` -Sample text here... -``` - -Syntax highlighting - -``` js -var foo = function (bar) { - return bar++; -}; - -console.log(foo(5)); -``` - -## Tables - -| Tag | Use | -| ------ | ----------- | -| p | Paragraph | -| table | Table | -| em | Emphasis | - -Left/right aligned columns - -| Project | Stars | -| :------ | -----------:| -| React | 80 759 | -| Vue.js | 73 322 | -| sse-channel | 50 | - - -## Links - -[Espen.Codes](https://espen.codes/) - -[Sanity](https://www.sanity.io/ "Sanity, the headless CMS and PaaS") - -Autoconverted link https://github.com/remarkjs/react-markdown - -[Link references][React] - -[React]: https://reactjs.org "React, A JavaScript library for building user interfaces" - - -## Images - -![React Markdown](https://espen.codes/assets/projects/react-markdown/320x180.png) -![Mead](https://espen.codes/assets/projects/mead/320x180.png "Mead, on-the-fly image transformer") - -Like links, Images also have a footnote style syntax - -![Alt text][someref] - -With a reference later in the document defining the URL location: - -[someref]: https://public.sanity.io/modell_@2x.png "Headless CMS" - -## Hard breaks - -Yeah, hard breaks\ -can be useful too. - -## HTML entities - -Some characters, like æ, & and similar should be handled properly. - -## HTML - -Does anyone actually like the fact that you can embed HTML in markdown? - - - -We used to have a known bug where inline HTML wasn't handled well. You can do basic tags like -code, as long as it doesn't contain any attributes. If you -have weird ordering on your tags, it won't work either. It does support nested -tags, however. And with the rehype-raw plugin, it can now properly handle HTML! Which is pretty sweet. - -

- -Cool, eh? diff --git a/test/loader.js b/test/loader.js index 28915afc..d78a33c7 100644 --- a/test/loader.js +++ b/test/loader.js @@ -1,10 +1,10 @@ import fs from 'node:fs/promises' import {fileURLToPath} from 'node:url' -import {transform, transformSync} from 'esbuild' +import {transform} from 'esbuild' -const {load, getFormat, transformSource} = createLoader() +const {getFormat, load, transformSource} = createLoader() -export {load, getFormat, transformSource} +export {getFormat, load, transformSource} /** * A tiny JSX loader. @@ -26,21 +26,21 @@ export function createLoader() { } const {code, warnings} = await transform(String(await fs.readFile(url)), { + format: 'esm', + loader: 'jsx', sourcefile: fileURLToPath(url), sourcemap: 'both', - loader: 'jsx', - target: 'esnext', - format: 'esm' + target: 'esnext' }) - if (warnings && warnings.length > 0) { + if (warnings) { for (const warning of warnings) { console.log(warning.location) console.log(warning.text) } } - return {format: 'module', source: code, shortCircuit: true} + return {format: 'module', shortCircuit: true, source: code} } // Pre version 17. @@ -69,15 +69,15 @@ export function createLoader() { return defaultTransformSource(value, context, defaultTransformSource) } - const {code, warnings} = transformSync(String(value), { + const {code, warnings} = await transform(String(value), { + format: context.format === 'module' ? 'esm' : 'cjs', + loader: 'jsx', sourcefile: fileURLToPath(url), sourcemap: 'both', - loader: 'jsx', - target: 'esnext', - format: context.format === 'module' ? 'esm' : 'cjs' + target: 'esnext' }) - if (warnings && warnings.length > 0) { + if (warnings) { for (const warning of warnings) { console.log(warning.location) console.log(warning.text) diff --git a/test/test.jsx b/test/test.jsx index b21bfb7a..fbc38f6c 100644 --- a/test/test.jsx +++ b/test/test.jsx @@ -1,1431 +1,1216 @@ +/* @jsxRuntime automatic @jsxImportSource react */ /** * @typedef {import('hast').Root} Root - * @typedef {import('hast').Element} Element - * @typedef {import('react').ReactNode} ReactNode + * @typedef {import('../lib/ast-to-react.js').HeadingProps} HeadingProps */ import assert from 'node:assert/strict' -import fs from 'node:fs/promises' import test from 'node:test' -import React from 'react' -import ReactDom from 'react-dom/server' +import {renderToStaticMarkup} from 'react-dom/server' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import remarkToc from 'remark-toc' import {visit} from 'unist-util-visit' import Markdown from '../index.js' -const own = {}.hasOwnProperty - -/** - * @param {ReturnType} input - * @returns {string} - */ -function asHtml(input) { - return ReactDom.renderToStaticMarkup(input) -} - -test('ReactMarkdown', async () => { - assert.deepEqual( - Object.keys(await import('../index.js')).sort(), - ['default', 'uriTransformer'], - 'should expose the public api' - ) -}) - -test('can render the most basic of documents (single paragraph)', () => { - assert.equal(asHtml(Test), '

Test

') -}) - -test('should warn when passed `source`', () => { - const warn = console.warn - /** @type {unknown} */ - let message - - console.warn = (/** @type {unknown} */ d) => { - message = d - } - - // @ts-expect-error runtime - assert.equal(asHtml(b), '

b

') - assert.equal( - message, - '[react-markdown] Warning: please use `children` instead of `source` (see for more info)' - ) - - console.warn = warn -}) - -test('should warn when passed non-string children (number)', () => { - const {error, warn} = console - /** @type {unknown} */ - let message - - console.error = () => {} - console.warn = (/** @type {unknown} */ d) => { - message = d - } - - // @ts-expect-error runtime - assert.equal(asHtml(), '') - assert.equal( - message, - '[react-markdown] Warning: please pass a string as `children` (not: `1`)' - ) - - console.error = error - console.warn = warn -}) +test('react-markdown', async function (t) { + await t.test('should expose the public api', async function () { + assert.deepEqual(Object.keys(await import('../index.js')).sort(), [ + 'default', + 'uriTransformer' + ]) + }) + + await t.test('should work', function () { + assert.equal(asHtml(a), '

a

') + }) + + await t.test('should warn w/ `source`', function () { + const warn = console.warn + /** @type {unknown} */ + let message + + console.warn = capture + + // @ts-expect-error: check how the runtime handles untyped `source`. + assert.equal(asHtml(b), '

b

') + assert.equal( + message, + '[react-markdown] Warning: please use `children` instead of `source` (see for more info)' + ) -test('should warn when passed non-string children (boolean)', () => { - const {error, warn} = console - /** @type {unknown} */ - let message - - console.error = () => {} - console.warn = (/** @type {unknown} */ d) => { - message = d - } - - // @ts-expect-error runtime - assert.equal(asHtml(), '') - assert.equal( - message, - '[react-markdown] Warning: please pass a string as `children` (not: `false`)' - ) + console.warn = warn - console.error = error - console.warn = warn -}) + /** + * @param {unknown} d + * @returns {undefined} + */ + function capture(d) { + message = d + } + }) -test('should not warn when passed `null` as children', () => { - // @ts-expect-error: types do not allow `null`. - assert.equal(asHtml(), '') -}) + await t.test('should warn w/ non-string children (number)', function () { + const {error, warn} = console + /** @type {unknown} */ + let message -test('should not warn when passed `undefined` as children', () => { - // @ts-expect-error: types do not allow `undefined`. - assert.equal(asHtml(), '') -}) + console.error = function () {} + console.warn = capture -test('should warn when passed `allowDangerousHtml`', () => { - const warn = console.warn - /** @type {unknown} */ - let message + // @ts-expect-error: check how the runtime handles invalid `children`. + assert.equal(asHtml(), '') + assert.equal( + message, + '[react-markdown] Warning: please pass a string as `children` (not: `1`)' + ) - console.warn = (/** @type {unknown} */ d) => { - message = d - } + console.error = error + console.warn = warn - // @ts-expect-error runtime - assert.equal(asHtml(a), '

a

') - assert.equal( - message, - '[react-markdown] Warning: please remove `allowDangerousHtml` (see for more info)' - ) + /** + * @param {unknown} d + * @returns {undefined} + */ + function capture(d) { + message = d + } + }) - console.warn = warn -}) + await t.test('should warn w/ non-string children (boolean)', function () { + const {error, warn} = console + /** @type {unknown} */ + let message -test('uses passed classname for root component', () => { - assert.equal( - asHtml(Test), - '

Test

' - ) -}) + console.error = function () {} + console.warn = capture -test('should handle multiple paragraphs properly', () => { - const input = 'React is awesome\nAnd so is markdown\n\nCombining = epic' - assert.equal( - asHtml(), - '

React is awesome\nAnd so is markdown

\n

Combining = epic

' - ) -}) + // @ts-expect-error: check how the runtime handles invalid `children`. + assert.equal(asHtml(), '') + assert.equal( + message, + '[react-markdown] Warning: please pass a string as `children` (not: `false`)' + ) -test('should handle multiline paragraphs properly (softbreak, paragraphs)', () => { - const input = 'React is awesome\nAnd so is markdown \nCombining = epic' - const actual = asHtml() - assert.equal( - actual, - '

React is awesome\nAnd so is markdown
\nCombining = epic

' - ) -}) + console.error = error + console.warn = warn -test('should handle emphasis', () => { - const input = 'React is _totally_ *awesome*' - const actual = asHtml() - assert.equal(actual, '

React is totally awesome

') -}) + /** + * @param {unknown} d + * @returns {undefined} + */ + function capture(d) { + message = d + } + }) -test('should handle bold/strong text', () => { - const input = 'React is __totally__ **awesome**' - const actual = asHtml() - assert.equal( - actual, - '

React is totally awesome

' - ) -}) + await t.test('should support `null` as children', function () { + assert.equal(asHtml(), '') + }) -test('should handle links without title attribute', () => { - const input = 'This is [a link](https://espen.codes/) to Espen.Codes.' - const actual = asHtml() - assert.equal( - actual, - '

This is a link to Espen.Codes.

' - ) -}) + await t.test('should support `undefined` as children', function () { + assert.equal(asHtml(), '') + }) -test('should handle links with title attribute', () => { - const input = - 'This is [a link](https://espen.codes/ "some title") to Espen.Codes.' - const actual = asHtml() - assert.equal( - actual, - '

This is a link to Espen.Codes.

' - ) -}) + await t.test('should warn w/ `allowDangerousHtml`', function () { + const warn = console.warn + /** @type {unknown} */ + let message -test('should handle links with uppercase protocol', () => { - const input = 'This is [a link](HTTPS://ESPEN.CODES/) to Espen.Codes.' - const actual = asHtml() - assert.equal( - actual, - '

This is a link to Espen.Codes.

' - ) -}) + console.warn = capture -test('should handle links with custom uri transformer', () => { - const input = 'This is [a link](https://espen.codes/) to Espen.Codes.' - const actual = asHtml( - uri.replace(/^https?:/, '')} - /> - ) - assert.equal( - actual, - '

This is a link to Espen.Codes.

' - ) -}) + // @ts-expect-error: check how the runtime handles deprecated `allowDangerousHtml`. + assert.equal(asHtml(a), '

a

') + assert.equal( + message, + '[react-markdown] Warning: please remove `allowDangerousHtml` (see for more info)' + ) -test('should handle empty links with custom uri transformer', () => { - const input = 'Empty: []()' - - const actual = asHtml( - { - assert.equal(uri, '', '`uri` should be an empty string') - assert.equal(title, null, '`title` should be null') - return '' - }} - /> - ) + console.warn = warn - assert.equal(actual, '

Empty:

') -}) + /** + * @param {unknown} d + * @returns {undefined} + */ + function capture(d) { + message = d + } + }) -test('should handle titles of links', () => { - const input = 'Empty: [](# "x")' - const actual = asHtml() - assert.equal(actual, '

Empty:

') -}) + await t.test('should support `className`', function () { + assert.equal( + asHtml(a), + '

a

' + ) + }) -test('should support images without alt, url, or title', () => { - const input = '![]()' - const actual = asHtml() - const expected = '

' - assert.equal(actual, expected) -}) + await t.test('should support a block quote', function () { + assert.equal( + asHtml(), + '
\n

a

\n
' + ) + }) -test('should handle images without title attribute', () => { - const input = 'This is ![an image](/ninja.png).' - const actual = asHtml() - assert.equal(actual, '

This is an image.

') -}) + await t.test('should support a break', function () { + assert.equal(asHtml(), '

a
\nb

') + }) -test('should handle images with title attribute', () => { - const input = 'This is ![an image](/ninja.png "foo bar").' - const actual = asHtml() - assert.equal( - actual, - '

This is an image.

' - ) -}) + await t.test('should support a code (block, flow; indented)', function () { + assert.equal( + asHtml(), + '
a\n
' + ) + }) -test('should handle images with custom uri transformer', () => { - const input = 'This is ![an image](/ninja.png).' - const actual = asHtml( - uri.replace(/\.png$/, '.jpg')} - /> - ) - assert.equal(actual, '

This is an image.

') -}) + await t.test('should support a code (block, flow; fenced)', function () { + assert.equal( + asHtml(), + '
a\n
' + ) + }) -test('should handle images with custom uri transformer', () => { - const input = 'Empty: ![]()' - const actual = asHtml( - { - assert.equal(uri, '', '`uri` should be an empty string') - assert.equal(alt, '', '`alt` should be an empty string') - assert.equal(title, null, '`title` should be null') - return '' - }} - /> - ) - assert.equal(actual, '

Empty:

') -}) + await t.test('should support a delete (GFM)', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) + + await t.test('should support an emphasis', function () { + assert.equal(asHtml(), '

a

') + }) + + await t.test('should support a footnote (GFM)', function () { + assert.equal( + asHtml( + + ), + '

a1

\n

Footnotes

\n
    \n
  1. \n

    y

    \n
  2. \n
\n
' + ) + }) -test('should handle images w/ titles with custom uri transformer', () => { - const input = 'Empty: ![](a "b")' - const actual = asHtml( - { - assert.equal(title, 'b', '`title` should be passed') - return src - }} - /> - ) - assert.equal(actual, '

Empty:

') -}) + await t.test('should support a heading', function () { + assert.equal(asHtml(), '

a

') + }) -test('should handle image references with custom uri transformer', () => { - const input = - 'This is ![The Waffle Ninja][ninja].\n\n[ninja]: https://some.host/img.png' - const actual = asHtml( - uri.replace(/\.png$/, '.jpg')} - /> - ) - assert.equal( - actual, - '

This is The Waffle Ninja.

' - ) -}) + await t.test('should support an html (default)', function () { + assert.equal( + asHtml(), + '

<i>a</i>

' + ) + }) -test('should support images references without alt, url, or title', () => { - const input = '![][a]\n\n[a]: <>' - const actual = asHtml() - const expected = '

' - assert.equal(actual, expected) -}) + await t.test('should support an html (w/ `rehype-raw`)', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should handle images with special characters in alternative text', () => { - const input = "This is ![a ninja's image](/ninja.png)." - const actual = asHtml() - assert.equal( - actual, - '

This is a ninja's image.

' - ) -}) + await t.test('should support an image', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should be able to render headers', () => { - assert.equal(asHtml(), '

Awesome

') - assert.equal(asHtml(), '

Awesome

') - assert.equal(asHtml(), '

Awesome

') - assert.equal(asHtml(), '

Awesome

') - assert.equal( - asHtml(), - '
Awesome
' - ) -}) + await t.test('should support an image w/ a title', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should be able to render inline code', () => { - const input = 'Just call `renderToStaticMarkup()`, already' - const actual = asHtml() - assert.equal( - actual, - '

Just call renderToStaticMarkup(), already

' - ) -}) + await t.test('should support an image reference / definition', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should handle code tags without any language specification', () => { - const input = "```\nvar foo = require('bar');\nfoo();\n```" - const actual = asHtml() - assert.equal( - actual, - '
var foo = require('bar');\nfoo();\n
' - ) -}) + await t.test('should support code (text, inline)', function () { + assert.equal(asHtml(), '

a

') + }) -test('should handle code tags with language specification', () => { - const input = "```js\nvar foo = require('bar');\nfoo();\n```" - const actual = asHtml() - assert.equal( - actual, - '
var foo = require('bar');\nfoo();\n
' - ) -}) + await t.test('should support a link', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should only use first language definition on code blocks', () => { - const input = "```js foo bar\nvar foo = require('bar');\nfoo();\n```" - const actual = asHtml() - assert.equal( - actual, - '
var foo = require('bar');\nfoo();\n
' - ) -}) + await t.test('should support a link w/ a title', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should support character references in code blocks', () => { - const input = `~~~js ololo i can haz class names !@#$%^&*()_ - woop - ~~~` - const actual = asHtml() - assert.equal( - actual, - '
  woop\n
' - ) -}) + await t.test('should support a link reference / definition', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) + + await t.test('should support prototype poluting identifiers', function () { + assert.equal( + asHtml( + + ), + '

' + ) + }) -test('should handle code blocks by indentation', () => { - const input = [ - '', - '
\n', - '', - '© 2014 Foo Bar\n', - '
' - ].join(' ') - assert.equal( - asHtml(), - '
<footer class="footer">\n    &copy; 2014 Foo Bar\n</footer>\n
' - ) -}) + await t.test('should support duplicate definitions', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should handle blockquotes', () => { - const input = '> Moo\n> Tools\n> FTW\n' - const actual = asHtml() - assert.equal(actual, '
\n

Moo\nTools\nFTW

\n
') -}) + await t.test('should support a list (unordered) / list item', function () { + assert.equal(asHtml(), '
    \n
  • a
  • \n
') + }) -test('should handle nested blockquotes', () => { - const input = [ - '> > Lots of ex-Mootoolers on the React team\n>\n', - "> Totally didn't know that.\n>\n", - "> > There's a reason why it turned out so awesome\n>\n", - "> Haha I guess you're right!" - ].join('') - - const actual = asHtml() - assert.equal( - actual, - '
\n
\n

Lots of ex-Mootoolers on the React team

\n
\n

Totally didn't know that.

\n
\n

There's a reason why it turned out so awesome

\n
\n

Haha I guess you're right!

\n
' - ) -}) + await t.test('should support a list (ordered) / list item', function () { + assert.equal( + asHtml(), + '
    \n
  1. a
  2. \n
' + ) + }) -test('should handle tight, unordered lists', () => { - const input = '* Unordered\n* Lists\n* Are cool\n' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  • Unordered
  • \n
  • Lists
  • \n
  • Are cool
  • \n
' - ) -}) + await t.test('should support a paragraph', function () { + assert.equal(asHtml(), '

a

') + }) -test('should handle loose, unordered lists', () => { - const input = '- foo\n\n- bar' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  • \n

    foo

    \n
  • \n
  • \n

    bar

    \n
  • \n
' - ) -}) + await t.test('should support a strong', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) + + await t.test('should support a table (GFM)', function () { + assert.equal( + asHtml( + + ), + '
a
b
' + ) + }) + + await t.test('should support a table (GFM; w/ align)', function () { + assert.equal( + asHtml( + + ), + '
abcd
' + ) + }) -test('should handle tight, unordered lists with sublists', () => { - const input = '* Unordered\n * Lists\n * Are cool\n' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  • Unordered\n
      \n
    • Lists\n
        \n
      • Are cool
      • \n
      \n
    • \n
    \n
  • \n
' - ) -}) + await t.test('should support a thematic break', function () { + assert.equal(asHtml(), '
') + }) -test('should handle loose, unordered lists with sublists', () => { - const input = '- foo\n\n - bar' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  • \n

    foo

    \n
      \n
    • bar
    • \n
    \n
  • \n
' - ) -}) + await t.test('should support ab absolute path', function () { + assert.equal( + asHtml(), + '

' + ) + }) -test('should handle ordered lists', () => { - const input = '1. Ordered\n2. Lists\n3. Are cool\n' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  1. Ordered
  2. \n
  3. Lists
  4. \n
  5. Are cool
  6. \n
' - ) -}) + await t.test('should support an absolute URL', function () { + assert.equal( + asHtml(), + '

' + ) + }) -test('should handle ordered lists with a start index', () => { - const input = '7. Ordered\n8. Lists\n9. Are cool\n' - const actual = asHtml() - assert.equal( - actual, - '
    \n
  1. Ordered
  2. \n
  3. Lists
  4. \n
  5. Are cool
  6. \n
' - ) -}) + await t.test('should support a URL w/ uppercase protocol', function () { + assert.equal( + asHtml(), + '

' + ) + }) + + await t.test('should make a `javascript:` URL safe', function () { + const consoleError = console.error + console.error = noop + assert.equal( + asHtml(), + '

' + ) + console.error = consoleError + }) + + await t.test('should make a `vbscript:` URL safe', function () { + const consoleError = console.error + console.error = noop + assert.equal( + asHtml(), + '

' + ) + console.error = consoleError + }) + + await t.test('should make a `VBSCRIPT:` URL safe', function () { + const consoleError = console.error + console.error = noop + assert.equal( + asHtml(), + '

' + ) + console.error = consoleError + }) + + await t.test('should make a `file:` URL safe', function () { + const consoleError = console.error + console.error = noop + assert.equal( + asHtml(), + '

' + ) + console.error = consoleError + }) -test('should pass `ordered`, `depth`, `checked`, `index` to list/listItem', () => { - const input = '- foo\n\n 2. bar\n 3. baz\n\n- root\n' - const actual = asHtml( - = 0, true) - return React.createElement('li', props) - }, - ol({node, ordered, depth, ...props}) { - assert.equal(ordered, true) - assert.equal(depth >= 0, true) - return React.createElement('ol', props) - }, - ul({node, ordered, depth, ...props}) { - assert.equal(ordered, false) - assert.equal(depth >= 0, true) - return React.createElement('ul', props) - } - }} - /> - ) + await t.test('should allow an empty URL', function () { + assert.equal(asHtml(), '

') + }) - assert.equal( - actual, - '
    \n
  • \n

    foo

    \n
      \n
    1. bar
    2. \n
    3. baz
    4. \n
    \n
  • \n
  • \n

    root

    \n
  • \n
' - ) -}) + await t.test('should support search (`?`) in a URL', function () { + assert.equal( + asHtml(), + '

' + ) + }) -test('should pass `inline: true` to inline code', () => { - const input = '```\na\n```\n\n\tb\n\n`c`' - const actual = asHtml( - - ) - const expected = - '
a\n
\n
b\n
\n

c

' - assert.equal(actual, expected) -}) + await t.test('should support hash (`#`) in a URL', function () { + assert.equal( + asHtml(), + '

' + ) + }) + + await t.test('should support `transformLinkUri`', function () { + assert.equal( + asHtml( + + ), + '

a

' + ) + }) + + await t.test('should support `transformLinkUri` w/ empty URLs', function () { + assert.equal( + asHtml( + + ), + '

' + ) + }) -test('should pass `isHeader: boolean` to `tr`s', () => { - const input = '| a |\n| - |\n| b |\n| c |' - const actual = asHtml( - + await t.test( + 'should support turning off `transformLinkUri` (dangerous)', + function () { + assert.equal( + asHtml( + + ), + '

' + ) + } ) - const expected = - '
a
b
c
' - assert.equal(actual, expected) -}) -test('should pass `isHeader: true` to `th`s, `isHeader: false` to `td`s', () => { - const input = '| a |\n| - |\n| b |\n| c |' - const actual = asHtml( - - ) - const expected = - '
a
b
c
' - assert.equal(actual, expected) -}) + await t.test('should support `transformImageUri`', function () { + assert.equal( + asHtml( + + ), + '

a

' + ) + }) + + await t.test('should support `transformImageUri` w/ empty URLs', function () { + assert.equal( + asHtml( + + ), + '

' + ) + }) -test('should pass `index: number`, `ordered: boolean`, `checked: boolean | null` to `li`s', () => { - const input = '* [x] a\n* [ ] b\n* c' - let count = 0 - const actual = asHtml( - + await t.test( + 'should support turning off `transformImageUri` (dangerous)', + function () { + assert.equal( + asHtml( + + ), + '

' + ) + } ) - const expected = - '
    \n
  • a
  • \n
  • b
  • \n
  • c
  • \n
' - assert.equal(actual, expected) -}) -test('should pass `level: number` to `h1`, `h2`, ...', () => { - const input = '#\n##\n###' - - /** - * @param {object} props - * @param {Element} props.node - * @param {number} props.level - */ - function heading({node, level, ...props}) { - return React.createElement(`h${level}`, props) - } - - const actual = asHtml( - - ) - const expected = '

\n

\n

' - assert.equal(actual, expected) -}) + await t.test('should support `skipHtml`', function () { + const actual = asHtml() + assert.equal(actual, '

abc

') + }) -test('should skip inline html with skipHtml option enabled', () => { - const input = 'I am having so much fun' - const actual = asHtml() - assert.equal(actual, '

I am having so much fun

') -}) + await t.test('should support `sourcePos`', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) -test('should escape html blocks by default', () => { - const input = [ - 'This is a regular paragraph.\n\n\n \n ', - '\n \n
Foo
\n\nThis is another', - ' regular paragraph.' - ].join('') - - const actual = asHtml() - assert.equal( - actual, - '

This is a regular paragraph.

\n<table>\n <tr>\n <td>Foo</td>\n </tr>\n</table>\n

This is another regular paragraph.

' + await t.test( + 'should support `allowedElements` (drop unlisted nodes)', + function () { + assert.equal( + asHtml( + + ), + '

\n
    \n
  • b
  • \n
' + ) + } ) -}) -test('should skip html blocks if skipHtml prop is set', () => { - const input = [ - 'This is a regular paragraph.\n\n\n \n ', - '\n \n
Foo
\n\nThis is another', - ' regular paragraph.' - ].join('') - - const actual = asHtml() - assert.equal( - actual, - '

This is a regular paragraph.

\n\n

This is another regular paragraph.

' - ) -}) + await t.test('should support `allowedElements` as a function', function () { + assert.equal( + asHtml( + + ), + '

b

' + ) + }) + await t.test('should support `disallowedElements`', function () { + assert.equal( + asHtml(), + '

\n
    \n
  • b
  • \n
' + ) + }) -test('should escape html blocks by default (with HTML parser plugin)', () => { - const input = [ - 'This is a regular paragraph.\n\n\n \n ', - '\n \n
Foo
\n\nThis is another', - ' regular paragraph.' - ].join('') - - const actual = asHtml() - assert.equal( - actual, - '

This is a regular paragraph.

\n<table>\n <tr>\n <td>Foo</td>\n </tr>\n</table>\n

This is another regular paragraph.

' + await t.test( + 'should fail for both `allowedElements` and `disallowedElements`', + function () { + assert.throws(function () { + asHtml( + + ) + }, /only one of/i) + } ) -}) - -test('should handle horizontal rules', () => { - const input = 'Foo\n\n------------\n\nBar' - const actual = asHtml() - assert.equal(actual, '

Foo

\n
\n

Bar

') -}) -test('should set source position attributes if sourcePos option is enabled', () => { - const input = 'Foo\n\n------------\n\nBar' - const actual = asHtml() - assert.equal( - actual, - '

Foo

\n
\n

Bar

' + await t.test( + 'should support `unwrapDisallowed` w/ `allowedElements`', + function () { + assert.equal( + asHtml( + + ), + '

a

' + ) + } ) -}) -test('should pass on raw source position to non-tag components if rawSourcePos option is enabled', () => { - const input = '*Foo*\n\n------------\n\n__Bar__' - const actual = asHtml( - - } - }} - /> + await t.test( + 'should support `unwrapDisallowed` w/ `disallowedElements`', + function () { + assert.equal( + asHtml( + + ), + '

a

' + ) + } ) - assert.equal( - actual, - '

Foo

\n
\n

Bar

' - ) -}) + await t.test('should support `remarkRehypeOptions`', function () { + assert.equal( + asHtml( + + ), + '

1

\n

Footnotes

\n
    \n
  1. \n

    a

    \n
  2. \n
\n
' + ) + }) -test('should pass on raw source position to non-tag components if rawSourcePos option is enabled and `rehype-raw` is used', () => { - const input = '*Foo*' - asHtml( - - ) -}) + await t.test('should support `components`', function () { + assert.equal( + asHtml(), + '

a

' + ) + }) + + await t.test('should support `components` as functions', function () { + assert.equal( + asHtml( + + } + }} + /> + ), + '
a
' + ) + }) + + await t.test('should fail on invalid component', function () { + assert.throws(function () { + asHtml( + + ) + }, /Component for name `h1`/) + }) + + await t.test('should support `components` (headings; `level`)', function () { + let calls = 0 + + assert.equal( + asHtml( + + ), + '

a

\n

b

' + ) -test('should skip nodes that are not defined as allowed', () => { - const input = - '# Header\n\nParagraph\n## New header\n1. List item\n2. List item 2' - const actual = asHtml( - - ) - assert.equal( - actual, - '\n

Paragraph

\n\n
    \n
  1. List item
  2. \n
  3. List item 2
  4. \n
' - ) -}) + assert.equal(calls, 2) -test('should skip nodes that are defined as disallowed', () => { - const input = - '# Header\n\nParagraph\n## New header\n1. List item\n2. List item 2\n\nFoo' - const actual = asHtml( - - ) - assert.equal( - actual, - '

Header

\n

Paragraph

\n

New header

\n
    \n\n\n
\n

Foo

' - ) -}) + /** + * @param {HeadingProps} props + */ + function heading(props) { + const {level, node, ...rest} = props + assert.equal(typeof level, 'number') + calls++ + const H = `h${level}` + return + } + }) + + await t.test('should support `components` (code; `inline`)', function () { + let calls = 0 + assert.equal( + asHtml( + + } + }} + /> + ), + '
a\n
\n
b\n
\n

c

' + ) -test('should unwrap child nodes from disallowed nodes, if unwrapDisallowed option is enabled', () => { - const input = - 'Espen *~~initiated~~ had the initial commit*, but has had several **contributors**' - const actual = asHtml( - - ) - assert.equal( - actual, - '

Espen initiated had the initial commit, but has had several contributors

' - ) -}) + assert.equal(calls, 3) + }) -test('should render tables', () => { - const input = [ - 'Languages are fun, right?', - '', - '| ID | English | Norwegian | Italian |', - '| :-- | :-----: | --------: | ------- |', - '| 1 | one | en | uno |', - '| 2 | two | to | due |', - '| 3 | three | tre | tre |', - '' - ].join('\n') - - assert.equal( - asHtml(), - '

Languages are fun, right?

\n
IDEnglishNorwegianItalian
1oneenuno
2twotodue
3threetretre
' - ) -}) + await t.test( + 'should support `components` (li; `checked`, `index`, `ordered`)', + function () { + let calls = 0 -test('should render partial tables', () => { - const input = 'User is writing a table by hand\n\n| Test | Test |\n|-|-|' + assert.equal( + asHtml( + = 0, true) + calls++ + return
  • + } + }} + remarkPlugins={[remarkGfm]} + /> + ), + '
      \n
    • a
    • \n
    \n
      \n
    1. b
    2. \n
    ' + ) - assert.equal( - asHtml(), - '

    User is writing a table by hand

    \n
    TestTest
    ' + assert.equal(calls, 2) + } ) -}) -test('should render link references', () => { - const input = [ - 'Stuff were changed in [1.1.4]. Check out the changelog for reference.', - '', - '[1.1.4]: https://github.com/remarkjs/react-markdown/compare/v1.1.3...v1.1.4' - ].join('\n') - - assert.equal( - asHtml(), - '

    Stuff were changed in 1.1.4. Check out the changelog for reference.

    ' - ) -}) + await t.test( + 'should support `components` (ol; `depth`, `ordered`)', + function () { + let calls = 0 -test('should render empty link references', () => { - const input = - 'Stuff were changed in [][]. Check out the changelog for reference.' + assert.equal( + asHtml( + + } + }} + /> + ), + '
      \n
    1. a
    2. \n
    ' + ) - assert.equal( - asHtml(), - '

    Stuff were changed in [][]. Check out the changelog for reference.

    ' + assert.equal(calls, 1) + } ) -}) -test('should render image references', () => { - const input = [ - 'Checkout out this ninja: ![The Waffle Ninja][ninja]. Pretty neat, eh?', - '', - '[ninja]: /assets/ninja.png' - ].join('\n') + await t.test( + 'should support `components` (ul; `depth`, `ordered`)', + function () { + let calls = 0 - assert.equal( - asHtml(), - '

    Checkout out this ninja: The Waffle Ninja. Pretty neat, eh?

    ' - ) -}) + assert.equal( + asHtml( + + } + }} + /> + ), + '
      \n
    • a
    • \n
    ' + ) -test('should render footnote with custom options', () => { - const input = [ - 'This is a statement[^1] with a citation.', - '', - '[^1]: This is a footnote for the citation.' - ].join('\n') - - assert.equal( - asHtml( - - ), - '

    This is a statement1 with a citation.

    \n

    Footnotes

    \n
      \n
    1. \n

      This is a footnote for the citation.

      \n
    2. \n
    \n
    ' + assert.equal(calls, 1) + } ) -}) - -test('should support definitions with funky keys', () => { - const input = - '[][__proto__] and [][constructor]\n\n[__proto__]: a\n[constructor]: b' - const actual = asHtml() - const expected = '

    and

    ' - assert.equal(actual, expected) -}) -test('should support duplicate definitions', () => { - const input = '[a][]\n\n[a]: b\n[a]: c' - const actual = asHtml() - const expected = '

    a

    ' - assert.equal(actual, expected) -}) + await t.test('should support `components` (tr; `isHeader`)', function () { + let calls = 0 + + assert.equal( + asHtml( + + } + }} + remarkPlugins={[remarkGfm]} + /> + ), + '
    a
    b
    ' + ) -test('should skip nodes that are defined as disallowed', () => { - /** @type {Record} */ - const samples = { - p: {input: 'Paragraphs are cool', shouldNotContain: 'Paragraphs are cool'}, - h1: {input: '# Headers are neat', shouldNotContain: 'Headers are neat'}, - br: {input: 'Text \nHardbreak', shouldNotContain: '
    '}, - a: { - input: "[Espen's blog](http://espen.codes/) yeh?", - shouldNotContain: '' - }, - blockquote: { - input: '> Moo\n> Tools\n> FTW\n', - shouldNotContain: '} */ - const inputs = [] - /** @type {keyof samples} */ - let key - - for (key in samples) { - if (own.call(samples, key)) { - inputs.push(samples[key].input) - } - } + assert.equal(calls, 2) + }) + + await t.test('should support `components` (td, th; `isHeader`)', function () { + let tdCalls = 0 + let thCalls = 0 + + assert.equal( + asHtml( + + }, + th(props) { + const {isHeader, node, ...rest} = props + assert.equal(isHeader, true) + thCalls++ + return + } + }} + remarkPlugins={[remarkGfm]} + /> + ), + '
    a
    b
    ' + ) - const fullInput = inputs.join('\n') + assert.equal(tdCalls, 1) + assert.equal(thCalls, 1) + }) + + await t.test('should pass `node` to components', function () { + let calls = 0 + assert.equal( + asHtml( + + } + }} + /> + ), + '

    a

    ' + ) - for (key in samples) { - if (own.call(samples, key)) { - const sample = samples[key] + assert.equal(calls, 1) + }) - // Just to make sure, let ensure that the opposite is true + await t.test( + 'should support `rawSourcePos` (pass `sourcePosition` to components)', + function () { + let calls = 0 assert.equal( - asHtml().includes( - sample.shouldNotContain + asHtml( + + } + }} + /> ), - true, - 'fixture should contain `' + - sample.shouldNotContain + - '` (`' + - key + - '`)' + '

    a

    ' ) + assert.equal(calls, 1) + } + ) + + await t.test( + 'should support `includeElementIndex` (pass `index` to components)', + function () { + let calls = 0 assert.equal( asHtml( - - ).includes(sample.shouldNotContain), - false, - '`' + - key + - '` should not contain `' + - sample.shouldNotContain + - '` when disallowed' + {rest.children}

    + } + }} + /> + ), + '

    a

    ' ) + assert.equal(calls, 1) } - } -}) - -test('should throw if both allowed and disallowed types is specified', () => { - assert.throws(() => { - asHtml( - - ) - }, /only one of/i) -}) - -test('should be able to use a custom function to determine if the node should be allowed', () => { - const input = [ - '# Header', - '[react-markdown](https://github.com/remarkjs/react-markdown/) is a nice helper', - 'Also check out [my website](https://espen.codes/)' - ].join('\n\n') - - assert.equal( - asHtml( - - element.tagName !== 'a' || - (element.properties && - typeof element.properties.href === 'string' && - element.properties.href.indexOf('https://github.com/') === 0) - } - /> - ), - [ - '

    Header

    ', - '

    react-markdown is a nice helper

    ', - '

    Also check out

    ' - ].join('\n') ) -}) -test('should be able to override components', () => { - const input = - '# Header\n\nParagraph\n## New header\n1. List item\n2. List item 2\n\nFoo' - /** - * @param {number} level - */ - const heading = (level) => { - /** - * @param {object} props - * @param {Array} props.children - */ - const component = (props) => ( - {props.children} + await t.test('should support plugins (`remark-gfm`)', function () { + assert.equal( + asHtml(), + '

    a b c

    ' ) - return component - } - - const actual = asHtml( - - ) - - assert.equal( - actual, - 'Header\n

    Paragraph

    \nNew header\n
      \n
    1. List item
    2. \n
    3. List item 2
    4. \n
    \n

    Foo

    ' - ) -}) - -test('should throw on invalid component', () => { - const input = - '# Header\n\nParagraph\n## New header\n1. List item\n2. List item 2\n\nFoo' - const components = {h1: 123} - assert.throws( - () => - // @ts-expect-error runtime - asHtml(), - /Component for name `h1`/ - ) -}) - -test('can render the whole spectrum of markdown within a single run', async () => { - const inputUrl = new URL('fixtures/runthrough.md', import.meta.url) - const expectedUrl = new URL('fixtures/runthrough.html', import.meta.url) - const input = String(await fs.readFile(inputUrl)) - const expected = String(await fs.readFile(expectedUrl)) - - const actual = asHtml( - - ) - - assert.equal(actual, expected) -}) - -test('sanitizes certain dangerous urls for links by default', () => { - const error = console.error - - console.error = () => {} - - const input = [ - '# [Much fun](javascript:alert("foo"))', - "Can be had with [XSS links](vbscript:foobar('test'))", - '> And [other](VBSCRIPT:bap) nonsense... [files](file:///etc/passwd) for instance', - '## [Entities]( javascript:alert("bazinga")) can be tricky, too', - 'Regular [links](https://foo.bar) must [be]() allowed', - '[Some ref][xss]', - '[xss]: javascript:alert("foo") "Dangerous stuff"', - 'Should allow [mailto](mailto:ex@ample.com) and [tel](tel:13133) links tho', - 'Also, [protocol-agnostic](//google.com) should be allowed', - 'local [paths](/foo/bar) should be [allowed](foo)', - 'allow [weird](?javascript:foo) query strings and [hashes](foo#vbscript:orders)' - ].join('\n\n') - - const actual = asHtml() - assert.equal( - actual, - '

    Much fun

    \n

    Can be had with XSS links

    \n
    \n

    And other nonsense... files for instance

    \n
    \n

    Entities can be tricky, too

    \n

    Regular links must be allowed

    \n

    Some ref

    \n

    Should allow mailto and tel links tho

    \n

    Also, protocol-agnostic should be allowed

    \n

    local paths should be allowed

    \n

    allow weird query strings and hashes

    ' - ) - - console.error = error -}) - -test('allows specifying a custom URI-transformer', () => { - const input = - 'Received a great [pull request](https://github.com/remarkjs/react-markdown/pull/15) today' - const actual = asHtml( - uri.replace(/^https?:\/\/github\.com\//i, '/')} - /> - ) - assert.equal( - actual, - '

    Received a great pull request today

    ' - ) -}) - -test('should support turning off the default URI transform', () => { - const input = '[a](data:text/html,)' - const actual = asHtml() - const expected = - '

    a

    ' - assert.equal(actual, expected) -}) + }) + + await t.test('should support plugins (`remark-toc`)', function () { + assert.equal( + asHtml( + + ), + `

    a

    +

    Contents

    +
      +
    • b +
        +
      • c
      • +
      +
    • +
    • d
    • +
    +

    b

    +

    c

    +

    d

    ` + ) + }) -test('can use parser plugins', () => { - const input = 'a ~b~ c' - const actual = asHtml( - - ) - assert.equal(actual, '

    a b c

    ') -}) + await t.test('should support aria properties', function () { + assert.equal( + asHtml(), + '

    c

    ' + ) -test('supports checkbox lists', () => { - const input = '- [ ] Foo\n- [x] Bar\n\n---\n\n- Foo\n- Bar' - const actual = asHtml( - - ) - assert.equal( - actual, - '
      \n
    • Foo
    • \n
    • Bar
    • \n
    \n
    \n
      \n
    • Foo
    • \n
    • Bar
    • \n
    ' - ) -}) + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'input', + properties: {id: 'a', ariaDescribedBy: 'b', required: true}, + children: [] + }) + } + } + }) -test('should pass index of a node under its parent to components if `includeElementIndex` option is enabled', () => { - const input = 'Foo\n\nBar\n\nBaz' - const actual = asHtml( - {otherProps.children}

    - } - }} - /> - ) - assert.equal(actual, '

    Foo

    \n

    Bar

    \n

    Baz

    ') -}) + await t.test('should support data properties', function () { + assert.equal( + asHtml(), + '

    b

    ' + ) -test('should be able to render components with forwardRef in HOC', () => { - /** - * @typedef {import('react').Ref} Ref - * @typedef {JSX.IntrinsicElements['a'] & import('../lib/ast-to-react.js').ReactMarkdownProps} Props - */ - - /** - * @param {(params: Props) => JSX.Element} Component - */ - const wrapper = (Component) => - React.forwardRef( + function plugin() { /** - * @param {Props} props - * @param {Ref} ref + * @param {Root} tree + * @returns {undefined} */ - (props, ref) => + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'i', + properties: {dataWhatever: 'a', dataIgnoreThis: undefined}, + children: [] + }) + } + } + }) + + await t.test('should support comma separated properties', function () { + assert.equal( + asHtml(), + '

    c

    ' ) - /** - * @param {Props} props - */ - // eslint-disable-next-line react/jsx-no-target-blank - const wrapped = (props) => + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'i', + properties: {accept: ['a', 'b']}, + children: [] + }) + } + } + }) - const actual = asHtml( - - [Link](https://example.com/) - - ) - assert.equal( - actual, - '

    Link

    ' - ) -}) + await t.test('should support `style` properties', function () { + assert.equal( + asHtml(), + '

    a

    ' + ) -test('should render table of contents plugin', () => { - const input = [ - '# Header', - '## Table of Contents', - '## First Section', - '## Second Section', - '### Subsection', - '## Third Section' - ].join('\n') - - const actual = asHtml( - - ) + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'i', + properties: {style: 'color: red; font-weight: bold'}, + children: [] + }) + } + } + }) - assert.equal( - actual, - '

    Header

    \n

    Table of Contents

    \n\n

    First Section

    \n

    Second Section

    \n

    Subsection

    \n

    Third Section

    ' - ) -}) + await t.test( + 'should support `style` properties w/ vendor prefixes', + function () { + assert.equal( + asHtml(), + '

    a

    ' + ) -test('should pass `node` as prop to all non-tag/non-fragment components', () => { - const input = "# So, *headers... they're _cool_*\n\n" - const actual = asHtml( - { - text += child.value + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'i', + properties: {style: '-ms-b: 1; -webkit-c: 2'}, + children: [] }) - return text } - }} - /> - ) - assert.equal(actual, 'So, headers... they're cool') -}) - -test('should support formatting at the start of a GFM tasklist (GH-494)', () => { - const input = '- [ ] *a*' - const actual = asHtml( - + } + } ) - const expected = - '
      \n
    • a
    • \n
    ' - assert.equal(actual, expected) -}) - -test('should support aria properties', () => { - const input = 'c' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'input', - properties: {id: 'a', ariaDescribedBy: 'b', required: true}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    c

    ' - assert.equal(actual, expected) -}) - -test('should support data properties', () => { - const input = 'b' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'i', - properties: {dataWhatever: 'a', dataIgnoreThis: undefined}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    b

    ' - assert.equal(actual, expected) -}) - -test('should support comma separated properties', () => { - const input = 'c' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'i', - properties: {accept: ['a', 'b']}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    c

    ' - assert.equal(actual, expected) -}) -test('should support `style` properties', () => { - const input = 'a' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'i', - properties: {style: 'color: red; font-weight: bold'}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    a

    ' - assert.equal(actual, expected) -}) - -test('should support `style` properties w/ vendor prefixes', () => { - const input = 'a' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'i', - properties: {style: '-ms-b: 1; -webkit-c: 2'}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    a

    ' - assert.equal(actual, expected) -}) - -test('should support broken `style` properties', () => { - const input = 'a' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'i', - properties: {style: 'broken'}, - children: [] - }) - } - - const actual = asHtml() - const expected = '

    a

    ' - assert.equal(actual, expected) -}) + await t.test('should support broken `style` properties', function () { + assert.equal( + asHtml(), + '

    a

    ' + ) -test('should support SVG elements', () => { - const input = 'a' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({ - type: 'element', - tagName: 'svg', - properties: {xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 500 500'}, - children: [ - { - type: 'element', - tagName: 'title', - properties: {}, - children: [{type: 'text', value: 'SVG `` element'}] - }, - { - type: 'element', - tagName: 'circle', - properties: {cx: 120, cy: 120, r: 100}, - children: [] - }, - // `strokeMiterLimit` in hast, `strokeMiterlimit` in React. - { + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ type: 'element', - tagName: 'path', - properties: {strokeMiterLimit: -1}, + tagName: 'i', + properties: {style: 'broken'}, children: [] - } - ] - }) - } - - const actual = asHtml() - const expected = - 'SVG `<circle>` element

    a

    ' - assert.equal(actual, expected) -}) - -test('should support (ignore) comments', () => { - const input = 'a' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - root.children.unshift({type: 'comment', value: 'things!'}) - } - - const actual = asHtml() - const expected = '

    a

    ' - assert.equal(actual, expected) -}) - -test('should support table cells w/ style', () => { - const input = '| a |\n| :- |' - - /** @type {import('unified').Plugin, Root>} */ - const plugin = () => (root) => { - visit(root, {type: 'element', tagName: 'th'}, (node) => { - node.properties = {...node.properties, style: 'color: red'} - }) - } - - const actual = asHtml( - - ) - const expected = - '
    a
    ' + }) + } + } + }) - assert.equal(actual, expected) -}) + await t.test('should support SVG elements', function () { + assert.equal( + asHtml(), + 'SVG `<circle>` element

    a

    ' + ) -test('should crash on a plugin replacing `root`', () => { - const input = 'a' - /** @type {import('unified').Plugin, Root>} */ - // @ts-expect-error: runtime. - const plugin = () => () => ({type: 'comment', value: 'things!'}) - assert.throws(() => { - asHtml() - }, /Expected a `root` node/) -}) + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({ + type: 'element', + tagName: 'svg', + properties: { + viewBox: '0 0 500 500', + xmlns: 'http://www.w3.org/2000/svg' + }, + children: [ + { + type: 'element', + tagName: 'title', + properties: {}, + children: [{type: 'text', value: 'SVG `` element'}] + }, + { + type: 'element', + tagName: 'circle', + properties: {cx: 120, cy: 120, r: 100}, + children: [] + }, + // `strokeMiterLimit` in hast, `strokeMiterlimit` in React. + { + type: 'element', + tagName: 'path', + properties: {strokeMiterLimit: -1}, + children: [] + } + ] + }) + } + } + }) -test('should support remark plugins with array parameter', async () => { - const error = console.error - /** @type {string} */ - let message = '' + await t.test('should support comments (ignore them)', function () { + const input = 'a' + const actual = asHtml( + + ) + const expected = '

    a

    ' + assert.equal(actual, expected) - console.error = (/** @type {string} */ d) => { - message = d - } + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + tree.children.unshift({type: 'comment', value: 'things!'}) + } + } + }) + + await t.test('should support table cells w/ style', function () { + assert.equal( + asHtml( + + ), + '
    a
    ' + ) - const input = 'a' - /** @type {import('unified').Plugin>, Root>} */ - const plugin = () => () => {} + function plugin() { + /** + * @param {Root} tree + * @returns {undefined} + */ + return function (tree) { + visit(tree, 'element', function (node) { + if (node.tagName === 'th') { + node.properties = {...node.properties, style: 'color: red'} + } + }) + } + } + }) - const actual = asHtml( - - ) - const expected = '

    a

    ' - assert.equal(actual, expected) + await t.test('should fail on a plugin replacing `root`', function () { + assert.throws(function () { + asHtml() + }, /Expected a `root` node/) - assert.doesNotMatch(message, /Warning: Failed/, 'Prop types should be valid') - console.error = error + function plugin() { + /** + * @returns {Root} + */ + return function () { + // @ts-expect-error: check how non-roots are handled. + return {type: 'comment', value: 'things!'} + } + } + }) }) -test('should support rehype plugins with array parameter', async () => { - const error = console.error - /** @type {string} */ - let message = '' - - console.error = (/** @type {string} */ d) => { - message = d - } - - const input = 'a' - /** @type {import('unified').Plugin>, Root>} */ - const plugin = () => () => {} - - const actual = asHtml( - - ) - const expected = '

    a

    ' - assert.equal(actual, expected) +/** + * @param {ReturnType} input + * @returns {string} + */ +function asHtml(input) { + return renderToStaticMarkup(input) +} - assert.doesNotMatch(message, /Warning: Failed/, 'Prop types should be valid') - console.error = error -}) +/** + * @returns {undefined} + */ +function noop() {} diff --git a/tsconfig.json b/tsconfig.json index 31c68dc0..7baf4f67 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,11 +6,9 @@ "emitDeclarationOnly": true, "exactOptionalPropertyTypes": true, "jsx": "preserve", - "lib": ["dom", "es2020"], + "lib": ["dom", "es2022"], "module": "node16", "strict": true, - // To do: remove after update. - "skipLibCheck": true, "target": "es2020" }, "exclude": ["coverage/", "node_modules/", "**/*.min.js"],