@sveltek/markdown
Version:
Svelte Markdown Preprocessor.
375 lines (358 loc) • 12.1 kB
JavaScript
import { visit, CONTINUE, SKIP } from 'unist-util-visit';
import { escapeSvelte } from './utils/index.mjs';
import { isArray, isString, isObject, meta, isFalse } from './shared/index.mjs';
import { preprocess, parse as parse$1 } from 'svelte/compiler';
import { unified } from 'unified';
import { VFile } from 'vfile';
import MagicString from 'magic-string';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import { parse } from 'yaml';
import { print } from 'esrap';
import ts from 'esrap/languages/ts';
import { relative, resolve, basename } from 'node:path';
import { toMarkdown } from 'mdast-util-to-markdown';
import { toHtml } from 'hast-util-to-html';
import { readFile } from 'node:fs/promises';
function defineConfig(config) {
return config;
}
const getLang = (el) => isArray(el.properties.className) && isString(el.properties.className[0]) && el.properties.className[0]?.replace("language-", "") || void 0;
const getMeta = (el) => el.data?.meta || void 0;
const getCode = (el) => el.children[0]?.type === "text" ? el.children[0].value.replace(/\n$/, "") : void 0;
const getHighlighterData = (el) => {
return {
lang: getLang(el),
meta: getMeta(el),
code: getCode(el)
};
};
const rehypeHighlight = (options) => {
return async (tree) => {
const { highlighter, root } = options;
if (!highlighter) return;
const els = [];
visit(tree, "element", (node) => {
if (node.tagName !== "pre" || !node.children?.length) return;
const [code] = node.children;
if (code.type === "element" && code.tagName === "code") els.push(code);
root?.(node);
});
const highlight = async (el) => {
const code = await highlighter?.(getHighlighterData(el));
if (code) Object.assign(el, { type: "raw", value: escapeSvelte(code) });
};
await Promise.all(els.map((el) => highlight(el)));
};
};
function parseFile(vfile, config = {}) {
const { frontmatter: { marker = "-", parser, defaults = {} } = {} } = config;
const parsedFile = { svelte: "" };
const data = vfile.data;
if (defaults) data.frontmatter = { ...defaults };
let file = String(vfile);
const rgxFm = new RegExp(
`^\\s*[${marker}]{3}\\s*\\r?\\n([\\s\\S]*?)\\r?\\n\\s*[${marker}]{3}`
);
if (parser) {
const parsed = parser(file);
if (parsed) data.frontmatter = { ...defaults, ...parsed };
} else {
const match = rgxFm.exec(file);
if (match && match[1]) {
data.frontmatter = { ...defaults, ...parse(match[1]) };
file = file.slice(match[0].length);
vfile.value = file;
}
}
if (data.frontmatter?.specialElements) {
if (file.includes("<svelte:")) {
const rgxSvelte = /<svelte:([a-zA-Z]+)(\s[^>]*)?(?:\/>|>([\s\S]*?)<\/svelte:\1>)/g;
file = file.replace(rgxSvelte, (match) => {
parsedFile.svelte += match;
return "";
});
vfile.value = file;
}
}
return parsedFile;
}
function getLayoutData(data, config = {}) {
const { layout } = data.frontmatter;
if (!config.layouts || !layout) return;
const layoutName = isObject(layout) ? layout.name : layout;
const layoutConfig = config.layouts[layoutName];
if (!layoutConfig) {
throw new TypeError(
`Invalid layout name. Valid names are: ${Object.keys(config.layouts).join(", ")}.`
);
}
return {
name: layoutName,
...layoutConfig
};
}
function getEntryData(data, config = {}) {
const { entry } = data.frontmatter;
if (!config.entries || !entry) return;
const entryName = isObject(entry) ? entry.name : entry;
const entryConfig = config.entries[entryName];
if (!entryConfig) {
throw new TypeError(
`Invalid entry name. Valid names are: ${Object.keys(config.entries).join(", ")}.`
);
}
return entryConfig;
}
function createSvelteModule(module, data) {
const keys = Object.keys(data.frontmatter);
let frontmatter = `export const frontmatter = ${JSON.stringify(data.frontmatter)};
`;
if (keys.length) {
frontmatter += `const { ${keys.join(", ")} } = frontmatter;
`;
}
if (!module) {
const content2 = `<script module>
${frontmatter}<\/script>
`;
return { start: 0, end: 0, content: content2 };
}
const content = `<script module>
${frontmatter}${print(module.content, ts()).code}
<\/script>
`;
return { start: module.start, end: module.end, content };
}
const posix = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
const hasNonAscii = /[^\0-\x80]+/.test(path);
if (isExtendedLengthPath || hasNonAscii) return path;
return path.replace(/\\/g, "/");
};
const getRelativePath = (from, to) => {
const path = posix(relative(resolve(from, ".."), to));
return path.startsWith(".") ? path : `./${path}`;
};
function createSvelteInstance(instance, filePath, layoutPath) {
const path = getRelativePath(filePath, layoutPath);
const imports = `import ${meta.layoutName}, * as ${meta.componentName} from "${path}";
`;
if (!instance) {
const content2 = `<script>
${imports}<\/script>
`;
return { start: 0, end: 0, content: content2 };
}
const content = `<script>
${imports}${print(instance.content, ts()).code}
<\/script>
`;
return { start: instance.start, end: instance.end, content };
}
const rgxSvelteBlock = /{[#:/@]\w+.*}/;
const rgxElementOrComponent = /<[A-Za-z]+[\s\S]*>/;
const convertToComponent = (value) => {
const tagMatch = value.match(/^::(\S+)/);
if (!tagMatch) return value;
const tagName = tagMatch[1];
const isBlock = value.match(/::\s*$/);
const attrs = value.slice(tagName.length + 2).split("\n")[0].trim() || "";
if (isBlock) {
const content = value.split("\n").slice(1, -1).join("\n").trim();
return `<${tagName} ${attrs}>${content}</${tagName}>`;
}
return `<${tagName} ${attrs} />`;
};
const convertToHtml = (node) => {
let value = "";
for (const child of node.children) {
if (child.type === "text" || child.type === "html") value += child.value;
else value += toMarkdown(child);
}
Object.assign(node, { type: "html", value });
};
const remarkSvelteHtml = () => {
return (tree) => {
visit(tree, "paragraph", (node) => {
const [child] = node.children;
if (child?.type !== "text" && child?.type !== "html") return CONTINUE;
if (child.value.startsWith("::")) {
child.value = convertToComponent(child.value);
convertToHtml(node);
return SKIP;
}
if (rgxSvelteBlock.test(child.value) || rgxElementOrComponent.test(child.value)) {
convertToHtml(node);
return SKIP;
}
});
};
};
const rehypeRenderCode = ({
htmlTag
} = {}) => {
return (tree) => {
visit(tree, "element", (node) => {
if (!["pre", "code"].includes(node.tagName)) return;
let code = node.tagName === "pre" ? node.children[0] : node;
if (code?.type !== "element" || code?.tagName !== "code") return;
const value = toHtml(code, {
characterReferences: { useNamedReferences: true }
});
const parsed = escapeSvelte(value);
Object.assign(code, {
type: "raw",
value: htmlTag ? `{@html \`${parsed}\`}` : parsed
});
});
};
};
const getExportedNames = (module) => {
const names = [];
if (module?.content) {
module.content.body.forEach((node) => {
if (node.type === "ExportNamedDeclaration") {
node.specifiers.forEach((specifier) => {
if (specifier.exported.type === "Identifier") {
names.push(specifier.exported.name);
}
});
}
});
}
return names;
};
const rehypeCreateLayout = () => {
return async (_, vfile) => {
const data = vfile.data;
const { layout } = data;
if (!layout) return;
const source = await readFile(layout.path, { encoding: "utf8" });
const filename = basename(layout.path);
const { code, dependencies } = await preprocess(
source,
data.preprocessors,
{ filename }
);
if (dependencies) data.dependencies?.push(...dependencies);
const { module } = parse$1(code, { filename, modern: true });
if (module) {
const namedExports = getExportedNames(module);
if (namedExports.length) data.components = namedExports;
}
};
};
const rehypeCreateComponents = () => {
return (tree, vfile) => {
const data = vfile.data;
const { layout, components } = data;
if (!layout || !components) return;
visit(tree, "element", (node) => {
if (components.includes(node.tagName)) {
node.tagName = `${meta.componentName}.${node.tagName}`;
}
});
};
};
const usePlugins = (plugins) => plugins ?? [];
async function compile(source, {
filename,
config = {},
htmlTag = true,
module: optionsModule = true
}) {
const {
preprocessors = [],
plugins: { remark = [], rehype = [] } = {},
highlight = {}
} = config;
const file = new VFile({
value: source,
path: filename,
data: {
preprocessors,
plugins: { remark, rehype },
dependencies: [],
frontmatter: {}
}
});
const parsed = parseFile(file, config);
const data = file.data;
if (isFalse(data.frontmatter?.plugins?.remark)) data.plugins.remark = [];
if (isFalse(data.frontmatter?.plugins?.rehype)) data.plugins.rehype = [];
if (highlight) data.plugins?.rehype?.push([rehypeHighlight, highlight]);
const layout = getLayoutData(data, config);
if (layout) {
data.layout = layout;
data.dependencies?.push(layout.path);
if (layout.plugins && isObject(data.frontmatter?.layout)) {
if (isFalse(data.frontmatter?.layout?.plugins?.remark)) {
layout.plugins.remark = [];
}
if (isFalse(data.frontmatter?.layout?.plugins?.rehype)) {
layout.plugins.rehype = [];
}
}
}
const entry = getEntryData(data, config);
if (entry) {
if (entry.plugins && isObject(data.frontmatter?.entry)) {
if (isFalse(data.frontmatter?.entry?.plugins?.remark)) {
entry.plugins.remark = [];
}
if (isFalse(data.frontmatter?.entry?.plugins?.rehype)) {
entry.plugins.rehype = [];
}
}
}
const processed = await unified().use(remarkParse).use(remarkSvelteHtml).use(usePlugins(data.plugins?.remark)).use(usePlugins(layout?.plugins?.remark)).use(usePlugins(entry?.plugins?.remark)).use(remarkRehype, { allowDangerousHtml: true }).use(usePlugins(data.plugins?.rehype)).use(usePlugins(layout?.plugins?.rehype)).use(usePlugins(entry?.plugins?.rehype)).use(rehypeRenderCode, { htmlTag }).use(rehypeCreateLayout).use(rehypeCreateComponents).use(rehypeStringify, { allowDangerousHtml: true }).process(file);
const { code, dependencies } = await preprocess(
parsed.svelte + String(processed),
preprocessors,
{ filename }
);
if (dependencies) data.dependencies?.push(...dependencies);
const s = new MagicString(code);
const { instance, module, css } = parse$1(code, { modern: true });
const svelteModule = createSvelteModule(module, data);
if (layout) {
let styles;
const svelteInstance = createSvelteInstance(
instance,
file.path,
layout.path
);
if (instance) s.remove(instance.start, instance.end);
if (module) s.remove(module.start, module.end);
if (css) {
styles = s.original.substring(css.start, css.end);
s.remove(css.start, css.end);
}
s.prepend(`<${meta.layoutName} {frontmatter}>
`);
s.append(`</${meta.layoutName}>
`);
if (styles) s.prepend(styles);
s.prepend(svelteInstance.content);
}
if (optionsModule) s.prepend(svelteModule.content);
return {
code: s.toString(),
map: s.generateMap({ source: filename }),
dependencies: data.dependencies
};
}
function svelteMarkdown(config = {}) {
return {
name: meta.name,
async markup({ content, filename }) {
const { extensions = [".md"] } = config;
const isExtSupported = extensions.some((ext) => filename?.endsWith(ext));
if (!isExtSupported) return;
return await compile(content, { filename, config });
}
};
}
export { compile, defineConfig, rehypeHighlight, svelteMarkdown };