import { htmlEscape, isFunction } from '@vitebook/core/node';
import type Token from 'markdown-it/lib/token';
import type { MarkdownHeader } from '../../shared';
/**
* Resolve headers from `markdown-it` tokens.
*/
export const resolveHeadersFromTokens = (
tokens: Token[],
{
level,
allowHtml,
escapeText,
slugify,
format,
}: {
level: number[];
allowHtml: boolean;
escapeText: boolean;
slugify: (str: string) => string;
format?: (str: string) => string;
},
): MarkdownHeader[] => {
const headers: MarkdownHeader[] = [];
// A temp headers stack for generating the headers tree.
const stack: MarkdownHeader[] = [];
// Push a header to the headers tree.
const push = (header: MarkdownHeader): void => {
while (stack.length !== 0 && header.level <= stack[0].level) {
stack.shift();
}
if (stack.length === 0) {
headers.push(header);
stack.push(header);
} else {
stack[0].children.push(header);
stack.unshift(header);
}
};
tokens.forEach((_, idx) => {
const token = tokens[idx];
// If the token type does not match, skip.
if (token?.type !== 'heading_open') {
return;
}
// Get the level from the tag, `h1 -> 1`.
const headerLevel = Number.parseInt(token.tag.slice(1), 10);
// If the level should not be extracted, skip.
if (!level.includes(headerLevel)) {
return;
}
// The next token of 'heading_open' contains the heading content.
const nextToken = tokens[idx + 1];
// If the next token does not exist, skip.
if (!nextToken) {
return;
}
const title = resolveTitleFromToken(nextToken, {
allowHtml,
escapeText,
});
/**
* The id of the heading anchor is the slugified result of `markdown-it-anchor` if the id
* does not exist, we'll slugify the title ourselves.
*/
const slug = token.attrGet('id') ?? slugify(title);
// Push the header to tree.
push({
level: headerLevel,
title: isFunction(format) ? format(title) : title,
slug,
children: [],
});
});
return headers;
};
/**
* Resolve header title from `markdown-it` token. Typically using the next token of
* `heading_open` token.
*/
export const resolveTitleFromToken = (
token: Token,
{
allowHtml,
escapeText,
}: {
allowHtml: boolean;
escapeText: boolean;
},
): string => {
// Children of the token contains the parsed result of the heading title.
const children = token.children ?? [];
// Type of tokens to be included in the heading title.
const titleTokenTypes = ['text', 'emoji', 'code_inline'];
// Include 'html_inline' or not.
if (allowHtml) {
titleTokenTypes.push('html_inline');
}
// Filter the token type to be included in the title.
const titleTokens = children.filter(
(item) =>
titleTokenTypes.includes(item.type) &&
// Filter permalink symbol that generated by `markdown-it-anchor`.
!item.meta?.isPermalinkSymbol,
);
// Get title from tokens.
return titleTokens
.reduce((result, item) => {
if (escapeText) {
// Escape the content of 'code_inline' and 'text'.
if (item.type === 'code_inline' || item.type === 'text') {
return `${result}${htmlEscape(item.content)}`;
}
}
// Keep the content of 'emoji' and 'html_inline'.
return `${result}${item.content}`;
}, '')
.trim();
};
// eslint-disable-next-line no-control-regex
const rControl = /[\u0000-\u001f]/g;
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
const rCombining = /[\u0300-\u036F]/g;
export const slugify = (str: string): string =>
str
.normalize('NFKD')
// Remove accents
.replace(rCombining, '')
// Remove control characters
.replace(rControl, '')
// Replace special characters
.replace(rSpecial, '-')
// Remove continuos separators
.replace(/-{2,}/g, '-')
// Remove prefixing and trailing separators
.replace(/^-+|-+$/g, '')
// Ensure it doesn't start with a number (#121)
.replace(/^(\d)/, '_$1')
// Lowercase
.toLowerCase();
/**
* Global constants and env variables will be statically replaced by Vite in build mode. This
* util helps avoid that by inserting escape sequences.
*
* @see https://vitejs.dev/guide/env-and-mode.html#production-replacement
*/
export function preventViteConstantsReplacement(
source: string,
define?: Record,
): string {
source = source
.replace(/\bimport\.meta/g, 'import.meta')
.replace(/\bprocess\.env/g, 'process.env');
// Also avoid replacing defines.
if (define) {
const regex = new RegExp(
`\\b(${Object.keys(define)
.map((key) => key.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'))
.join('|')})`,
'g',
);
source = source.replace(regex, (_) => `${_[0]}${_.slice(1)}`);
}
return source;
}