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/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Markdown Rendering Simplified
4
4
  [![tests](https://github.com/jaredwray/writr/actions/workflows/tests.yml/badge.svg)](https://github.com/jaredwray/writr/actions/workflows/tests.yml)
5
+ [![ai-integration-tests](https://github.com/jaredwray/writr/actions/workflows/ai-integration-tests.yml/badge.svg)](https://github.com/jaredwray/writr/actions/workflows/ai-integration-tests.yml)
5
6
  [![GitHub license](https://img.shields.io/github/license/jaredwray/writr)](https://github.com/jaredwray/writr/blob/master/LICENSE)
6
7
  [![codecov](https://codecov.io/gh/jaredwray/writr/branch/master/graph/badge.svg?token=1YdMesM07X)](https://codecov.io/gh/jaredwray/writr)
7
8
  [![npm](https://img.shields.io/npm/dm/writr)](https://npmjs.com/package/writr)
@@ -22,21 +23,13 @@
22
23
  * Emoji Support (remark-emoji).
23
24
  * MDX Support (remark-mdx).
24
25
  * Built in Hooks for adding code to render pipeline.
25
-
26
- # Unified Processor Engine
27
-
28
- Writr builds on top of the open source [unified](https://github.com/unifiedjs/unified) processor – the core project that powers
29
- [remark](https://github.com/remarkjs/remark), [rehype](https://github.com/rehypejs/rehype), and many other content tools. Unified
30
- provides a pluggable pipeline where each plugin transforms a syntax tree. Writr configures a default set of plugins to turn
31
- Markdown into HTML, but you can access the processor through the `.engine` property to add your own behavior with
32
- `writr.engine.use(myPlugin)`. The [unified documentation](https://unifiedjs.com/) has more details and guides for building
33
- plugins and working with the processor directly.
26
+ * AI-powered metadata generation, SEO, and translation via the [Vercel AI SDK](https://sdk.vercel.ai).
34
27
 
35
28
  # Table of Contents
36
- - [Unified Processor Engine](#unified-processor-engine)
37
29
  - [Getting Started](#getting-started)
38
30
  - [API](#api)
39
31
  - [`new Writr(arg?: string | WritrOptions, options?: WritrOptions)`](#new-writrarg-string--writroptions-options-writroptions)
32
+ - [`.ai`](#writrai)
40
33
  - [`.content`](#content)
41
34
  - [`.body`](#body)
42
35
  - [`.options`](#options)
@@ -68,6 +61,19 @@ plugins and working with the processor directly.
68
61
  - [Methods that Emit Errors](#methods-that-emit-errors)
69
62
  - [Error Event Examples](#error-event-examples)
70
63
  - [Event Emitter Methods](#event-emitter-methods)
64
+ - [AI](#ai)
65
+ - [AI Options](#ai-options)
66
+ - [AI Provider Configuration](#ai-provider-configuration)
67
+ - [Metadata](#metadata)
68
+ - [Generating Metadata](#generating-metadata)
69
+ - [Applying Metadata to Frontmatter](#applying-metadata-to-frontmatter)
70
+ - [Overwrite](#overwrite)
71
+ - [Field Mapping](#field-mapping)
72
+ - [SEO](#seo)
73
+ - [Translation](#translation)
74
+ - [Using WritrAI Directly](#using-writrai-directly)
75
+ - [Migrating to v6](#migrating-to-v6)
76
+ - [Unified Processor Engine](#unified-processor-engine)
71
77
  - [Benchmarks](#benchmarks)
72
78
  - [ESM and Node Version Support](#esm-and-node-version-support)
73
79
  - [Code of Conduct and Contributing](#code-of-conduct-and-contributing)
@@ -104,7 +110,6 @@ An example passing in the options also via the constructor:
104
110
  ```javascript
105
111
  import { Writr, WritrOptions } from 'writr';
106
112
  const writrOptions = {
107
- throwErrors: true,
108
113
  renderOptions: {
109
114
  emoji: true,
110
115
  toc: true,
@@ -129,7 +134,6 @@ By default the constructor takes in a markdown `string` or `WritrOptions` in the
129
134
  ```javascript
130
135
  import { Writr, WritrOptions } from 'writr';
131
136
  const writrOptions = {
132
- throwErrors: true,
133
137
  renderOptions: {
134
138
  emoji: true,
135
139
  toc: true,
@@ -178,7 +182,6 @@ Accessing the default options for this instance of Writr. Here is the default se
178
182
 
179
183
  ```javascript
180
184
  {
181
- throwErrors: false,
182
185
  renderOptions: {
183
186
  emoji: true,
184
187
  toc: true,
@@ -186,8 +189,8 @@ Accessing the default options for this instance of Writr. Here is the default se
186
189
  highlight: true,
187
190
  gfm: true,
188
191
  math: true,
189
- mdx: true,
190
- caching: false,
192
+ mdx: false,
193
+ caching: true,
191
194
  }
192
195
  }
193
196
  ```
@@ -335,7 +338,7 @@ console.log(writr.content); // Still "# Hello World\n\nThis is a test."
335
338
  const invalidWritr = new Writr('Put invalid markdown here');
336
339
  const errorResult = await invalidWritr.validate();
337
340
  console.log(errorResult.valid); // false
338
- console.log(errorResult.error?.message); // "Failed to render markdown: Invalid plugin"
341
+ console.log(errorResult.error?.message); // "Invalid plugin"
339
342
  ```
340
343
 
341
344
  ## `.validateSync(content?: string, options?: RenderOptions)`
@@ -623,38 +626,35 @@ writr.on('error', (error) => {
623
626
  // Log to error tracking service, display to user, etc.
624
627
  });
625
628
 
626
- // Now when any error occurs, your listener will be notified
627
- try {
628
- await writr.render();
629
- } catch (error) {
630
- // Error is also thrown, so you can handle it here too
631
- }
629
+ // With a listener registered, errors are emitted to the listener
630
+ // and the method returns its fallback value (e.g. "" for render)
631
+ const html = await writr.render();
632
632
  ```
633
633
 
634
634
  ### Methods that Emit Errors
635
635
 
636
- The following methods emit error events when they fail:
636
+ All methods use an emit-only error pattern they call `this.emit('error', error)` but never explicitly re-throw. If no error listener is registered and `throwOnEmptyListeners` is `true` (the default), the `emit('error')` call itself will throw, following standard Node.js EventEmitter behavior.
637
637
 
638
- **Rendering Methods:**
639
- - `render()` - Emits error before throwing when markdown rendering fails
640
- - `renderSync()` - Emits error before throwing when markdown rendering fails
641
- - `renderReact()` - Emits error before throwing when React rendering fails
642
- - `renderReactSync()` - Emits error before throwing when React rendering fails
638
+ **Rendering Methods** — emit error, return `""`:
639
+ - `render()` - Emits error when markdown rendering fails, returns empty string
640
+ - `renderSync()` - Emits error when markdown rendering fails, returns empty string
641
+ - `renderReact()` - Emits error when React rendering fails, returns empty string
642
+ - `renderReactSync()` - Emits error when React rendering fails, returns empty string
643
643
 
644
644
  **Validation Methods:**
645
- - `validate()` - Emits error when validation fails (returns error in result object)
646
- - `validateSync()` - Emits error when validation fails (returns error in result object)
647
-
648
- **File Operations:**
649
- - `renderToFile()` - Emits error when file writing fails (does not throw if `throwErrors: false`)
650
- - `renderToFileSync()` - Emits error when file writing fails (does not throw if `throwErrors: false`)
651
- - `loadFromFile()` - Emits error when file reading fails (does not throw if `throwErrors: false`)
652
- - `loadFromFileSync()` - Emits error when file reading fails (does not throw if `throwErrors: false`)
653
- - `saveToFile()` - Emits error when file writing fails (does not throw if `throwErrors: false`)
654
- - `saveToFileSync()` - Emits error when file writing fails (does not throw if `throwErrors: false`)
655
-
656
- **Front Matter Operations:**
657
- - `frontMatter` getter - Emits error when YAML parsing fails
645
+ - `validate()` - Does **not** emit errors. Returns `{ valid: false, error }` on failure
646
+ - `validateSync()` - Emits error and returns `{ valid: false, error }` on failure
647
+
648
+ **File Operations** — emit error, return void:
649
+ - `renderToFile()` - Emits error when rendering or file writing fails
650
+ - `renderToFileSync()` - Emits error when rendering or file writing fails
651
+ - `loadFromFile()` - Emits error when file reading fails
652
+ - `loadFromFileSync()` - Emits error when file reading fails
653
+ - `saveToFile()` - Emits error when file writing fails
654
+ - `saveToFileSync()` - Emits error when file writing fails
655
+
656
+ **Front Matter Operations** — emit error, return fallback:
657
+ - `frontMatter` getter - Emits error when YAML parsing fails, returns `{}`
658
658
  - `frontMatter` setter - Emits error when YAML serialization fails
659
659
 
660
660
  ### Error Event Examples
@@ -706,15 +706,16 @@ if (!result.valid) {
706
706
  ```javascript
707
707
  import { Writr } from 'writr';
708
708
 
709
- const writr = new Writr('# Content', { throwErrors: false });
709
+ const writr = new Writr('# Content');
710
710
 
711
711
  writr.on('error', (error) => {
712
712
  console.error('File operation failed:', error.message);
713
713
  // Handle gracefully - maybe use default content
714
714
  });
715
715
 
716
- // Won't throw, but will emit error event if file doesn't exist
716
+ // With a listener registered, errors are emitted and the method returns normally
717
717
  await writr.loadFromFile('./maybe-missing.md');
718
+ // Note: without a listener, this will throw by default (throwOnEmptyListeners is true)
718
719
  ```
719
720
 
720
721
  ### Event Emitter Methods
@@ -728,6 +729,319 @@ Since Writr extends Hookified, you have access to standard event emitter methods
728
729
 
729
730
  For more information about event handling capabilities, see the [Hookified documentation](https://github.com/jaredwray/hookified).
730
731
 
732
+ # AI
733
+
734
+ Writr includes built-in AI capabilities for metadata generation, SEO, and translation powered by the [Vercel AI SDK](https://sdk.vercel.ai). Plug in any supported model provider (OpenAI, Anthropic, Google, etc.) via the `ai` option.
735
+
736
+ ```typescript
737
+ import { Writr } from 'writr';
738
+ import { openai } from '@ai-sdk/openai';
739
+
740
+ const writr = new Writr('# My Document\n\nSome markdown content here.', {
741
+ ai: { model: openai('gpt-4.1-mini') },
742
+ });
743
+
744
+ // Generate metadata
745
+ const metadata = await writr.ai.getMetadata();
746
+
747
+ // Generate only specific fields
748
+ const metadata = await writr.ai.getMetadata({ title: true, description: true });
749
+
750
+ // Generate SEO metadata
751
+ const seo = await writr.ai.getSEO();
752
+
753
+ // Translate to Spanish
754
+ const translated = await writr.ai.getTranslation({ to: 'es' });
755
+
756
+ // Apply generated metadata to frontmatter
757
+ const result = await writr.ai.applyMetadata({
758
+ generate: { description: true, category: true },
759
+ overwrite: true,
760
+ });
761
+ ```
762
+
763
+ ## AI Options
764
+
765
+ Pass `ai` in the `WritrOptions` to enable AI features:
766
+
767
+ | Property | Type | Required | Description |
768
+ |----------|------|----------|-------------|
769
+ | `model` | `LanguageModel` | Yes | The AI SDK model instance (e.g. `openai("gpt-4.1-mini")`). |
770
+ | `cache` | `boolean` | No | Enables in-memory caching of AI results. |
771
+ | `prompts` | `WritrAIPrompts` | No | Custom prompt overrides for metadata, SEO, and translation. |
772
+
773
+ ```typescript
774
+ const writr = new Writr('# My Document', {
775
+ ai: {
776
+ model: openai('gpt-4.1-mini'),
777
+ cache: true,
778
+ },
779
+ });
780
+ ```
781
+
782
+ ## AI Provider Configuration
783
+
784
+ By default, the provider imports read API keys from environment variables:
785
+
786
+ | Provider | Import | Environment Variable |
787
+ |----------|--------|---------------------|
788
+ | OpenAI | `openai` from `@ai-sdk/openai` | `OPENAI_API_KEY` |
789
+ | Anthropic | `anthropic` from `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` |
790
+ | Google | `google` from `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
791
+
792
+ ```typescript
793
+ // Uses OPENAI_API_KEY from environment
794
+ import { openai } from '@ai-sdk/openai';
795
+
796
+ const writr = new Writr('# Hello', { ai: { model: openai('gpt-4.1-mini') } });
797
+ ```
798
+
799
+ To set API keys programmatically, use the provider factory functions instead:
800
+
801
+ ```typescript
802
+ import { Writr } from 'writr';
803
+ import { createOpenAI } from '@ai-sdk/openai';
804
+ import { createAnthropic } from '@ai-sdk/anthropic';
805
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
806
+
807
+ // OpenAI
808
+ const openai = createOpenAI({ apiKey: 'your-openai-key' });
809
+ const writr = new Writr('# Hello', { ai: { model: openai('gpt-4.1-mini') } });
810
+
811
+ // Anthropic
812
+ const anthropic = createAnthropic({ apiKey: 'your-anthropic-key' });
813
+ const writr = new Writr('# Hello', { ai: { model: anthropic('claude-sonnet-4-20250514') } });
814
+
815
+ // Google
816
+ const google = createGoogleGenerativeAI({ apiKey: 'your-google-key' });
817
+ const writr = new Writr('# Hello', { ai: { model: google('gemini-2.0-flash') } });
818
+ ```
819
+
820
+ ## Metadata
821
+
822
+ Generate metadata from your document content using `writr.ai.getMetadata()`, or generate and apply it directly to frontmatter with `writr.ai.applyMetadata()`.
823
+
824
+ ### Generating Metadata
825
+
826
+ `getMetadata()` analyzes the document and returns a `WritrMetadata` object. By default all fields are generated. Pass options to select specific fields.
827
+
828
+ ```typescript
829
+ // Generate all metadata fields
830
+ const metadata = await writr.ai.getMetadata();
831
+ console.log(metadata.title); // "Getting Started with Writr"
832
+ console.log(metadata.tags); // ["markdown", "rendering", "typescript"]
833
+ console.log(metadata.description); // "A guide to using Writr for markdown processing."
834
+ console.log(metadata.readingTime); // 3 (minutes)
835
+ console.log(metadata.wordCount); // 450
836
+
837
+ // Generate only specific fields
838
+ const partial = await writr.ai.getMetadata({
839
+ title: true,
840
+ description: true,
841
+ tags: true,
842
+ });
843
+ ```
844
+
845
+ **Generated fields:**
846
+
847
+ | Field | Type | Description |
848
+ |-------|------|-------------|
849
+ | `title` | `string` | The best-fit title for the document. |
850
+ | `description` | `string` | A concise meta-style description of the document. |
851
+ | `tags` | `string[]` | Human-friendly labels for organizing the document. |
852
+ | `keywords` | `string[]` | Search-oriented terms related to the document content. |
853
+ | `preview` | `string` | A short teaser or preview snippet of the content. |
854
+ | `summary` | `string` | A slightly longer overview of the document. |
855
+ | `category` | `string` | A broad grouping such as "docs", "guide", or "blog". |
856
+ | `topic` | `string` | The primary subject the document is about. |
857
+ | `audience` | `string` | The intended audience for the document. |
858
+ | `difficulty` | `"beginner" \| "intermediate" \| "advanced"` | The estimated skill level required. |
859
+ | `readingTime` | `number` | Estimated reading time in minutes (computed, not AI-generated). |
860
+ | `wordCount` | `number` | Total word count of the document (computed, not AI-generated). |
861
+
862
+ ### Applying Metadata to Frontmatter
863
+
864
+ `applyMetadata()` generates metadata and writes it into the document's frontmatter. The result tells you exactly what happened:
865
+
866
+ - **`applied`** — fields that were newly written because they were missing from frontmatter.
867
+ - **`skipped`** — fields that already existed and were not overwritten.
868
+ - **`overwritten`** — fields that replaced existing frontmatter values.
869
+
870
+ ```typescript
871
+ const result = await writr.ai.applyMetadata();
872
+ console.log(result.applied); // ["description", "tags", "category"]
873
+ console.log(result.skipped); // ["title"] (already existed)
874
+ console.log(result.overwritten); // []
875
+ ```
876
+
877
+ ### Overwrite
878
+
879
+ By default, `applyMetadata()` only fills in missing fields — existing frontmatter values are never touched. The `overwrite` option changes this behavior:
880
+
881
+ - **Default (no overwrite):** Only missing fields are written. Existing values are preserved.
882
+ - **`overwrite: true`:** All generated fields replace existing frontmatter values.
883
+ - **`overwrite: ['field1', 'field2']`:** Only the listed fields are overwritten. Other existing values are preserved.
884
+
885
+ ```typescript
886
+ // Overwrite all generated fields, even if they already exist
887
+ const result = await writr.ai.applyMetadata({
888
+ generate: { title: true, description: true },
889
+ overwrite: true,
890
+ });
891
+
892
+ // Only overwrite title, leave description alone if it already exists
893
+ const result = await writr.ai.applyMetadata({
894
+ generate: { title: true, description: true, category: true },
895
+ overwrite: ['title'],
896
+ });
897
+ ```
898
+
899
+ ### Field Mapping
900
+
901
+ The `fieldMap` option maps generated metadata keys to different frontmatter field names. This is useful when your frontmatter schema uses different naming conventions than the default metadata keys.
902
+
903
+ ```typescript
904
+ const result = await writr.ai.applyMetadata({
905
+ generate: { description: true, tags: true },
906
+ fieldMap: {
907
+ description: 'meta_description',
908
+ tags: 'labels',
909
+ },
910
+ });
911
+ // writr.frontMatter.meta_description === "A guide to..."
912
+ // writr.frontMatter.labels === ["markdown", "rendering"]
913
+ ```
914
+
915
+ The mapping applies to all behaviors — field existence checks, overwrites, and skips all use the mapped key when checking frontmatter.
916
+
917
+ ## SEO
918
+
919
+ Generate SEO metadata using `writr.ai.getSEO()`. By default all fields are generated. Pass options to select specific fields.
920
+
921
+ ```typescript
922
+ const seo = await writr.ai.getSEO();
923
+ console.log(seo.slug); // "getting-started-with-writr"
924
+ console.log(seo.openGraph?.title); // "Getting Started with Writr"
925
+
926
+ // Generate only a slug
927
+ const seo = await writr.ai.getSEO({ slug: true });
928
+ ```
929
+
930
+ **Available fields:** `slug`, `openGraph` (includes `title`, `description`, `image`).
931
+
932
+ ## Translation
933
+
934
+ Translate the document into another language using `writr.ai.getTranslation()`. Returns a new `Writr` instance with the translated content.
935
+
936
+ ```typescript
937
+ const spanish = await writr.ai.getTranslation({ to: 'es' });
938
+ console.log(spanish.body); // Spanish markdown
939
+
940
+ // With source language and frontmatter translation
941
+ const french = await writr.ai.getTranslation({
942
+ to: 'fr',
943
+ from: 'en',
944
+ translateFrontMatter: true,
945
+ });
946
+ ```
947
+
948
+ | Option | Type | Required | Description |
949
+ |--------|------|----------|-------------|
950
+ | `to` | `string` | Yes | Target language or locale. |
951
+ | `from` | `string` | No | Source language or locale. |
952
+ | `translateFrontMatter` | `boolean` | No | Also translate frontmatter string values. |
953
+
954
+ ## Using WritrAI Directly
955
+
956
+ `WritrAI` is exported as a named export and can be instantiated independently from the `Writr` constructor. This is useful when you want to configure the AI instance separately or swap models on the fly.
957
+
958
+ ```typescript
959
+ import { Writr, WritrAI } from 'writr';
960
+ import { openai } from '@ai-sdk/openai';
961
+
962
+ const writr = new Writr('# My Document\n\nSome markdown content here.');
963
+ const ai = new WritrAI(writr, {
964
+ model: openai('gpt-4.1-mini'),
965
+ cache: true,
966
+ });
967
+
968
+ // Generate metadata
969
+ const metadata = await ai.getMetadata();
970
+ console.log(metadata.title);
971
+ console.log(metadata.tags);
972
+
973
+ // Generate SEO data
974
+ const seo = await ai.getSEO();
975
+ console.log(seo.slug);
976
+
977
+ // Translate
978
+ const translated = await ai.getTranslation({ to: 'es' });
979
+ console.log(translated.body);
980
+
981
+ // Apply metadata to frontmatter
982
+ const result = await ai.applyMetadata({
983
+ generate: { title: true, description: true, tags: true },
984
+ overwrite: true,
985
+ });
986
+ ```
987
+
988
+ # Migrating to v6
989
+
990
+ Writr v6 upgrades [hookified](https://github.com/jaredwray/hookified) from v1 to v2 and removes `throwErrors` in favor of hookified's built-in error handling options.
991
+
992
+ ## Breaking Changes
993
+
994
+ ### `throwErrors` removed
995
+
996
+ The `throwErrors` option has been removed from `WritrOptions`. Use `throwOnEmitError` instead, which is provided by hookified's `HookifiedOptions` (now spread into `WritrOptions`).
997
+
998
+ **Before (v5):**
999
+
1000
+ ```typescript
1001
+ const writr = new Writr('# Hello', { throwErrors: true });
1002
+ ```
1003
+
1004
+ **After (v6):**
1005
+
1006
+ ```typescript
1007
+ const writr = new Writr('# Hello', { throwOnEmitError: true });
1008
+ ```
1009
+
1010
+ ### Error handling redesign
1011
+
1012
+ All methods now use an **emit-only** pattern — errors are emitted via `emit('error', error)` but never explicitly re-thrown. Methods return fallback values on error (`""` for render methods, `{}` for frontMatter getter, `{ valid: false, error }` for validate).
1013
+
1014
+ **How errors propagate:**
1015
+
1016
+ - **With a listener registered:** Errors are passed to the listener. The method returns its fallback value without throwing.
1017
+ - **Without a listener (default behavior):** Since `throwOnEmptyListeners` defaults to `true`, the `emit('error')` call itself throws, following standard Node.js EventEmitter behavior. This means unhandled errors will still surface as exceptions.
1018
+ - **With `throwOnEmitError: true`:** Every `emit('error')` call throws, even when listeners are registered. This affects all methods that emit errors.
1019
+
1020
+ **Other changes:**
1021
+
1022
+ - `render()` and `renderSync()` no longer throw wrapped `"Failed to render markdown: ..."` errors. They emit the original error and return `""`.
1023
+ - `validate()` (async) no longer emits errors — it only returns `{ valid: false, error }`. `validateSync()` still emits.
1024
+
1025
+ ### hookified v2
1026
+
1027
+ Writr now uses hookified v2 which introduces several new options available through `WritrOptions`:
1028
+
1029
+ - `throwOnEmitError` — Throw when `emit("error")` is called, even with listeners (default: `false`)
1030
+ - `throwOnHookError` — Throw when a hook handler throws (default: `false`)
1031
+ - `throwOnEmptyListeners` — Throw when emitting `error` with no listeners (default: `true`)
1032
+ - `eventLogger` — Logger instance for event logging
1033
+
1034
+ See the [hookified documentation](https://github.com/jaredwray/hookified) for full details.
1035
+
1036
+ # Unified Processor Engine
1037
+
1038
+ Writr builds on top of the open source [unified](https://github.com/unifiedjs/unified) processor – the core project that powers
1039
+ [remark](https://github.com/remarkjs/remark), [rehype](https://github.com/rehypejs/rehype), and many other content tools. Unified
1040
+ provides a pluggable pipeline where each plugin transforms a syntax tree. Writr configures a default set of plugins to turn
1041
+ Markdown into HTML, but you can access the processor through the `.engine` property to add your own behavior with
1042
+ `writr.engine.use(myPlugin)`. The [unified documentation](https://unifiedjs.com/) has more details and guides for building
1043
+ plugins and working with the processor directly.
1044
+
731
1045
  # Benchmarks
732
1046
 
733
1047
  This is a comparison with minimal configuration where we have disabled all rendering pipeline and just did straight caching + rendering to compare it against the fastest: