diff --git a/.gitignore b/.gitignore index 33d4929..53a29e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ coverage/ node_modules/ .DS_Store +*.d.ts *.log yarn.lock diff --git a/index.js b/index.js index d9b07fc..3e93d5a 100644 --- a/index.js +++ b/index.js @@ -1,51 +1,104 @@ +/** + * @typedef {import('mdast').Root} MdastRoot + * @typedef {import('hast').Root} HastRoot + * @typedef {import('hast').Element} HastElement + * @typedef {HastRoot|HastRoot['children'][number]} HastNode + * @typedef {import('virtual-dom').VNode} VNode + * @typedef {import('virtual-dom').VChild} VChild + * @typedef {import('virtual-dom').h} H + * @typedef {import('hast-util-sanitize').Schema} Schema + * + * @callback Component + * @param {string} name + * @param {Record} props + * @param {VNode[]} children + * @returns {VNode|VNode[]} + * + * @typedef Options + * Configuration. + * @property {boolean|Schema|null|undefined} [sanitize] + * How to sanitize the output. + * + * Sanitation is done by `hast-util-sanitize`, except when `false` is + * given. + * If an object is passed in, it’s given as a schema to `sanitize`. + * By default, input is sanitized according to GitHub’s sanitation rules. + * + * Embedded HTML is **always** stripped. + * @property {string|null|undefined} [prefix='h-'] + * Optimization hint. + * @property {H|null|undefined} [h] + * Hyperscript to use. + * @property {Record} [components={}] + * Map of tag names to custom components. + * That component is invoked with `tagName`, `props`, and `children`. + * It can return any VDOM compatible value (such as `VNode`, `VText`, + * `Widget`). + */ + import {toHast} from 'mdast-util-to-hast' import {sanitize} from 'hast-util-sanitize' import {toH} from 'hast-to-hyperscript' -import hyperscript from 'virtual-dom/h.js' +import {h as defaultH} from 'virtual-dom' const own = {}.hasOwnProperty -// Attach a VDOM compiler. -export default function remarkVdom(options) { - const settings = options || {} - const info = settings.sanitize +/** + * Plugin to compile Markdown to Virtual DOM. + * + * @type {import('unified').Plugin<[Options?]|void[], MdastRoot, VNode>} + */ +export default function remarkVdom(options = {}) { + const info = options.sanitize const clean = info !== false const schema = info && typeof info === 'object' ? info : null - const components = settings.components || {} - const h = settings.h || hyperscript + const components = options.components || {} + const h = options.h || defaultH - this.Compiler = compiler + Object.assign(this, {Compiler: compiler}) - // Compile mdast to vdom. + /** @type {import('unified').CompilerFunction} */ function compiler(node) { + /** @type {HastNode} */ + // @ts-expect-error: assume a root w/o doctypes. let hast = div(toHast(node).children) if (clean) { - hast = sanitize(hast, schema) + hast = sanitize(hast, schema || undefined) // If `div` is removed by sanitation, add it back. if (hast.type === 'root') { + // @ts-expect-error: assume a root w/o doctypes. hast = div(hast.children) } } - return toH(w, hast, settings.prefix) + // @ts-expect-error: assume no doctypes. + return toH(w, hast, options.prefix) } - // Wrapper around `h` to pass components in. + /** + * Wrapper around `h` to pass components in. + * + * @param {string} name + * @param {object} props + * @param {VChild[]|undefined} children + * @returns {VNode|VNode[]} + */ function w(name, props, children) { const id = name.toLowerCase() const fn = own.call(components, id) ? components[id] : h - return fn(name, props, children) + // @ts-expect-error: vdom types vague. + return fn(name, props, children || []) } - // Wrap `children` in a hast div. + /** + * Wrap `children` in a hast div. + * + * @param {HastElement['children']} children + * @returns {HastElement} + */ function div(children) { - return { - type: 'element', - tagName: 'div', - properties: {}, - children - } + return {type: 'element', tagName: 'div', properties: {}, children} } } diff --git a/package.json b/package.json index dfac17c..daba41a 100644 --- a/package.json +++ b/package.json @@ -31,30 +31,41 @@ "sideEffects": false, "type": "module", "main": "index.js", + "types": "index.d.ts", "files": [ + "index.d.ts", "index.js" ], "dependencies": { + "@types/hast": "^2.3.2", + "@types/mdast": "^3.0.0", "hast-to-hyperscript": "^10.0.0", "hast-util-sanitize": "^4.0.0", "mdast-util-to-hast": "^11.0.0", + "unified": "^10.0.0", "virtual-dom": "^2.0.0" }, "devDependencies": { + "@types/tape": "^4.0.0", + "@types/virtual-dom": "^2.1.1", "c8": "^7.0.0", "prettier": "^2.0.0", "remark": "^14.0.0", "remark-cli": "^10.0.0", "remark-preset-wooorm": "^8.0.0", + "rimraf": "^3.0.0", "tape": "^5.0.0", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", "vdom-to-html": "^2.0.0", "xo": "^0.39.0" }, "scripts": { + "build": "rimraf \"*.d.ts\" && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node --conditions development test.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api", - "test": "npm run format && npm run test-coverage" + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { "tabWidth": 2, @@ -71,5 +82,11 @@ "plugins": [ "preset-wooorm" ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true, + "ignoreCatch": true } } diff --git a/test.js b/test.js index 28fcc49..af5f50a 100644 --- a/test.js +++ b/test.js @@ -1,10 +1,20 @@ +/** + * @typedef {import('./index.js').Options} Options + * @typedef {import('virtual-dom').VNode} VNode + */ + import test from 'tape' import {remark} from 'remark' -import h from 'virtual-dom/h.js' +import {h} from 'virtual-dom' +// @ts-expect-error: untyped. import vdom2html from 'vdom-to-html' import vdom from './index.js' test('remark-vdom', (t) => { + /** + * @param {string} [fixture] + * @param {Options} [options] + */ function check(fixture, options) { return vdom2html(remark().use(vdom, options).processSync(fixture).result) } @@ -23,6 +33,7 @@ test('remark-vdom', (t) => { t.equal( check('_Emphasis_!', { + // @ts-expect-error: TS tripping up, it’s fine. h(name, props, children) { return h(name === 'EM' ? 'I' : name, props, children) } @@ -50,7 +61,7 @@ test('remark-vdom', (t) => { t.equal( check('_Emphasis_!', { components: { - em(name, props, children) { + em(_, _1, children) { return children } } @@ -59,9 +70,9 @@ test('remark-vdom', (t) => { '`components`' ) - const node = remark() - .use(vdom, {prefix: 'f-'}) - .processSync('_Emphasis_!').result + const node = /** @type {VNode} */ ( + remark().use(vdom, {prefix: 'f-'}).processSync('_Emphasis_!').result + ) t.equal(node.key, 'f-1', '`prefix`') diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e31adf8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["*.js"], + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "strict": true + } +}