@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
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 = /([*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 };