writr 6.1.0 → 6.1.2

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