diff --git a/.gitignore b/.gitignore index 53a29e3..fcb2607 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ *.d.ts *.log yarn.lock +!/index.d.ts diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..528cf8b --- /dev/null +++ b/index.d.ts @@ -0,0 +1,22 @@ +import type {Root} from 'mdast' +import type {Plugin} from 'unified' +import type {Options} from './lib/index.js' + +export type {Options} from './lib/index.js' + +/** + * Add support for serializing to HTML. + * + * @this + * Unified processor. + * @param + * Configuration (optional). + * @returns + * Nothing. + */ +declare const remarkHtml: Plugin< + [(Readonly | null | undefined)?], + Root, + string +> +export default remarkHtml diff --git a/index.js b/index.js index a49a278..6a4c25d 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,2 @@ -/** - * @typedef {import('./lib/index.js').Options} Options - */ - +// Note: types exposed from `index.d.ts`. export {default} from './lib/index.js' diff --git a/lib/index.js b/lib/index.js index 5ddfac8..17eb009 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,67 +1,73 @@ /** - * @typedef {import('mdast').Root} Root * @typedef {import('hast-util-sanitize').Schema} Schema - * + * @typedef {import('hast-util-to-html').Options} ToHtmlOptions + * @typedef {import('mdast').Root} Root + * @typedef {import('mdast-util-to-hast').Handlers} Handlers + * @typedef {import('unified').Compiler} Compiler + * @typedef {import('unified').Processor} Processor + */ + +/** * @typedef ExtraOptionsFields - * Configuration (optional). - * @property {boolean|Schema|null} [sanitize] - * How to sanitize the output. - * @property {import('mdast-util-to-hast').Handlers} [handlers={}] - * Object mapping mdast nodes to functions handling them. + * Extra fields. + * @property {Readonly | null | undefined} [handlers] + * How to turn mdast nodes into hast nodes (optional); + * passed to `mdast-util-to-hast`. + * @property {Readonly | boolean | null | undefined} [sanitize] + * Sanitize the output, and how (default: `true`). * - * @typedef {import('hast-util-to-html').Options & ExtraOptionsFields} Options + * @typedef {ToHtmlOptions & ExtraOptionsFields} Options + * Configuration. */ -import {toHtml} from 'hast-util-to-html' import {sanitize} from 'hast-util-sanitize' import {toHast} from 'mdast-util-to-hast' +import {toHtml} from 'hast-util-to-html' + +/** @type {Readonly} */ +const emptyOptions = {} /** - * Plugin to serialize markdown as HTML. + * Serialize markdown as HTML. * - * @this {import('unified').Processor} - * @type {import('unified').Plugin<[Options?] | [], Root, string>} + * @param {Readonly | null | undefined} [options] + * Configuration (optional). + * @returns {undefined} + * Nothing. */ -export default function remarkHtml(settings = {}) { - const options = {...settings} - /** @type {boolean|undefined} */ - let clean - - if (typeof options.sanitize === 'boolean') { - clean = options.sanitize - // @ts-expect-error: to do: fix. - options.sanitize = undefined - } +export default function remarkHtml(options) { + /** @type {Processor} */ + // @ts-expect-error: TS in JSDoc generates wrong types if `this` is typed regularly. + // eslint-disable-next-line unicorn/no-this-assignment + const self = this + const {handlers, sanitize: clean, ...toHtmlOptions} = options || emptyOptions + let allowDangerousHtml = false + /** @type {Readonly | undefined} */ + let schema - if (typeof clean !== 'boolean') { - clean = true + if (typeof clean === 'boolean') { + allowDangerousHtml = !clean + } else if (clean) { + schema = clean } - Object.assign(this, {compiler}) + self.compiler = compiler /** - * @type {import('unified').Compiler} + * @type {Compiler} */ - function compiler(node, file) { - const hast = toHast(node, { - allowDangerousHtml: !clean, - handlers: options.handlers - }) - // @ts-expect-error: to do: no longer boolean. - const cleanHast = clean ? sanitize(hast, options.sanitize) : hast - const result = toHtml( - cleanHast, - Object.assign({}, options, {allowDangerousHtml: !clean}) - ) + function compiler(tree, file) { + const hast = toHast(tree, {handlers, allowDangerousHtml}) + const safeHast = allowDangerousHtml ? hast : sanitize(hast, schema) + const result = toHtml(safeHast, {...toHtmlOptions, allowDangerousHtml}) if (file.extname) { file.extname = '.html' } // Add an eof eol. - return node && - node.type && - node.type === 'root' && + return tree && + tree.type === 'root' && result && /[^\r\n]/.test(result.charAt(result.length - 1)) ? result + '\n' diff --git a/package.json b/package.json index c89c26a..f0da438 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,14 @@ }, "xo": { "overrides": [ + { + "files": [ + "**/*.ts" + ], + "rules": { + "@typescript-eslint/ban-types": "off" + } + }, { "files": [ "test/**/*.js" diff --git a/test/index.js b/test/index.js index bdebdd0..a023a0c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,7 +1,7 @@ /** - * @typedef {import('mdast').Root} Root - * @typedef {import('mdast').Paragraph} Paragraph * @typedef {import('hast').Element} Element + * @typedef {import('mdast').Paragraph} Paragraph + * @typedef {import('mdast').Root} Root * @typedef {import('unified').Pluggable} Pluggable * @typedef {import('../index.js').Options} Options */ @@ -24,24 +24,31 @@ import {VFile} from 'vfile' import remarkHtml from '../index.js' test('remarkHtml', async function (t) { - await t.test('should stringify unknown nodes', async function () { + await t.test('should expose the public api', async function () { + assert.deepEqual(Object.keys(await import('../index.js')).sort(), [ + 'default' + ]) + }) + + await t.test('should stringify unknown void nodes', async function () { assert.equal( unified() .use(remarkParse) - .use(remarkHtml, {sanitize: false}) + .use(remarkHtml) + // @ts-expect-error: check how an unknown node is handled. .stringify({type: 'alpha'}), '
' ) }) - await t.test('should stringify unknown nodes', async function () { + await t.test('should stringify unknown nodes w/ children', async function () { assert.equal( unified() .use(remarkParse) - .use(remarkHtml, {sanitize: false}) + .use(remarkHtml) .stringify({ + // @ts-expect-error: check how an unknown node is handled. type: 'alpha', - // @ts-expect-error: unknown node. children: [ {type: 'strong', children: [{type: 'text', value: 'bravo'}]} ] @@ -50,32 +57,34 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should stringify unknown nodes', async function () { - assert.equal( - unified() - .use(remarkParse) - .use(remarkHtml, {sanitize: false}) - .stringify({ - type: 'alpha', - // @ts-expect-error: unknown node. - children: [{type: 'text', value: 'bravo'}], - data: { - hName: 'i', - hProperties: {className: 'charlie'}, - hChildren: [{type: 'text', value: 'delta'}] - } - }), - 'delta' - ) - }) + await t.test( + 'should stringify unknown nodes w/ data fields', + async function () { + assert.equal( + unified() + .use(remarkParse) + .use(remarkHtml, {sanitize: false}) + .stringify({ + // @ts-expect-error: check how an unknown node is handled. + type: 'alpha', + children: [{type: 'text', value: 'bravo'}], + data: { + hName: 'i', + hProperties: {className: 'charlie'}, + hChildren: [{type: 'text', value: 'delta'}] + } + }), + 'delta' + ) + } + ) - await t.test('should allow overriding handlers', async function () { + await t.test('should support handlers', async function () { assert.equal( String( await unified() .use(remarkParse) .use(remarkHtml, { - sanitize: false, handlers: { /** @param {Paragraph} node */ paragraph(state, node) { @@ -108,35 +117,51 @@ test('remarkHtml', async function (t) { String( await unified() .use(remarkParse) - .use( - /** @type {import('unified').Plugin} */ - () => (ast) => { - // @ts-expect-error: assume it exists. - ast.children[0].children[0].data = { + .use(function () { + /** + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + const paragraph = tree.children[0] + assert(paragraph.type === 'paragraph') + const image = paragraph.children[0] + assert(image.type === 'image') + image.data = { hProperties: {title: 'overwrite'} } } - ) - .use(remarkHtml, {sanitize: false}) + }) + .use(remarkHtml) .process('![hello](example.jpg "overwritten")') ), '

hello

\n' ) }) - await t.test('should overwrite a tag-name', async function () { + await t.test('should overwrite a tag name', async function () { assert.equal( String( await unified() .use(remarkParse) - .use( - /** @type {import('unified').Plugin} */ - () => (ast) => { - // @ts-expect-error: assume it exists. - ast.children[0].children[0].data = {hName: 'b'} + .use(function () { + /** + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + const paragraph = tree.children[0] + assert(paragraph.type === 'paragraph') + const strong = paragraph.children[0] + assert(strong.type === 'strong') + strong.data = {hName: 'b'} } - ) - .use(remarkHtml, {sanitize: false}) + }) + .use(remarkHtml) .process('**Bold!**') ), '

Bold!

\n' @@ -148,24 +173,30 @@ test('remarkHtml', async function (t) { String( await unified() .use(remarkParse) - .use( - /** @type {import('unified').Plugin} */ - () => (ast) => { - // @ts-expect-error: assume it exists. - const code = ast.children[0].children[0] - - code.data = { + .use(function () { + /** + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + const paragraph = tree.children[0] + assert(paragraph.type === 'paragraph') + const inlineCode = paragraph.children[0] + assert(inlineCode.type === 'inlineCode') + inlineCode.data = { hChildren: [ { type: 'element', tagName: 'span', properties: {className: ['token']}, - children: [{type: 'text', value: code.value}] + children: [{type: 'text', value: inlineCode.value}] } ] } } - ) + }) .use(remarkHtml, {sanitize: false}) .process('`var`') ), @@ -173,52 +204,60 @@ test('remarkHtml', async function (t) { ) }) - await t.test( - 'should not overwrite content in `sanitize` mode', - async function () { - assert.equal( - String( - await unified() - .use(remarkParse) - .use( - /** @type {import('unified').Plugin} */ - () => (ast) => { - // @ts-expect-error: assume it exists. - const code = ast.children[0].children[0] - - code.data = { - hChildren: [ - { - type: 'element', - tagName: 'output', - properties: {className: ['token']}, - children: [{type: 'text', value: code.value}] - } - ] - } + await t.test('should sanitize overwriten content', async function () { + assert.equal( + String( + await unified() + .use(remarkParse) + .use(function () { + /** + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + const paragraph = tree.children[0] + assert(paragraph.type === 'paragraph') + const inlineCode = paragraph.children[0] + assert(inlineCode.type === 'inlineCode') + inlineCode.data = { + hChildren: [ + { + type: 'element', + tagName: 'span', + properties: {className: ['token']}, + children: [{type: 'text', value: inlineCode.value}] + } + ] } - ) - .use(remarkHtml, {sanitize: true}) - .process('`var`') - ), - '

var

\n' - ) - } - ) + } + }) + .use(remarkHtml, {sanitize: true}) + .process('`var`') + ), + '

var

\n' + ) + }) await t.test('should overwrite classes on code', async function () { assert.equal( String( await unified() .use(remarkParse) - .use( - /** @type {import('unified').Plugin} */ - () => (ast) => { - ast.children[0].data = { - hProperties: {className: 'foo'} - } + .use(function () { + /** + * @param {Root} tree + * Tree. + * @returns {undefined} + * Nothing. + */ + return function (tree) { + const code = tree.children[0] + assert(code.type === 'code') + code.data = {hProperties: {className: 'foo'}} } - ) + }) .use(remarkHtml, {sanitize: false}) .process('```js\nvar\n```\n') ), @@ -226,7 +265,7 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should be `sanitation: true` by default', async function () { + await t.test('should be `sanitize: true` by default', async function () { assert.equal( String( await unified() @@ -238,7 +277,7 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should support sanitation: true', async function () { + await t.test('should support `sanitize: true`', async function () { assert.equal( String( await unified() @@ -250,7 +289,7 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should support sanitation: null', async function () { + await t.test('should support `sanitize: null`', async function () { assert.equal( String( await unified() @@ -262,7 +301,7 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should support sanitation: false', async function () { + await t.test('should support `sanitize: false`', async function () { assert.equal( String( await unified() @@ -274,7 +313,7 @@ test('remarkHtml', async function (t) { ) }) - await t.test('should support sanitation schemas', async function () { + await t.test('should support sanitize schemas', async function () { assert.equal( String( await unified() @@ -292,7 +331,7 @@ test('CommonMark', async function (t) { const skip = new Set() let start = 0 let index = -1 - /** @type {string|undefined} */ + /** @type {string | undefined} */ let section while (++index < commonmark.length) { @@ -355,11 +394,7 @@ test('fixtures', async function (t) { } catch {} const actual = String( - await unified() - .use(remarkParse) - // @ts-expect-error: to do. - .use(remarkHtml, config) - .process(input) + await unified().use(remarkParse).use(remarkHtml, config).process(input) ) try { @@ -386,8 +421,7 @@ test('integrations', async function (t) { gfm: remarkGfm, github: remarkGithub, toc: [ - // @ts-expect-error: legacy. - // To do: remove? + // @ts-expect-error: legacy; to do: remove? remarkSlug, remarkToc ] @@ -410,7 +444,7 @@ test('integrations', async function (t) { const actual = String( await unified() .use(remarkParse) - // @ts-expect-error: to do. + // @ts-expect-error: fine. .use(integrationMap[folder]) .use(remarkHtml, {sanitize: false}) .process(new VFile({path: folder + '.md', value: input})) diff --git a/tsconfig.json b/tsconfig.json index 1c08c37..ad1496e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,9 @@ "exactOptionalPropertyTypes": true, "lib": ["es2020"], "module": "node16", - // To do: remove soon. - "skipLibCheck": true, "strict": true, "target": "es2020" }, "exclude": ["coverage/", "node_modules/"], - "include": ["**/*.js"] + "include": ["**/*.js", "index.d.ts"] }