UNPKG

@ng-doc/utils

Version:

<!-- PROJECT LOGO --> <br /> <div align="center"> <a href="https://github.com/ng-doc/ng-doc"> <img src="https://ng-doc.com/assets/images/ng-doc.svg?raw=true" alt="Logo" height="150px"> </a>

504 lines (481 loc) 14.1 kB
import rehypeMinifyWhitespace from 'rehype-minify-whitespace'; import rehypeParse from 'rehype-parse'; import rehypeStringify from 'rehype-stringify'; import { unified } from 'unified'; import { KEYWORD_ALLOWED_LANGUAGES, asArray, NG_DOC_ELEMENT } from '@ng-doc/core'; import { isElement as isElement$1 } from 'hast-util-is-element'; import { toString } from 'hast-util-to-string'; import { visitParents, SKIP } from 'unist-util-visit-parents'; import rehypeShiki from '@shikijs/rehype'; import { visit } from 'unist-util-visit'; import GithubSlugger from 'github-slugger'; import { hasProperty } from 'hast-util-has-property'; import { headingRank } from 'hast-util-heading-rank'; import { rehype } from 'rehype'; import { filter } from 'unist-util-filter'; import { stringifyEntities as stringifyEntities$1 } from 'stringify-entities'; /** * * @param html */ function minify(html) { return unified().use(rehypeParse, { fragment: true }).use(rehypeStringify).use(rehypeMinifyWhitespace).processSync(html).toString(); } /** * * @param node * @param attr */ function attrValue(node, attr) { const attrKey = attr.toLowerCase(); return node.properties?.[attrKey] ? String(node.properties[attrKey]) : undefined; } /** * * @param node * @param cls */ function hasClass(node, cls) { const className = node.properties?.['className']?.toString() ?? ''; return !!className && className.includes(cls); } /** * * @param ancestors */ function hasLinkAncestor(ancestors) { return ancestors.some(ancestor => isElement$1(ancestor, 'a')); } /** * * @param parent * @param node */ function isCodeBlock(parent, node) { return parent?.type === 'element' && parent.tagName === 'pre' && node.tagName === 'code'; } /** * * @param node */ function isCodeNode(node) { return isElement$1(node, 'code') || isElement$1(node, 'ng-doc-code'); } /** * * @param node */ function isElement(node) { return node?.type === 'element'; } const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; /** * * @param node */ function isHeading(node) { return isElement$1(node, HEADINGS); } const ALWAYS_ALLOWED_LANGUAGES = ['typescript', 'ts', 'angular-ts']; const LANGUAGES = ['typescript', 'ts', 'angular-ts', ...KEYWORD_ALLOWED_LANGUAGES]; const SPLIT_REGEXP = /([*A-Za-z0-9_$@-]+(?:[.#][A-Za-z0-9_-]+)?(?:\?[\w=&]+)?)/; const MATCH_KEYWORD_REGEXP = /(?<key>[*A-Za-z0-9_$@-]+)((?<delimiter>[.#])(?<anchor>[A-Za-z0-9_-]+))?(?<queryParams>\?[\w=&]+)?/; /** * * @param config */ function keywordsPlugin(config) { return tree => visitParents(tree, 'element', (node, ancestors) => { if (!isCodeNode(node)) { return; } const isInlineCode = !isElement$1(ancestors[ancestors.length - 1], 'pre'); const lang = asArray(node.properties?.['className'] ?? []).find(className => className.startsWith('language-'))?.replace('language-', '') ?? ''; if (isInlineCode || LANGUAGES.includes(lang)) { visitParents(node, 'text', (node, ancestors) => { if (hasLinkAncestor(ancestors)) { return; } const parent = ancestors[ancestors.length - 1]; const index = parent.children.indexOf(node); // Parse the text for words that we can convert to links const nodes = getNodes(node, parent, isInlineCode, config, lang); // Replace the text node with the links and leftover text nodes // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes)); // Do not visit this node's children or the newly added nodes return [SKIP, index + nodes.length]; }); } }); } /** * * @param node * @param parent * @param isInlineCode * @param config * @param language */ function getNodes(node, parent, isInlineCode, config, language) { const { addUsedKeyword, getKeyword } = config; return toString(node).split(SPLIT_REGEXP).map(word => { const match = word.match(MATCH_KEYWORD_REGEXP); if (!match) { return { type: 'text', value: word }; } const { key = '', delimiter = '', anchor = '', queryParams = '' } = match.groups; const usedKeyword = `${key}${delimiter}${anchor?.toLowerCase()}`; const rootKeyword = getKeyword?.(key); const keyword = getKeyword?.(usedKeyword); const isGuideKeyword = key.startsWith('*'); const isLanguageAllowed = !keyword?.languages && ALWAYS_ALLOWED_LANGUAGES.includes(language) || !!keyword?.languages?.includes(language); // If language of code block is not allowed, return the word as is if (!isInlineCode && keyword && !isLanguageAllowed) { return { type: 'text', value: word }; } // If the keyword is just an asterisk, return the word as is if (isGuideKeyword && key.length === 1) { return { type: 'text', value: word }; } addUsedKeyword?.(usedKeyword); const notFoundGuideKeyword = isGuideKeyword && !keyword; const notFoundApiAnchorKeyword = !!rootKeyword && !!anchor && !keyword; if (getKeyword && isInlineCode && (notFoundGuideKeyword || notFoundApiAnchorKeyword)) { throw new Error(`Route with keyword "${word}" is missing.`); } // Convert code tag to a link tag or highlight it with a class if (parent.properties) { if (isInlineCode && keyword?.type === 'link') { parent.tagName = 'a'; parent.properties = { href: `${keyword.path}${queryParams ?? ''}`, className: [NG_DOC_ELEMENT] }; return { type: 'text', value: keyword.title }; } else if (isInlineCode && keyword) { parent.properties['className'] = [NG_DOC_ELEMENT, 'ng-doc-code-with-link']; } } // Add link inside the code if it's a link to the API entity return keyword ? createLinkNode(isInlineCode ? keyword.title : word, keyword.path, keyword.type, keyword.description) : { type: 'text', value: word }; }); } /** * * @param text * @param href * @param type * @param description */ function createLinkNode(text, href, type, description) { return { type: 'element', tagName: 'a', properties: { href: href, class: ['ng-doc-code-anchor', NG_DOC_ELEMENT], 'data-link-type': type, ngDocTooltip: description }, children: [{ type: 'text', value: text }] }; } /** * * @param html * @param config * @param config.addUsedKeyword * @param addUsedKeyword */ async function postProcessHtml(html) { const usedKeywords = new Set(); try { const content = await unified().use(rehypeParse, { fragment: true }).use(rehypeStringify).use(keywordsPlugin, { addUsedKeyword: usedKeywords.add.bind(usedKeywords) }).process(html).then(file => file.toString()); return { content, usedKeywords: Array.from(usedKeywords) }; } catch (error) { return { content: html, usedKeywords: [], error }; } } const NO_ANCHOR_CLASS = 'no-anchor'; /** * * @param options * @param route */ function addHeadingAnchors(route) { return tree => visit(tree, 'element', node => { if (route && isHeading(node) && node.properties && node.properties['id'] && !hasClass(node, NO_ANCHOR_CLASS)) { node.children.push({ type: 'element', tagName: 'ng-doc-heading-anchor', properties: { className: ['ng-doc-anchor'], anchor: node.properties['id'] }, children: [] }); node.properties = { ...node.properties, href: route, headingLink: 'true' }; } }); } /** * Highlight code lines */ function highlightCodeLines() { return tree => { visit(tree, 'element', (node, _i, parent) => { if (isCodeBlock(parent, node) && parent?.type === 'element') { const highlightedLines = JSON.parse(attrValue(parent, 'highlightedLines') ?? '[]'); highlightedLines.length && node.children.filter(isElement).forEach((child, i) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (child.properties.class === 'line' && highlightedLines.includes(i + 1)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore child.properties.class = 'line highlighted'; } }); } }); }; } /** * Marks all elements with the NG_DOC_ELEMENT class name * @param tree * @param headings */ function markElementsPlugin() { return tree => { visit(tree, 'element', node => { if (node.properties) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore node.properties['className'] = [...asArray(node.properties.className), NG_DOC_ELEMENT]; } }); }; } /** * Plugin to transform mermaid code blocks into mermaid elements that are going to be rendered by the mermaid processor. * @param tree * @param headings */ function mermaidPlugin() { return tree => { visit(tree, 'element', node => { if (node.tagName === 'pre') { const codeNode = node.children[0]; if (codeNode.tagName === 'code' && codeNode.properties?.['lang'] === 'mermaid') { node.properties = { className: 'mermaid' }; node.children = [{ type: 'text', value: toString(codeNode) }]; } } }); }; } /** * * @param tree * @param addAnchor * @param headings */ function sluggerPlugin(addAnchor, headings = ['h1', 'h2', 'h3', 'h4']) { if (!addAnchor) { return () => {}; } const slugger = new GithubSlugger(); return tree => { slugger.reset(); visitParents(tree, 'element', (node, ancestors) => { const scope = getKeywordScope(ancestors); const isHeading = !!headingRank(node) && !hasProperty(node, 'id') && !!headings?.includes(node.tagName.toLowerCase()); const attrSlug = attrValue(node, 'dataSlug'); const attrSlugTitle = attrValue(node, 'dataSlugTitle'); const attrSlugType = attrValue(node, 'dataSlugType'); const dataToSlug = isHeading ? toString(node).trim() : attrSlug; if (dataToSlug) { if (node.properties) { const id = attrSlug && attrSlugType === 'member' ? attrSlug : slugger.slug(dataToSlug); node.properties['id'] = id; addAnchor({ anchorId: id, anchor: new GithubSlugger().slug(dataToSlug), title: attrSlugTitle || dataToSlug, scope, type: isHeading && attrSlugType !== 'member' || attrSlug && attrSlugType === 'heading' ? 'heading' : 'member' }); } } }); }; } /** * * @param ancestors */ function getKeywordScope(ancestors) { const keywordScope = ancestors.find(ancestor => isElement$1(ancestor) && ancestor.tagName === 'ng-doc-keyword-scope'); const key = keywordScope?.properties?.['id']; return key ? { key: String(key), title: String(keywordScope?.properties?.['title']) } : undefined; } /** * */ function wrapTablePlugin () { return tree => { visit(tree, 'element', (node, index, parent) => { const table = node?.tagName === 'table'; if (table) { const wrapper = { type: 'element', tagName: 'div', properties: { className: 'ng-doc-table-wrapper' }, children: [node] }; // @ts-expect-error - parent is a hast node parent.children[index] = wrapper; } }); }; } /** * * @param html * @param config */ async function processHtml(html, config) { const anchors = new Set(); try { const content = await unified().use(rehypeParse, { fragment: true }).use(rehypeStringify).use(mermaidPlugin) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .use(rehypeShiki, { defaultLanguage: 'ts', fallbackLanguage: 'text', addLanguageClass: true, parseMetaString: meta => JSON.parse(meta?.replace(/\\/g, '') || '{}'), themes: { light: config.lightTheme ?? 'github-light', dark: config.darkTheme ?? 'ayu-dark' } }).use(highlightCodeLines).use(wrapTablePlugin).use(sluggerPlugin, anchors.add.bind(anchors), config.headings).use(rehypeMinifyWhitespace).use(addHeadingAnchors, config.route).use(markElementsPlugin).process(html).then(file => file.toString()); return { content, anchors: Array.from(anchors) }; } catch (error) { return { content: html, anchors: [], error }; } } /** * * @param tree * @param headings */ function removeNotIndexableContentPlugin() { return tree => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return filter(tree, { cascade: true }, node => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const preWithCode = node?.tagName === 'pre' && node?.children?.some(isCodeNode); const notIndexable = node?.properties?.['indexable'] === 'false'; return !node?.tagName || !preWithCode && !notIndexable; }); }; } /** * * @param html */ async function removeNotIndexableContent(html) { return rehype().use(removeNotIndexableContentPlugin).process(html).then(file => file.toString()); } /** * * @param html * @param config * @param config.getKeyword * @param getKeyword */ async function replaceKeywords(html, { getKeyword }) { return unified().use(rehypeParse, { fragment: true }).use(rehypeStringify).use(keywordsPlugin, { getKeyword }).process(html).then(file => file.toString()); } /** * * @param content */ function stringifyEntities(content) { return stringifyEntities$1(content); } export { minify, postProcessHtml, processHtml, removeNotIndexableContent, replaceKeywords, stringifyEntities };