diff --git a/.editorconfig b/.editorconfig index c6c8b36..0f17867 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,9 @@ root = true [*] -indent_style = space -indent_size = 2 -end_of_line = lf charset = utf-8 -trim_trailing_whitespace = true +end_of_line = lf +indent_size = 2 +indent_style = space insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/bb.yml b/.github/workflows/bb.yml index 0198fc3..3dbfce5 100644 --- a/.github/workflows/bb.yml +++ b/.github/workflows/bb.yml @@ -1,9 +1,3 @@ -name: bb -on: - issues: - types: [opened, reopened, edited, closed, labeled, unlabeled] - pull_request_target: - types: [opened, reopened, edited, closed, labeled, unlabeled] jobs: main: runs-on: ubuntu-latest @@ -11,3 +5,9 @@ jobs: - uses: unifiedjs/beep-boop-beta@main with: repo-token: ${{secrets.GITHUB_TOKEN}} +name: bb +on: + issues: + types: [closed, edited, labeled, opened, reopened, unlabeled] + pull_request_target: + types: [closed, edited, labeled, opened, reopened, unlabeled] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 69924a4..ade3921 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,21 +1,21 @@ -name: main -on: - - pull_request - - push jobs: main: name: ${{matrix.node}} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: dcodeIO/setup-node-nvm@master + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{matrix.node}} - run: npm install - run: npm test - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v5 strategy: matrix: node: - - lts/fermium + - lts/hydrogen - node +name: main +on: + - pull_request + - push diff --git a/.gitignore b/.gitignore index c977c85..388388c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ -.DS_Store *.d.ts *.log +*.map +*.tsbuildinfo +.DS_Store coverage/ node_modules/ yarn.lock diff --git a/.npmrc b/.npmrc index 43c97e7..3757b30 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ +ignore-scripts=true package-lock=false diff --git a/.prettierignore b/.prettierignore index 619aa6b..88f61cf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,3 @@ -coverage/ *.html *.md +coverage/ diff --git a/.remarkignore b/.remarkignore index a843dc4..8be5c35 100644 --- a/.remarkignore +++ b/.remarkignore @@ -1 +1 @@ -test/fixtures +test/fixtures/ diff --git a/index.js b/index.js index 53052ea..4f680a7 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,13 @@ /** - * @typedef {import('./lib/types.js').Options} Options - * @typedef {import('./lib/types.js').Context} Context - * @typedef {import('./lib/types.js').H} H - * @typedef {import('./lib/types.js').Handle} Handle + * @typedef {import('./lib/state.js').Handle} Handle + * @typedef {import('./lib/state.js').NodeHandle} NodeHandle + * @typedef {import('./lib/state.js').Options} Options + * @typedef {import('./lib/state.js').State} State */ -export {one, all, defaultHandlers, toMdast} from './lib/index.js' +export {toMdast} from './lib/index.js' + +export { + handlers as defaultHandlers, + nodeHandlers as defaultNodeHandlers +} from './lib/handlers/index.js' diff --git a/lib/all.js b/lib/all.js deleted file mode 100644 index 042f2b3..0000000 --- a/lib/all.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @typedef {import('./types.js').H} H - * @typedef {import('./types.js').Node} Node - * @typedef {import('./types.js').MdastNode} MdastNode - */ - -import {one} from './one.js' - -/** - * @param {H} h - * @param {Node} parent - * @returns {Array} - */ -export function all(h, parent) { - /** @type {Array} */ - // @ts-expect-error Assume `parent` is a parent. - const nodes = parent.children || [] - /** @type {Array} */ - const values = [] - let index = -1 - - while (++index < nodes.length) { - // @ts-expect-error assume `parent` is a parent. - const result = one(h, nodes[index], parent) - - if (Array.isArray(result)) { - values.push(...result) - } else if (result) { - values.push(result) - } - } - - let start = 0 - let end = values.length - - while (start < end && values[start].type === 'break') { - start++ - } - - while (end > start && values[end - 1].type === 'break') { - end-- - } - - return start === 0 && end === values.length - ? values - : values.slice(start, end) -} diff --git a/lib/handlers/a.js b/lib/handlers/a.js index a78f70d..4b77ddc 100644 --- a/lib/handlers/a.js +++ b/lib/handlers/a.js @@ -1,27 +1,30 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Properties} Properties + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Link, PhrasingContent} from 'mdast' */ -import {all} from '../all.js' -import {resolve} from '../util/resolve.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Link} + * mdast node. */ -export function a(h, node) { - /** @type {Properties} */ - // @ts-expect-error: `props` are defined. - const props = node.properties - return h( - node, - 'link', - { - title: props.title || null, - url: resolve(h, String(props.href || '') || null) - }, - all(h, node) - ) +export function a(state, node) { + const properties = node.properties || {} + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (state.all(node)) + + /** @type {Link} */ + const result = { + type: 'link', + url: state.resolve(String(properties.href || '') || null), + title: properties.title ? String(properties.title) : null, + children + } + state.patch(node, result) + return result } diff --git a/lib/handlers/base.js b/lib/handlers/base.js index 8b5e9fa..b533230 100644 --- a/lib/handlers/base.js +++ b/lib/handlers/base.js @@ -1,16 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' */ /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {undefined} + * Nothing. */ -export function base(h, node) { - if (!h.baseFound) { - h.frozenBaseUrl = - String((node.properties && node.properties.href) || '') || null - h.baseFound = true +export function base(state, node) { + if (!state.baseFound) { + state.frozenBaseUrl = + String((node.properties && node.properties.href) || '') || undefined + state.baseFound = true } } diff --git a/lib/handlers/blockquote.js b/lib/handlers/blockquote.js index d76e5f7..81ed068 100644 --- a/lib/handlers/blockquote.js +++ b/lib/handlers/blockquote.js @@ -1,14 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Blockquote} from 'mdast' */ -import {wrapChildren} from '../util/wrap-children.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Blockquote} + * mdast node. */ -export function blockquote(h, node) { - return h(node, 'blockquote', wrapChildren(h, node)) +export function blockquote(state, node) { + /** @type {Blockquote} */ + const result = {type: 'blockquote', children: state.toFlow(state.all(node))} + state.patch(node, result) + return result } diff --git a/lib/handlers/br.js b/lib/handlers/br.js index cd79a08..d0a85aa 100644 --- a/lib/handlers/br.js +++ b/lib/handlers/br.js @@ -1,12 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Break} from 'mdast' */ /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Break} + * mdast node. */ -export function br(h, node) { - return h.wrapText ? h(node, 'break') : h(node, 'text', ' ') +export function br(state, node) { + /** @type {Break} */ + const result = {type: 'break'} + state.patch(node, result) + return result } diff --git a/lib/handlers/code.js b/lib/handlers/code.js index 0e8a58b..e08c679 100644 --- a/lib/handlers/code.js +++ b/lib/handlers/code.js @@ -1,37 +1,37 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').ElementChild} ElementChild + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Code} from 'mdast' */ -import {convertElement} from 'hast-util-is-element' import {toText} from 'hast-util-to-text' import {trimTrailingLines} from 'trim-trailing-lines' -import {wrapText} from '../util/wrap-text.js' const prefix = 'language-' -const pre = convertElement('pre') -const isCode = convertElement('code') - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Code} + * mdast node. */ -export function code(h, node) { +export function code(state, node) { const children = node.children let index = -1 - /** @type {Array|undefined} */ + /** @type {Array | undefined} */ let classList - /** @type {string|undefined} */ + /** @type {string | undefined} */ let lang - if (pre(node)) { + if (node.tagName === 'pre') { while (++index < children.length) { const child = children[index] if ( - isCode(child) && + child.type === 'element' && + child.tagName === 'code' && child.properties && child.properties.className && Array.isArray(child.properties.className) @@ -53,10 +53,13 @@ export function code(h, node) { } } - return h( - node, - 'code', - {lang: lang || null, meta: null}, - trimTrailingLines(wrapText(h, toText(node))) - ) + /** @type {Code} */ + const result = { + type: 'code', + lang: lang || null, + meta: null, + value: trimTrailingLines(toText(node)) + } + state.patch(node, result) + return result } diff --git a/lib/handlers/comment.js b/lib/handlers/comment.js index ad5ffd9..fe029e6 100644 --- a/lib/handlers/comment.js +++ b/lib/handlers/comment.js @@ -1,13 +1,23 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Comment} Comment + * @import {State} from 'hast-util-to-mdast' + * @import {Comment} from 'hast' + * @import {Html} from 'mdast' */ -import {wrapText} from '../util/wrap-text.js' /** - * @type {Handle} - * @param {Comment} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Html} + * mdast node. */ -export function comment(h, node) { - return h(node, 'html', '') +export function comment(state, node) { + /** @type {Html} */ + const result = { + type: 'html', + value: '' + } + state.patch(node, result) + return result } diff --git a/lib/handlers/del.js b/lib/handlers/del.js index c2731b8..da35484 100644 --- a/lib/handlers/del.js +++ b/lib/handlers/del.js @@ -1,14 +1,23 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Delete, PhrasingContent} from 'mdast' */ -import {all} from '../all.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Delete} + * mdast node. */ -export function del(h, node) { - return h(node, 'delete', all(h, node)) +export function del(state, node) { + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (state.all(node)) + /** @type {Delete} */ + const result = {type: 'delete', children} + state.patch(node, result) + return result } diff --git a/lib/handlers/dl.js b/lib/handlers/dl.js index 0c9de32..469d2cf 100644 --- a/lib/handlers/dl.js +++ b/lib/handlers/dl.js @@ -1,60 +1,64 @@ /** - * @typedef {import('../types.js').H} H - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').ElementChild} ElementChild - * @typedef {import('../types.js').MdastNode} MdastNode - * @typedef {import('../types.js').MdastListContent} MdastListContent - * @typedef {import('../types.js').MdastBlockContent} MdastBlockContent - * @typedef {import('../types.js').MdastDefinitionContent} MdastDefinitionContent - * + * @import {State} from 'hast-util-to-mdast' + * @import {ElementContent, Element} from 'hast' + * @import {BlockContent, DefinitionContent, ListContent, ListItem, List} from 'mdast' + */ + +/** * @typedef Group + * Title/definition group. * @property {Array} titles - * @property {Array} definitions + * One or more titles. + * @property {Array} definitions + * One or more definitions. */ -import {convertElement} from 'hast-util-is-element' import {listItemsSpread} from '../util/list-items-spread.js' -import {wrapListItems} from '../util/wrap-list-items.js' - -const div = convertElement('div') -const dt = convertElement('dt') -const dd = convertElement('dd') /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {List | undefined} + * mdast node. */ -export function dl(h, node) { - const children = node.children - let index = -1 - /** @type {Array} */ - let clean = [] +export function dl(state, node) { + /** @type {Array} */ + const clean = [] /** @type {Array} */ const groups = [] - /** @type {Group} */ - let group = {titles: [], definitions: []} - /** @type {ElementChild} */ - let child - /** @type {Array} */ - let result + let index = -1 // Unwrap `
`s - while (++index < children.length) { - child = children[index] - clean = clean.concat(div(child) ? child.children : child) + while (++index < node.children.length) { + const child = node.children[index] + + if (child.type === 'element' && child.tagName === 'div') { + clean.push(...child.children) + } else { + clean.push(child) + } } + /** @type {Group} */ + let group = {definitions: [], titles: []} index = -1 // Group titles and definitions. while (++index < clean.length) { - child = clean[index] + const child = clean[index] + + if (child.type === 'element' && child.tagName === 'dt') { + const previous = clean[index - 1] - if (dt(child)) { - if (dd(clean[index - 1])) { + if ( + previous && + previous.type === 'element' && + previous.tagName === 'dd' + ) { groups.push(group) - group = {titles: [], definitions: []} + group = {definitions: [], titles: []} } group.titles.push(child) @@ -67,13 +71,13 @@ export function dl(h, node) { // Create items. index = -1 - /** @type {Array} */ + /** @type {Array} */ const content = [] while (++index < groups.length) { - result = [ - ...handle(h, groups[index].titles), - ...handle(h, groups[index].definitions) + const result = [ + ...handle(state, groups[index].titles), + ...handle(state, groups[index].definitions) ] if (result.length > 0) { @@ -88,29 +92,37 @@ export function dl(h, node) { // Create a list if there are items. if (content.length > 0) { - return h( - node, - 'list', - {ordered: false, start: null, spread: listItemsSpread(content)}, - content - ) + /** @type {List} */ + const result = { + type: 'list', + ordered: false, + start: null, + spread: listItemsSpread(content), + children: content + } + state.patch(node, result) + return result } } /** - * @param {H} h - * @param {Array} children - * @returns {Array} + * @param {State} state + * State. + * @param {Array} children + * hast element children to transform. + * @returns {Array} + * mdast nodes. */ -function handle(h, children) { - const nodes = wrapListItems(h, {type: 'element', tagName: 'x', children}) +function handle(state, children) { + const nodes = state.all({type: 'root', children}) + const listItems = state.toSpecificContent(nodes, create) - if (nodes.length === 0) { + if (listItems.length === 0) { return [] } - if (nodes.length === 1) { - return nodes[0].children + if (listItems.length === 1) { + return listItems[0].children } return [ @@ -118,8 +130,15 @@ function handle(h, children) { type: 'list', ordered: false, start: null, - spread: listItemsSpread(nodes), - children: nodes + spread: listItemsSpread(listItems), + children: listItems } ] } + +/** + * @returns {ListItem} + */ +function create() { + return {type: 'listItem', spread: false, checked: null, children: []} +} diff --git a/lib/handlers/em.js b/lib/handlers/em.js index 79d3fb7..96a11f8 100644 --- a/lib/handlers/em.js +++ b/lib/handlers/em.js @@ -1,14 +1,24 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Emphasis, PhrasingContent} from 'mdast' */ -import {all} from '../all.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Emphasis} + * mdast node. */ -export function em(h, node) { - return h(node, 'emphasis', all(h, node)) +export function em(state, node) { + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (state.all(node)) + + /** @type {Emphasis} */ + const result = {type: 'emphasis', children} + state.patch(node, result) + return result } diff --git a/lib/handlers/heading.js b/lib/handlers/heading.js index ffb8872..8a16946 100644 --- a/lib/handlers/heading.js +++ b/lib/handlers/heading.js @@ -1,24 +1,30 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').MdastNode} MdastNode + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Heading, PhrasingContent} from 'mdast' */ -import {all} from '../all.js' +import {dropSurroundingBreaks} from '../util/drop-surrounding-breaks.js' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Heading} + * mdast node. */ -export function heading(h, node) { - // `else` shouldn’t happen, of course… - /* c8 ignore next */ - const depth = Number(node.tagName.charAt(1)) || 1 - const wrap = h.wrapText - - h.wrapText = false - const result = h(node, 'heading', {depth}, all(h, node)) - h.wrapText = wrap +export function heading(state, node) { + const depth = /** @type {Heading['depth']} */ ( + /* c8 ignore next */ + Number(node.tagName.charAt(1)) || 1 + ) + const children = dropSurroundingBreaks( + /** @type {Array} */ (state.all(node)) + ) + /** @type {Heading} */ + const result = {type: 'heading', depth, children} + state.patch(node, result) return result } diff --git a/lib/handlers/hr.js b/lib/handlers/hr.js index 0c0acbd..d5d713a 100644 --- a/lib/handlers/hr.js +++ b/lib/handlers/hr.js @@ -1,12 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {ThematicBreak} from 'mdast' */ /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {ThematicBreak} + * mdast node. */ -export function hr(h, node) { - return h(node, 'thematicBreak') +export function hr(state, node) { + /** @type {ThematicBreak} */ + const result = {type: 'thematicBreak'} + state.patch(node, result) + return result } diff --git a/lib/handlers/iframe.js b/lib/handlers/iframe.js index 4a5dbe2..c9e76c9 100644 --- a/lib/handlers/iframe.js +++ b/lib/handlers/iframe.js @@ -1,33 +1,35 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Properties} Properties + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Link} from 'mdast' */ -import {resolve} from '../util/resolve.js' -import {wrapText} from '../util/wrap-text.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Link | undefined} + * mdast node. */ -export function iframe(h, node) { - /** @type {Properties} */ - // @ts-expect-error: `props` are defined. - const props = node.properties - const src = String(props.src || '') - const title = String(props.title || '') +export function iframe(state, node) { + const properties = node.properties || {} + const source = String(properties.src || '') + const title = String(properties.title || '') // Only create a link if there is a title. // We can’t use the content of the frame because conforming HTML parsers treat // it as text, whereas legacy parsers treat it as HTML, so it will likely // contain tags that will show up in text. - if (src && title) { - return { + if (source && title) { + /** @type {Link} */ + const result = { type: 'link', title: null, - url: resolve(h, src), - children: [{type: 'text', value: wrapText(h, title)}] + url: state.resolve(source), + children: [{type: 'text', value: title}] } + state.patch(node, result) + return result } } diff --git a/lib/handlers/img.js b/lib/handlers/img.js index e11b962..56f733c 100644 --- a/lib/handlers/img.js +++ b/lib/handlers/img.js @@ -1,22 +1,27 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Properties} Properties + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Image} from 'mdast' */ -import {resolve} from '../util/resolve.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Image} + * mdast node. */ -export function img(h, node) { - /** @type {Properties} */ - // @ts-expect-error: `props` are defined. - const props = node.properties - return h(node, 'image', { - url: resolve(h, String(props.src || '') || null), - title: props.title || null, - alt: props.alt || '' - }) +export function img(state, node) { + const properties = node.properties || {} + + /** @type {Image} */ + const result = { + type: 'image', + url: state.resolve(String(properties.src || '') || null), + title: properties.title ? String(properties.title) : null, + alt: properties.alt ? String(properties.alt) : '' + } + state.patch(node, result) + return result } diff --git a/lib/handlers/index.js b/lib/handlers/index.js index 4b166de..f694402 100644 --- a/lib/handlers/index.js +++ b/lib/handlers/index.js @@ -1,5 +1,8 @@ -import {all} from '../all.js' -import {wrapChildren} from '../util/wrap-children.js' +/** + * @import {State} from 'hast-util-to-mdast' + * @import {Parents} from 'hast' + */ + import {a} from './a.js' import {base} from './base.js' import {blockquote} from './blockquote.js' @@ -30,12 +33,25 @@ import {text} from './text.js' import {textarea} from './textarea.js' import {wbr} from './wbr.js' -export const handlers = { - root, - text, +/** + * Default handlers for nodes. + * + * Each key is a node type, each value is a `NodeHandler`. + */ +export const nodeHandlers = { comment, doctype: ignore, + root, + text +} +/** + * Default handlers for elements. + * + * Each key is an element name, each value is a `Handler`. + */ +export const handlers = { + // Ignore: applet: ignore, area: ignore, basefont: ignore, @@ -74,6 +90,7 @@ export const handlers = { title: ignore, track: ignore, + // Use children: abbr: all, acronym: all, bdi: all, @@ -113,27 +130,29 @@ export const handlers = { thead: all, time: all, - address: wrapChildren, - article: wrapChildren, - aside: wrapChildren, - body: wrapChildren, - center: wrapChildren, - div: wrapChildren, - fieldset: wrapChildren, - figcaption: wrapChildren, - figure: wrapChildren, - form: wrapChildren, - footer: wrapChildren, - header: wrapChildren, - hgroup: wrapChildren, - html: wrapChildren, - legend: wrapChildren, - main: wrapChildren, - multicol: wrapChildren, - nav: wrapChildren, - picture: wrapChildren, - section: wrapChildren, + // Use children as flow. + address: flow, + article: flow, + aside: flow, + body: flow, + center: flow, + div: flow, + fieldset: flow, + figcaption: flow, + figure: flow, + form: flow, + footer: flow, + header: flow, + hgroup: flow, + html: flow, + legend: flow, + main: flow, + multicol: flow, + nav: flow, + picture: flow, + section: flow, + // Handle. a, audio: media, b: strong, @@ -188,4 +207,27 @@ export const handlers = { xmp: code } +/** + * @param {State} state + * State. + * @param {Parents} node + * Parent to transform. + */ +function all(state, node) { + return state.all(node) +} + +/** + * @param {State} state + * State. + * @param {Parents} node + * Parent to transform. + */ +function flow(state, node) { + return state.toFlow(state.all(node)) +} + +/** + * @returns {undefined} + */ function ignore() {} diff --git a/lib/handlers/inline-code.js b/lib/handlers/inline-code.js index f266c4a..dc2d193 100644 --- a/lib/handlers/inline-code.js +++ b/lib/handlers/inline-code.js @@ -1,15 +1,22 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {InlineCode} from 'mdast' */ import {toText} from 'hast-util-to-text' -import {wrapText} from '../util/wrap-text.js' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {InlineCode} + * mdast node. */ -export function inlineCode(h, node) { - return h(node, 'inlineCode', wrapText(h, toText(node))) +export function inlineCode(state, node) { + /** @type {InlineCode} */ + const result = {type: 'inlineCode', value: toText(node)} + state.patch(node, result) + return result } diff --git a/lib/handlers/input.js b/lib/handlers/input.js index 4c155c9..5f12d3c 100644 --- a/lib/handlers/input.js +++ b/lib/handlers/input.js @@ -1,75 +1,85 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Properties} Properties - * @typedef {import('../types.js').MdastNode} MdastNode + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Image, Link, Text} from 'mdast' + * @import {Options} from '../util/find-selected-options.js' */ -import {convertElement} from 'hast-util-is-element' import {findSelectedOptions} from '../util/find-selected-options.js' -import {own} from '../util/own.js' -import {resolve} from '../util/resolve.js' -import {wrapText} from '../util/wrap-text.js' -const datalist = convertElement('datalist') +const defaultChecked = '[x]' +const defaultUnchecked = '[ ]' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Array | Image | Text | undefined} + * mdast node. */ // eslint-disable-next-line complexity -export function input(h, node) { - /** @type {Properties} */ - // @ts-expect-error: `props` are defined. - const props = node.properties - let value = String(props.value || props.placeholder || '') - /** @type {Array} */ - const results = [] - /** @type {Array} */ - const texts = [] - /** @type {Array<[string, string|null]>} */ - let values = [] - let index = -1 - /** @type {string} */ - let list - - if (props.disabled || props.type === 'hidden' || props.type === 'file') { +export function input(state, node) { + const properties = node.properties || {} + const value = String(properties.value || properties.placeholder || '') + + if ( + properties.disabled || + properties.type === 'hidden' || + properties.type === 'file' + ) { return } - if (props.type === 'checkbox' || props.type === 'radio') { - return h( - node, - 'text', - wrapText(h, h[props.checked ? 'checked' : 'unchecked']) - ) + if (properties.type === 'checkbox' || properties.type === 'radio') { + /** @type {Text} */ + const result = { + type: 'text', + value: properties.checked + ? state.options.checked || defaultChecked + : state.options.unchecked || defaultUnchecked + } + state.patch(node, result) + return result } - if (props.type === 'image') { - return props.alt || value - ? h(node, 'image', { - url: resolve(h, String(props.src || '') || null), - title: wrapText(h, String(props.title || '')) || null, - alt: wrapText(h, String(props.alt || value)) - }) - : [] + if (properties.type === 'image') { + const alt = properties.alt || value + + if (alt) { + /** @type {Image} */ + const result = { + type: 'image', + url: state.resolve(String(properties.src || '') || null), + title: String(properties.title || '') || null, + alt: String(alt) + } + state.patch(node, result) + return result + } + + return } + /** @type {Options} */ + let values = [] + if (value) { - values = [[value, null]] + values = [[value, undefined]] } else if ( // `list` is not supported on these types: - props.type !== 'password' && - props.type !== 'file' && - props.type !== 'submit' && - props.type !== 'reset' && - props.type !== 'button' && - props.list + properties.type !== 'button' && + properties.type !== 'file' && + properties.type !== 'password' && + properties.type !== 'reset' && + properties.type !== 'submit' && + properties.list ) { - list = String(props.list).toUpperCase() + const list = String(properties.list) + const datalist = state.elementById.get(list) - if (own.call(h.nodeById, list) && datalist(h.nodeById[list])) { - values = findSelectedOptions(h, h.nodeById[list], props) + if (datalist && datalist.tagName === 'datalist') { + values = findSelectedOptions(datalist, properties) } } @@ -78,26 +88,27 @@ export function input(h, node) { } // Hide password value. - if (props.type === 'password') { + if (properties.type === 'password') { // Passwords don’t support `list`. - values[0] = ['•'.repeat(values[0][0].length), null] + values[0] = ['•'.repeat(values[0][0].length), undefined] } - if (props.type === 'url' || props.type === 'email') { + if (properties.type === 'email' || properties.type === 'url') { + /** @type {Array} */ + const results = [] + let index = -1 + while (++index < values.length) { - value = resolve(h, values[index][0]) - - results.push( - h( - node, - 'link', - { - title: null, - url: wrapText(h, props.type === 'email' ? 'mailto:' + value : value) - }, - [{type: 'text', value: wrapText(h, values[index][1] || value)}] - ) - ) + const value = state.resolve(values[index][0]) + /** @type {Link} */ + const result = { + type: 'link', + title: null, + url: properties.type === 'email' ? 'mailto:' + value : value, + children: [{type: 'text', value: values[index][1] || value}] + } + + results.push(result) if (index !== values.length - 1) { results.push({type: 'text', value: ', '}) @@ -107,6 +118,10 @@ export function input(h, node) { return results } + /** @type {Array} */ + const texts = [] + let index = -1 + while (++index < values.length) { texts.push( values[index][1] @@ -115,5 +130,8 @@ export function input(h, node) { ) } - return h(node, 'text', wrapText(h, texts.join(', '))) + /** @type {Text} */ + const result = {type: 'text', value: texts.join(', ')} + state.patch(node, result) + return result } diff --git a/lib/handlers/li.js b/lib/handlers/li.js index 01f1ffa..4192c5c 100644 --- a/lib/handlers/li.js +++ b/lib/handlers/li.js @@ -1,51 +1,147 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').ElementChild} ElementChild - * @typedef {import('../types.js').MdastNode} MdastNode + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {ListItem} from 'mdast' */ -import {convertElement} from 'hast-util-is-element' -import {wrapChildren} from '../util/wrap-children.js' +/** + * @typedef ExtractResult + * Result of extracting a leading checkbox. + * @property {Element | undefined} checkbox + * The checkbox that was removed, if any. + * @property {Element} rest + * If there was a leading checkbox, a deep clone of the node w/o the leading + * checkbox; otherwise a reference to the given, untouched, node. + */ -const p = convertElement('p') -const input = convertElement('input') +import {phrasing} from 'hast-util-phrasing' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {ListItem} + * mdast node. */ -export function li(h, node) { - const head = node.children[0] - /** @type {boolean|null} */ - let checked = null - /** @type {ElementChild} */ - let checkbox - /** @type {Element|undefined} */ - let clone - - // Check if this node starts with a checkbox. - if (p(head)) { - checkbox = head.children[0] - - if ( - input(checkbox) && - checkbox.properties && - (checkbox.properties.type === 'checkbox' || - checkbox.properties.type === 'radio') - ) { - checked = Boolean(checkbox.properties.checked) - clone = { - ...node, - children: [ - {...head, children: head.children.slice(1)}, - ...node.children.slice(1) - ] +export function li(state, node) { + // If the list item starts with a checkbox, remove the checkbox and mark the + // list item as a GFM task list item. + const {rest, checkbox} = extractLeadingCheckbox(node) + const checked = checkbox ? Boolean(checkbox.properties.checked) : null + const spread = spreadout(rest) + const children = state.toFlow(state.all(rest)) + + /** @type {ListItem} */ + const result = {type: 'listItem', spread, checked, children} + state.patch(node, result) + return result +} + +/** + * Check if an element should spread out. + * + * The reason to spread out a markdown list item is primarily whether writing + * the equivalent in markdown, would yield a spread out item. + * + * A spread out item results in `

` and `

` tags. + * Otherwise, the phrasing would be output directly. + * We can check for that: if there’s a `

` element, spread it out. + * + * But what if there are no paragraphs? + * In that case, we can also assume that if two “block” things were written in + * an item, that it is spread out, because blocks are typically joined by blank + * lines, which also means a spread item. + * + * Lastly, because in HTML things can be wrapped in a `

` or similar, we + * delve into non-phrasing elements here to figure out if they themselves + * contain paragraphs or 2 or more flow non-phrasing elements. + * + * @param {Readonly} node + * @returns {boolean} + */ +function spreadout(node) { + let index = -1 + let seenFlow = false + + while (++index < node.children.length) { + const child = node.children[index] + + if (child.type === 'element') { + if (phrasing(child)) continue + + if (child.tagName === 'p' || seenFlow || spreadout(child)) { + return true } + + seenFlow = true } } - const content = wrapChildren(h, clone || node) + return false +} + +/** + * Extract a leading checkbox from a list item. + * + * If there was a leading checkbox, makes a deep clone of the node w/o the + * leading checkbox; otherwise a reference to the given, untouched, node is + * given back. + * + * So for example: + * + * ```html + *
  • Text
  • + * ``` + * + * …becomes: + * + * ```html + *
  • Text
  • + * ``` + * + * ```html + *
  • Text

  • + * ``` + * + * …becomes: + * + * ```html + *
  • Text

  • + * ``` + * + * @param {Readonly} node + * @returns {ExtractResult} + */ +function extractLeadingCheckbox(node) { + const head = node.children[0] + + if ( + head && + head.type === 'element' && + head.tagName === 'input' && + head.properties && + (head.properties.type === 'checkbox' || head.properties.type === 'radio') + ) { + const rest = {...node, children: node.children.slice(1)} + return {checkbox: head, rest} + } + + // The checkbox may be nested in another element. + // If the first element has children, look for a leading checkbox inside it. + // + // This only handles nesting in `

    ` elements, which is most common. + // It’s possible a leading checkbox might be nested in other types of flow or + // phrasing elements (and *deeply* nested, which is not possible with `

    `). + // Limiting things to `

    ` elements keeps this simpler for now. + if (head && head.type === 'element' && head.tagName === 'p') { + const {checkbox, rest: restHead} = extractLeadingCheckbox(head) + + if (checkbox) { + const rest = {...node, children: [restHead, ...node.children.slice(1)]} + return {checkbox, rest} + } + } - return h(node, 'listItem', {spread: content.length > 1, checked}, content) + return {checkbox: undefined, rest: node} } diff --git a/lib/handlers/list.js b/lib/handlers/list.js index eca7ee7..e5b251d 100644 --- a/lib/handlers/list.js +++ b/lib/handlers/list.js @@ -1,36 +1,47 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {ListItem, List} from 'mdast' */ -import {convertElement} from 'hast-util-is-element' -import {hasProperty} from 'hast-util-has-property' import {listItemsSpread} from '../util/list-items-spread.js' -import {wrapListItems} from '../util/wrap-list-items.js' - -const ol = convertElement('ol') /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {List} + * mdast node. */ -export function list(h, node) { - const ordered = ol(node) - const children = wrapListItems(h, node) - /** @type {number|null} */ +export function list(state, node) { + const ordered = node.tagName === 'ol' + const children = state.toSpecificContent(state.all(node), create) + /** @type {number | null} */ let start = null if (ordered) { - start = hasProperty(node, 'start') - ? // @ts-expect-error: `props` exist. - Number.parseInt(String(node.properties.start), 10) - : 1 + start = + node.properties && node.properties.start + ? Number.parseInt(String(node.properties.start), 10) + : 1 } - return h( - node, - 'list', - {ordered, start, spread: listItemsSpread(children)}, + /** @type {List} */ + const result = { + type: 'list', + ordered, + start, + spread: listItemsSpread(children), children - ) + } + state.patch(node, result) + return result +} + +/** + * @returns {ListItem} + */ +function create() { + return {type: 'listItem', spread: false, checked: null, children: []} } diff --git a/lib/handlers/media.js b/lib/handlers/media.js index 823c35b..cb5e799 100644 --- a/lib/handlers/media.js +++ b/lib/handlers/media.js @@ -1,38 +1,38 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Properties} Properties - * @typedef {import('../types.js').ElementChild} ElementChild + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Image, Link, PhrasingContent, RootContent as MdastRootContent, Root} from 'mdast' */ -import {convertElement} from 'hast-util-is-element' import {toString} from 'mdast-util-to-string' -import {visit, EXIT} from 'unist-util-visit' -import {all} from '../all.js' -import {resolve} from '../util/resolve.js' +import {EXIT, visit} from 'unist-util-visit' import {wrapNeeded} from '../util/wrap.js' -const source = convertElement('source') -const video = convertElement('video') - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Array | Link} + * mdast node. */ -export function media(h, node) { - let nodes = all(h, node) - /** @type {Properties} */ - // @ts-expect-error: `props` are defined. - const properties = node.properties - const poster = video(node) && String(properties.poster || '') - let src = String(properties.src || '') +export function media(state, node) { + const properties = node.properties || {} + const poster = node.tagName === 'video' ? String(properties.poster || '') : '' + let source = String(properties.src || '') let index = -1 - /** @type {boolean} */ let linkInFallbackContent = false - /** @type {ElementChild} */ - let child + let nodes = state.all(node) + + /** @type {Root} */ + const fragment = {type: 'root', children: nodes} - visit({type: 'root', children: nodes}, 'link', findLink) + visit(fragment, function (node) { + if (node.type === 'link') { + linkInFallbackContent = true + return EXIT + } + }) // If the content links to something, or if it’s not phrasing… if (linkInFallbackContent || wrapNeeded(nodes)) { @@ -40,38 +40,43 @@ export function media(h, node) { } // Find the source. - while (!src && ++index < node.children.length) { - child = node.children[index] - if (source(child)) { - // @ts-expect-error: `props` are defined. - src = String(child.properties.src || '') + while (!source && ++index < node.children.length) { + const child = node.children[index] + + if ( + child.type === 'element' && + child.tagName === 'source' && + child.properties + ) { + source = String(child.properties.src || '') } } // If there’s a poster defined on the video, create an image. if (poster) { - nodes = [ - { - type: 'image', - title: null, - url: resolve(h, poster), - alt: toString({children: nodes}) - } - ] + /** @type {Image} */ + const image = { + type: 'image', + title: null, + url: state.resolve(poster), + alt: toString(nodes) + } + state.patch(node, image) + nodes = [image] } + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (nodes) + // Link to the media resource. - return { + /** @type {Link} */ + const result = { type: 'link', - // @ts-expect-error Types are broken. - title: node.properties.title || null, - url: resolve(h, src), - // @ts-expect-error Assume phrasing content. - children: nodes - } - - function findLink() { - linkInFallbackContent = true - return EXIT + title: properties.title ? String(properties.title) : null, + url: state.resolve(source), + children } + state.patch(node, result) + return result } diff --git a/lib/handlers/p.js b/lib/handlers/p.js index 9c19b2b..fbfb590 100644 --- a/lib/handlers/p.js +++ b/lib/handlers/p.js @@ -1,18 +1,30 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Paragraph, PhrasingContent} from 'mdast' */ -import {all} from '../all.js' +import {dropSurroundingBreaks} from '../util/drop-surrounding-breaks.js' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Paragraph | undefined} + * mdast node. */ -export function p(h, node) { - const nodes = all(h, node) +export function p(state, node) { + const children = dropSurroundingBreaks( + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + /** @type {Array} */ (state.all(node)) + ) - if (nodes.length > 0) { - return h(node, 'paragraph', nodes) + if (children.length > 0) { + /** @type {Paragraph} */ + const result = {type: 'paragraph', children} + state.patch(node, result) + return result } } diff --git a/lib/handlers/q.js b/lib/handlers/q.js index 9b05fc5..33abbf0 100644 --- a/lib/handlers/q.js +++ b/lib/handlers/q.js @@ -1,28 +1,43 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').MdastNode} MdastNode + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {RootContent as MdastRootContent} from 'mdast' */ -import {all} from '../all.js' +const defaultQuotes = ['"'] /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Array} + * mdast nodes. */ -export function q(h, node) { - const expected = h.quotes[h.qNesting % h.quotes.length] +export function q(state, node) { + const quotes = state.options.quotes || defaultQuotes - h.qNesting++ - const contents = all(h, node) - h.qNesting-- + state.qNesting++ + const contents = state.all(node) + state.qNesting-- - contents.unshift({type: 'text', value: expected.charAt(0)}) + const quote = quotes[state.qNesting % quotes.length] + const head = contents[0] + const tail = contents[contents.length - 1] + const open = quote.charAt(0) + const close = quote.length > 1 ? quote.charAt(1) : quote - contents.push({ - type: 'text', - value: expected.length > 1 ? expected.charAt(1) : expected - }) + if (head && head.type === 'text') { + head.value = open + head.value + } else { + contents.unshift({type: 'text', value: open}) + } + + if (tail && tail.type === 'text') { + tail.value += close + } else { + contents.push({type: 'text', value: close}) + } return contents } diff --git a/lib/handlers/root.js b/lib/handlers/root.js index 7c0ec82..4d5c5eb 100644 --- a/lib/handlers/root.js +++ b/lib/handlers/root.js @@ -1,21 +1,28 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Root} Root + * @import {State} from 'hast-util-to-mdast' + * @import {Root as HastRoot} from 'hast' + * @import {Root as MdastRoot} from 'mdast' */ -import {all} from '../all.js' import {wrap, wrapNeeded} from '../util/wrap.js' /** - * @type {Handle} - * @param {Root} node + * @param {State} state + * State. + * @param {Readonly} node + * hast root to transform. + * @returns {MdastRoot} + * mdast node. */ -export function root(h, node) { - let children = all(h, node) +export function root(state, node) { + let children = state.all(node) - if (h.document || wrapNeeded(children)) { + if (state.options.document || wrapNeeded(children)) { children = wrap(children) } - return h(node, 'root', children) + /** @type {MdastRoot} */ + const result = {type: 'root', children} + state.patch(node, result) + return result } diff --git a/lib/handlers/select.js b/lib/handlers/select.js index f5408b6..b7809fc 100644 --- a/lib/handlers/select.js +++ b/lib/handlers/select.js @@ -1,29 +1,34 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Text} from 'mdast' */ import {findSelectedOptions} from '../util/find-selected-options.js' -import {wrapText} from '../util/wrap-text.js' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Text | undefined} + * mdast node. */ -export function select(h, node) { - const values = findSelectedOptions(h, node) +export function select(state, node) { + const values = findSelectedOptions(node) let index = -1 /** @type {Array} */ const results = [] - /** @type {[string, string|null]} */ - let value while (++index < values.length) { - value = values[index] + const value = values[index] results.push(value[1] ? value[1] + ' (' + value[0] + ')' : value[0]) } if (results.length > 0) { - return h(node, 'text', wrapText(h, results.join(', '))) + /** @type {Text} */ + const result = {type: 'text', value: results.join(', ')} + state.patch(node, result) + return result } } diff --git a/lib/handlers/strong.js b/lib/handlers/strong.js index 721e386..42023a8 100644 --- a/lib/handlers/strong.js +++ b/lib/handlers/strong.js @@ -1,14 +1,24 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {PhrasingContent, Strong} from 'mdast' */ -import {all} from '../all.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Strong} + * mdast node. */ -export function strong(h, node) { - return h(node, 'strong', all(h, node)) +export function strong(state, node) { + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (state.all(node)) + + /** @type {Strong} */ + const result = {type: 'strong', children} + state.patch(node, result) + return result } diff --git a/lib/handlers/table-cell.js b/lib/handlers/table-cell.js index dd733ef..73b03cb 100644 --- a/lib/handlers/table-cell.js +++ b/lib/handlers/table-cell.js @@ -1,29 +1,38 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').MdastNode} MdastNode + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {PhrasingContent, TableCell} from 'mdast' */ -import {all} from '../all.js' - /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {TableCell} + * mdast node. */ -export function tableCell(h, node) { - const wrap = h.wrapText +export function tableCell(state, node) { + // Allow potentially “invalid” nodes, they might be unknown. + // We also support straddling later. + const children = /** @type {Array} */ (state.all(node)) - h.wrapText = false + /** @type {TableCell} */ + const result = {type: 'tableCell', children} + state.patch(node, result) - const result = h(node, 'tableCell', all(h, node)) + if (node.properties) { + const rowSpan = node.properties.rowSpan + const colSpan = node.properties.colSpan - if (node.properties && (node.properties.rowSpan || node.properties.colSpan)) { - const data = result.data || (result.data = {}) - if (node.properties.rowSpan) data.rowSpan = node.properties.rowSpan - if (node.properties.colSpan) data.colSpan = node.properties.colSpan + if (rowSpan || colSpan) { + const data = /** @type {Record} */ ( + result.data || (result.data = {}) + ) + if (rowSpan) data.hastUtilToMdastTemporaryRowSpan = rowSpan + if (colSpan) data.hastUtilToMdastTemporaryColSpan = colSpan + } } - h.wrapText = wrap - return result } diff --git a/lib/handlers/table-row.js b/lib/handlers/table-row.js index 56f3c36..303ce1b 100644 --- a/lib/handlers/table-row.js +++ b/lib/handlers/table-row.js @@ -1,14 +1,29 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {RowContent, TableRow} from 'mdast' */ -import {all} from '../all.js' +/** + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {TableRow} + * mdast node. + */ +export function tableRow(state, node) { + const children = state.toSpecificContent(state.all(node), create) + + /** @type {TableRow} */ + const result = {type: 'tableRow', children} + state.patch(node, result) + return result +} /** - * @type {Handle} - * @param {Element} node + * @returns {RowContent} */ -export function tableRow(h, node) { - return h(node, 'tableRow', all(h, node)) +function create() { + return {type: 'tableCell', children: []} } diff --git a/lib/handlers/table.js b/lib/handlers/table.js index 0b37eca..dfa4567 100644 --- a/lib/handlers/table.js +++ b/lib/handlers/table.js @@ -1,42 +1,60 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').MdastNode} MdastNode - * @typedef {import('../types.js').MdastTableContent} MdastTableContent - * @typedef {import('../types.js').MdastRowContent} MdastRowContent - * @typedef {import('../types.js').MdastPhrasingContent} MdastPhrasingContent - * + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {AlignType, RowContent, TableContent, Table, Text} from 'mdast' + */ + +/** * @typedef Info - * @property {Array} align + * Inferred info on a table. + * @property {Array} align + * Alignment. * @property {boolean} headless + * Whether a `thead` is missing. */ -import {convertElement} from 'hast-util-is-element' import {toText} from 'hast-util-to-text' -import {visit, SKIP} from 'unist-util-visit' -import {wrapText} from '../util/wrap-text.js' -import {all} from '../all.js' - -const thead = convertElement('thead') -const tr = convertElement('tr') -const cell = convertElement(['th', 'td']) +import {SKIP, visit} from 'unist-util-visit' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Table | Text} + * mdast node. */ -export function table(h, node) { - if (h.inTable) { - return h(node, 'text', wrapText(h, toText(node))) +// eslint-disable-next-line complexity +export function table(state, node) { + // Ignore nested tables. + if (state.inTable) { + /** @type {Text} */ + const result = {type: 'text', value: toText(node)} + state.patch(node, result) + return result } - h.inTable = true + state.inTable = true + + const {align, headless} = inspect(node) + const rows = state.toSpecificContent(state.all(node), createRow) + + // Add an empty header row. + if (headless) { + rows.unshift(createRow()) + } - const {headless, align} = inspect(node) - const rows = toRows(all(h, node), headless) - let columns = 1 let rowIndex = -1 + while (++rowIndex < rows.length) { + const row = rows[rowIndex] + const cells = state.toSpecificContent(row.children, createCell) + row.children = cells + } + + let columns = 1 + rowIndex = -1 + while (++rowIndex < rows.length) { const cells = rows[rowIndex].children let cellIndex = -1 @@ -45,8 +63,11 @@ export function table(h, node) { const cell = cells[cellIndex] if (cell.data) { - const colSpan = Number.parseInt(String(cell.data.colSpan), 10) || 1 - const rowSpan = Number.parseInt(String(cell.data.rowSpan), 10) || 1 + const data = /** @type {Record} */ (cell.data) + const colSpan = + Number.parseInt(String(data.hastUtilToMdastTemporaryColSpan), 10) || 1 + const rowSpan = + Number.parseInt(String(data.hastUtilToMdastTemporaryRowSpan), 10) || 1 if (colSpan > 1 || rowSpan > 1) { let otherRowIndex = rowIndex - 1 @@ -61,7 +82,7 @@ export function table(h, node) { break } - /** @type {Array} */ + /** @type {Array} */ const newCells = [] if (otherRowIndex !== rowIndex || colIndex !== cellIndex) { @@ -74,8 +95,10 @@ export function table(h, node) { } // Clean the data fields. - if ('colSpan' in cell.data) delete cell.data.colSpan - if ('rowSpan' in cell.data) delete cell.data.rowSpan + if ('hastUtilToMdastTemporaryColSpan' in cell.data) + delete cell.data.hastUtilToMdastTemporaryColSpan + if ('hastUtilToMdastTemporaryRowSpan' in cell.data) + delete cell.data.hastUtilToMdastTemporaryRowSpan if (Object.keys(cell.data).length === 0) delete cell.data } } @@ -99,143 +122,82 @@ export function table(h, node) { align.push(null) } - h.inTable = false + state.inTable = false - return h(node, 'table', {align}, rows) + /** @type {Table} */ + const result = {type: 'table', align, children: rows} + state.patch(node, result) + return result } /** * Infer whether the HTML table has a head and how it aligns. * - * @param {Element} node + * @param {Readonly} node + * Table element to check. * @returns {Info} + * Info. */ function inspect(node) { - let headless = true + /** @type {Info} */ + const info = {align: [null], headless: true} let rowIndex = 0 let cellIndex = 0 - /** @type {Array} */ - const align = [null] - visit(node, 'element', (child) => { - if (child.tagName === 'table' && node !== child) { - return SKIP - } - - // If there is a `thead`, assume there is a header row. - if (cell(child) && child.properties) { - if (!align[cellIndex]) { - align[cellIndex] = String(child.properties.align || '') || null + visit(node, function (child) { + if (child.type === 'element') { + // Don’t enter nested tables. + if (child.tagName === 'table' && node !== child) { + return SKIP } - // If there is a th in the first row, assume there is a header row. - if (headless && rowIndex < 2 && child.tagName === 'th') { - headless = false - } + if ( + (child.tagName === 'th' || child.tagName === 'td') && + child.properties + ) { + if (!info.align[cellIndex]) { + const value = String(child.properties.align || '') || null + + if ( + value === 'center' || + value === 'left' || + value === 'right' || + value === null + ) { + info.align[cellIndex] = value + } + } + + // If there is a `th` in the first row, assume there is a header row. + if (info.headless && rowIndex < 2 && child.tagName === 'th') { + info.headless = false + } - cellIndex++ - } else if (thead(child)) { - headless = false - } else if (tr(child)) { - rowIndex++ - cellIndex = 0 + cellIndex++ + } + // If there is a `thead`, assume there is a header row. + else if (child.tagName === 'thead') { + info.headless = false + } else if (child.tagName === 'tr') { + rowIndex++ + cellIndex = 0 + } } }) - return {align, headless} + return info } /** - * Ensure the rows are properly structured. - * - * @param {Array} children - * @param {boolean} headless - * @returns {Array} + * @returns {RowContent} */ -function toRows(children, headless) { - let index = -1 - /** @type {Array} */ - const nodes = [] - /** @type {Array|undefined} */ - let queue - - // Add an empty header row. - if (headless) { - nodes.push({type: 'tableRow', children: []}) - } - - while (++index < children.length) { - const node = children[index] - - if (node.type === 'tableRow') { - if (queue) { - node.children.unshift(...queue) - queue = undefined - } - - nodes.push(node) - } else { - if (!queue) queue = [] - // @ts-expect-error Assume row content. - queue.push(node) - } - } - - if (queue) { - nodes[nodes.length - 1].children.push(...queue) - } - - index = -1 - - while (++index < nodes.length) { - nodes[index].children = toCells(nodes[index].children) - } - - return nodes +function createCell() { + return {type: 'tableCell', children: []} } /** - * Ensure the cells in a row are properly structured. - * - * @param {Array} children - * @returns {Array} + * @returns {TableContent} */ -function toCells(children) { - /** @type {Array} */ - const nodes = [] - let index = -1 - /** @type {MdastNode} */ - let node - /** @type {Array|undefined} */ - let queue - - while (++index < children.length) { - node = children[index] - - if (node.type === 'tableCell') { - if (queue) { - node.children.unshift(...queue) - queue = undefined - } - - nodes.push(node) - } else { - if (!queue) queue = [] - // @ts-expect-error Assume phrasing content. - queue.push(node) - } - } - - if (queue) { - node = nodes[nodes.length - 1] - - if (!node) { - node = {type: 'tableCell', children: []} - nodes.push(node) - } - - node.children.push(...queue) - } - - return nodes +function createRow() { + return {type: 'tableRow', children: []} } diff --git a/lib/handlers/text.js b/lib/handlers/text.js index f4aa03e..864c970 100644 --- a/lib/handlers/text.js +++ b/lib/handlers/text.js @@ -1,14 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Text} Text + * @import {State} from 'hast-util-to-mdast' + * @import {Text as HastText} from 'hast' + * @import {Text as MdastText} from 'mdast' */ -import {wrapText} from '../util/wrap-text.js' - /** - * @type {Handle} - * @param {Text} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {MdastText} + * mdast node. */ -export function text(h, node) { - return h(node, 'text', wrapText(h, node.value)) +export function text(state, node) { + /** @type {MdastText} */ + const result = {type: 'text', value: node.value} + state.patch(node, result) + return result } diff --git a/lib/handlers/textarea.js b/lib/handlers/textarea.js index 566b30a..d053043 100644 --- a/lib/handlers/textarea.js +++ b/lib/handlers/textarea.js @@ -1,15 +1,22 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Text} from 'mdast' */ import {toText} from 'hast-util-to-text' -import {wrapText} from '../util/wrap-text.js' /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Text} + * mdast node. */ -export function textarea(h, node) { - return h(node, 'text', wrapText(h, toText(node))) +export function textarea(state, node) { + /** @type {Text} */ + const result = {type: 'text', value: toText(node)} + state.patch(node, result) + return result } diff --git a/lib/handlers/wbr.js b/lib/handlers/wbr.js index f09f1e2..6ec4c3e 100644 --- a/lib/handlers/wbr.js +++ b/lib/handlers/wbr.js @@ -1,12 +1,20 @@ /** - * @typedef {import('../types.js').Handle} Handle - * @typedef {import('../types.js').Element} Element + * @import {State} from 'hast-util-to-mdast' + * @import {Element} from 'hast' + * @import {Text} from 'mdast' */ /** - * @type {Handle} - * @param {Element} node + * @param {State} state + * State. + * @param {Readonly} node + * hast element to transform. + * @returns {Text} + * mdast node. */ -export function wbr(h, node) { - return h(node, 'text', '\u200B') +export function wbr(state, node) { + /** @type {Text} */ + const result = {type: 'text', value: '\u200B'} + state.patch(node, result) + return result } diff --git a/lib/index.js b/lib/index.js index 7ff93a3..ea4b0eb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,179 +1,111 @@ /** - * @typedef {import('./types.js').Node} Node - * @typedef {import('./types.js').Element} Element - * @typedef {import('./types.js').Options} Options - * @typedef {import('./types.js').Properties} Properties - * @typedef {import('./types.js').H} H - * @typedef {import('./types.js').HWithoutProps} HWithoutProps - * @typedef {import('./types.js').HWithProps} HWithProps - * @typedef {import('./types.js').MdastNode} MdastNode - * @typedef {import('./types.js').MdastRoot} MdastRoot + * @import {Options} from 'hast-util-to-mdast' + * @import {Nodes} from 'hast' + * @import {Nodes as MdastNodes, RootContent as MdastRootContent} from 'mdast' */ +import structuredClone from '@ungap/structured-clone' import rehypeMinifyWhitespace from 'rehype-minify-whitespace' -import {convert} from 'unist-util-is' import {visit} from 'unist-util-visit' -import {one} from './one.js' -import {handlers} from './handlers/index.js' -import {own} from './util/own.js' +import {createState} from './state.js' -export {one} from './one.js' -export {all} from './all.js' - -const block = convert(['heading', 'paragraph', 'root']) +/** @type {Readonly} */ +const emptyOptions = {} /** * Transform hast to mdast. * - * @param {Node} tree - * Tree (hast). - * @param {Options} [options] + * @param {Readonly} tree + * hast tree to transform. + * @param {Readonly | null | undefined} [options] * Configuration (optional). + * @returns {MdastNodes} + * mdast tree. */ -export function toMdast(tree, options = {}) { - /** @type {Record} */ - const byId = {} - /** @type {MdastNode|MdastRoot} */ +export function toMdast(tree, options) { + // We have to clone, cause we’ll use `rehype-minify-whitespace` on the tree, + // which modifies. + const cleanTree = structuredClone(tree) + const settings = options || emptyOptions + const transformWhitespace = rehypeMinifyWhitespace({ + newlines: settings.newlines === true + }) + const state = createState(settings) + /** @type {MdastNodes} */ let mdast - /** - * @type {H} - */ - const h = Object.assign( - /** - * @type {HWithProps & HWithoutProps} - */ - ( - /** - * @param {Node} node - * @param {string} type - * @param {Properties|string|Array} [props] - * @param {string|Array} [children] - */ - (node, type, props, children) => { - /** @type {Properties|undefined} */ - let properties - - if (typeof props === 'string' || Array.isArray(props)) { - children = props - properties = {} - } else { - properties = props - } - - /** @type {Node} */ - // @ts-expect-error Assume valid `type` and `children`/`value`. - const result = {type, ...properties} + // @ts-expect-error: fine to pass an arbitrary node. + transformWhitespace(cleanTree) - if (typeof children === 'string') { - // @ts-expect-error: Looks like a literal. - result.value = children - } else if (children) { - // @ts-expect-error: Looks like a parent. - result.children = children - } - - if (node.position) { - result.position = node.position - } + visit(cleanTree, function (node) { + if (node && node.type === 'element' && node.properties) { + const id = String(node.properties.id || '') || undefined - return result + if (id && !state.elementById.has(id)) { + state.elementById.set(id, node) } - ), - { - nodeById: byId, - baseFound: false, - inTable: false, - wrapText: true, - /** @type {string|null} */ - frozenBaseUrl: null, - qNesting: 0, - handlers: options.handlers - ? {...handlers, ...options.handlers} - : handlers, - document: options.document, - checked: options.checked || '[x]', - unchecked: options.unchecked || '[ ]', - quotes: options.quotes || ['"'] - } - ) - - visit(tree, 'element', (node) => { - const id = - node.properties && - 'id' in node.properties && - String(node.properties.id).toUpperCase() - - if (id && !own.call(byId, id)) { - byId[id] = node } }) - // @ts-expect-error: does return a transformer, that does accept any node. - rehypeMinifyWhitespace({newlines: options.newlines === true})(tree) - - const result = one(h, tree, undefined) + const result = state.one(cleanTree, undefined) if (!result) { mdast = {type: 'root', children: []} } else if (Array.isArray(result)) { - mdast = {type: 'root', children: result} + // Assume content. + const children = /** @type {Array} */ (result) + mdast = {type: 'root', children} } else { mdast = result } - visit(mdast, 'text', ontext) - - return mdast - - /** - * Collapse text nodes, and fix whitespace. - * Most of this is taken care of by `rehype-minify-whitespace`, but - * we’re generating some whitespace too, and some nodes are in the end - * ignored. - * So clean up. - * - * @type {import('unist-util-visit/complex-types').BuildVisitor} - */ - function ontext(node, index, parent) { - /* c8 ignore next 3 */ - if (index === null || !parent) { - return - } - - const previous = parent.children[index - 1] - - if (previous && previous.type === node.type) { - previous.value += node.value - parent.children.splice(index, 1) + // Collapse text nodes, and fix whitespace. + // + // Most of this is taken care of by `rehype-minify-whitespace`, but + // we’re generating some whitespace too, and some nodes are in the end + // ignored. + // So clean up. + visit(mdast, function (node, index, parent) { + if (node.type === 'text' && index !== undefined && parent) { + const previous = parent.children[index - 1] + + if (previous && previous.type === node.type) { + previous.value += node.value + parent.children.splice(index, 1) + + if (previous.position && node.position) { + previous.position.end = node.position.end + } - if (previous.position && node.position) { - previous.position.end = node.position.end + // Iterate over the previous node again, to handle its total value. + return index - 1 } - // Iterate over the previous node again, to handle its total value. - return index - 1 - } - - node.value = node.value.replace(/[\t ]*(\r?\n|\r)[\t ]*/, '$1') + node.value = node.value.replace(/[\t ]*(\r?\n|\r)[\t ]*/, '$1') + + // We don’t care about other phrasing nodes in between (e.g., `[ asd ]()`), + // as there the whitespace matters. + if ( + parent && + (parent.type === 'heading' || + parent.type === 'paragraph' || + parent.type === 'root') + ) { + if (!index) { + node.value = node.value.replace(/^[\t ]+/, '') + } - // We don’t care about other phrasing nodes in between (e.g., `[ asd ]()`), - // as there the whitespace matters. - if (parent && block(parent)) { - if (!index) { - node.value = node.value.replace(/^[\t ]+/, '') + if (index === parent.children.length - 1) { + node.value = node.value.replace(/[\t ]+$/, '') + } } - if (index === parent.children.length - 1) { - node.value = node.value.replace(/[\t ]+$/, '') + if (!node.value) { + parent.children.splice(index, 1) + return index } } + }) - if (!node.value) { - parent.children.splice(index, 1) - return index - } - } + return mdast } - -export {handlers as defaultHandlers} from './handlers/index.js' diff --git a/lib/one.js b/lib/one.js deleted file mode 100644 index 054eba8..0000000 --- a/lib/one.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @typedef {import('./types.js').H} H - * @typedef {import('./types.js').Node} Node - * @typedef {import('./types.js').Parent} Parent - * @typedef {import('./types.js').Handle} Handle - * @typedef {import('./types.js').MdastNode} MdastNode - */ - -import {all} from './all.js' -import {own} from './util/own.js' -import {wrapText} from './util/wrap-text.js' - -/** - * @param {H} h - * @param {Node} node - * @param {Parent|undefined} parent - * @returns {MdastNode|Array|void} - */ -export function one(h, node, parent) { - /** @type {Handle|undefined} */ - let fn - - if (node.type === 'element') { - if (node.properties && node.properties.dataMdast === 'ignore') { - return - } - - if (own.call(h.handlers, node.tagName)) { - fn = h.handlers[node.tagName] - } - } else if (own.call(h.handlers, node.type)) { - fn = h.handlers[node.type] - } - - if (typeof fn === 'function') { - return fn(h, node, parent) - } - - return unknown(h, node) -} - -/** - * @type {Handle} - * @param {Node} node - */ -function unknown(h, node) { - // @ts-expect-error: Looks like a literal. - if (typeof node.value === 'string') { - // @ts-expect-error: Looks like a literal. - return h(node, 'text', wrapText(h, node.value)) - } - - return all(h, node) -} diff --git a/lib/state.js b/lib/state.js new file mode 100644 index 0000000..2fe13c1 --- /dev/null +++ b/lib/state.js @@ -0,0 +1,389 @@ +/** + * @import {Element, Nodes, Parents} from 'hast' + * @import { + * BlockContent as MdastBlockContent, + * DefinitionContent as MdastDefinitionContent, + * Nodes as MdastNodes, + * Parents as MdastParents, + * RootContent as MdastRootContent + * } from 'mdast' + */ + +/** + * @typedef {MdastBlockContent | MdastDefinitionContent} MdastFlowContent + */ + +/** + * @callback All + * Transform the children of a hast parent to mdast. + * @param {Parents} parent + * Parent. + * @returns {Array} + * mdast children. + * + * @callback Handle + * Handle a particular element. + * @param {State} state + * Info passed around about the current state. + * @param {Element} element + * Element to transform. + * @param {Parents | undefined} parent + * Parent of `element`. + * @returns {Array | MdastNodes | undefined | void} + * mdast node or nodes. + * + * Note: `void` is included until TS nicely infers `undefined`. + * + * @callback NodeHandle + * Handle a particular node. + * @param {State} state + * Info passed around about the current state. + * @param {any} node + * Node to transform. + * @param {Parents | undefined} parent + * Parent of `node`. + * @returns {Array | MdastNodes | undefined | void} + * mdast node or nodes. + * + * Note: `void` is included until TS nicely infers `undefined`. + * + * @callback One + * Transform a hast node to mdast. + * @param {Nodes} node + * Expected hast node. + * @param {Parents | undefined} parent + * Parent of `node`. + * @returns {Array | MdastNodes | undefined} + * mdast result. + * + * @typedef Options + * Configuration. + * @property {string | null | undefined} [checked='[x]'] + * Value to use for a checked checkbox or radio input (default: `'[x]'`) + * @property {boolean | null | undefined} [document] + * Whether the given tree represents a complete document (optional). + * + * Applies when the `tree` is a `root` node. + * When the tree represents a complete document, then things are wrapped in + * paragraphs when needed, and otherwise they’re left as-is. + * The default checks for whether there’s mixed content: some phrasing nodes + * *and* some non-phrasing nodes. + * @property {Record | null | undefined} [handlers] + * Object mapping tag names to functions handling the corresponding elements + * (optional). + * + * Merged into the defaults. + * @property {boolean | null | undefined} [newlines=false] + * Keep line endings when collapsing whitespace (default: `false`). + * + * The default collapses to a single space. + * @property {Record | null | undefined} [nodeHandlers] + * Object mapping node types to functions handling the corresponding nodes + * (optional). + * + * Merged into the defaults. + * @property {Array | null | undefined} [quotes=['"']] + * List of quotes to use (default: `['"']`). + * + * Each value can be one or two characters. + * When two, the first character determines the opening quote and the second + * the closing quote at that level. + * When one, both the opening and closing quote are that character. + * + * The order in which the preferred quotes appear determines which quotes to + * use at which level of nesting. + * So, to prefer `‘’` at the first level of nesting, and `“”` at the second, + * pass `['‘’', '“”']`. + * If ``s are nested deeper than the given amount of quotes, the markers + * wrap around: a third level of nesting when using `['«»', '‹›']` should + * have double guillemets, a fourth single, a fifth double again, etc. + * @property {string | null | undefined} [unchecked='[ ]'] + * Value to use for an unchecked checkbox or radio input (default: `'[ ]'`). + * + * @callback Patch + * Copy a node’s positional info. + * @param {Nodes} from + * hast node to copy from. + * @param {MdastNodes} to + * mdast node to copy into. + * @returns {undefined} + * Nothing. + * + * @callback Resolve + * Resolve a URL relative to a base. + * @param {string | null | undefined} url + * Possible URL value. + * @returns {string} + * URL, resolved to a `base` element, if any. + * + * @typedef State + * Info passed around about the current state. + * @property {All} all + * Transform the children of a hast parent to mdast. + * @property {boolean} baseFound + * Whether a `` element was seen. + * @property {Map} elementById + * Elements by their `id`. + * @property {string | undefined} frozenBaseUrl + * `href` of ``, if any. + * @property {Record} handlers + * Applied element handlers. + * @property {boolean} inTable + * Whether we’re in a table. + * @property {Record} nodeHandlers + * Applied node handlers. + * @property {One} one + * Transform a hast node to mdast. + * @property {Options} options + * User configuration. + * @property {Patch} patch + * Copy a node’s positional info. + * @property {number} qNesting + * Non-negative finite integer representing how deep we’re in ``s. + * @property {Resolve} resolve + * Resolve a URL relative to a base. + * @property {ToFlow} toFlow + * Transform a list of mdast nodes to flow. + * @property {}>(nodes: Array, build: (() => ParentType)) => Array} toSpecificContent + * Turn arbitrary content into a list of a particular node type. + * + * This is useful for example for lists, which must have list items as + * content. + * in this example, when non-items are found, they will be queued, and + * inserted into an adjacent item. + * When no actual items exist, one will be made with `build`. + * + * @callback ToFlow + * Transform a list of mdast nodes to flow. + * @param {Array} nodes + * mdast nodes. + * @returns {Array} + * mdast flow children. + */ + +import {position} from 'unist-util-position' +import {handlers, nodeHandlers} from './handlers/index.js' +import {wrap} from './util/wrap.js' + +const own = {}.hasOwnProperty + +/** + * Create a state. + * + * @param {Readonly} options + * User configuration. + * @returns {State} + * State. + */ +export function createState(options) { + return { + all, + baseFound: false, + elementById: new Map(), + frozenBaseUrl: undefined, + handlers: {...handlers, ...options.handlers}, + inTable: false, + nodeHandlers: {...nodeHandlers, ...options.nodeHandlers}, + one, + options, + patch, + qNesting: 0, + resolve, + toFlow, + toSpecificContent + } +} + +/** + * Transform the children of a hast parent to mdast. + * + * You might want to combine this with `toFlow` or `toSpecificContent`. + * + * @this {State} + * Info passed around about the current state. + * @param {Parents} parent + * Parent. + * @returns {Array} + * mdast children. + */ +function all(parent) { + const children = parent.children || [] + /** @type {Array} */ + const results = [] + let index = -1 + + while (++index < children.length) { + const child = children[index] + // Content -> content. + const result = + /** @type {Array | MdastRootContent | undefined} */ ( + this.one(child, parent) + ) + + if (Array.isArray(result)) { + results.push(...result) + } else if (result) { + results.push(result) + } + } + + return results +} + +/** + * Transform a hast node to mdast. + * + * @this {State} + * Info passed around about the current state. + * @param {Nodes} node + * hast node to transform. + * @param {Parents | undefined} parent + * Parent of `node`. + * @returns {Array | MdastNodes | undefined} + * mdast result. + */ +function one(node, parent) { + if (node.type === 'element') { + if (node.properties && node.properties.dataMdast === 'ignore') { + return + } + + if (own.call(this.handlers, node.tagName)) { + return this.handlers[node.tagName](this, node, parent) || undefined + } + } else if (own.call(this.nodeHandlers, node.type)) { + return this.nodeHandlers[node.type](this, node, parent) || undefined + } + + // Unknown literal. + if ('value' in node && typeof node.value === 'string') { + /** @type {MdastRootContent} */ + const result = {type: 'text', value: node.value} + this.patch(node, result) + return result + } + + // Unknown parent. + if ('children' in node) { + return this.all(node) + } +} + +/** + * Copy a node’s positional info. + * + * @param {Nodes} origin + * hast node to copy from. + * @param {MdastNodes} node + * mdast node to copy into. + * @returns {undefined} + * Nothing. + */ +function patch(origin, node) { + if (origin.position) node.position = position(origin) +} + +/** + * @this {State} + * Info passed around about the current state. + * @param {string | null | undefined} url + * Possible URL value. + * @returns {string} + * URL, resolved to a `base` element, if any. + */ +function resolve(url) { + const base = this.frozenBaseUrl + + if (url === null || url === undefined) { + return '' + } + + if (base) { + return String(new URL(url, base)) + } + + return url +} + +/** + * Transform a list of mdast nodes to flow. + * + * @this {State} + * Info passed around about the current state. + * @param {Array} nodes + * Parent. + * @returns {Array} + * mdast flow children. + */ +function toFlow(nodes) { + return wrap(nodes) +} + +/** + * Turn arbitrary content into a particular node type. + * + * This is useful for example for lists, which must have list items as content. + * in this example, when non-items are found, they will be queued, and + * inserted into an adjacent item. + * When no actual items exist, one will be made with `build`. + * + * @template {MdastNodes} ChildType + * Node type of children. + * @template {MdastParents & {'children': Array}} ParentType + * Node type of parent. + * @param {Array} nodes + * Nodes, which are either `ParentType`, or will be wrapped in one. + * @param {() => ParentType} build + * Build a parent if needed (must have empty `children`). + * @returns {Array} + * List of parents. + */ +function toSpecificContent(nodes, build) { + const reference = build() + /** @type {Array} */ + const results = [] + /** @type {Array} */ + let queue = [] + let index = -1 + + while (++index < nodes.length) { + const node = nodes[index] + + if (expectedParent(node)) { + if (queue.length > 0) { + node.children.unshift(...queue) + queue = [] + } + + results.push(node) + } else { + // Assume `node` can be a child of `ParentType`. + // If we start checking nodes, we’d run into problems with unknown nodes, + // which we do want to support. + const child = /** @type {ChildType} */ (node) + queue.push(child) + } + } + + if (queue.length > 0) { + let node = results[results.length - 1] + + if (!node) { + node = build() + results.push(node) + } + + node.children.push(...queue) + queue = [] + } + + return results + + /** + * @param {MdastNodes} node + * @returns {node is ParentType} + */ + function expectedParent(node) { + return node.type === reference.type + } +} diff --git a/lib/types.js b/lib/types.js deleted file mode 100644 index 5024f5a..0000000 --- a/lib/types.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @typedef {import('unist').Node} UnistNode - * - * @typedef {import('mdast').Root} MdastRoot - * @typedef {import('mdast').Content} MdastNode - * @typedef {import('mdast').Parent} MdastParent - * @typedef {import('mdast').ListContent} MdastListContent - * @typedef {import('mdast').PhrasingContent} MdastPhrasingContent - * @typedef {import('mdast').DefinitionContent} MdastDefinitionContent - * @typedef {import('mdast').BlockContent} MdastBlockContent - * @typedef {import('mdast').TableContent} MdastTableContent - * @typedef {import('mdast').RowContent} MdastRowContent - * - * @typedef {import('hast').Parent} Parent - * @typedef {import('hast').Root} Root - * @typedef {import('hast').Element} Element - * @typedef {import('hast').Text} Text - * @typedef {import('hast').Comment} Comment - * @typedef {Element['children'][number]} ElementChild - * @typedef {Parent['children'][number]} Child - * @typedef {Child|Root} Node - * - * @typedef {(h: H, node: any, parent?: Parent) => MdastNode|Array|void} Handle - * - * @typedef {Record} Properties - * - * @typedef Options - * Configuration (optional). - * @property {boolean} [newlines=false] - * Keep line endings when collapsing whitespace. - * The default collapses to a single space. - * @property {string} [checked='[x]'] - * Value to use for a checked checkbox or radio input. - * @property {string} [unchecked='[ ]'] - * Value to use for an unchecked checkbox or radio input. - * @property {Array} [quotes=['"']] - * List of quotes to use. - * Each value can be one or two characters. - * When two, the first character determines the opening quote and the second - * the closing quote at that level. - * When one, both the opening and closing quote are that character. - * The order in which the preferred quotes appear determines which quotes to - * use at which level of nesting. - * So, to prefer `‘’` at the first level of nesting, and `“”` at the second, - * pass `['‘’', '“”']`. - * If ``s are nested deeper than the given amount of quotes, the markers - * wrap around: a third level of nesting when using `['«»', '‹›']` should - * have double guillemets, a fourth single, a fifth double again, etc. - * @property {boolean} [document] - * Whether the given tree represents a complete document. - * Applies when the `tree` is a `root` node. - * When the tree represents a complete document, then things are wrapped in - * paragraphs when needed, and otherwise they’re left as-is. - * The default checks for whether there’s mixed content: some phrasing nodes - * *and* some non-phrasing nodes. - * @property {Record} [handlers] - * Object mapping tag names or node types to functions handling the - * corresponding nodes. - * See `handlers/` for examples. - * - * In a handler, you have access to `h`, which should be used to create mdast - * nodes from hast nodes. - * On `h`, there are several fields that may be of interest. - * Most interesting of them is `h.wrapText`, which is `true` if the mdast - * content can include newlines, and `false` if not (such as in headings or - * table cells). - * - * @typedef Context - * @property {Record} nodeById - * @property {boolean} baseFound - * @property {string|null} frozenBaseUrl - * @property {boolean} wrapText - * @property {boolean} inTable - * @property {number} qNesting - * @property {Record} handlers - * @property {boolean|undefined} document - * @property {string} checked - * @property {string} unchecked - * @property {Array} quotes - * - * @typedef {(node: Node, type: string, props?: Properties, children?: string|Array) => MdastNode} HWithProps - * @typedef {(node: Node, type: string, children?: string|Array) => MdastNode} HWithoutProps - * - * @typedef {HWithProps & HWithoutProps & Context} H - */ - -export {} diff --git a/lib/util/drop-surrounding-breaks.js b/lib/util/drop-surrounding-breaks.js new file mode 100644 index 0000000..8a2a18f --- /dev/null +++ b/lib/util/drop-surrounding-breaks.js @@ -0,0 +1,23 @@ +/** + * @import {Nodes} from 'mdast' + */ + +/** + * Drop trailing initial and final `br`s. + * + * @template {Nodes} Node + * Node type. + * @param {Array} nodes + * List of nodes. + * @returns {Array} + * List of nodes w/o `break`s. + */ +export function dropSurroundingBreaks(nodes) { + let start = 0 + let end = nodes.length + + while (start < end && nodes[start].type === 'break') start++ + while (end > start && nodes[end - 1].type === 'break') end-- + + return start === 0 && end === nodes.length ? nodes : nodes.slice(start, end) +} diff --git a/lib/util/find-selected-options.js b/lib/util/find-selected-options.js index f91728b..433e297 100644 --- a/lib/util/find-selected-options.js +++ b/lib/util/find-selected-options.js @@ -1,83 +1,87 @@ /** - * @typedef {import('../types.js').H} H - * @typedef {import('../types.js').Parent} Parent - * @typedef {import('../types.js').Element} Element - * @typedef {import('../types.js').Child} Child - * @typedef {import('../types.js').Properties} Properties + * @import {Element, Properties} from 'hast' */ -import {hasProperty} from 'hast-util-has-property' -import {convertElement} from 'hast-util-is-element' -import {toText} from 'hast-util-to-text' -import {wrapText} from './wrap-text.js' +/** + * @typedef {[string, Value]} Option + * Option, where the item at `0` is the label, the item at `1` the value. + * + * @typedef {Array