UNPKG

@sveltek/markdown

Version:

Svelte Markdown Preprocessor.

375 lines (358 loc) 12.1 kB
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 };