UNPKG

@sveltek/markdown

Version:

Svelte Markdown Preprocessor.

438 lines (421 loc) 13.7 kB
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 };