@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
JavaScript
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