UNPKG

writr

Version:

Markdown Rendering Simplified

609 lines (606 loc) 18.3 kB
// src/writr.ts import fs from "fs"; import { dirname } from "path"; import { Hookified } from "hookified"; import parse from "html-react-parser"; import * as yaml from "js-yaml"; import rehypeHighlight from "rehype-highlight"; import rehypeKatex from "rehype-katex"; import rehypeSlug from "rehype-slug"; import rehypeStringify from "rehype-stringify"; import remarkEmoji from "remark-emoji"; import remarkGfm from "remark-gfm"; import remarkGithubBlockquoteAlert from "remark-github-blockquote-alert"; import remarkMath from "remark-math"; import remarkMDX from "remark-mdx"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import remarkToc from "remark-toc"; import { unified } from "unified"; // src/writr-cache.ts import { Cacheable, CacheableMemory } from "cacheable"; var WritrCache = class { _store = new CacheableMemory(); _hashStore = new CacheableMemory(); _hash = new Cacheable(); get store() { return this._store; } get hashStore() { return this._hashStore; } get(markdown, options) { const key = this.hash(markdown, options); return this._store.get(key); } set(markdown, value, options) { const key = this.hash(markdown, options); this._store.set(key, value); } clear() { this._store.clear(); this._hashStore.clear(); } hash(markdown, options) { const content = { markdown, options }; const key = JSON.stringify(content); let result = this._hashStore.get(key); if (result) { return result; } result = this._hash.hash(content); this._hashStore.set(key, result); return result; } }; // src/writr.ts var WritrHooks = /* @__PURE__ */ ((WritrHooks2) => { WritrHooks2["beforeRender"] = "beforeRender"; WritrHooks2["afterRender"] = "afterRender"; WritrHooks2["saveToFile"] = "saveToFile"; WritrHooks2["renderToFile"] = "renderToFile"; WritrHooks2["loadFromFile"] = "loadFromFile"; return WritrHooks2; })(WritrHooks || {}); var Writr = class extends Hookified { engine = unified().use(remarkParse).use(remarkGfm).use(remarkToc).use(remarkEmoji).use(remarkRehype).use(rehypeSlug).use(remarkMath).use(rehypeKatex).use(rehypeHighlight).use(rehypeStringify); // Stringify HTML _options = { throwErrors: false, renderOptions: { emoji: true, toc: true, slug: true, highlight: true, gfm: true, math: true, mdx: false, caching: true } }; _content = ""; _cache = new WritrCache(); /** * Initialize Writr. Accepts a string or options object. * @param {string | WritrOptions} [arguments1] If you send in a string, it will be used as the markdown content. If you send in an object, it will be used as the options. * @param {WritrOptions} [arguments2] This is if you send in the content in the first argument and also want to send in options. * * @example * const writr = new Writr('Hello, world!', {caching: false}); */ constructor(arguments1, arguments2) { super(); if (typeof arguments1 === "string") { this._content = arguments1; } else if (arguments1) { this._options = this.mergeOptions(this._options, arguments1); if (this._options.renderOptions) { this.engine = this.createProcessor(this._options.renderOptions); } } if (arguments2) { this._options = this.mergeOptions(this._options, arguments2); if (this._options.renderOptions) { this.engine = this.createProcessor(this._options.renderOptions); } } } /** * Get the options. * @type {WritrOptions} */ get options() { return this._options; } /** * Get the Content. This is the markdown content and front matter if it exists. * @type {WritrOptions} */ get content() { return this._content; } /** * Set the Content. This is the markdown content and front matter if it exists. * @type {WritrOptions} */ set content(value) { this._content = value; } /** * Get the cache. * @type {WritrCache} */ get cache() { return this._cache; } /** * Get the front matter raw content. * @type {string} The front matter content including the delimiters. */ get frontMatterRaw() { if (!this._content.trimStart().startsWith("---")) { return ""; } const match = /^\s*(---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$))/.exec( this._content ); if (match) { return match[1]; } return ""; } /** * Get the body content without the front matter. * @type {string} The markdown content without the front matter. */ get body() { const frontMatter = this.frontMatterRaw; if (frontMatter === "") { return this._content; } return this._content.slice(this._content.indexOf(frontMatter) + frontMatter.length).trim(); } /** * Get the markdown content. This is an alias for the body property. * @type {string} The markdown content. */ get markdown() { return this.body; } /** * Get the front matter content as an object. * @type {Record<string, any>} The front matter content as an object. */ // biome-ignore lint/suspicious/noExplicitAny: expected get frontMatter() { const frontMatter = this.frontMatterRaw; const match = /^---\s*([\s\S]*?)\s*---\s*/.exec(frontMatter); if (match) { try { return yaml.load(match[1].trim()); } catch (error) { this.emit("error", error); } } return {}; } /** * Set the front matter content as an object. * @type {Record<string, any>} The front matter content as an object. */ // biome-ignore lint/suspicious/noExplicitAny: expected set frontMatter(data) { try { const frontMatter = this.frontMatterRaw; const yamlString = yaml.dump(data); const newFrontMatter = `--- ${yamlString}--- `; this._content = this._content.replace(frontMatter, newFrontMatter); } catch (error) { this.emit("error", error); } } /** * Get the front matter value for a key. * @param {string} key The key to get the value for. * @returns {T} The value for the key. */ getFrontMatterValue(key) { return this.frontMatter[key]; } /** * Render the markdown content to HTML. * @param {RenderOptions} [options] The render options. * @returns {Promise<string>} The rendered HTML content. */ async render(options) { try { let { engine } = this; if (options) { options = { ...this._options.renderOptions, ...options }; engine = this.createProcessor(options); } const renderData = { content: this._content, body: this.body, options }; await this.hook("beforeRender" /* beforeRender */, renderData); const resultData = { result: "" }; if (this.isCacheEnabled(renderData.options)) { const cached = this._cache.get(renderData.content, renderData.options); if (cached) { return cached; } } const file = await engine.process(renderData.body); resultData.result = String(file); if (this.isCacheEnabled(renderData.options)) { this._cache.set( renderData.content, resultData.result, renderData.options ); } await this.hook("afterRender" /* afterRender */, resultData); return resultData.result; } catch (error) { this.emit("error", error); throw new Error(`Failed to render markdown: ${error.message}`); } } /** * Render the markdown content to HTML synchronously. * @param {RenderOptions} [options] The render options. * @returns {string} The rendered HTML content. */ renderSync(options) { try { let { engine } = this; if (options) { options = { ...this._options.renderOptions, ...options }; engine = this.createProcessor(options); } const renderData = { content: this._content, body: this.body, options }; this.hook("beforeRender" /* beforeRender */, renderData); const resultData = { result: "" }; if (this.isCacheEnabled(renderData.options)) { const cached = this._cache.get(renderData.content, renderData.options); if (cached) { return cached; } } const file = engine.processSync(renderData.body); resultData.result = String(file); if (this.isCacheEnabled(renderData.options)) { this._cache.set( renderData.content, resultData.result, renderData.options ); } this.hook("afterRender" /* afterRender */, resultData); return resultData.result; } catch (error) { this.emit("error", error); throw new Error(`Failed to render markdown: ${error.message}`); } } /** * Validate the markdown content by attempting to render it. * @param {string} [content] The markdown content to validate. If not provided, uses the current content. * @param {RenderOptions} [options] The render options. * @returns {Promise<WritrValidateResult>} An object with a valid boolean and optional error. */ async validate(content, options) { const originalContent = this._content; try { if (content !== void 0) { this._content = content; } let { engine } = this; if (options) { options = { ...this._options.renderOptions, ...options, caching: false }; engine = this.createProcessor(options); } await engine.run(engine.parse(this.body)); if (content !== void 0) { this._content = originalContent; } return { valid: true }; } catch (error) { this.emit("error", error); if (content !== void 0) { this._content = originalContent; } return { valid: false, error }; } } /** * Validate the markdown content by attempting to render it synchronously. * @param {string} [content] The markdown content to validate. If not provided, uses the current content. * @param {RenderOptions} [options] The render options. * @returns {WritrValidateResult} An object with a valid boolean and optional error. */ validateSync(content, options) { const originalContent = this._content; try { if (content !== void 0) { this._content = content; } let { engine } = this; if (options) { options = { ...this._options.renderOptions, ...options, caching: false }; engine = this.createProcessor(options); } engine.runSync(engine.parse(this.body)); if (content !== void 0) { this._content = originalContent; } return { valid: true }; } catch (error) { this.emit("error", error); if (content !== void 0) { this._content = originalContent; } return { valid: false, error }; } } /** * Render the markdown content and save it to a file. If the directory doesn't exist it will be created. * @param {string} filePath The file path to save the rendered markdown content to. * @param {RenderOptions} [options] the render options. */ async renderToFile(filePath, options) { try { const { writeFile, mkdir } = fs.promises; const directoryPath = dirname(filePath); const content = await this.render(options); await mkdir(directoryPath, { recursive: true }); const data = { filePath, content }; await this.hook("renderToFile" /* renderToFile */, data); await writeFile(data.filePath, data.content); } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } /** * Render the markdown content and save it to a file synchronously. If the directory doesn't exist it will be created. * @param {string} filePath The file path to save the rendered markdown content to. * @param {RenderOptions} [options] the render options. */ renderToFileSync(filePath, options) { try { const directoryPath = dirname(filePath); const content = this.renderSync(options); fs.mkdirSync(directoryPath, { recursive: true }); const data = { filePath, content }; this.hook("renderToFile" /* renderToFile */, data); fs.writeFileSync(data.filePath, data.content); } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } /** * Render the markdown content to React. * @param {RenderOptions} [options] The render options. * @param {HTMLReactParserOptions} [reactParseOptions] The HTML React parser options. * @returns {Promise<string | React.JSX.Element | React.JSX.Element[]>} The rendered React content. */ async renderReact(options, reactParseOptions) { try { const html = await this.render(options); return parse(html, reactParseOptions); } catch (error) { this.emit("error", error); throw new Error(`Failed to render React: ${error.message}`); } } /** * Render the markdown content to React synchronously. * @param {RenderOptions} [options] The render options. * @param {HTMLReactParserOptions} [reactParseOptions] The HTML React parser options. * @returns {string | React.JSX.Element | React.JSX.Element[]} The rendered React content. */ renderReactSync(options, reactParseOptions) { try { const html = this.renderSync(options); return parse(html, reactParseOptions); } catch (error) { this.emit("error", error); throw new Error(`Failed to render React: ${error.message}`); } } /** * Load markdown content from a file. * @param {string} filePath The file path to load the markdown content from. * @returns {Promise<void>} */ async loadFromFile(filePath) { try { const { readFile } = fs.promises; const data = { content: "" }; data.content = await readFile(filePath, "utf8"); await this.hook("loadFromFile" /* loadFromFile */, data); this._content = data.content; } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } /** * Load markdown content from a file synchronously. * @param {string} filePath The file path to load the markdown content from. * @returns {void} */ loadFromFileSync(filePath) { try { const data = { content: "" }; data.content = fs.readFileSync(filePath, "utf8"); this.hook("loadFromFile" /* loadFromFile */, data); this._content = data.content; } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } /** * Save the markdown content to a file. If the directory doesn't exist it will be created. * @param {string} filePath The file path to save the markdown content to. * @returns {Promise<void>} */ async saveToFile(filePath) { try { const { writeFile, mkdir } = fs.promises; const directoryPath = dirname(filePath); await mkdir(directoryPath, { recursive: true }); const data = { filePath, content: this._content }; await this.hook("saveToFile" /* saveToFile */, data); await writeFile(data.filePath, data.content); } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } /** * Save the markdown content to a file synchronously. If the directory doesn't exist it will be created. * @param {string} filePath The file path to save the markdown content to. * @returns {void} */ saveToFileSync(filePath) { try { const directoryPath = dirname(filePath); fs.mkdirSync(directoryPath, { recursive: true }); const data = { filePath, content: this._content }; this.hook("saveToFile" /* saveToFile */, data); fs.writeFileSync(data.filePath, data.content); } catch (error) { this.emit("error", error); if (this._options.throwErrors) { throw error; } } } isCacheEnabled(options) { if (options?.caching !== void 0) { return options.caching; } return this._options?.renderOptions?.caching ?? false; } // biome-ignore lint/suspicious/noExplicitAny: expected unified processor createProcessor(options) { const processor = unified().use(remarkParse); if (options.gfm) { processor.use(remarkGfm); processor.use(remarkGithubBlockquoteAlert); } if (options.toc) { processor.use(remarkToc, { heading: "toc|table of contents" }); } if (options.emoji) { processor.use(remarkEmoji); } processor.use(remarkRehype); if (options.slug) { processor.use(rehypeSlug); } if (options.highlight) { processor.use(rehypeHighlight); } if (options.math) { processor.use(remarkMath).use(rehypeKatex); } if (options.mdx) { processor.use(remarkMDX); } processor.use(rehypeStringify); return processor; } mergeOptions(current, options) { if (options.throwErrors !== void 0) { current.throwErrors = options.throwErrors; } if (options.renderOptions) { current.renderOptions ??= {}; this.mergeRenderOptions(current.renderOptions, options.renderOptions); } return current; } mergeRenderOptions(current, options) { if (options.emoji !== void 0) { current.emoji = options.emoji; } if (options.toc !== void 0) { current.toc = options.toc; } if (options.slug !== void 0) { current.slug = options.slug; } if (options.highlight !== void 0) { current.highlight = options.highlight; } if (options.gfm !== void 0) { current.gfm = options.gfm; } if (options.math !== void 0) { current.math = options.math; } if (options.mdx !== void 0) { current.mdx = options.mdx; } if (options.caching !== void 0) { current.caching = options.caching; } return current; } }; export { Writr, WritrHooks }; /* v8 ignore next -- @preserve */