diff --git a/.gitignore b/.gitignore index c977c85..3699f82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -.DS_Store -*.d.ts -*.log coverage/ node_modules/ +.DS_Store +*.log +index.d.ts +test.d.ts yarn.lock diff --git a/complex-types.d.ts b/complex-types.d.ts new file mode 100644 index 0000000..e3daf92 --- /dev/null +++ b/complex-types.d.ts @@ -0,0 +1,15 @@ +import type {Literal} from 'hast' + +export interface Raw extends Literal { + type: 'raw' +} + +declare module 'hast' { + interface RootContentMap { + raw: Raw + } + + interface ElementContentMap { + raw: Raw + } +} diff --git a/index.js b/index.js index 5a39165..5449ad5 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ /** * @typedef {import('./lib/index.js').Options} Options + * @typedef {import('./complex-types').Raw} Raw */ export {raw} from './lib/index.js' diff --git a/lib/index.js b/lib/index.js index d00849c..90a2431 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,29 +1,31 @@ /** + * @typedef {import('vfile').VFile} VFile * @typedef {import('parse5').Document} P5Document * @typedef {import('parse5').DocumentFragment} P5Fragment * @typedef {Omit} P5Element * @typedef {import('parse5').Attribute} P5Attribute * @typedef {Omit & {startOffset: number|undefined, endOffset: number|undefined}} P5Location * @typedef {import('parse5').ParserOptions} P5ParserOptions - * @typedef {import('unist').Node} UnistNode - * @typedef {import('hast').Parent} Parent - * @typedef {import('hast').Literal} Literal * @typedef {import('hast').Root} Root * @typedef {import('hast').DocType} Doctype * @typedef {import('hast').Element} Element * @typedef {import('hast').Text} Text * @typedef {import('hast').Comment} Comment - * @typedef {Literal & {type: 'raw'}} Raw - * @typedef {Parent['children'][number]} Child - * @typedef {Child|Root|Raw} Node - * @typedef {import('vfile').VFile} VFile - * @typedef {Literal & {type: 'comment', value: {stitch: UnistNode}}} Stitch + * @typedef {import('hast').Content} Content + * @typedef {Root|Content} Node + * @typedef {import('../complex-types').Raw} Raw + * + * @typedef {Omit & {value: {stitch: Node}}} Stitch * * @typedef Options - * @property {Array.} [passThrough] List of custom hast node types to pass through (keep) in hast. If the passed through nodes have children, those children are expected to be hast and will be handled + * @property {Array.} [passThrough] + * List of custom hast node types to pass through (keep) in hast. + * If the passed through nodes have children, those children are expected to + * be hast and will be handled. * * @typedef HiddenTokenizer - * @property {Array.} __mixins Way too simple, but works for us. + * @property {Array.} __mixins + * Way too simple, but works for us. * @property {HiddenPreprocessor} preprocessor * @property {(value: string) => void} write * @property {Array.} tokenQueue @@ -89,322 +91,344 @@ const parseOptions = {sourceCodeLocationInfo: true, scriptingEnabled: false} * Given a hast tree and an optional vfile (for positional info), return a new * parsed-again hast tree. * - * @param {Node} tree Original hast tree - * @param {VFile} [file] Virtual file for positional info - * @param {Options} [options] Configuration + * @param tree + * Original hast tree. + * @param file + * Virtual file for positional info, optional. + * @param options + * Configuration. */ -export function raw(tree, file, options) { - let index = -1 - const parser = new Parser(parseOptions) - const one = zwitch('type', { - // @ts-expect-error: hush. - handlers: {root, element, text, comment, doctype, raw: handleRaw}, - // @ts-expect-error: hush. - unknown - }) - /** @type {boolean|undefined} */ - let stitches - /** @type {HiddenTokenizer|undefined} */ - let tokenizer - /** @type {HiddenPreprocessor|undefined} */ - let preprocessor - /** @type {HiddenPosTracker|undefined} */ - let posTracker - /** @type {HiddenLocationTracker|undefined} */ - let locationTracker - - if (isOptions(file)) { - options = file - file = undefined - } - - if (options && options.passThrough) { - while (++index < options.passThrough.length) { - // @ts-expect-error: hush. - one.handlers[options.passThrough[index]] = stitch - } - } - - const result = fromParse5(documentMode(tree) ? document() : fragment(), file) - - if (stitches) { - visit(result, 'comment', (node, index, parent) => { - const stitch = /** @type {Stitch} */ (node) - if (stitch.value.stitch && parent !== null && index !== null) { - parent.children[index] = stitch.value.stitch - return index - } - }) - } - - // Unpack if possible and when not given a `root`. - if ( - tree.type !== 'root' && - result.type === 'root' && - result.children.length === 1 - ) { - return result.children[0] - } - - return result - +export const raw = /** - * @returns {P5Fragment} + * @type {( + * ((tree: Node, file: VFile|undefined, options?: Options) => Node) & + * ((tree: Node, options?: Options) => Node) + * )} */ - function fragment() { - /** @type {P5Element} */ - const context = { - nodeName: 'template', - tagName: 'template', - attrs: [], - namespaceURI: webNamespaces.html, - childNodes: [] - } - /** @type {P5Element} */ - const mock = { - nodeName: 'documentmock', - tagName: 'documentmock', - attrs: [], - namespaceURI: webNamespaces.html, - childNodes: [] - } - /** @type {P5Fragment} */ - const doc = {nodeName: '#document-fragment', childNodes: []} - - parser._bootstrap(mock, context) - parser._pushTmplInsertionMode(inTemplateMode) - parser._initTokenizerForFragmentParsing() - parser._insertFakeRootElement() - parser._resetInsertionMode() - parser._findFormInFragmentContext() + ( + /** + * @param {Node} tree + * @param {VFile} [file] + * @param {Options} [options] + */ + function (tree, file, options) { + let index = -1 + const parser = new Parser(parseOptions) + const one = zwitch('type', { + // @ts-expect-error: hush. + handlers: {root, element, text, comment, doctype, raw: handleRaw}, + // @ts-expect-error: hush. + unknown + }) + /** @type {boolean|undefined} */ + let stitches + /** @type {HiddenTokenizer|undefined} */ + let tokenizer + /** @type {HiddenPreprocessor|undefined} */ + let preprocessor + /** @type {HiddenPosTracker|undefined} */ + let posTracker + /** @type {HiddenLocationTracker|undefined} */ + let locationTracker + + if (isOptions(file)) { + options = file + file = undefined + } - tokenizer = parser.tokenizer - /* c8 ignore next */ - if (!tokenizer) throw new Error('Expected `tokenizer`') - preprocessor = tokenizer.preprocessor - locationTracker = tokenizer.__mixins[0] - posTracker = locationTracker.posTracker + if (options && options.passThrough) { + while (++index < options.passThrough.length) { + // @ts-expect-error: hush. + one.handlers[options.passThrough[index]] = stitch + } + } - one(tree) + const result = fromParse5( + documentMode(tree) ? document() : fragment(), + file + ) + + if (stitches) { + visit(result, 'comment', (node, index, parent) => { + const stitch = /** @type {Stitch} */ (/** @type {unknown} */ (node)) + if (stitch.value.stitch && parent !== null && index !== null) { + parent.children[index] = stitch.value.stitch + return index + } + }) + } - parser._adoptNodes(mock.childNodes[0], doc) + // Unpack if possible and when not given a `root`. + if ( + tree.type !== 'root' && + result.type === 'root' && + result.children.length === 1 + ) { + return result.children[0] + } - return doc - } + return result + + /** + * @returns {P5Fragment} + */ + function fragment() { + /** @type {P5Element} */ + const context = { + nodeName: 'template', + tagName: 'template', + attrs: [], + namespaceURI: webNamespaces.html, + childNodes: [] + } + /** @type {P5Element} */ + const mock = { + nodeName: 'documentmock', + tagName: 'documentmock', + attrs: [], + namespaceURI: webNamespaces.html, + childNodes: [] + } + /** @type {P5Fragment} */ + const doc = {nodeName: '#document-fragment', childNodes: []} + + parser._bootstrap(mock, context) + parser._pushTmplInsertionMode(inTemplateMode) + parser._initTokenizerForFragmentParsing() + parser._insertFakeRootElement() + parser._resetInsertionMode() + parser._findFormInFragmentContext() + + tokenizer = parser.tokenizer + /* c8 ignore next */ + if (!tokenizer) throw new Error('Expected `tokenizer`') + preprocessor = tokenizer.preprocessor + locationTracker = tokenizer.__mixins[0] + posTracker = locationTracker.posTracker + + one(tree) + + parser._adoptNodes(mock.childNodes[0], doc) + + return doc + } - /** - * @returns {P5Document} - */ - function document() { - /** @type {P5Document} */ - const doc = parser.treeAdapter.createDocument() + /** + * @returns {P5Document} + */ + function document() { + /** @type {P5Document} */ + const doc = parser.treeAdapter.createDocument() - parser._bootstrap(doc, undefined) - tokenizer = parser.tokenizer - /* c8 ignore next */ - if (!tokenizer) throw new Error('Expected `tokenizer`') - preprocessor = tokenizer.preprocessor - locationTracker = tokenizer.__mixins[0] - posTracker = locationTracker.posTracker + parser._bootstrap(doc, undefined) + tokenizer = parser.tokenizer + /* c8 ignore next */ + if (!tokenizer) throw new Error('Expected `tokenizer`') + preprocessor = tokenizer.preprocessor + locationTracker = tokenizer.__mixins[0] + posTracker = locationTracker.posTracker - one(tree) + one(tree) - return doc - } + return doc + } - /** - * @param {Array.} nodes - * @returns {void} - */ - function all(nodes) { - let index = -1 + /** + * @param {Content[]} nodes + * @returns {void} + */ + function all(nodes) { + let index = -1 + + /* istanbul ignore else - invalid nodes, see rehypejs/rehype-raw#7. */ + if (nodes) { + while (++index < nodes.length) { + one(nodes[index]) + } + } + } - /* istanbul ignore else - invalid nodes, see rehypejs/rehype-raw#7. */ - if (nodes) { - while (++index < nodes.length) { - one(nodes[index]) + /** + * @param {Root} node + * @returns {void} + */ + function root(node) { + all(node.children) } - } - } - /** - * @param {Root} node - * @returns {void} - */ - function root(node) { - all(node.children) - } + /** + * @param {Element} node + * @returns {void} + */ + function element(node) { + resetTokenizer() + parser._processToken(startTag(node), webNamespaces.html) - /** - * @param {Element} node - * @returns {void} - */ - function element(node) { - resetTokenizer() - parser._processToken(startTag(node), webNamespaces.html) + all(node.children) - all(node.children) + if (!htmlVoidElements.includes(node.tagName)) { + resetTokenizer() + parser._processToken(endTag(node)) + } + } - if (!htmlVoidElements.includes(node.tagName)) { - resetTokenizer() - parser._processToken(endTag(node)) - } - } + /** + * @param {Text} node + * @returns {void} + */ + function text(node) { + resetTokenizer() + parser._processToken({ + type: characterToken, + chars: node.value, + location: createParse5Location(node) + }) + } - /** - * @param {Text} node - * @returns {void} - */ - function text(node) { - resetTokenizer() - parser._processToken({ - type: characterToken, - chars: node.value, - location: createParse5Location(node) - }) - } + /** + * @param {Doctype} node + * @returns {void} + */ + function doctype(node) { + resetTokenizer() + parser._processToken({ + type: doctypeToken, + name: 'html', + forceQuirks: false, + publicId: '', + systemId: '', + location: createParse5Location(node) + }) + } - /** - * @param {Doctype} node - * @returns {void} - */ - function doctype(node) { - resetTokenizer() - parser._processToken({ - type: doctypeToken, - name: 'html', - forceQuirks: false, - publicId: '', - systemId: '', - location: createParse5Location(node) - }) - } + /** + * @param {Comment|Stitch} node + * @returns {void} + */ + function comment(node) { + resetTokenizer() + parser._processToken({ + type: commentToken, + data: node.value, + location: createParse5Location(node) + }) + } - /** - * @param {Comment|Stitch} node - * @returns {void} - */ - function comment(node) { - resetTokenizer() - parser._processToken({ - type: commentToken, - data: node.value, - location: createParse5Location(node) - }) - } + /** + * @param {Raw} node + * @returns {void} + */ + function handleRaw(node) { + const start = pointStart(node) + const line = start.line || 1 + const column = start.column || 1 + const offset = start.offset || 0 + + /* c8 ignore next 4 */ + if (!preprocessor) throw new Error('Expected `preprocessor`') + if (!tokenizer) throw new Error('Expected `tokenizer`') + if (!posTracker) throw new Error('Expected `posTracker`') + if (!locationTracker) throw new Error('Expected `locationTracker`') + + // Reset preprocessor: + // See: . + preprocessor.html = undefined + preprocessor.pos = -1 + preprocessor.lastGapPos = -1 + preprocessor.lastCharPos = -1 + preprocessor.gapStack = [] + preprocessor.skipNextNewLine = false + preprocessor.lastChunkWritten = false + preprocessor.endOfChunkHit = false + + // Reset preprocessor mixin: + // See: . + posTracker.isEol = false + posTracker.lineStartPos = -column + 1 // Looks weird, but ensures we get correct positional info. + posTracker.droppedBufferSize = offset + posTracker.offset = 0 + posTracker.col = 1 + posTracker.line = line + + // Reset location tracker: + // See: . + locationTracker.currentAttrLocation = undefined + locationTracker.ctLoc = createParse5Location(node) + + // See the code for `parse` and `parseFragment`: + // See: . + tokenizer.write(node.value) + parser._runParsingLoop(undefined) + + // Process final characters if they’re still there after hibernating. + // Similar to: + // See: . + const token = tokenizer.currentCharacterToken + + if (token) { + token.location.endLine = posTracker.line + token.location.endCol = posTracker.col + 1 + token.location.endOffset = posTracker.offset + 1 + parser._processToken(token) + } + } - /** - * @param {Raw} node - * @returns {void} - */ - function handleRaw(node) { - const start = pointStart(node) - const line = start.line || 1 - const column = start.column || 1 - const offset = start.offset || 0 - - /* c8 ignore next 4 */ - if (!preprocessor) throw new Error('Expected `preprocessor`') - if (!tokenizer) throw new Error('Expected `tokenizer`') - if (!posTracker) throw new Error('Expected `posTracker`') - if (!locationTracker) throw new Error('Expected `locationTracker`') - - // Reset preprocessor: - // See: . - preprocessor.html = undefined - preprocessor.pos = -1 - preprocessor.lastGapPos = -1 - preprocessor.lastCharPos = -1 - preprocessor.gapStack = [] - preprocessor.skipNextNewLine = false - preprocessor.lastChunkWritten = false - preprocessor.endOfChunkHit = false - - // Reset preprocessor mixin: - // See: . - posTracker.isEol = false - posTracker.lineStartPos = -column + 1 // Looks weird, but ensures we get correct positional info. - posTracker.droppedBufferSize = offset - posTracker.offset = 0 - posTracker.col = 1 - posTracker.line = line - - // Reset location tracker: - // See: . - locationTracker.currentAttrLocation = undefined - locationTracker.ctLoc = createParse5Location(node) - - // See the code for `parse` and `parseFragment`: - // See: . - tokenizer.write(node.value) - parser._runParsingLoop(undefined) - - // Process final characters if they’re still there after hibernating. - // Similar to: - // See: . - const token = tokenizer.currentCharacterToken - - if (token) { - token.location.endLine = posTracker.line - token.location.endCol = posTracker.col + 1 - token.location.endOffset = posTracker.offset + 1 - parser._processToken(token) - } - } + /** + * @param {Node} node + */ + function stitch(node) { + stitches = true + + /** @type {Node} */ + let clone + + // Recurse, because to somewhat handle `[]` (where `[]` denotes the + // passed through node). + if ('children' in node) { + clone = { + ...node, + children: raw( + {type: 'root', children: node.children}, + file, + options + // @ts-expect-error Assume a given parent yields a parent. + ).children + } + } else { + clone = {...node} + } + + // Hack: `value` is supposed to be a string, but as none of the tools + // (`parse5` or `hast-util-from-parse5`) looks at it, we can pass nodes + // through. + comment({type: 'comment', value: {stitch: clone}}) + } - /** - * @param {UnistNode} node - */ - function stitch(node) { - const clone = Object.assign({}, node) - - stitches = true - - // Recurse, because to somewhat handle `[]` (where `[]` denotes the - // passed through node). - if ('children' in node) { - // @ts-expect-error Assume parent. - clone.children = raw( - // @ts-expect-error Assume parent. - {type: 'root', children: node.children}, - file, - options - // @ts-expect-error Assume parent. - ).children + function resetTokenizer() { + /* c8 ignore next */ + if (!tokenizer) throw new Error('Expected `tokenizer`') + + // Reset tokenizer: + // See: . + // Especially putting it back in the `data` state is useful: some elements, + // like textareas and iframes, change the state. + // See GH-7. + // But also if broken HTML is in `raw`, and then a correct element is given. + // See GH-11. + tokenizer.tokenQueue = [] + tokenizer.state = dataState + tokenizer.returnState = '' + tokenizer.charRefCode = -1 + tokenizer.tempBuff = [] + tokenizer.lastStartTagName = '' + tokenizer.consumedAfterSnapshot = -1 + tokenizer.active = false + tokenizer.currentCharacterToken = undefined + tokenizer.currentToken = undefined + tokenizer.currentAttr = undefined + } } - - // Hack: `value` is supposed to be a string, but as none of the tools - // (`parse5` or `hast-util-from-parse5`) looks at it, we can pass nodes - // through. - // @ts-expect-error - comment({value: {stitch: clone}}) - } - - function resetTokenizer() { - /* c8 ignore next */ - if (!tokenizer) throw new Error('Expected `tokenizer`') - - // Reset tokenizer: - // See: . - // Especially putting it back in the `data` state is useful: some elements, - // like textareas and iframes, change the state. - // See GH-7. - // But also if broken HTML is in `raw`, and then a correct element is given. - // See GH-11. - tokenizer.tokenQueue = [] - tokenizer.state = dataState - tokenizer.returnState = '' - tokenizer.charRefCode = -1 - tokenizer.tempBuff = [] - tokenizer.lastStartTagName = '' - tokenizer.consumedAfterSnapshot = -1 - tokenizer.active = false - tokenizer.currentCharacterToken = undefined - tokenizer.currentToken = undefined - tokenizer.currentAttr = undefined - } -} - + ) /** * @param {Element} node * @returns {HiddenToken} @@ -479,7 +503,7 @@ function documentMode(node) { } /** - * @param {Node} node + * @param {Node|Stitch} node * @returns {P5Location} */ function createParse5Location(node) { diff --git a/package.json b/package.json index dc05254..b455fbd 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "scripts": { "prepack": "npm run build && npm run format", - "build": "rimraf \"{lib/**,}*.d.ts\" && tsc && type-coverage", + "build": "rimraf \"lib/**/*.d.ts\" \"{index,test}.d.ts\" && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node test.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node test.js", diff --git a/test-types.d.ts b/test-types.d.ts new file mode 100644 index 0000000..dfca8aa --- /dev/null +++ b/test-types.d.ts @@ -0,0 +1,21 @@ +import type {Parent, Literal} from 'hast' + +export interface CustomParent extends Parent { + type: 'customParent' +} + +export interface CustomLiteral extends Literal { + type: 'customLiteral' +} + +declare module 'hast' { + interface RootContentMap { + customLiteral: CustomLiteral + customParent: CustomParent + } + + interface ElementContentMap { + customLiteral: CustomLiteral + customParent: CustomParent + } +} diff --git a/test.js b/test.js index 89839cc..b03c901 100644 --- a/test.js +++ b/test.js @@ -1,3 +1,8 @@ +/** + * @typedef {import('hast').Root} Root + * @typedef {import('./test-types')} DoNotTouchAsThisImportIncludesCustomInTree + */ + import test from 'tape' import {u} from 'unist-builder' import {h} from 'hastscript' @@ -10,10 +15,9 @@ import {raw} from './index.js' test('raw', (t) => { t.throws( () => { - // @ts-expect-error runtime. - raw(u('unknown')) + raw(u('root', [u('customLiteral', '')])) }, - /^Error: Cannot compile `unknown` node$/, + /^Error: Cannot compile `customLiteral` node$/, 'should throw for unknown nodes' ) @@ -70,7 +74,6 @@ test('raw', (t) => { t.deepEqual( raw( - // @ts-expect-error `raw` is nonstandard. u('root', [ h('img', {alt: 'foo', src: 'bar.jpg'}), u('raw', 'foo') @@ -84,7 +87,6 @@ test('raw', (t) => { ) t.deepEqual( - // @ts-expect-error `raw` is nonstandard. raw(u('root', [u('raw', '

Foo, bar!'), h('ol', h('li', 'baz'))])), u('root', {data: {quirksMode: false}}, [ h('p', 'Foo, bar!'), @@ -95,7 +97,6 @@ test('raw', (t) => { t.deepEqual( raw( - // @ts-expect-error `raw` is nonstandard. u('root', [ h('iframe', {height: 500, src: 'https://ddg.gg'}), u('raw', 'foo') @@ -110,7 +111,6 @@ test('raw', (t) => { t.deepEqual( raw( - // @ts-expect-error `raw` is nonstandard. u('root', [ h('textarea', u('text', 'Some text that is not HTML.')), u('raw', 'foo') @@ -125,7 +125,6 @@ test('raw', (t) => { t.deepEqual( raw( - // @ts-expect-error `raw` is nonstandard. u('root', [ u('raw', ''), u('raw', 'foo') @@ -140,7 +139,6 @@ test('raw', (t) => { t.deepEqual( raw( - // @ts-expect-error `raw` is nonstandard. u('root', [ u('raw', '