@sveltek/markdown
Version:
Svelte Markdown Preprocessor.
438 lines (421 loc) • 13.7 kB
JavaScript
import { CONTINUE, SKIP, visit } from "unist-util-visit";
import { escapeSvelte } from "./utils/index.js";
import { isArray, isFalse, isObject, isString, meta } from "./shared/index.js";
import { parse, preprocess } 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 as parse$1 } from "yaml";
import { print } from "esrap";
import ts from "esrap/languages/ts";
import { basename, relative, resolve } from "node:path";
import { toMarkdown } from "mdast-util-to-markdown";
import { toHtml } from "hast-util-to-html";
import { readFile } from "node:fs/promises";
export * from "unist-util-visit"
//#region src/config/index.ts
/**
* Defines configuration via custom `options` object that contains all available settings.
*
* @example
*
* ```ts
* import { defineConfig } from '@sveltek/markdown'
*
* export const markdownConfig = defineConfig({
* frontmatter: {
* defaults: {
* layout: 'default',
* author: 'Sveltek',
* },
* },
* layouts: {
* default: {
* path: 'lib/content/layouts/default/layout.svelte',
* },
* },
* })
* ```
*/
function defineConfig(config) {
return config;
}
//#endregion
//#region src/plugins/rehype/highlight/utils.ts
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)
};
};
//#endregion
//#region src/plugins/rehype/highlight/index.ts
/**
* A custom `Rehype` plugin that creates code highlighter.
*
* @example
*
* ```ts
* import { rehypeHighlight } from '@sveltek/markdown'
*
* svelteMarkdown({
* plugins: {
* rehype: [[rehypeHighlight, options]]
* }
* })
* ```
*/
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;
let data;
if (code.type === "element" && code.tagName === "code") {
data = getHighlighterData(code);
els.push({
el: code,
data
});
}
const d = node.data || (node.data = {});
d.highlight = { data };
root?.(node);
});
const highlight = async (el, data) => {
const code = await highlighter(data);
if (code) Object.assign(el, {
type: "raw",
value: escapeSvelte(code)
});
};
await Promise.all(els.map(({ el, data }) => highlight(el, data)));
};
};
//#endregion
//#region src/compile/file.ts
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 = /* @__PURE__ */ 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$1(match[1])
};
file = file.slice(match[0].length);
vfile.value = file;
}
}
if (data.frontmatter?.specialElements) {
if (file.includes("<svelte:")) {
file = file.replace(/<svelte:([a-zA-Z]+)(\s[^>]*)?(?:\/>|>([\s\S]*?)<\/svelte:\1>)/g, (match) => {
parsedFile.svelte += match;
return "";
});
vfile.value = file;
}
}
return parsedFile;
}
//#endregion
//#region src/compile/layouts.ts
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
};
}
//#endregion
//#region src/compile/entries.ts
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;
}
//#endregion
//#region src/compile/module.ts
function createSvelteModule(module, data) {
const keys = Object.keys(data.frontmatter);
let frontmatter = `export const frontmatter = ${JSON.stringify(data.frontmatter)};\n`;
if (keys.length) frontmatter += `const { ${keys.join(", ")} } = frontmatter;\n`;
if (!module) return {
start: 0,
end: 0,
content: `<script module>\n${frontmatter}<\/script>\n`
};
const content = `<script module>\n${frontmatter}${print(module.content, ts()).code}\n<\/script>\n`;
return {
start: module.start,
end: module.end,
content
};
}
//#endregion
//#region src/compile/instance.ts
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}`;
};
const getImports = (imports) => imports?.map((value) => `${value.path};`).join("\n").concat("\n") || "";
function createSvelteInstance(instance, { filePath, layoutPath, imports }) {
const isLayout = filePath && layoutPath;
let code = "";
const globals = getImports(imports);
if (isLayout) {
const path = getRelativePath(filePath, layoutPath);
code = `${globals}import ${meta.layoutName}, * as ${meta.componentName} from "${path}";\n`;
} else code = globals;
if (!instance) return {
start: 0,
end: 0,
content: code ? `<script>\n${code}<\/script>\n` : ""
};
const content = `<script>\n${code}${print(instance.content, ts()).code}\n<\/script>\n`;
return {
start: instance.start,
end: instance.end,
content
};
}
//#endregion
//#region src/plugins/remark/html.ts
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) return `<${tagName} ${attrs}>${value.split("\n").slice(1, -1).join("\n").trim()}</${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;
}
});
};
};
//#endregion
//#region src/plugins/rehype/code.ts
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 parsed = escapeSvelte(toHtml(code, { characterReferences: { useNamedReferences: true } }));
Object.assign(code, {
type: "raw",
value: htmlTag ? `{@html \`${parsed}\`}` : parsed
});
});
};
};
//#endregion
//#region src/plugins/rehype/layouts.ts
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(code, {
filename,
modern: true
});
if (module) {
const namedExports = getExportedNames(module);
if (namedExports.length) data.components = namedExports;
}
};
};
//#endregion
//#region src/plugins/rehype/components.ts
const rehypeCreateComponents = () => {
return (tree, vfile) => {
const { layout, components } = vfile.data;
if (!layout || !components) return;
visit(tree, "element", (node) => {
if (components.includes(node.tagName)) node.tagName = `${meta.componentName}.${node.tagName}`;
});
};
};
//#endregion
//#region src/plugins/utils.ts
const usePlugins = (plugins) => plugins ?? [];
//#endregion
//#region src/compile/index.ts
async function compile(source, { filename, config = {}, htmlTag = true, module: optionsModule = true }) {
const { preprocessors = [], plugins: { remark = [], rehype = [] } = {}, highlight = {}, imports } = 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(code, { modern: true });
const svelteModule = createSvelteModule(module, data);
const svelteInstance = createSvelteInstance(instance, {
filePath: file.path,
layoutPath: layout?.path,
imports
});
if (instance) s.remove(instance.start, instance.end);
if (layout) {
let styles;
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}>\n`);
s.append(`</${meta.layoutName}>\n`);
if (styles) s.prepend(styles);
}
if (svelteInstance.content) s.prepend(svelteInstance.content);
if (optionsModule) s.prepend(svelteModule.content);
return {
code: s.toString(),
map: s.generateMap({ source: filename }),
dependencies: data.dependencies
};
}
//#endregion
//#region src/preprocessor/index.ts
/**
* Svelte Markdown Preprocessor.
*
* @example
*
* ```ts
* // svelte.config.js
* import adapter from '@sveltejs/adapter-static'
* import { svelteMarkdown } from '@sveltek/markdown'
*
* const config = {
* kit: { adapter: adapter() },
* preprocess: [svelteMarkdown()],
* extensions: ['.svelte', '.md'],
* }
*
* export default config
* ```
*
* @see [Repository](https://github.com/sveltek/markdown)
*/
function svelteMarkdown(config = {}) {
return {
name: meta.name,
async markup({ content, filename }) {
const { extensions = [".md"] } = config;
if (!extensions.some((ext) => filename?.endsWith(ext))) return;
return await compile(content, {
filename,
config
});
}
};
}
//#endregion
export { compile, defineConfig, rehypeHighlight, svelteMarkdown };