import { unified, type Plugin } from "unified" import remarkParse from "remark-parse" import remarkGfm from "remark-gfm" import remarkRehype from "remark-rehype" import rehypeRaw from "rehype-raw" import rehypeStringify from "rehype-stringify" import { defaultSchema } from "rehype-sanitize" import rehypeSlug from "rehype-slug" import rehypeHighlight from "rehype-highlight" import rehypeAutolinkHeadings from "rehype-autolink-headings" import { preprocess } from "./preprocess.js" import yaml from "yaml" import remarkFrontmatter from "remark-frontmatter" import { visit } from "unist-util-visit" import { remarkGithubAlerts } from "./remark-github-alerts.js" import { rehypeCodeBlocks } from "./html/rehype-codeblocks.js" import { remarkExternalLinks, type ExternalLinksOptions } from "./remark-external-links.js" export type UrlResolverContext = { kind: "link" | "image" } export type UrlResolver = (url: string, context: UrlResolverContext) => string | undefined export type ParseOptions = { /** * When enabled, external links open in a new tab and get a safe rel. * * `true` applies defaults to all absolute http(s) links (treated as external). * You can pass an object to customize detection and attributes. * * @default false * * @example * parse(markdown, { externalLinks: true }) */ externalLinks?: boolean | ExternalLinksOptions /** * Resolve relative URLs (images/links) against a base URL/path. * * @example * // Markdown: ![Architecture](./assets/architecture.jpg) * // Result: /blog/my-post/assets/architecture.jpg * parse(markdown, { assetBaseUrl: "/blog/my-post/" }) * * @example * // Markdown: [Intro](docs/intro) * // Result: https://example.com/docs/intro * parse(markdown, { assetBaseUrl: "https://example.com/" }) */ assetBaseUrl?: string /** * Resolve Markdown link URLs before rendering. * * Return a string to override the URL. Return `undefined` to keep the * default behavior, including `assetBaseUrl` fallback for relative URLs. */ resolveHref?: UrlResolver /** * Resolve Markdown image URLs before rendering. * * Return a string to override the URL. Return `undefined` to keep the * default behavior, including `assetBaseUrl` fallback for relative URLs. */ resolveSrc?: UrlResolver } /* Converts the markdown with remark and the html with rehype to be suitable for being rendered */ export async function parse( markdown: string, options?: ParseOptions ): Promise<{ frontmatter: Record & { imports?: string[] } detectedCustomElements: string[] html: string }> { const withDefaults = { externalLinks: false, assetBaseUrl: undefined, ...options, } const processor = unified() // @ts-ignore .use(remarkParse) // @ts-ignore .use(remarkGfm) .use(remarkFrontmatter, ["yaml"]) .use(remarkGithubAlerts as any) if (withDefaults.externalLinks) { if (typeof withDefaults.externalLinks === "object") { processor.use(remarkExternalLinks as any, withDefaults.externalLinks) } else { processor.use(remarkExternalLinks as any) } } if (withDefaults.assetBaseUrl || withDefaults.resolveHref || withDefaults.resolveSrc) { processor.use(remarkResolveUrls as any, { baseUrl: withDefaults.assetBaseUrl, resolveHref: withDefaults.resolveHref, resolveSrc: withDefaults.resolveSrc, }) } processor .use(() => (tree, file) => { // @ts-ignore const yamlNode = tree.children.find((node: any) => node.type === "yaml") if (yamlNode && yamlNode.value) { try { file.data.frontmatter = yaml.parse(yamlNode.value) } catch (e) { // @ts-ignore throw new Error(`Failed to parse frontmatter: ${e.message}`) } // @ts-ignore tree.children = tree.children.filter((node: any) => node.type !== "yaml") } }) .use(mermaidTransformer) .use(customElementDetector) // @ts-ignore .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) // TODO sanitization // sanitization broke for attributes of custom elements // took too much time to fix now // .use(() => (tree, file) => { // // @ts-ignore // return rehypeSanitize(createSanitizeOptions(file.data.customElements || []))(tree, file) // }) .use(rehypeHighlight) .use(rehypeSlug) .use(rehypeCodeBlocks as any) processor .use(rehypeAutolinkHeadings, { behavior: "wrap", }) .use(rehypeStringify) const content = await processor.process(preprocess(markdown)) let html = String(content) let frontmatter = (content.data.frontmatter as Record & { imports?: string[] }) ?? {} const hasMermaidDiagram = html.includes("") if (hasMermaidDiagram) { // import markdown-wc-mermaid component frontmatter.imports = [ ...(frontmatter.imports ?? []), "https://cdn.jsdelivr.net/npm/@opral/markdown-wc/dist/markdown-wc-mermaid.js", ] } return { frontmatter, detectedCustomElements: (content.data.customElements as []) ?? [], html, } } type RemarkResolveUrlsOptions = { baseUrl?: string resolveHref?: UrlResolver resolveSrc?: UrlResolver } const remarkResolveUrls: Plugin<[RemarkResolveUrlsOptions]> = ( options: RemarkResolveUrlsOptions ) => { return (tree) => { visit(tree, ["image", "link"], (node: any) => { if (!node?.url || typeof node.url !== "string") return const kind = node.type === "link" ? "link" : "image" const resolver = kind === "link" ? options.resolveHref : options.resolveSrc const resolvedUrl = resolver?.(node.url, { kind }) if (resolvedUrl !== undefined) { node.url = resolvedUrl return } if (!options.baseUrl || !isRelativeUrl(node.url)) return node.url = resolveRelativeUrl(node.url, options.baseUrl) }) } } function isRelativeUrl(url: string) { if (url.startsWith("/") || url.startsWith("#")) return false return !/^[a-z][a-z0-9+.-]*:/.test(url) } function resolveRelativeUrl(url: string, baseUrl: string) { const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` const isAbsoluteBase = /^https?:\/\//.test(normalizedBase) const base = isAbsoluteBase ? normalizedBase : `https://local${normalizedBase.startsWith("/") ? "" : "/"}${normalizedBase}` const resolved = new URL(url, base) if (isAbsoluteBase) { return resolved.toString() } return `${resolved.pathname}${resolved.search}${resolved.hash}` } function createSanitizeOptions(customElements: string[]) { return { ...defaultSchema, tagNames: [...customElements, ...(defaultSchema.tagNames ?? [])], attributes: { ...customElements.reduce( (acc, customElement) => ({ ...acc, [customElement]: ["*"], }), {} ), ...(defaultSchema.attributes ?? {}), }, } } const customElementDetector: Plugin = () => (tree, file) => { const markdownText = String(file.value) const regex = /<\/([a-zA-Z0-9-]+)>/g const customElements = new Set() let match while ((match = regex.exec(markdownText)) !== null) { customElements.add(match[1]!) } // Store detected custom elements in file.data file.data.customElements = Array.from(customElements) } function mermaidTransformer() { return (tree: any) => { visit(tree, "code", (node: any) => { if (node.lang === "mermaid") { node.type = "html" node.value = `${node.value}` } }) } }