@diplodoc/transform
Version:
A simple transformer of text in YFM (Yandex Flavored Markdown) to HTML
240 lines (199 loc) • 7.42 kB
text/typescript
import type Token from 'markdown-it/lib/token';
import type {MarkdownItPluginCb, MarkdownItPluginOpts} from '../typings';
import type {ImageOptions, StateCore} from '../../typings';
import {join, sep} from 'path';
import {bold} from 'chalk';
import {optimize} from 'svgo';
import {readFileSync} from 'fs';
import {isFileExists, resolveRelativePath} from '../../utilsFS';
import {filterTokens, getSrcTokenAttr, isExternalHref} from '../../utils';
const sanitizeAttribute = (value: string): string => value.replace(/(\d*[%a-z]{0,5}).*/gi, '$1');
interface ImageOpts extends MarkdownItPluginOpts {
assetsPublicPath: string;
inlineSvg?: boolean;
}
function replaceImageSrc(
state: StateCore,
currentPath: string,
path: string,
imgSrc: string,
{assetsPublicPath = sep, root = '', log}: ImageOpts,
) {
if (isFileExists(path)) {
state.md.assets?.push(imgSrc);
} else {
log.error(`Asset not found: ${bold(imgSrc)} in ${bold(currentPath)}`);
}
const relativeToRoot = path.replace(root + sep, '');
const publicSrc = join(assetsPublicPath, relativeToRoot);
return publicSrc;
}
interface InlineOptions {
enabled: boolean;
maxFileSize: number;
}
interface SVGOpts extends MarkdownItPluginOpts {
notFoundCb: (s: string) => void;
imageOpts: ImageOptions;
svgInline: InlineOptions;
}
function getSvgContent(file: string, from: string, {rawContent, notFoundCb, log, root = ''}: Opts) {
try {
return rawContent(file);
} catch (e: unknown) {
const path = file.replace(root, '');
log.error(`SVG ${path} from ${from} not found`);
if (notFoundCb) {
notFoundCb(path);
}
return null;
}
}
type Opts = SVGOpts &
ImageOpts & {
rawContent: (path: string) => string;
calcPath: (root: string, path: string) => string;
replaceImageSrc: (
state: StateCore,
currentPath: string,
path: string,
imgSrc: string,
opts: ImageOpts,
) => string;
file: string;
};
function shouldBeInlined(token: Token, opts: InlineOptions) {
if (!token.attrGet('src')?.endsWith('.svg')) {
return false;
}
const forceInlineSvg = token.attrGet('inline') === 'true';
const shouldInlineSvg =
forceInlineSvg || (token.attrGet('inline') !== 'false' && opts.enabled !== false);
return shouldInlineSvg;
}
const getRawFile = (path: string) => {
return readFileSync(path, 'utf8').toString();
};
const index: MarkdownItPluginCb<Opts> = (md, opts) => {
const {
rawContent = getRawFile,
calcPath = resolveRelativePath,
replaceImageSrc: replaceImage = replaceImageSrc,
} = opts;
// TODO:goldserg need remove support opts.inlineSvg
if (opts.inlineSvg !== undefined) {
opts.svgInline = {
...opts.svgInline,
enabled: opts.inlineSvg,
};
}
md.assets = [];
const plugin = (state: StateCore) => {
const tokens = state.tokens;
filterTokens(tokens, 'inline', (inline, {commented}) => {
if (commented || !inline.children) {
return;
}
const childrenTokens = inline.children || [];
filterTokens(childrenTokens, 'image', (image, {commented, index}) => {
const didPatch = image.attrGet('yfm_patched') || false;
if (didPatch || commented) {
return;
}
const imgSrc = getSrcTokenAttr(image);
if (isExternalHref(imgSrc)) {
return;
}
const forceInlineSvg = image.attrGet('inline') === 'true';
const shouldInlineSvg = shouldBeInlined(image, opts.svgInline);
const imageOpts = {
width: image.attrGet('width'),
height: image.attrGet('height'),
};
const from = state.env.path || opts.path;
const file = calcPath(from, imgSrc);
if (shouldInlineSvg) {
const svgContent = getSvgContent(file, from, {
...opts,
rawContent,
});
if (svgContent) {
if (svgContent.length > opts.svgInline.maxFileSize && !forceInlineSvg) {
image.attrSet(
'YFM011',
`Svg size: ${svgContent.length}; Config size: ${opts.svgInline.maxFileSize}; Src: ${bold(file)}`,
);
} else {
const svgToken = new state.Token('image_svg', '', 0);
svgToken.attrSet('content', replaceSvgContent(svgContent, imageOpts));
childrenTokens[index] = svgToken;
}
}
}
if (childrenTokens[index].type === 'image') {
image.attrSet('src', replaceImage(state, from, file, imgSrc, opts));
image.attrSet('yfm_patched', '1');
}
});
});
};
try {
md.core.ruler.before('includes', 'images', plugin);
} catch (e) {
md.core.ruler.push('images', plugin);
}
md.renderer.rules.image_svg = (tokens, index) => {
const token = tokens[index];
return token.attrGet('content') || '';
};
};
function replaceSvgContent(content: string | null, options: ImageOptions) {
if (!content) {
return '';
}
// monoline
content = content.replace(/>\r?\n</g, '><').replace(/\r?\n/g, ' ');
// remove <?xml...?>
content = content.replace(/<\?xml.*?\?>.*?(<svg.*)/g, '$1');
// width, height
let svgRoot = content.replace(/.*?<svg([^>]*)>.*/g, '$1');
const {width, height} = svgRoot
.match(/(?:width="(.*?)")|(?:height="(.*?)")/g)
?.reduce((acc: {[key: string]: string}, val) => {
const [key, value] = val.split('=');
acc[key] = value;
return acc;
}, {}) || {width: undefined, height: undefined};
if (!width && options.width) {
const sanitizedWidth = sanitizeAttribute(options.width.toString());
svgRoot = `${svgRoot} width="${sanitizedWidth}"`;
}
if (!height && options.height) {
const sanitizedHeight = sanitizeAttribute(options.height.toString());
svgRoot = `${svgRoot} height="${sanitizedHeight}"`;
}
if ((!width && options.width) || (!height && options.height)) {
content = content.replace(/.*?<svg([^>]*)>/, `<svg${svgRoot}>`);
}
// randomize ids
content = optimize(content, {
plugins: [
{
name: 'prefixIds',
params: {
prefix: 'rnd-' + Math.floor(Math.random() * 1e9).toString(16),
prefixClassNames: false,
},
},
],
}).data;
return content;
}
// Create an object that is the index function with an additional replaceSvgContent property
const imagesPlugin: typeof index & {replaceSvgContent: typeof replaceSvgContent} = Object.assign(
index,
{
replaceSvgContent,
},
);
export = imagesPlugin;