UNPKG

writr

Version:

Markdown Rendering Simplified

509 lines (506 loc) 15.4 kB
// src/writr.ts import fs from "node:fs"; import { dirname } from "node:path"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import rehypeSlug from "rehype-slug"; import rehypeHighlight from "rehype-highlight"; import rehypeStringify from "rehype-stringify"; import remarkToc from "remark-toc"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkEmoji from "remark-emoji"; import remarkMDX from "remark-mdx"; import parse from "html-react-parser"; import * as yaml from "js-yaml"; import { Hookified } from "hookified"; // src/writr-cache.ts import { CacheableMemory } from "cacheable"; var WritrCache = class { _store = new CacheableMemory(); _hashStore = new CacheableMemory(); 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._hashStore.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(remarkMDX).use(rehypeStringify); // Stringify HTML _options = { throwErrors: false, renderOptions: { emoji: true, toc: true, slug: true, highlight: true, gfm: true, math: true, mdx: true, caching: false } }; _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 start = this._content.indexOf("---\n"); const end = this._content.indexOf("\n---\n", start + 4); if (end === -1) { return ""; } return this._content.slice(start, end + 5); } /** * Get the body content without the front matter. * @type {string} The markdown content without the front matter. */ get body() { if (this.frontMatterRaw === "") { return this._content; } const end = this._content.indexOf("\n---\n"); return this._content.slice(Math.max(0, end + 5)).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. */ 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. */ set frontMatter(data) { const frontMatter = this.frontMatterRaw; const yamlString = yaml.dump(data); const newFrontMatter = `--- ${yamlString}--- `; this._content = this._content.replace(frontMatter, newFrontMatter); } /** * 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) { 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) { throw new Error(`Failed to render markdown: ${error.message}`); } } /** * 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) { const html = await this.render(options); return parse(html, reactParseOptions); } /** * 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) { const html = this.renderSync(options); return parse(html, reactParseOptions); } /** * 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; } createProcessor(options) { const processor = unified().use(remarkParse); if (options.gfm) { processor.use(remarkGfm); } 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 };