writr
Version:
Markdown Rendering Simplified
509 lines (506 loc) • 15.4 kB
JavaScript
// 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
};