writr 5.0.4 → 6.0.0

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 +358 -43
  2. package/dist/writr.d.ts +365 -24
  3. package/dist/writr.js +382 -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,362 @@ 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", "canonical", "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("canonical")) {
424
+ entries.push([
425
+ "canonical",
426
+ z.string().describe("The preferred canonical URL for the document")
427
+ ]);
428
+ }
429
+ if (fieldSet.has("openGraph")) {
430
+ entries.push([
431
+ "openGraph",
432
+ z.object({
433
+ title: z.string().describe("The social sharing title"),
434
+ description: z.string().describe("The social sharing description"),
435
+ image: z.string().describe("The image URL for social sharing").nullable()
436
+ }).describe("Open Graph metadata")
437
+ ]);
438
+ }
439
+ return z.object(Object.fromEntries(entries));
440
+ }
441
+ computeWordCount() {
442
+ const text = this.writr.body;
443
+ const words = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "").replace(/[#*_[\]()>~|-]/g, " ").split(/\s+/).filter((word) => word.length > 0);
444
+ return words.length;
445
+ }
446
+ computeReadingTime() {
447
+ const wordCount = this.computeWordCount();
448
+ return Math.max(1, Math.ceil(wordCount / AVERAGE_WORDS_PER_MINUTE));
449
+ }
450
+ stripCodeFence(text) {
451
+ const trimmed = text.trim();
452
+ const match = /^```\w*\n([\s\S]*?)```$/.exec(trimmed);
453
+ return match ? match[1].trim() : text;
454
+ }
455
+ };
456
+
457
+ // src/writr.ts
106
458
  var Writr = class extends Hookified {
107
459
  engine = unified().use(remarkParse).use(remarkGfm).use(remarkToc).use(remarkEmoji).use(remarkRehype).use(rehypeSlug).use(remarkMath).use(rehypeKatex).use(rehypeHighlight).use(rehypeStringify);
108
460
  // Stringify HTML
109
461
  _options = {
110
- throwErrors: false,
111
462
  renderOptions: {
112
463
  emoji: true,
113
464
  toc: true,
@@ -121,6 +472,7 @@ var Writr = class extends Hookified {
121
472
  };
122
473
  _content = "";
123
474
  _cache = new WritrCache();
475
+ _ai;
124
476
  /**
125
477
  * Initialize Writr. Accepts a string or options object.
126
478
  * @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 +482,13 @@ var Writr = class extends Hookified {
130
482
  * const writr = new Writr('Hello, world!', {caching: false});
131
483
  */
132
484
  constructor(arguments1, arguments2) {
133
- super();
485
+ const options = typeof arguments1 === "object" ? arguments1 : arguments2;
486
+ super({
487
+ throwOnEmitError: options?.throwOnEmitError,
488
+ throwOnHookError: options?.throwOnHookError,
489
+ throwOnEmptyListeners: options?.throwOnEmptyListeners,
490
+ eventLogger: options?.eventLogger
491
+ });
134
492
  if (typeof arguments1 === "string") {
135
493
  this._content = arguments1;
136
494
  } else if (arguments1) {
@@ -145,6 +503,9 @@ var Writr = class extends Hookified {
145
503
  this.engine = this.createProcessor(this._options.renderOptions);
146
504
  }
147
505
  }
506
+ if (this._options.ai) {
507
+ this._ai = new WritrAI(this, this._options.ai);
508
+ }
148
509
  }
149
510
  /**
150
511
  * Get the options.
@@ -153,6 +514,13 @@ var Writr = class extends Hookified {
153
514
  get options() {
154
515
  return this._options;
155
516
  }
517
+ /**
518
+ * Get the WritrAI instance if AI options were provided.
519
+ * @type {WritrAI | undefined}
520
+ */
521
+ get ai() {
522
+ return this._ai;
523
+ }
156
524
  /**
157
525
  * Get the Content. This is the markdown content and front matter if it exists.
158
526
  * @type {WritrOptions}
@@ -290,8 +658,8 @@ ${yamlString}---
290
658
  return resultData.result;
291
659
  } catch (error) {
292
660
  this.emit("error", error);
293
- throw new Error(`Failed to render markdown: ${error.message}`);
294
661
  }
662
+ return "";
295
663
  }
296
664
  /**
297
665
  * Render the markdown content to HTML synchronously.
@@ -333,8 +701,8 @@ ${yamlString}---
333
701
  return resultData.result;
334
702
  } catch (error) {
335
703
  this.emit("error", error);
336
- throw new Error(`Failed to render markdown: ${error.message}`);
337
704
  }
705
+ return "";
338
706
  }
339
707
  /**
340
708
  * Validate the markdown content by attempting to render it.
@@ -363,7 +731,6 @@ ${yamlString}---
363
731
  }
364
732
  return { valid: true };
365
733
  } catch (error) {
366
- this.emit("error", error);
367
734
  if (content !== void 0) {
368
735
  this._content = originalContent;
369
736
  }
@@ -423,9 +790,6 @@ ${yamlString}---
423
790
  await writeFile(data.filePath, data.content);
424
791
  } catch (error) {
425
792
  this.emit("error", error);
426
- if (this._options.throwErrors) {
427
- throw error;
428
- }
429
793
  }
430
794
  }
431
795
  /**
@@ -446,9 +810,6 @@ ${yamlString}---
446
810
  fs.writeFileSync(data.filePath, data.content);
447
811
  } catch (error) {
448
812
  this.emit("error", error);
449
- if (this._options.throwErrors) {
450
- throw error;
451
- }
452
813
  }
453
814
  }
454
815
  /**
@@ -463,8 +824,8 @@ ${yamlString}---
463
824
  return parse(html, reactParseOptions);
464
825
  } catch (error) {
465
826
  this.emit("error", error);
466
- throw new Error(`Failed to render React: ${error.message}`);
467
827
  }
828
+ return "";
468
829
  }
469
830
  /**
470
831
  * Render the markdown content to React synchronously.
@@ -478,8 +839,8 @@ ${yamlString}---
478
839
  return parse(html, reactParseOptions);
479
840
  } catch (error) {
480
841
  this.emit("error", error);
481
- throw new Error(`Failed to render React: ${error.message}`);
482
842
  }
843
+ return "";
483
844
  }
484
845
  /**
485
846
  * Load markdown content from a file.
@@ -497,9 +858,6 @@ ${yamlString}---
497
858
  this._content = data.content;
498
859
  } catch (error) {
499
860
  this.emit("error", error);
500
- if (this._options.throwErrors) {
501
- throw error;
502
- }
503
861
  }
504
862
  }
505
863
  /**
@@ -517,9 +875,6 @@ ${yamlString}---
517
875
  this._content = data.content;
518
876
  } catch (error) {
519
877
  this.emit("error", error);
520
- if (this._options.throwErrors) {
521
- throw error;
522
- }
523
878
  }
524
879
  }
525
880
  /**
@@ -540,9 +895,6 @@ ${yamlString}---
540
895
  await writeFile(data.filePath, data.content);
541
896
  } catch (error) {
542
897
  this.emit("error", error);
543
- if (this._options.throwErrors) {
544
- throw error;
545
- }
546
898
  }
547
899
  }
548
900
  /**
@@ -562,19 +914,19 @@ ${yamlString}---
562
914
  fs.writeFileSync(data.filePath, data.content);
563
915
  } catch (error) {
564
916
  this.emit("error", error);
565
- if (this._options.throwErrors) {
566
- throw error;
567
- }
568
917
  }
569
918
  }
570
919
  mergeOptions(current, options) {
571
- if (options.throwErrors !== void 0) {
572
- current.throwErrors = options.throwErrors;
920
+ if (options.throwOnEmitError !== void 0) {
921
+ this.throwOnEmitError = options.throwOnEmitError;
573
922
  }
574
923
  if (options.renderOptions) {
575
924
  current.renderOptions ??= {};
576
925
  this.mergeRenderOptions(current.renderOptions, options.renderOptions);
577
926
  }
927
+ if (options.ai) {
928
+ current.ai = options.ai;
929
+ }
578
930
  return current;
579
931
  }
580
932
  isCacheEnabled(options) {
@@ -642,6 +994,8 @@ ${yamlString}---
642
994
  };
643
995
  export {
644
996
  Writr,
997
+ WritrAI,
998
+ WritrAICache,
645
999
  WritrHooks
646
1000
  };
647
1001
  /* 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.0",
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
  }