diff --git a/packages/core/src/html/MdAttributeRenderer.ts b/packages/core/src/html/MdAttributeRenderer.ts index d04cddcfcb..0a0da8ef1e 100644 --- a/packages/core/src/html/MdAttributeRenderer.ts +++ b/packages/core/src/html/MdAttributeRenderer.ts @@ -5,6 +5,7 @@ import { getVslotShorthandName } from './vueSlotSyntaxProcessor'; import type { MarkdownProcessor } from './MarkdownProcessor'; import * as logger from '../utils/logger'; import { createSlotTemplateNode } from './elements'; +import { NodeOrText } from '../utils/node'; const _ = { has, @@ -44,7 +45,7 @@ export class MdAttributeRenderer { rendered = this.markdownProcessor.renderMd(node.attribs[attribute]); } - const attributeSlotElement = createSlotTemplateNode(slotName, rendered); + const attributeSlotElement: NodeOrText[] = createSlotTemplateNode(slotName, rendered); node.children = node.children ? attributeSlotElement.concat(node.children) : attributeSlotElement; diff --git a/packages/core/src/html/NodeProcessor.ts b/packages/core/src/html/NodeProcessor.ts index 2b6d4d891a..885294a731 100644 --- a/packages/core/src/html/NodeProcessor.ts +++ b/packages/core/src/html/NodeProcessor.ts @@ -26,6 +26,7 @@ import { PageNavProcessor, renderSiteNav, addSitePageNavPortal } from './siteAnd import { highlightCodeBlock, setCodeLineNumbers } from './codeblockProcessor'; import { setHeadingId, assignPanelId } from './headerProcessor'; import { FootnoteProcessor } from './FootnoteProcessor'; +import { MbNode, NodeOrText, TextElement } from '../utils/node'; const fm = require('fastmatter'); @@ -87,7 +88,9 @@ export class NodeProcessor { * Private utility functions */ - static _trimNodes(node: DomElement) { + static _trimNodes(nodeOrText: NodeOrText) { + if (NodeProcessor._isText(nodeOrText)) return; + const node = nodeOrText as MbNode; if (node.name === 'pre' || node.name === 'code') { return; } @@ -105,14 +108,14 @@ export class NodeProcessor { } } - static _isText(node: DomElement) { + static _isText(node: NodeOrText) { return node.type === 'text' || node.type === 'comment'; } /* * Frontmatter collection */ - _processFrontmatter(node: DomElement, context: Context) { + _processFrontmatter(node: MbNode, context: Context) { let currentFrontmatter = {}; const frontmatter = cheerio(node); if (!context.processingOptions.omitFrontmatter && frontmatter.text().trim()) { @@ -121,8 +124,8 @@ export class NodeProcessor { // The latter case will result in the data being wrapped in a div const frontmatterIncludeDiv = frontmatter.find('div'); const frontmatterData = frontmatterIncludeDiv.length - ? ((frontmatterIncludeDiv[0] as DomElement).children as DomElement[])[0].data - : ((frontmatter[0] as DomElement).children as DomElement[])[0].data; + ? ((frontmatterIncludeDiv[0] as MbNode).children as MbNode[])[0].data + : ((frontmatter[0] as MbNode).children as MbNode[])[0].data; const frontmatterWrapped = `${FRONTMATTER_FENCE}\n${frontmatterData}\n${FRONTMATTER_FENCE}`; currentFrontmatter = fm(frontmatterWrapped).attributes; @@ -139,7 +142,7 @@ export class NodeProcessor { * Layout element collection */ - private static collectLayoutEl(node: DomElement): string | null { + private static collectLayoutEl(node: MbNode): string | null { const $ = cheerio(node); const html = $.html(); $.remove(); @@ -149,7 +152,7 @@ export class NodeProcessor { /** * Removes the node if modal id already exists, processes node otherwise */ - private processModal(node: DomElement) { + private processModal(node: MbNode) { if (node.attribs) { if (this.processedModals[node.attribs.id]) { cheerio(node).remove(); @@ -168,11 +171,10 @@ export class NodeProcessor { /* * API */ - processNode(node: DomElement, context: Context): Context { + processNode(nodeOrText: NodeOrText, context: Context): Context { try { - if (!node.name || !node.attribs) { - return context; - } + if (NodeProcessor._isText(nodeOrText)) return context; + const node = nodeOrText as MbNode; transformOldSlotSyntax(node); shiftSlotNodeDeeper(node); @@ -274,7 +276,10 @@ export class NodeProcessor { return context; } - postProcessNode(node: DomElement) { + postProcessNode(nodeOrText: NodeOrText) { + if (NodeProcessor._isText(nodeOrText)) return; + const node = nodeOrText as MbNode; + try { switch (node.name) { case 'pre': @@ -316,13 +321,12 @@ export class NodeProcessor { } } - private traverse(node: DomElement, context: Context): DomElement { - if (NodeProcessor._isText(node)) { - return node; - } - if (node.name) { - node.name = node.name.toLowerCase(); + private traverse(dom: DomElement, context: Context): NodeOrText { + if (NodeProcessor._isText(dom)) { + return dom as TextElement; } + const node = dom as MbNode; + node.name = node.name.toLowerCase(); if (linkProcessor.hasTagLink(node)) { linkProcessor.convertRelativeLinks(node, context.cwf, this.config.rootPath, this.config.baseUrl); linkProcessor.convertMdExtToHtmlExt(node); @@ -363,16 +367,14 @@ export class NodeProcessor { addSitePageNavPortal(node); - if (node.name) { - const isHeadingTag = (/^h[1-6]$/).test(node.name); - if (isHeadingTag && !node.attribs?.id) { - setHeadingId(node, this.config); - } + const isHeadingTag = (/^h[1-6]$/).test(node.name); + if (isHeadingTag && !node.attribs.id) { + setHeadingId(node, this.config); + } - // Generate dummy spans as anchor points for header[sticky] - if (isHeadingTag && node.attribs?.id) { - cheerio(node).prepend(``); - } + // Generate dummy spans as anchor points for header[sticky] + if (isHeadingTag && node.attribs.id) { + cheerio(node).prepend(``); } this.pluginManager.postProcessNode(node); @@ -407,7 +409,7 @@ export class NodeProcessor { }); mainHtmlNodes.forEach(d => NodeProcessor._trimNodes(d)); - const footnotesHtml = this.footnoteProcessor.combineFootnotes((node: DomElement) => this.processNode( + const footnotesHtml = this.footnoteProcessor.combineFootnotes(node => this.processNode( node, new Context(cwf, [], extraVariables, {}), )); const mainHtml = cheerio(mainHtmlNodes).html(); diff --git a/packages/core/src/html/elements.ts b/packages/core/src/html/elements.ts index 9c53a4b98d..bd8e4fc962 100644 --- a/packages/core/src/html/elements.ts +++ b/packages/core/src/html/elements.ts @@ -1,22 +1,22 @@ import cheerio from 'cheerio'; -import { DomElement } from 'htmlparser2'; import pick from 'lodash/pick'; +import { MbNode, NodeOrText } from '../utils/node'; const _ = { pick }; -export function createErrorNode(element: DomElement, error: any) { +export function createErrorNode(element: NodeOrText, error: any) { const errorElement = cheerio.parseHTML( `
${error.message}
`, undefined, true, )[0]; - return Object.assign(element, _.pick(errorElement, ['name', 'attribs', 'children'])); + return Object.assign(element, _.pick(errorElement, ['name', 'attribs', 'children'])) as MbNode; } export function createEmptyNode() { return cheerio.parseHTML('
', undefined, true)[0]; } -export function createSlotTemplateNode(slotName: string, content: string): DomElement[] { +export function createSlotTemplateNode(slotName: string, content: string): MbNode[] { return cheerio.parseHTML( ``, undefined, true, - ) as unknown as DomElement[]; + ) as unknown as MbNode[]; } diff --git a/packages/core/src/html/includePanelProcessor.ts b/packages/core/src/html/includePanelProcessor.ts index ffc6165cc1..4505130dd2 100644 --- a/packages/core/src/html/includePanelProcessor.ts +++ b/packages/core/src/html/includePanelProcessor.ts @@ -4,7 +4,6 @@ import parse from 'url-parse'; import has from 'lodash/has'; import isEmpty from 'lodash/isEmpty'; -import { DomElement } from 'htmlparser2'; import { createErrorNode, createSlotTemplateNode } from './elements'; import CyclicReferenceError from '../errors/CyclicReferenceError'; @@ -14,6 +13,7 @@ import * as urlUtil from '../utils/urlUtil'; import type { Context } from './Context'; import type { PageSources } from '../Page/PageSources'; import type VariableProcessor from '../variables/VariableProcessor'; +import { MbNode, NodeOrText } from '../utils/node'; require('../patches/htmlparser2'); @@ -26,7 +26,7 @@ const _ = { has, isEmpty }; /** * Returns a boolean representing whether the file specified exists. */ -function _checkAndWarnFileExists(element: DomElement, context: Context, actualFilePath: string, +function _checkAndWarnFileExists(element: MbNode, context: Context, actualFilePath: string, pageSources: PageSources, isOptional = false) { if (!fsUtil.fileExists(actualFilePath)) { if (isOptional) { @@ -48,11 +48,7 @@ function _checkAndWarnFileExists(element: DomElement, context: Context, actualFi return true; } -function _getBoilerplateFilePath(node: DomElement, filePath: string, config: Record) { - const element = node; - - if (!element.attribs) return undefined; - +function _getBoilerplateFilePath(element: MbNode, filePath: string, config: Record) { const isBoilerplate = _.has(element.attribs, 'boilerplate'); if (isBoilerplate) { element.attribs.boilerplate = element.attribs.boilerplate || path.basename(filePath); @@ -66,8 +62,7 @@ function _getBoilerplateFilePath(node: DomElement, filePath: string, config: Rec /** * Retrieves several flags and file paths from the src attribute specified in the element. */ -function _getSrcFlagsAndFilePaths(element: DomElement & { attribs: { src: string } }, - config: Record) { +function _getSrcFlagsAndFilePaths(element: MbNode, config: Record) { const isUrl = urlUtil.isUrl(element.attribs.src); // We do this even if the src is not a url to get the hash, if any @@ -109,10 +104,10 @@ function _getSrcFlagsAndFilePaths(element: DomElement & { attribs: { src: string * Otherwise, sets the fragment attribute of the panel as parsed from the src, * and adds the appropriate include. */ -export function processPanelSrc(node: DomElement, context: Context, pageSources: PageSources, +export function processPanelSrc(node: MbNode, context: Context, pageSources: PageSources, config: Record): Context { const hasSrc = _.has(node.attribs, 'src'); - if (!hasSrc || !node.attribs) { + if (!hasSrc) { return context; } @@ -122,7 +117,7 @@ export function processPanelSrc(node: DomElement, context: Context, pageSources: filePath, actualFilePath, // We can typecast here as we have checked for src above. - } = _getSrcFlagsAndFilePaths(node as DomElement & { attribs: { src: string } }, config); + } = _getSrcFlagsAndFilePaths(node, config); const fileExists = _checkAndWarnFileExists(node, context, actualFilePath, pageSources); if (!fileExists) { @@ -153,23 +148,21 @@ export function processPanelSrc(node: DomElement, context: Context, pageSources: * Includes */ -function _deleteIncludeAttributes(node: DomElement) { - const nodeAttribs = node.attribs; - if (!nodeAttribs) return; +function _deleteIncludeAttributes(node: MbNode) { // Delete variable attributes in include tags as they are no longer needed // e.g. '' - Object.keys(nodeAttribs).forEach((attribute) => { + Object.keys(node.attribs).forEach((attribute) => { if (attribute.startsWith('var-')) { - delete nodeAttribs[attribute]; + delete node.attribs[attribute]; } }); - delete nodeAttribs.boilerplate; - delete nodeAttribs.src; - delete nodeAttribs.inline; - delete nodeAttribs.trim; - delete nodeAttribs.optional; - delete nodeAttribs.omitFrontmatter; + delete node.attribs.boilerplate; + delete node.attribs.src; + delete node.attribs.inline; + delete node.attribs.trim; + delete node.attribs.optional; + delete node.attribs.omitFrontmatter; } /** @@ -177,14 +170,14 @@ function _deleteIncludeAttributes(node: DomElement) { * Replaces it with an error node if the specified src is invalid, * or an empty node if the src is invalid but optional. */ -export function processInclude(node: DomElement, context: Context, pageSources: PageSources, +export function processInclude(node: MbNode, context: Context, pageSources: PageSources, variableProcessor: VariableProcessor, renderMd: (text: string) => string, renderMdInline: (text: string) => string, config: Record): Context { - if (_.isEmpty(node.attribs?.src)) { + if (_.isEmpty(node.attribs.src)) { const error = new Error(`Empty src attribute in include in: ${context.cwf}`); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } @@ -194,7 +187,7 @@ export function processInclude(node: DomElement, context: Context, pageSources: filePath, actualFilePath, // We can typecast here as we have checked for src above. - } = _getSrcFlagsAndFilePaths(node as DomElement & { attribs: { src: string } }, config); + } = _getSrcFlagsAndFilePaths(node, config); // No need to process url contents if (isUrl) { @@ -243,7 +236,7 @@ export function processInclude(node: DomElement, context: Context, pageSources: + `Missing reference in ${context.cwf}`); logger.error(error); - actualContent = cheerio.html(createErrorNode(node, error) as cheerio.Element); + actualContent = cheerio.html(createErrorNode(node, error)); } } @@ -262,7 +255,7 @@ export function processInclude(node: DomElement, context: Context, pageSources: if (childContext.hasExceededMaxCallstackSize()) { const error = new CyclicReferenceError(childContext.callStack); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } } @@ -277,17 +270,17 @@ export function processInclude(node: DomElement, context: Context, pageSources: * Replaces it with an error node if the specified src is invalid. * Else, appends the content to the node. */ -export function processPopoverSrc(node: DomElement, context: Context, pageSources: PageSources, +export function processPopoverSrc(node: MbNode, context: Context, pageSources: PageSources, variableProcessor: VariableProcessor, renderMd: (text: string) => string, config: Record): Context { - if (!node.attribs || !_.has(node.attribs, 'src')) { + if (!_.has(node.attribs, 'src')) { return context; } if (_.isEmpty(node.attribs.src)) { const error = new Error(`Empty src attribute in include in: ${context.cwf}`); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } @@ -297,13 +290,13 @@ export function processPopoverSrc(node: DomElement, context: Context, pageSource filePath, actualFilePath, // We can typecast here as we have checked for src above. - } = _getSrcFlagsAndFilePaths(node as DomElement & { attribs: { src: string } }, config); + } = _getSrcFlagsAndFilePaths(node, config); // No need to process url contents if (isUrl) { const error = new Error('URLs are not allowed in the \'src\' attribute'); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } @@ -338,7 +331,7 @@ export function processPopoverSrc(node: DomElement, context: Context, pageSource + `Missing reference in ${context.cwf}`); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } @@ -346,18 +339,18 @@ export function processPopoverSrc(node: DomElement, context: Context, pageSource actualContent = actualContent.trim(); - if (node.children && node.children.length > 0) { + if (node.children.length > 0) { childContext.addCwfToCallstack(context.cwf); if (childContext.hasExceededMaxCallstackSize()) { const error = new CyclicReferenceError(childContext.callStack); logger.error(error); - cheerio(node).replaceWith(createErrorNode(node, error) as cheerio.Element); + cheerio(node).replaceWith(createErrorNode(node, error)); return context; } } - const attributeSlotElement = createSlotTemplateNode('content', actualContent); + const attributeSlotElement: NodeOrText[] = createSlotTemplateNode('content', actualContent); node.children = node.children ? attributeSlotElement.concat(node.children) : attributeSlotElement; delete node.attribs.src; diff --git a/packages/core/src/utils/node.ts b/packages/core/src/utils/node.ts new file mode 100644 index 0000000000..b0eb872476 --- /dev/null +++ b/packages/core/src/utils/node.ts @@ -0,0 +1,22 @@ +import { DomElement } from 'htmlparser2'; + +/* + * A TextElement is a simple node that does not need complex processing. + */ +export type TextElement = DomElement; + +/* + * MbNode (MarkBindNode) is an element that can be operated on by cheerio and our own node processing + * methods. It must have a name (used to identify what kind of node it is), attributes (possibly empty), + * and children nodes (possibly empty). This type allows us to assert that these attributes exist. + */ +export type MbNode = DomElement & cheerio.Element & { + name: string, + attribs: { [key: string]: any }, + children: NodeOrText[], +}; + +/* + * NodeOrText is used before a node can be casted to either TextElement or MbNode. + */ +export type NodeOrText = TextElement | MbNode;