writr 5.0.4 → 6.0.1

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.
Files changed (4) hide show
  1. package/README.md +357 -43
  2. package/dist/writr.d.ts +357 -24
  3. package/dist/writr.js +376 -28
  4. package/package.json +17 -10
package/dist/writr.js CHANGED
@@ -94,7 +94,7 @@ var WritrCache = class {
94
94
  }
95
95
  };
96
96
 
97
- // src/writr.ts
97
+ // src/types.ts
98
98
  var WritrHooks = /* @__PURE__ */ ((WritrHooks2) => {
99
99
  WritrHooks2["beforeRender"] = "beforeRender";
100
100
  WritrHooks2["afterRender"] = "afterRender";
@@ -103,11 +103,356 @@ var WritrHooks = /* @__PURE__ */ ((WritrHooks2) => {
103
103
  WritrHooks2["loadFromFile"] = "loadFromFile";
104
104
  return WritrHooks2;
105
105
  })(WritrHooks || {});
106
+
107
+ // src/writr-ai.ts
108
+ import { generateText, Output } from "ai";
109
+ import { z } from "zod";
110
+
111
+ // src/writr-ai-cache.ts
112
+ import { CacheableMemory as CacheableMemory2 } from "cacheable";
113
+ import { Hashery as Hashery2 } from "hashery";
114
+ var WritrAICache = class {
115
+ _store = new CacheableMemory2();
116
+ _hashStore = new CacheableMemory2();
117
+ _hash = new Hashery2();
118
+ get store() {
119
+ return this._store;
120
+ }
121
+ get hashStore() {
122
+ return this._hashStore;
123
+ }
124
+ get(key, context) {
125
+ const hash = this.hash(key, context);
126
+ return this._store.get(hash);
127
+ }
128
+ set(key, context, value) {
129
+ const hash = this.hash(key, context);
130
+ this._store.set(hash, value);
131
+ }
132
+ hash(key, context) {
133
+ const content = { key, context };
134
+ const cacheKey = JSON.stringify(content);
135
+ const result = this._hashStore.get(cacheKey);
136
+ if (result) {
137
+ return result;
138
+ }
139
+ const hash = this._hash.toHashSync(content);
140
+ this._hashStore.set(cacheKey, hash);
141
+ return hash;
142
+ }
143
+ clear() {
144
+ this._store.clear();
145
+ this._hashStore.clear();
146
+ }
147
+ };
148
+
149
+ // src/writr-ai.ts
150
+ var AVERAGE_WORDS_PER_MINUTE = 200;
151
+ var WritrAI = class {
152
+ /**
153
+ * Creates a new WritrAI instance bound to a specific Writr document.
154
+ *
155
+ * @param writr - The base Writr instance this AI helper operates on.
156
+ * @param options - The AI model and optional cache/prompt settings.
157
+ */
158
+ constructor(writr, options) {
159
+ this.writr = writr;
160
+ this.model = options.model;
161
+ this.prompts = options.prompts ?? {};
162
+ this.cache = options.cache ? new WritrAICache() : void 0;
163
+ }
164
+ /**
165
+ * The AI SDK model used for all generation requests.
166
+ */
167
+ model;
168
+ /**
169
+ * The prompt templates used by this WritrAI instance.
170
+ */
171
+ prompts;
172
+ /**
173
+ * Optional in-memory cache for generated AI results.
174
+ */
175
+ cache;
176
+ /**
177
+ * Generates metadata for the current markdown document.
178
+ *
179
+ * @param options - Controls which metadata fields should be generated.
180
+ * @returns A metadata object for the current document.
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) {
186
+ return cached;
187
+ }
188
+ const fields = this.resolveMetadataFields(options);
189
+ const result = {};
190
+ if (fields.includes("wordCount")) {
191
+ result.wordCount = this.computeWordCount();
192
+ }
193
+ if (fields.includes("readingTime")) {
194
+ result.readingTime = this.computeReadingTime();
195
+ }
196
+ const aiFields = fields.filter(
197
+ (f) => f !== "wordCount" && f !== "readingTime"
198
+ );
199
+ if (aiFields.length > 0) {
200
+ const schema = this.buildMetadataSchema(aiFields);
201
+ const prompt = this.prompts.metadata ?? "Analyze the following markdown document and generate metadata for it. Be concise and accurate.";
202
+ const { output } = await generateText({
203
+ model: this.model,
204
+ output: Output.object({ schema }),
205
+ prompt: `${prompt}
206
+
207
+ ---
208
+
209
+ ${this.writr.content}`
210
+ });
211
+ Object.assign(result, output);
212
+ }
213
+ this.cache?.set(cacheKey, this.writr.content, result);
214
+ return result;
215
+ }
216
+ /**
217
+ * Generates SEO metadata for the current markdown document.
218
+ *
219
+ * @param options - Controls which SEO fields should be generated.
220
+ * @returns An SEO metadata object for the current document.
221
+ */
222
+ async getSEO(options) {
223
+ const cacheKey = `seo:${JSON.stringify(options ?? {})}`;
224
+ const cached = this.cache?.get(cacheKey, this.writr.content);
225
+ if (cached) {
226
+ return cached;
227
+ }
228
+ const fields = this.resolveSEOFields(options);
229
+ const schema = this.buildSEOSchema(fields);
230
+ const prompt = this.prompts.seo ?? "Analyze the following markdown document and generate SEO metadata for it. Be concise and accurate.";
231
+ const { output } = await generateText({
232
+ model: this.model,
233
+ output: Output.object({ schema }),
234
+ prompt: `${prompt}
235
+
236
+ ---
237
+
238
+ ${this.writr.content}`
239
+ });
240
+ const result = output;
241
+ this.cache?.set(cacheKey, this.writr.content, result);
242
+ return result;
243
+ }
244
+ /**
245
+ * Generates a translated version of the current document.
246
+ *
247
+ * @param options - Translation settings including target locale.
248
+ * @returns A new translated Writr instance.
249
+ */
250
+ async getTranslation(options) {
251
+ const cacheKey = `translation:${JSON.stringify(options)}`;
252
+ const cached = this.cache?.get(cacheKey, this.writr.content);
253
+ if (cached) {
254
+ return new Writr(cached);
255
+ }
256
+ const fromClause = options.from ? ` from ${options.from}` : "";
257
+ const frontMatterClause = options.translateFrontMatter ? " Also translate any string values in the YAML frontmatter." : " Preserve the YAML frontmatter exactly as-is without translating it.";
258
+ 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.`;
259
+ let { text } = await generateText({
260
+ model: this.model,
261
+ prompt: `${prompt}
262
+
263
+ ---
264
+
265
+ ${this.writr.content}`
266
+ });
267
+ text = this.stripCodeFence(text);
268
+ this.cache?.set(cacheKey, this.writr.content, text);
269
+ return new Writr(text);
270
+ }
271
+ /**
272
+ * Generates metadata and applies it to the document frontmatter.
273
+ *
274
+ * @param options - Controls generation, overwrite behavior, and field mapping.
275
+ * @returns A result object describing what metadata was generated and applied.
276
+ */
277
+ async applyMetadata(options) {
278
+ const generated = await this.getMetadata(options?.generate);
279
+ const frontMatter = { ...this.writr.frontMatter };
280
+ const fieldMap = options?.fieldMap ?? {};
281
+ const overwrite = options?.overwrite;
282
+ const applied = [];
283
+ const overwritten = [];
284
+ const skipped = [];
285
+ const overwriteSet = Array.isArray(overwrite) ? new Set(overwrite) : void 0;
286
+ for (const key of Object.keys(generated)) {
287
+ const value = generated[key];
288
+ if (value === void 0) {
289
+ continue;
290
+ }
291
+ const frontMatterKey = fieldMap[key] ?? key;
292
+ const exists = frontMatterKey in frontMatter;
293
+ if (!exists) {
294
+ frontMatter[frontMatterKey] = value;
295
+ applied.push(key);
296
+ } else if (overwrite === true || overwriteSet?.has(key)) {
297
+ frontMatter[frontMatterKey] = value;
298
+ overwritten.push(key);
299
+ } else {
300
+ skipped.push(key);
301
+ }
302
+ }
303
+ this.writr.frontMatter = frontMatter;
304
+ return {
305
+ writr: this.writr,
306
+ generated,
307
+ applied,
308
+ overwritten,
309
+ skipped
310
+ };
311
+ }
312
+ resolveMetadataFields(options) {
313
+ const allFields = [
314
+ "title",
315
+ "tags",
316
+ "keywords",
317
+ "description",
318
+ "preview",
319
+ "summary",
320
+ "category",
321
+ "topic",
322
+ "audience",
323
+ "difficulty",
324
+ "readingTime",
325
+ "wordCount"
326
+ ];
327
+ if (!options) {
328
+ return allFields;
329
+ }
330
+ return allFields.filter(
331
+ (field) => options[field] === true
332
+ );
333
+ }
334
+ resolveSEOFields(options) {
335
+ const allFields = ["slug", "openGraph"];
336
+ if (!options) {
337
+ return allFields;
338
+ }
339
+ return allFields.filter(
340
+ (field) => options[field] === true
341
+ );
342
+ }
343
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic schema construction
344
+ buildMetadataSchema(fields) {
345
+ const fieldSet = new Set(fields);
346
+ const entries = [];
347
+ if (fieldSet.has("title")) {
348
+ entries.push([
349
+ "title",
350
+ z.string().describe("The best-fit title for the document")
351
+ ]);
352
+ }
353
+ if (fieldSet.has("tags")) {
354
+ entries.push([
355
+ "tags",
356
+ z.array(z.string()).describe("Human-friendly labels for organizing the document")
357
+ ]);
358
+ }
359
+ if (fieldSet.has("keywords")) {
360
+ entries.push([
361
+ "keywords",
362
+ z.array(z.string()).describe("Search-oriented terms related to the document")
363
+ ]);
364
+ }
365
+ if (fieldSet.has("description")) {
366
+ entries.push([
367
+ "description",
368
+ z.string().describe("A concise meta-style description of the document")
369
+ ]);
370
+ }
371
+ if (fieldSet.has("preview")) {
372
+ entries.push([
373
+ "preview",
374
+ z.string().describe("A short teaser or preview of the content")
375
+ ]);
376
+ }
377
+ if (fieldSet.has("summary")) {
378
+ entries.push([
379
+ "summary",
380
+ z.string().describe("A slightly longer overview of the document")
381
+ ]);
382
+ }
383
+ if (fieldSet.has("category")) {
384
+ entries.push([
385
+ "category",
386
+ z.string().describe('A broad grouping such as "docs", "guide", or "blog"')
387
+ ]);
388
+ }
389
+ if (fieldSet.has("topic")) {
390
+ entries.push([
391
+ "topic",
392
+ z.string().describe("The primary subject the document is about")
393
+ ]);
394
+ }
395
+ if (fieldSet.has("audience")) {
396
+ entries.push([
397
+ "audience",
398
+ z.string().describe(
399
+ 'The intended audience such as "developers" or "beginners"'
400
+ )
401
+ ]);
402
+ }
403
+ if (fieldSet.has("difficulty")) {
404
+ entries.push([
405
+ "difficulty",
406
+ z.enum(["beginner", "intermediate", "advanced"]).describe(
407
+ "The estimated skill level required to understand the document"
408
+ )
409
+ ]);
410
+ }
411
+ return z.object(Object.fromEntries(entries));
412
+ }
413
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic schema construction
414
+ buildSEOSchema(fields) {
415
+ const fieldSet = new Set(fields);
416
+ const entries = [];
417
+ if (fieldSet.has("slug")) {
418
+ entries.push([
419
+ "slug",
420
+ z.string().describe("A URL-safe identifier for the document")
421
+ ]);
422
+ }
423
+ if (fieldSet.has("openGraph")) {
424
+ entries.push([
425
+ "openGraph",
426
+ z.object({
427
+ title: z.string().describe("The social sharing title"),
428
+ description: z.string().describe("The social sharing description"),
429
+ image: z.string().describe("The image URL for social sharing").nullable()
430
+ }).describe("Open Graph metadata")
431
+ ]);
432
+ }
433
+ return z.object(Object.fromEntries(entries));
434
+ }
435
+ computeWordCount() {
436
+ const text = this.writr.body;
437
+ const words = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "").replace(/[#*_[\]()>~|-]/g, " ").split(/\s+/).filter((word) => word.length > 0);
438
+ return words.length;
439
+ }
440
+ computeReadingTime() {
441
+ const wordCount = this.computeWordCount();
442
+ return Math.max(1, Math.ceil(wordCount / AVERAGE_WORDS_PER_MINUTE));
443
+ }
444
+ stripCodeFence(text) {
445
+ const trimmed = text.trim();
446
+ const match = /^```\w*\n([\s\S]*?)```$/.exec(trimmed);
447
+ return match ? match[1].trim() : text;
448
+ }
449
+ };
450
+
451
+ // src/writr.ts
106
452
  var Writr = class extends Hookified {
107
453
  engine = unified().use(remarkParse).use(remarkGfm).use(remarkToc).use(remarkEmoji).use(remarkRehype).use(rehypeSlug).use(remarkMath).use(rehypeKatex).use(rehypeHighlight).use(rehypeStringify);
108
454
  // Stringify HTML
109
455
  _options = {
110
- throwErrors: false,
111
456
  renderOptions: {
112
457
  emoji: true,
113
458
  toc: true,
@@ -121,6 +466,7 @@ var Writr = class extends Hookified {
121
466
  };
122
467
  _content = "";
123
468
  _cache = new WritrCache();
469
+ _ai;
124
470
  /**
125
471
  * Initialize Writr. Accepts a string or options object.
126
472
  * @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.
@@ -130,7 +476,13 @@ var Writr = class extends Hookified {
130
476
  * const writr = new Writr('Hello, world!', {caching: false});
131
477
  */
132
478
  constructor(arguments1, arguments2) {
133
- super();
479
+ const options = typeof arguments1 === "object" ? arguments1 : arguments2;
480
+ super({
481
+ throwOnEmitError: options?.throwOnEmitError,
482
+ throwOnHookError: options?.throwOnHookError,
483
+ throwOnEmptyListeners: options?.throwOnEmptyListeners,
484
+ eventLogger: options?.eventLogger
485
+ });
134
486
  if (typeof arguments1 === "string") {
135
487
  this._content = arguments1;
136
488
  } else if (arguments1) {
@@ -145,6 +497,9 @@ var Writr = class extends Hookified {
145
497
  this.engine = this.createProcessor(this._options.renderOptions);
146
498
  }
147
499
  }
500
+ if (this._options.ai) {
501
+ this._ai = new WritrAI(this, this._options.ai);
502
+ }
148
503
  }
149
504
  /**
150
505
  * Get the options.
@@ -153,6 +508,13 @@ var Writr = class extends Hookified {
153
508
  get options() {
154
509
  return this._options;
155
510
  }
511
+ /**
512
+ * Get the WritrAI instance if AI options were provided.
513
+ * @type {WritrAI | undefined}
514
+ */
515
+ get ai() {
516
+ return this._ai;
517
+ }
156
518
  /**
157
519
  * Get the Content. This is the markdown content and front matter if it exists.
158
520
  * @type {WritrOptions}
@@ -290,8 +652,8 @@ ${yamlString}---
290
652
  return resultData.result;
291
653
  } catch (error) {
292
654
  this.emit("error", error);
293
- throw new Error(`Failed to render markdown: ${error.message}`);
294
655
  }
656
+ return "";
295
657
  }
296
658
  /**
297
659
  * Render the markdown content to HTML synchronously.
@@ -333,8 +695,8 @@ ${yamlString}---
333
695
  return resultData.result;
334
696
  } catch (error) {
335
697
  this.emit("error", error);
336
- throw new Error(`Failed to render markdown: ${error.message}`);
337
698
  }
699
+ return "";
338
700
  }
339
701
  /**
340
702
  * Validate the markdown content by attempting to render it.
@@ -363,7 +725,6 @@ ${yamlString}---
363
725
  }
364
726
  return { valid: true };
365
727
  } catch (error) {
366
- this.emit("error", error);
367
728
  if (content !== void 0) {
368
729
  this._content = originalContent;
369
730
  }
@@ -423,9 +784,6 @@ ${yamlString}---
423
784
  await writeFile(data.filePath, data.content);
424
785
  } catch (error) {
425
786
  this.emit("error", error);
426
- if (this._options.throwErrors) {
427
- throw error;
428
- }
429
787
  }
430
788
  }
431
789
  /**
@@ -446,9 +804,6 @@ ${yamlString}---
446
804
  fs.writeFileSync(data.filePath, data.content);
447
805
  } catch (error) {
448
806
  this.emit("error", error);
449
- if (this._options.throwErrors) {
450
- throw error;
451
- }
452
807
  }
453
808
  }
454
809
  /**
@@ -463,8 +818,8 @@ ${yamlString}---
463
818
  return parse(html, reactParseOptions);
464
819
  } catch (error) {
465
820
  this.emit("error", error);
466
- throw new Error(`Failed to render React: ${error.message}`);
467
821
  }
822
+ return "";
468
823
  }
469
824
  /**
470
825
  * Render the markdown content to React synchronously.
@@ -478,8 +833,8 @@ ${yamlString}---
478
833
  return parse(html, reactParseOptions);
479
834
  } catch (error) {
480
835
  this.emit("error", error);
481
- throw new Error(`Failed to render React: ${error.message}`);
482
836
  }
837
+ return "";
483
838
  }
484
839
  /**
485
840
  * Load markdown content from a file.
@@ -497,9 +852,6 @@ ${yamlString}---
497
852
  this._content = data.content;
498
853
  } catch (error) {
499
854
  this.emit("error", error);
500
- if (this._options.throwErrors) {
501
- throw error;
502
- }
503
855
  }
504
856
  }
505
857
  /**
@@ -517,9 +869,6 @@ ${yamlString}---
517
869
  this._content = data.content;
518
870
  } catch (error) {
519
871
  this.emit("error", error);
520
- if (this._options.throwErrors) {
521
- throw error;
522
- }
523
872
  }
524
873
  }
525
874
  /**
@@ -540,9 +889,6 @@ ${yamlString}---
540
889
  await writeFile(data.filePath, data.content);
541
890
  } catch (error) {
542
891
  this.emit("error", error);
543
- if (this._options.throwErrors) {
544
- throw error;
545
- }
546
892
  }
547
893
  }
548
894
  /**
@@ -562,19 +908,19 @@ ${yamlString}---
562
908
  fs.writeFileSync(data.filePath, data.content);
563
909
  } catch (error) {
564
910
  this.emit("error", error);
565
- if (this._options.throwErrors) {
566
- throw error;
567
- }
568
911
  }
569
912
  }
570
913
  mergeOptions(current, options) {
571
- if (options.throwErrors !== void 0) {
572
- current.throwErrors = options.throwErrors;
914
+ if (options.throwOnEmitError !== void 0) {
915
+ this.throwOnEmitError = options.throwOnEmitError;
573
916
  }
574
917
  if (options.renderOptions) {
575
918
  current.renderOptions ??= {};
576
919
  this.mergeRenderOptions(current.renderOptions, options.renderOptions);
577
920
  }
921
+ if (options.ai) {
922
+ current.ai = options.ai;
923
+ }
578
924
  return current;
579
925
  }
580
926
  isCacheEnabled(options) {
@@ -642,6 +988,8 @@ ${yamlString}---
642
988
  };
643
989
  export {
644
990
  Writr,
991
+ WritrAI,
992
+ WritrAICache,
645
993
  WritrHooks
646
994
  };
647
995
  /* v8 ignore next -- @preserve */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "writr",
3
- "version": "5.0.4",
3
+ "version": "6.0.1",
4
4
  "description": "Markdown Rendering Simplified",
5
5
  "type": "module",
6
6
  "main": "./dist/writr.js",
@@ -49,9 +49,10 @@
49
49
  "markdown-to-react"
50
50
  ],
51
51
  "dependencies": {
52
- "cacheable": "^2.3.1",
52
+ "ai": "^6.0.116",
53
+ "cacheable": "^2.3.3",
53
54
  "hashery": "^1.5.0",
54
- "hookified": "^1.15.1",
55
+ "hookified": "^2.1.0",
55
56
  "html-react-parser": "^5.2.17",
56
57
  "js-yaml": "^4.1.1",
57
58
  "react": "^19.2.4",
@@ -61,31 +62,36 @@
61
62
  "rehype-stringify": "^10.0.1",
62
63
  "remark-emoji": "^5.0.2",
63
64
  "remark-gfm": "^4.0.1",
64
- "remark-github-blockquote-alert": "^2.0.1",
65
+ "remark-github-blockquote-alert": "^2.1.0",
65
66
  "remark-math": "^6.0.0",
66
67
  "remark-mdx": "^3.1.1",
67
68
  "remark-parse": "^11.0.0",
68
69
  "remark-rehype": "^11.1.2",
69
70
  "remark-toc": "^9.0.0",
70
- "unified": "^11.0.5"
71
+ "unified": "^11.0.5",
72
+ "zod": "^4.3.6"
71
73
  },
72
74
  "devDependencies": {
73
- "@biomejs/biome": "^2.4.4",
75
+ "@ai-sdk/anthropic": "^3.0.58",
76
+ "@ai-sdk/google": "^3.0.43",
77
+ "@ai-sdk/openai": "^3.0.41",
78
+ "@biomejs/biome": "^2.4.7",
74
79
  "@monstermann/tinybench-pretty-printer": "^0.3.0",
75
80
  "@types/js-yaml": "^4.0.9",
76
81
  "@types/markdown-it": "^14.1.2",
77
- "@types/node": "^25.3.0",
82
+ "@types/node": "^25.5.0",
78
83
  "@types/react": "^19.2.14",
79
- "@vitest/coverage-v8": "^4.0.18",
84
+ "@vitest/coverage-v8": "^4.1.0",
80
85
  "docula": "^0.40.0",
86
+ "dotenv": "^17.3.1",
81
87
  "markdown-it": "^14.1.1",
82
- "marked": "^17.0.3",
88
+ "marked": "^17.0.4",
83
89
  "rimraf": "^6.1.3",
84
90
  "tinybench": "^6.0.0",
85
91
  "tsup": "^8.5.1",
86
92
  "tsx": "^4.21.0",
87
93
  "typescript": "^5.9.3",
88
- "vitest": "^4.0.18"
94
+ "vitest": "^4.1.0"
89
95
  },
90
96
  "files": [
91
97
  "dist",
@@ -99,6 +105,7 @@
99
105
  "benchmark": "tsx benchmark/benchmark-minimal.ts && tsx benchmark/benchmark-standard.ts",
100
106
  "test": "pnpm lint && vitest run --coverage",
101
107
  "test:ci": "biome check --error-on-warnings && vitest run --coverage",
108
+ "test:integration": "vitest run --config vitest.integration.config.ts",
102
109
  "website:build": "rimraf ./site/README.md ./site/dist && npx docula build -s ./site -o ./site/dist",
103
110
  "website:serve": "rimraf ./site/README.md ./site/dist && npx docula serve -s ./site -o ./site/dist"
104
111
  }