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>

499 lines (476 loc) 15.9 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 = /([*\p{L}\w$@-]+(?:[.#][\p{L}\w-]+)?(?:\?[\w=&]+)?)/u; const MATCH_KEYWORD_REGEXP = /(?<key>[*\p{L}\w$@-]+)((?<delimiter>[.#])(?<anchor>[\p{L}\w-]+))?(?<queryParams>\?[\w=&]+)?/u; /** * * @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 }; //# sourceMappingURL=index.esm.js.map