writr 6.1.1 → 6.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/writr.d.mts +592 -0
- package/dist/writr.mjs +848 -0
- package/package.json +35 -30
- package/dist/writr.d.ts +0 -559
- package/dist/writr.js +0 -1031
package/dist/writr.mjs
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Hookified } from "hookified";
|
|
4
|
+
import parse from "html-react-parser";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
|
+
import rehypeHighlight from "rehype-highlight";
|
|
7
|
+
import rehypeKatex from "rehype-katex";
|
|
8
|
+
import rehypeRaw from "rehype-raw";
|
|
9
|
+
import rehypeSlug from "rehype-slug";
|
|
10
|
+
import rehypeStringify from "rehype-stringify";
|
|
11
|
+
import remarkEmoji from "remark-emoji";
|
|
12
|
+
import remarkGfm from "remark-gfm";
|
|
13
|
+
import remarkGithubBlockquoteAlert from "remark-github-blockquote-alert";
|
|
14
|
+
import remarkMath from "remark-math";
|
|
15
|
+
import remarkMDX from "remark-mdx";
|
|
16
|
+
import remarkParse from "remark-parse";
|
|
17
|
+
import remarkRehype from "remark-rehype";
|
|
18
|
+
import remarkToc from "remark-toc";
|
|
19
|
+
import { unified } from "unified";
|
|
20
|
+
import { CacheableMemory } from "cacheable";
|
|
21
|
+
import { Hashery } from "hashery";
|
|
22
|
+
import { Output, generateText } from "ai";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
//#region src/writr-cache.ts
|
|
25
|
+
var WritrCache = class {
|
|
26
|
+
_store = new CacheableMemory();
|
|
27
|
+
_hashStore = new CacheableMemory();
|
|
28
|
+
_hash = new Hashery();
|
|
29
|
+
get store() {
|
|
30
|
+
return this._store;
|
|
31
|
+
}
|
|
32
|
+
get hashStore() {
|
|
33
|
+
return this._hashStore;
|
|
34
|
+
}
|
|
35
|
+
get(markdown, options) {
|
|
36
|
+
const key = this.hash(markdown, options);
|
|
37
|
+
return this._store.get(key);
|
|
38
|
+
}
|
|
39
|
+
set(markdown, value, options) {
|
|
40
|
+
const key = this.hash(markdown, options);
|
|
41
|
+
this._store.set(key, value);
|
|
42
|
+
}
|
|
43
|
+
clear() {
|
|
44
|
+
this._store.clear();
|
|
45
|
+
this._hashStore.clear();
|
|
46
|
+
}
|
|
47
|
+
hash(markdown, options) {
|
|
48
|
+
const content = {
|
|
49
|
+
markdown,
|
|
50
|
+
options: this.sanitizeOptions(options)
|
|
51
|
+
};
|
|
52
|
+
const key = JSON.stringify(content);
|
|
53
|
+
const result = this._hashStore.get(key);
|
|
54
|
+
if (result) return result;
|
|
55
|
+
const hash = this._hash.toHashSync(content);
|
|
56
|
+
this._hashStore.set(key, hash);
|
|
57
|
+
return hash;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Sanitizes render options to only include serializable properties for caching.
|
|
61
|
+
* This prevents issues with structuredClone when options contain Promises, functions, or circular references.
|
|
62
|
+
* @param {RenderOptions} [options] The render options to sanitize
|
|
63
|
+
* @returns {RenderOptions | undefined} A new object with only the known RenderOptions properties
|
|
64
|
+
*/
|
|
65
|
+
sanitizeOptions(options) {
|
|
66
|
+
if (!options) return;
|
|
67
|
+
const sanitized = {};
|
|
68
|
+
if (options.emoji !== void 0) sanitized.emoji = options.emoji;
|
|
69
|
+
/* v8 ignore next -- @preserve */
|
|
70
|
+
if (options.toc !== void 0) sanitized.toc = options.toc;
|
|
71
|
+
if (options.slug !== void 0) sanitized.slug = options.slug;
|
|
72
|
+
if (options.highlight !== void 0) sanitized.highlight = options.highlight;
|
|
73
|
+
if (options.gfm !== void 0) sanitized.gfm = options.gfm;
|
|
74
|
+
if (options.math !== void 0) sanitized.math = options.math;
|
|
75
|
+
if (options.mdx !== void 0) sanitized.mdx = options.mdx;
|
|
76
|
+
if (options.caching !== void 0) sanitized.caching = options.caching;
|
|
77
|
+
return sanitized;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/types.ts
|
|
82
|
+
let WritrHooks = /* @__PURE__ */ function(WritrHooks) {
|
|
83
|
+
WritrHooks["beforeRender"] = "beforeRender";
|
|
84
|
+
WritrHooks["afterRender"] = "afterRender";
|
|
85
|
+
WritrHooks["saveToFile"] = "saveToFile";
|
|
86
|
+
WritrHooks["renderToFile"] = "renderToFile";
|
|
87
|
+
WritrHooks["loadFromFile"] = "loadFromFile";
|
|
88
|
+
return WritrHooks;
|
|
89
|
+
}({});
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/writr-ai-cache.ts
|
|
92
|
+
var WritrAICache = class {
|
|
93
|
+
_store = new CacheableMemory();
|
|
94
|
+
_hashStore = new CacheableMemory();
|
|
95
|
+
_hash = new Hashery();
|
|
96
|
+
get store() {
|
|
97
|
+
return this._store;
|
|
98
|
+
}
|
|
99
|
+
get hashStore() {
|
|
100
|
+
return this._hashStore;
|
|
101
|
+
}
|
|
102
|
+
get(key, context) {
|
|
103
|
+
const hash = this.hash(key, context);
|
|
104
|
+
return this._store.get(hash);
|
|
105
|
+
}
|
|
106
|
+
set(key, context, value) {
|
|
107
|
+
const hash = this.hash(key, context);
|
|
108
|
+
this._store.set(hash, value);
|
|
109
|
+
}
|
|
110
|
+
hash(key, context) {
|
|
111
|
+
const content = {
|
|
112
|
+
key,
|
|
113
|
+
context
|
|
114
|
+
};
|
|
115
|
+
const cacheKey = JSON.stringify(content);
|
|
116
|
+
const result = this._hashStore.get(cacheKey);
|
|
117
|
+
if (result) return result;
|
|
118
|
+
const hash = this._hash.toHashSync(content);
|
|
119
|
+
this._hashStore.set(cacheKey, hash);
|
|
120
|
+
return hash;
|
|
121
|
+
}
|
|
122
|
+
clear() {
|
|
123
|
+
this._store.clear();
|
|
124
|
+
this._hashStore.clear();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/writr-ai.ts
|
|
129
|
+
const AVERAGE_WORDS_PER_MINUTE = 200;
|
|
130
|
+
/**
|
|
131
|
+
* AI companion for a {@link Writr} instance.
|
|
132
|
+
*
|
|
133
|
+
* WritrAI provides metadata generation, SEO generation,
|
|
134
|
+
* translation, and metadata application for markdown documents.
|
|
135
|
+
*/
|
|
136
|
+
var WritrAI = class {
|
|
137
|
+
writr;
|
|
138
|
+
/**
|
|
139
|
+
* The AI SDK model used for all generation requests.
|
|
140
|
+
*/
|
|
141
|
+
model;
|
|
142
|
+
/**
|
|
143
|
+
* The prompt templates used by this WritrAI instance.
|
|
144
|
+
*/
|
|
145
|
+
prompts;
|
|
146
|
+
/**
|
|
147
|
+
* Optional in-memory cache for generated AI results.
|
|
148
|
+
*/
|
|
149
|
+
cache;
|
|
150
|
+
/**
|
|
151
|
+
* Creates a new WritrAI instance bound to a specific Writr document.
|
|
152
|
+
*
|
|
153
|
+
* @param writr - The base Writr instance this AI helper operates on.
|
|
154
|
+
* @param options - The AI model and optional cache/prompt settings.
|
|
155
|
+
*/
|
|
156
|
+
constructor(writr, options) {
|
|
157
|
+
this.writr = writr;
|
|
158
|
+
this.model = options.model;
|
|
159
|
+
this.prompts = options.prompts ?? {};
|
|
160
|
+
this.cache = options.cache ? new WritrAICache() : void 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Generates metadata for the current markdown document.
|
|
164
|
+
*
|
|
165
|
+
* Pass `allowedTags`, `allowedKeywords`, or `allowedCategories` to constrain the
|
|
166
|
+
* AI to a controlled vocabulary — values are enforced via a Zod response schema
|
|
167
|
+
* (`z.array(z.enum(...))` for tags/keywords, `z.enum(...)` for category) so the
|
|
168
|
+
* model cannot return anything outside the list. Providing a non-empty `allowed*`
|
|
169
|
+
* array also implicitly enables its corresponding field.
|
|
170
|
+
*
|
|
171
|
+
* @param options - Controls which metadata fields should be generated.
|
|
172
|
+
* @returns A metadata object for the current document.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const metadata = await writr.ai.getMetadata({
|
|
177
|
+
* allowedTags: ['javascript', 'typescript', 'python'],
|
|
178
|
+
* allowedCategories: ['tutorial', 'guide', 'blog'],
|
|
179
|
+
* });
|
|
180
|
+
* // metadata.tags ⊆ allowedTags, metadata.category ∈ allowedCategories
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
async getMetadata(options) {
|
|
184
|
+
const cacheKey = `metadata:${JSON.stringify(options ?? {})}`;
|
|
185
|
+
const cached = this.cache?.get(cacheKey, this.writr.content);
|
|
186
|
+
if (cached) return cached;
|
|
187
|
+
const fields = this.resolveMetadataFields(options);
|
|
188
|
+
const result = {};
|
|
189
|
+
if (fields.includes("wordCount")) result.wordCount = this.computeWordCount();
|
|
190
|
+
if (fields.includes("readingTime")) result.readingTime = this.computeReadingTime();
|
|
191
|
+
const aiFields = fields.filter((f) => f !== "wordCount" && f !== "readingTime");
|
|
192
|
+
if (aiFields.length > 0) {
|
|
193
|
+
const schema = this.buildMetadataSchema(aiFields, options);
|
|
194
|
+
let prompt = this.prompts.metadata ?? "Analyze the following markdown document and generate metadata for it. Be concise and accurate.";
|
|
195
|
+
const aiFieldSet = new Set(aiFields);
|
|
196
|
+
const constraints = [];
|
|
197
|
+
if (aiFieldSet.has("tags") && options?.allowedTags?.length) constraints.push(`Tags must be selected from: ${options.allowedTags.join(", ")}`);
|
|
198
|
+
if (aiFieldSet.has("keywords") && options?.allowedKeywords?.length) constraints.push(`Keywords must be selected from: ${options.allowedKeywords.join(", ")}`);
|
|
199
|
+
if (aiFieldSet.has("category") && options?.allowedCategories?.length) constraints.push(`Category must be one of: ${options.allowedCategories.join(", ")}`);
|
|
200
|
+
if (constraints.length > 0) prompt += `\n\nConstraints:\n${constraints.map((c) => `- ${c}`).join("\n")}`;
|
|
201
|
+
const { output } = await generateText({
|
|
202
|
+
model: this.model,
|
|
203
|
+
output: Output.object({ schema }),
|
|
204
|
+
prompt: `${prompt}\n\n---\n\n${this.writr.content}`
|
|
205
|
+
});
|
|
206
|
+
Object.assign(result, output);
|
|
207
|
+
}
|
|
208
|
+
this.cache?.set(cacheKey, this.writr.content, result);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Generates SEO metadata for the current markdown document.
|
|
213
|
+
*
|
|
214
|
+
* @param options - Controls which SEO fields should be generated.
|
|
215
|
+
* @returns An SEO metadata object for the current document.
|
|
216
|
+
*/
|
|
217
|
+
async getSEO(options) {
|
|
218
|
+
const cacheKey = `seo:${JSON.stringify(options ?? {})}`;
|
|
219
|
+
const cached = this.cache?.get(cacheKey, this.writr.content);
|
|
220
|
+
if (cached) return cached;
|
|
221
|
+
const fields = this.resolveSEOFields(options);
|
|
222
|
+
const schema = this.buildSEOSchema(fields);
|
|
223
|
+
const prompt = this.prompts.seo ?? "Analyze the following markdown document and generate SEO metadata for it. Be concise and accurate.";
|
|
224
|
+
const { output } = await generateText({
|
|
225
|
+
model: this.model,
|
|
226
|
+
output: Output.object({ schema }),
|
|
227
|
+
prompt: `${prompt}\n\n---\n\n${this.writr.content}`
|
|
228
|
+
});
|
|
229
|
+
const result = output;
|
|
230
|
+
this.cache?.set(cacheKey, this.writr.content, result);
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Generates a translated version of the current document.
|
|
235
|
+
*
|
|
236
|
+
* @param options - Translation settings including target locale.
|
|
237
|
+
* @returns A new translated Writr instance.
|
|
238
|
+
*/
|
|
239
|
+
async getTranslation(options) {
|
|
240
|
+
const cacheKey = `translation:${JSON.stringify(options)}`;
|
|
241
|
+
const cached = this.cache?.get(cacheKey, this.writr.content);
|
|
242
|
+
if (cached) return new Writr(cached);
|
|
243
|
+
const fromClause = options.from ? ` from ${options.from}` : "";
|
|
244
|
+
const frontMatterClause = options.translateFrontMatter ? " Also translate any string values in the YAML frontmatter." : " Preserve the YAML frontmatter exactly as-is without translating it.";
|
|
245
|
+
const prompt = this.prompts.translation ?? `Translate the following markdown document${fromClause} to ${options.to}.${frontMatterClause} Preserve all markdown formatting, links, code blocks, and structure. Return only the translated markdown document with no additional commentary.`;
|
|
246
|
+
let { text } = await generateText({
|
|
247
|
+
model: this.model,
|
|
248
|
+
prompt: `${prompt}\n\n---\n\n${this.writr.content}`
|
|
249
|
+
});
|
|
250
|
+
text = this.stripCodeFence(text);
|
|
251
|
+
this.cache?.set(cacheKey, this.writr.content, text);
|
|
252
|
+
return new Writr(text);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Generates metadata and applies it to the document frontmatter.
|
|
256
|
+
*
|
|
257
|
+
* @param options - Controls generation, overwrite behavior, and field mapping.
|
|
258
|
+
* @returns A result object describing what metadata was generated and applied.
|
|
259
|
+
*/
|
|
260
|
+
async applyMetadata(options) {
|
|
261
|
+
const generated = await this.getMetadata(options?.generate);
|
|
262
|
+
const frontMatter = { ...this.writr.frontMatter };
|
|
263
|
+
const fieldMap = options?.fieldMap ?? {};
|
|
264
|
+
const overwrite = options?.overwrite;
|
|
265
|
+
const applied = [];
|
|
266
|
+
const overwritten = [];
|
|
267
|
+
const skipped = [];
|
|
268
|
+
const overwriteSet = Array.isArray(overwrite) ? new Set(overwrite) : void 0;
|
|
269
|
+
for (const key of Object.keys(generated)) {
|
|
270
|
+
const value = generated[key];
|
|
271
|
+
if (value === void 0) continue;
|
|
272
|
+
const frontMatterKey = fieldMap[key] ?? key;
|
|
273
|
+
if (!(frontMatterKey in frontMatter)) {
|
|
274
|
+
frontMatter[frontMatterKey] = value;
|
|
275
|
+
applied.push(key);
|
|
276
|
+
} else if (overwrite === true || overwriteSet?.has(key)) {
|
|
277
|
+
frontMatter[frontMatterKey] = value;
|
|
278
|
+
overwritten.push(key);
|
|
279
|
+
} else skipped.push(key);
|
|
280
|
+
}
|
|
281
|
+
this.writr.frontMatter = frontMatter;
|
|
282
|
+
return {
|
|
283
|
+
writr: this.writr,
|
|
284
|
+
generated,
|
|
285
|
+
applied,
|
|
286
|
+
overwritten,
|
|
287
|
+
skipped
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
resolveMetadataFields(options) {
|
|
291
|
+
const allFields = [
|
|
292
|
+
"title",
|
|
293
|
+
"tags",
|
|
294
|
+
"keywords",
|
|
295
|
+
"description",
|
|
296
|
+
"preview",
|
|
297
|
+
"summary",
|
|
298
|
+
"category",
|
|
299
|
+
"topic",
|
|
300
|
+
"audience",
|
|
301
|
+
"difficulty",
|
|
302
|
+
"readingTime",
|
|
303
|
+
"wordCount"
|
|
304
|
+
];
|
|
305
|
+
if (!options) return allFields;
|
|
306
|
+
const effective = { ...options };
|
|
307
|
+
if (effective.allowedTags?.length && effective.tags === void 0) effective.tags = true;
|
|
308
|
+
if (effective.allowedKeywords?.length && effective.keywords === void 0) effective.keywords = true;
|
|
309
|
+
if (effective.allowedCategories?.length && effective.category === void 0) effective.category = true;
|
|
310
|
+
return allFields.filter((field) => effective[field] === true);
|
|
311
|
+
}
|
|
312
|
+
resolveSEOFields(options) {
|
|
313
|
+
const allFields = ["slug", "openGraph"];
|
|
314
|
+
if (!options) return allFields;
|
|
315
|
+
return allFields.filter((field) => options[field] === true);
|
|
316
|
+
}
|
|
317
|
+
buildMetadataSchema(fields, options) {
|
|
318
|
+
const fieldSet = new Set(fields);
|
|
319
|
+
const entries = [];
|
|
320
|
+
if (fieldSet.has("title")) entries.push(["title", z.string().describe("The best-fit title for the document")]);
|
|
321
|
+
if (fieldSet.has("tags")) {
|
|
322
|
+
const uniqueTags = options?.allowedTags?.length ? [...new Set(options.allowedTags)] : void 0;
|
|
323
|
+
const itemSchema = uniqueTags?.length ? z.enum(uniqueTags) : z.string();
|
|
324
|
+
entries.push(["tags", z.array(itemSchema).describe(uniqueTags?.length ? `Human-friendly labels selected from: ${uniqueTags.join(", ")}` : "Human-friendly labels for organizing the document")]);
|
|
325
|
+
}
|
|
326
|
+
if (fieldSet.has("keywords")) {
|
|
327
|
+
const uniqueKeywords = options?.allowedKeywords?.length ? [...new Set(options.allowedKeywords)] : void 0;
|
|
328
|
+
const itemSchema = uniqueKeywords?.length ? z.enum(uniqueKeywords) : z.string();
|
|
329
|
+
entries.push(["keywords", z.array(itemSchema).describe(uniqueKeywords?.length ? `Search-oriented terms selected from: ${uniqueKeywords.join(", ")}` : "Search-oriented terms related to the document")]);
|
|
330
|
+
}
|
|
331
|
+
if (fieldSet.has("description")) entries.push(["description", z.string().describe("A concise meta-style description of the document")]);
|
|
332
|
+
if (fieldSet.has("preview")) entries.push(["preview", z.string().describe("A short teaser or preview of the content")]);
|
|
333
|
+
if (fieldSet.has("summary")) entries.push(["summary", z.string().describe("A slightly longer overview of the document")]);
|
|
334
|
+
if (fieldSet.has("category")) {
|
|
335
|
+
const uniqueCategories = options?.allowedCategories?.length ? [...new Set(options.allowedCategories)] : void 0;
|
|
336
|
+
const schema = uniqueCategories?.length ? z.enum(uniqueCategories) : z.string();
|
|
337
|
+
entries.push(["category", schema.describe(uniqueCategories?.length ? `A broad grouping selected from: ${uniqueCategories.join(", ")}` : "A broad grouping such as \"docs\", \"guide\", or \"blog\"")]);
|
|
338
|
+
}
|
|
339
|
+
if (fieldSet.has("topic")) entries.push(["topic", z.string().describe("The primary subject the document is about")]);
|
|
340
|
+
if (fieldSet.has("audience")) entries.push(["audience", z.string().describe("The intended audience such as \"developers\" or \"beginners\"")]);
|
|
341
|
+
if (fieldSet.has("difficulty")) entries.push(["difficulty", z.enum([
|
|
342
|
+
"beginner",
|
|
343
|
+
"intermediate",
|
|
344
|
+
"advanced"
|
|
345
|
+
]).describe("The estimated skill level required to understand the document")]);
|
|
346
|
+
return z.object(Object.fromEntries(entries));
|
|
347
|
+
}
|
|
348
|
+
buildSEOSchema(fields) {
|
|
349
|
+
const fieldSet = new Set(fields);
|
|
350
|
+
const entries = [];
|
|
351
|
+
if (fieldSet.has("slug")) entries.push(["slug", z.string().describe("A URL-safe identifier for the document")]);
|
|
352
|
+
if (fieldSet.has("openGraph")) entries.push(["openGraph", z.object({
|
|
353
|
+
title: z.string().describe("The social sharing title"),
|
|
354
|
+
description: z.string().describe("The social sharing description"),
|
|
355
|
+
image: z.string().describe("The image URL for social sharing").nullable()
|
|
356
|
+
}).describe("Open Graph metadata")]);
|
|
357
|
+
return z.object(Object.fromEntries(entries));
|
|
358
|
+
}
|
|
359
|
+
computeWordCount() {
|
|
360
|
+
return this.writr.body.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "").replace(/[#*_[\]()>~|-]/g, " ").split(/\s+/).filter((word) => word.length > 0).length;
|
|
361
|
+
}
|
|
362
|
+
computeReadingTime() {
|
|
363
|
+
const wordCount = this.computeWordCount();
|
|
364
|
+
return Math.max(1, Math.ceil(wordCount / AVERAGE_WORDS_PER_MINUTE));
|
|
365
|
+
}
|
|
366
|
+
stripCodeFence(text) {
|
|
367
|
+
const trimmed = text.trim();
|
|
368
|
+
const match = /^```\w*\n([\s\S]*?)```$/.exec(trimmed);
|
|
369
|
+
return match ? match[1].trim() : text;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/writr.ts
|
|
374
|
+
var Writr = class extends Hookified {
|
|
375
|
+
engine;
|
|
376
|
+
_options = { renderOptions: {
|
|
377
|
+
emoji: true,
|
|
378
|
+
toc: true,
|
|
379
|
+
slug: true,
|
|
380
|
+
highlight: true,
|
|
381
|
+
gfm: true,
|
|
382
|
+
math: true,
|
|
383
|
+
mdx: false,
|
|
384
|
+
rawHtml: false,
|
|
385
|
+
caching: true
|
|
386
|
+
} };
|
|
387
|
+
_content = "";
|
|
388
|
+
_cache = new WritrCache();
|
|
389
|
+
_ai;
|
|
390
|
+
/**
|
|
391
|
+
* Initialize Writr. Accepts a string or options object.
|
|
392
|
+
* @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.
|
|
393
|
+
* @param {WritrOptions} [arguments2] This is if you send in the content in the first argument and also want to send in options.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* const writr = new Writr('Hello, world!', {caching: false});
|
|
397
|
+
*/
|
|
398
|
+
constructor(arguments1, arguments2) {
|
|
399
|
+
const options = typeof arguments1 === "object" ? arguments1 : arguments2;
|
|
400
|
+
super({
|
|
401
|
+
throwOnEmitError: options?.throwOnEmitError,
|
|
402
|
+
throwOnHookError: options?.throwOnHookError,
|
|
403
|
+
throwOnEmptyListeners: options?.throwOnEmptyListeners,
|
|
404
|
+
eventLogger: options?.eventLogger
|
|
405
|
+
});
|
|
406
|
+
if (typeof arguments1 === "string") this._content = arguments1;
|
|
407
|
+
else if (arguments1) this._options = this.mergeOptions(this._options, arguments1);
|
|
408
|
+
if (arguments2) this._options = this.mergeOptions(this._options, arguments2);
|
|
409
|
+
this.engine = this.createProcessor(this._options.renderOptions ?? {});
|
|
410
|
+
if (this._options.ai) this._ai = new WritrAI(this, this._options.ai);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get the options.
|
|
414
|
+
* @type {WritrOptions}
|
|
415
|
+
*/
|
|
416
|
+
get options() {
|
|
417
|
+
return this._options;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Get the WritrAI instance if AI options were provided.
|
|
421
|
+
* @type {WritrAI | undefined}
|
|
422
|
+
*/
|
|
423
|
+
get ai() {
|
|
424
|
+
return this._ai;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Get the Content. This is the markdown content and front matter if it exists.
|
|
428
|
+
* @type {WritrOptions}
|
|
429
|
+
*/
|
|
430
|
+
get content() {
|
|
431
|
+
return this._content;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Set the Content. This is the markdown content and front matter if it exists.
|
|
435
|
+
* @type {WritrOptions}
|
|
436
|
+
*/
|
|
437
|
+
set content(value) {
|
|
438
|
+
this._content = value;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Get the cache.
|
|
442
|
+
* @type {WritrCache}
|
|
443
|
+
*/
|
|
444
|
+
get cache() {
|
|
445
|
+
return this._cache;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Get the front matter raw content.
|
|
449
|
+
* @type {string} The front matter content including the delimiters.
|
|
450
|
+
*/
|
|
451
|
+
get frontMatterRaw() {
|
|
452
|
+
if (!this._content.trimStart().startsWith("---")) return "";
|
|
453
|
+
const match = /^\s*(---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$))/.exec(this._content);
|
|
454
|
+
if (match) return match[1];
|
|
455
|
+
return "";
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Get the body content without the front matter.
|
|
459
|
+
* @type {string} The markdown content without the front matter.
|
|
460
|
+
*/
|
|
461
|
+
get body() {
|
|
462
|
+
const frontMatter = this.frontMatterRaw;
|
|
463
|
+
if (frontMatter === "") return this._content;
|
|
464
|
+
return this._content.slice(this._content.indexOf(frontMatter) + frontMatter.length).trim();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Get the markdown content. This is an alias for the body property.
|
|
468
|
+
* @type {string} The markdown content.
|
|
469
|
+
*/
|
|
470
|
+
get markdown() {
|
|
471
|
+
return this.body;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Get the front matter content as an object.
|
|
475
|
+
* @type {Record<string, any>} The front matter content as an object.
|
|
476
|
+
*/
|
|
477
|
+
get frontMatter() {
|
|
478
|
+
const frontMatter = this.frontMatterRaw;
|
|
479
|
+
const match = /^---\s*([\s\S]*?)\s*---\s*/.exec(frontMatter);
|
|
480
|
+
if (match) try {
|
|
481
|
+
return yaml.load(match[1].trim());
|
|
482
|
+
} catch (error) {
|
|
483
|
+
/* v8 ignore next -- @preserve */
|
|
484
|
+
this.emit("error", error);
|
|
485
|
+
}
|
|
486
|
+
return {};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Set the front matter content as an object.
|
|
490
|
+
* @type {Record<string, any>} The front matter content as an object.
|
|
491
|
+
*/
|
|
492
|
+
set frontMatter(data) {
|
|
493
|
+
try {
|
|
494
|
+
const frontMatter = this.frontMatterRaw;
|
|
495
|
+
const newFrontMatter = `---\n${yaml.dump(data)}---\n`;
|
|
496
|
+
this._content = this._content.replace(frontMatter, newFrontMatter);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
/* v8 ignore next -- @preserve */
|
|
499
|
+
this.emit("error", error);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Get the front matter value for a key.
|
|
504
|
+
* @param {string} key The key to get the value for.
|
|
505
|
+
* @returns {T} The value for the key.
|
|
506
|
+
*/
|
|
507
|
+
getFrontMatterValue(key) {
|
|
508
|
+
return this.frontMatter[key];
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Render the markdown content to HTML.
|
|
512
|
+
* @param {RenderOptions} [options] The render options.
|
|
513
|
+
* @returns {Promise<string>} The rendered HTML content.
|
|
514
|
+
*/
|
|
515
|
+
async render(options) {
|
|
516
|
+
try {
|
|
517
|
+
let { engine } = this;
|
|
518
|
+
if (options) {
|
|
519
|
+
options = {
|
|
520
|
+
...this._options.renderOptions,
|
|
521
|
+
...options
|
|
522
|
+
};
|
|
523
|
+
engine = this.createProcessor(options);
|
|
524
|
+
}
|
|
525
|
+
const renderData = {
|
|
526
|
+
content: this._content,
|
|
527
|
+
body: this.body,
|
|
528
|
+
options
|
|
529
|
+
};
|
|
530
|
+
await this.hook("beforeRender", renderData);
|
|
531
|
+
const resultData = { result: "" };
|
|
532
|
+
if (this.isCacheEnabled(renderData.options)) {
|
|
533
|
+
const cached = this._cache.get(renderData.content, renderData.options);
|
|
534
|
+
if (cached) return cached;
|
|
535
|
+
}
|
|
536
|
+
const file = await engine.process(renderData.body);
|
|
537
|
+
resultData.result = String(file);
|
|
538
|
+
if (this.isCacheEnabled(renderData.options)) this._cache.set(renderData.content, resultData.result, renderData.options);
|
|
539
|
+
await this.hook("afterRender", resultData);
|
|
540
|
+
return resultData.result;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
this.emit("error", error);
|
|
543
|
+
}
|
|
544
|
+
return "";
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Render the markdown content to HTML synchronously.
|
|
548
|
+
* @param {RenderOptions} [options] The render options.
|
|
549
|
+
* @returns {string} The rendered HTML content.
|
|
550
|
+
*/
|
|
551
|
+
renderSync(options) {
|
|
552
|
+
try {
|
|
553
|
+
let { engine } = this;
|
|
554
|
+
if (options) {
|
|
555
|
+
options = {
|
|
556
|
+
...this._options.renderOptions,
|
|
557
|
+
...options
|
|
558
|
+
};
|
|
559
|
+
engine = this.createProcessor(options);
|
|
560
|
+
}
|
|
561
|
+
const renderData = {
|
|
562
|
+
content: this._content,
|
|
563
|
+
body: this.body,
|
|
564
|
+
options
|
|
565
|
+
};
|
|
566
|
+
this.hook("beforeRender", renderData);
|
|
567
|
+
const resultData = { result: "" };
|
|
568
|
+
/* v8 ignore next -- @preserve */
|
|
569
|
+
if (this.isCacheEnabled(renderData.options)) {
|
|
570
|
+
const cached = this._cache.get(renderData.content, renderData.options);
|
|
571
|
+
if (cached) return cached;
|
|
572
|
+
}
|
|
573
|
+
const file = engine.processSync(renderData.body);
|
|
574
|
+
resultData.result = String(file);
|
|
575
|
+
if (this.isCacheEnabled(renderData.options)) this._cache.set(renderData.content, resultData.result, renderData.options);
|
|
576
|
+
this.hook("afterRender", resultData);
|
|
577
|
+
return resultData.result;
|
|
578
|
+
} catch (error) {
|
|
579
|
+
this.emit("error", error);
|
|
580
|
+
}
|
|
581
|
+
return "";
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Validate the markdown content by attempting to render it.
|
|
585
|
+
* @param {string} [content] The markdown content to validate. If not provided, uses the current content.
|
|
586
|
+
* @param {RenderOptions} [options] The render options.
|
|
587
|
+
* @returns {Promise<WritrValidateResult>} An object with a valid boolean and optional error.
|
|
588
|
+
*/
|
|
589
|
+
async validate(content, options) {
|
|
590
|
+
const originalContent = this._content;
|
|
591
|
+
try {
|
|
592
|
+
if (content !== void 0) this._content = content;
|
|
593
|
+
let { engine } = this;
|
|
594
|
+
if (options) {
|
|
595
|
+
options = {
|
|
596
|
+
...this._options.renderOptions,
|
|
597
|
+
...options,
|
|
598
|
+
caching: false
|
|
599
|
+
};
|
|
600
|
+
engine = this.createProcessor(options);
|
|
601
|
+
}
|
|
602
|
+
await engine.run(engine.parse(this.body));
|
|
603
|
+
if (content !== void 0) this._content = originalContent;
|
|
604
|
+
return { valid: true };
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (content !== void 0) this._content = originalContent;
|
|
607
|
+
return {
|
|
608
|
+
valid: false,
|
|
609
|
+
error
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Validate the markdown content by attempting to render it synchronously.
|
|
615
|
+
* @param {string} [content] The markdown content to validate. If not provided, uses the current content.
|
|
616
|
+
* @param {RenderOptions} [options] The render options.
|
|
617
|
+
* @returns {WritrValidateResult} An object with a valid boolean and optional error.
|
|
618
|
+
*/
|
|
619
|
+
validateSync(content, options) {
|
|
620
|
+
const originalContent = this._content;
|
|
621
|
+
try {
|
|
622
|
+
if (content !== void 0) this._content = content;
|
|
623
|
+
let { engine } = this;
|
|
624
|
+
if (options) {
|
|
625
|
+
options = {
|
|
626
|
+
...this._options.renderOptions,
|
|
627
|
+
...options,
|
|
628
|
+
caching: false
|
|
629
|
+
};
|
|
630
|
+
engine = this.createProcessor(options);
|
|
631
|
+
}
|
|
632
|
+
engine.runSync(engine.parse(this.body));
|
|
633
|
+
if (content !== void 0) this._content = originalContent;
|
|
634
|
+
return { valid: true };
|
|
635
|
+
} catch (error) {
|
|
636
|
+
this.emit("error", error);
|
|
637
|
+
if (content !== void 0) this._content = originalContent;
|
|
638
|
+
return {
|
|
639
|
+
valid: false,
|
|
640
|
+
error
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Render the markdown content and save it to a file. If the directory doesn't exist it will be created.
|
|
646
|
+
* @param {string} filePath The file path to save the rendered markdown content to.
|
|
647
|
+
* @param {RenderOptions} [options] the render options.
|
|
648
|
+
*/
|
|
649
|
+
async renderToFile(filePath, options) {
|
|
650
|
+
try {
|
|
651
|
+
const { writeFile, mkdir } = fs.promises;
|
|
652
|
+
const directoryPath = dirname(filePath);
|
|
653
|
+
const content = await this.render(options);
|
|
654
|
+
await mkdir(directoryPath, { recursive: true });
|
|
655
|
+
const data = {
|
|
656
|
+
filePath,
|
|
657
|
+
content
|
|
658
|
+
};
|
|
659
|
+
await this.hook("renderToFile", data);
|
|
660
|
+
await writeFile(data.filePath, data.content);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
this.emit("error", error);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Render the markdown content and save it to a file synchronously. If the directory doesn't exist it will be created.
|
|
667
|
+
* @param {string} filePath The file path to save the rendered markdown content to.
|
|
668
|
+
* @param {RenderOptions} [options] the render options.
|
|
669
|
+
*/
|
|
670
|
+
renderToFileSync(filePath, options) {
|
|
671
|
+
try {
|
|
672
|
+
const directoryPath = dirname(filePath);
|
|
673
|
+
const content = this.renderSync(options);
|
|
674
|
+
fs.mkdirSync(directoryPath, { recursive: true });
|
|
675
|
+
const data = {
|
|
676
|
+
filePath,
|
|
677
|
+
content
|
|
678
|
+
};
|
|
679
|
+
this.hook("renderToFile", data);
|
|
680
|
+
fs.writeFileSync(data.filePath, data.content);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
this.emit("error", error);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Render the markdown content to React.
|
|
687
|
+
* @param {RenderOptions} [options] The render options.
|
|
688
|
+
* @param {HTMLReactParserOptions} [reactParseOptions] The HTML React parser options.
|
|
689
|
+
* @returns {Promise<string | React.JSX.Element | React.JSX.Element[]>} The rendered React content.
|
|
690
|
+
*/
|
|
691
|
+
async renderReact(options, reactParseOptions) {
|
|
692
|
+
try {
|
|
693
|
+
return parse(await this.render(options), reactParseOptions);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
this.emit("error", error);
|
|
696
|
+
}
|
|
697
|
+
return "";
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Render the markdown content to React synchronously.
|
|
701
|
+
* @param {RenderOptions} [options] The render options.
|
|
702
|
+
* @param {HTMLReactParserOptions} [reactParseOptions] The HTML React parser options.
|
|
703
|
+
* @returns {string | React.JSX.Element | React.JSX.Element[]} The rendered React content.
|
|
704
|
+
*/
|
|
705
|
+
renderReactSync(options, reactParseOptions) {
|
|
706
|
+
try {
|
|
707
|
+
return parse(this.renderSync(options), reactParseOptions);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
this.emit("error", error);
|
|
710
|
+
}
|
|
711
|
+
return "";
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Load markdown content from a file.
|
|
715
|
+
* @param {string} filePath The file path to load the markdown content from.
|
|
716
|
+
* @returns {Promise<void>}
|
|
717
|
+
*/
|
|
718
|
+
async loadFromFile(filePath) {
|
|
719
|
+
try {
|
|
720
|
+
const { readFile } = fs.promises;
|
|
721
|
+
const data = { content: "" };
|
|
722
|
+
data.content = await readFile(filePath, "utf8");
|
|
723
|
+
await this.hook("loadFromFile", data);
|
|
724
|
+
this._content = data.content;
|
|
725
|
+
} catch (error) {
|
|
726
|
+
this.emit("error", error);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Load markdown content from a file synchronously.
|
|
731
|
+
* @param {string} filePath The file path to load the markdown content from.
|
|
732
|
+
* @returns {void}
|
|
733
|
+
*/
|
|
734
|
+
loadFromFileSync(filePath) {
|
|
735
|
+
try {
|
|
736
|
+
const data = { content: "" };
|
|
737
|
+
data.content = fs.readFileSync(filePath, "utf8");
|
|
738
|
+
this.hook("loadFromFile", data);
|
|
739
|
+
this._content = data.content;
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.emit("error", error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Save the markdown content to a file. If the directory doesn't exist it will be created.
|
|
746
|
+
* @param {string} filePath The file path to save the markdown content to.
|
|
747
|
+
* @returns {Promise<void>}
|
|
748
|
+
*/
|
|
749
|
+
async saveToFile(filePath) {
|
|
750
|
+
try {
|
|
751
|
+
const { writeFile, mkdir } = fs.promises;
|
|
752
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
753
|
+
const data = {
|
|
754
|
+
filePath,
|
|
755
|
+
content: this._content
|
|
756
|
+
};
|
|
757
|
+
await this.hook("saveToFile", data);
|
|
758
|
+
await writeFile(data.filePath, data.content);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
this.emit("error", error);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Save the markdown content to a file synchronously. If the directory doesn't exist it will be created.
|
|
765
|
+
* @param {string} filePath The file path to save the markdown content to.
|
|
766
|
+
* @returns {void}
|
|
767
|
+
*/
|
|
768
|
+
saveToFileSync(filePath) {
|
|
769
|
+
try {
|
|
770
|
+
const directoryPath = dirname(filePath);
|
|
771
|
+
fs.mkdirSync(directoryPath, { recursive: true });
|
|
772
|
+
const data = {
|
|
773
|
+
filePath,
|
|
774
|
+
content: this._content
|
|
775
|
+
};
|
|
776
|
+
this.hook("saveToFile", data);
|
|
777
|
+
fs.writeFileSync(data.filePath, data.content);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
this.emit("error", error);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
mergeOptions(current, options) {
|
|
783
|
+
if (options.throwOnEmitError !== void 0) this.throwOnEmitError = options.throwOnEmitError;
|
|
784
|
+
/* v8 ignore next -- @preserve */
|
|
785
|
+
if (options.renderOptions) {
|
|
786
|
+
current.renderOptions ??= {};
|
|
787
|
+
this.mergeRenderOptions(current.renderOptions, options.renderOptions);
|
|
788
|
+
}
|
|
789
|
+
if (options.ai) current.ai = options.ai;
|
|
790
|
+
return current;
|
|
791
|
+
}
|
|
792
|
+
isCacheEnabled(options) {
|
|
793
|
+
if (options?.caching !== void 0) return options.caching;
|
|
794
|
+
/* v8 ignore next -- @preserve */
|
|
795
|
+
return this._options?.renderOptions?.caching ?? false;
|
|
796
|
+
}
|
|
797
|
+
createProcessor(options) {
|
|
798
|
+
const processor = unified().use(remarkParse);
|
|
799
|
+
if (options.gfm) {
|
|
800
|
+
processor.use(remarkGfm);
|
|
801
|
+
processor.use(remarkGithubBlockquoteAlert);
|
|
802
|
+
}
|
|
803
|
+
if (options.toc) processor.use(remarkToc);
|
|
804
|
+
if (options.emoji) processor.use(remarkEmoji);
|
|
805
|
+
if (options.mdx) processor.use(remarkMDX);
|
|
806
|
+
if (options.math) processor.use(remarkMath);
|
|
807
|
+
const rehypeOptions = {};
|
|
808
|
+
if (options.rawHtml) rehypeOptions.allowDangerousHtml = true;
|
|
809
|
+
if (options.mdx) rehypeOptions.handlers = {
|
|
810
|
+
mdxJsxFlowElement: mdxJsxHandler,
|
|
811
|
+
mdxJsxTextElement: mdxJsxHandler
|
|
812
|
+
};
|
|
813
|
+
processor.use(remarkRehype, rehypeOptions);
|
|
814
|
+
if (options.rawHtml) processor.use(rehypeRaw);
|
|
815
|
+
if (options.slug) processor.use(rehypeSlug);
|
|
816
|
+
if (options.highlight) processor.use(rehypeHighlight);
|
|
817
|
+
if (options.math) processor.use(rehypeKatex);
|
|
818
|
+
processor.use(rehypeStringify);
|
|
819
|
+
return processor;
|
|
820
|
+
}
|
|
821
|
+
mergeRenderOptions(current, options) {
|
|
822
|
+
if (options.emoji !== void 0) current.emoji = options.emoji;
|
|
823
|
+
if (options.toc !== void 0) current.toc = options.toc;
|
|
824
|
+
if (options.slug !== void 0) current.slug = options.slug;
|
|
825
|
+
if (options.highlight !== void 0) current.highlight = options.highlight;
|
|
826
|
+
if (options.gfm !== void 0) current.gfm = options.gfm;
|
|
827
|
+
if (options.math !== void 0) current.math = options.math;
|
|
828
|
+
if (options.mdx !== void 0) current.mdx = options.mdx;
|
|
829
|
+
if (options.rawHtml !== void 0) current.rawHtml = options.rawHtml;
|
|
830
|
+
if (options.caching !== void 0) current.caching = options.caching;
|
|
831
|
+
return current;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
function mdxJsxHandler(state, node) {
|
|
835
|
+
const properties = {};
|
|
836
|
+
for (const attr of node.attributes) if (attr.type === "mdxJsxAttribute") {
|
|
837
|
+
if (attr.value === null) properties[attr.name] = true;
|
|
838
|
+
else if (typeof attr.value === "string") properties[attr.name] = attr.value;
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
type: "element",
|
|
842
|
+
tagName: node.name ?? "div",
|
|
843
|
+
properties,
|
|
844
|
+
children: state.all(node)
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
export { Writr, WritrAI, WritrAICache, WritrHooks };
|