@ai-react-markdown/core 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,14 @@ For LaTeX math rendering, include the KaTeX stylesheet:
49
49
  import 'katex/dist/katex.min.css';
50
50
  ```
51
51
 
52
+ `katex` is declared as an **optional peer dependency**. It ships transitively via `rehype-katex`, so hoisted installers (npm, yarn classic, default pnpm) resolve the import automatically. Strict-isolation installers (yarn PnP, `pnpm --node-linker=isolated`) need it installed explicitly:
53
+
54
+ ```bash
55
+ npm install katex
56
+ ```
57
+
58
+ Skip the install only if you have no `import 'katex/…'` calls in your app and don't render math.
59
+
52
60
  For the built-in default typography, include the typography CSS:
53
61
 
54
62
  ```tsx
@@ -81,23 +89,23 @@ function StreamingChat({ content, isStreaming }: { content: string; isStreaming:
81
89
 
82
90
  ### `AIMarkdownProps<TConfig, TRenderData>`
83
91
 
84
- | Prop | Type | Default | Description |
85
- | ---------------------- | -------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | `content` | `string` | **(required)** | Raw markdown content to render. |
87
- | `streaming` | `boolean` | `false` | Whether content is actively being streamed (e.g. from an LLM). |
88
- | `fontSize` | `number \| string` | `'0.9375rem'` | Base font size. Numbers are treated as pixels. |
89
- | `variant` | `AIMarkdownVariant` | `'default'` | Typography variant name. |
90
- | `colorScheme` | `AIMarkdownColorScheme` | `'light'` | Color scheme name (`'light'`, `'dark'`, or custom). |
91
- | `config` | `PartialDeep<TConfig>` | `undefined` | Partial render config, deep-merged with defaults. |
92
- | `defaultConfig` | `TConfig` | `defaultAIMarkdownRenderConfig` | Base config to merge against. Sub-packages can pass extended defaults. |
93
- | `metadata` | `TRenderData` | `undefined` | Arbitrary data passed to custom components via a dedicated context. |
94
- | `contentPreprocessors` | `AIMDContentPreprocessor[]` | `[]` | Additional preprocessors run after the built-in LaTeX preprocessor. |
95
- | `customComponents` | `AIMarkdownCustomComponents` | `undefined` | `react-markdown` component overrides for specific HTML elements. |
96
- | `Typography` | `AIMarkdownTypographyComponent` | `DefaultTypography` | Typography wrapper component. |
97
- | `ExtraStyles` | `AIMarkdownExtraStylesComponent` | `undefined` | Optional extra style wrapper rendered between typography and content. |
92
+ | Prop | Type | Default | Description |
93
+ | ---------------------- | -------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | `content` | `string` | **(required)** | Raw markdown content to render. |
95
+ | `streaming` | `boolean` | `false` | Whether content is actively being streamed (e.g. from an LLM). |
96
+ | `fontSize` | `number \| string` | `'0.9375rem'` | Base font size. Numbers are treated as pixels. |
97
+ | `variant` | `AIMarkdownVariant` | `'default'` | Typography variant name. |
98
+ | `colorScheme` | `AIMarkdownColorScheme` | `'light'` | Color scheme name (`'light'`, `'dark'`, or custom). |
99
+ | `config` | `PartialDeep<TConfig>` | `undefined` | Partial render config, deep-merged with defaults. |
100
+ | `defaultConfig` | `TConfig` | `defaultAIMarkdownRenderConfig` | Base config to merge against. Sub-packages can pass extended defaults. |
101
+ | `metadata` | `TRenderData` | `undefined` | Arbitrary data passed to custom components via a dedicated context. |
102
+ | `contentPreprocessors` | `AIMDContentPreprocessor[]` | `[]` | Additional preprocessors run after the built-in LaTeX preprocessor. |
103
+ | `customComponents` | `AIMarkdownCustomComponents` | `undefined` | `react-markdown` component overrides for specific HTML elements. |
104
+ | `Typography` | `AIMarkdownTypographyComponent` | `DefaultTypography` | Typography wrapper component. |
105
+ | `ExtraStyles` | `AIMarkdownExtraStylesComponent` | `undefined` | Optional extra style wrapper rendered between typography and content. |
98
106
  | `documentId` | `string` | auto via `useId()` | Stable id for the _logical markdown document_ this `<AIMarkdown>` is rendering. Used as the id namespace for clobberable attributes (`id`, hash hrefs) so two documents on the same page do not cross-link (footnote `[^1]` in message A won't scroll to `[^1]` in message B). When one document is split into chunks rendered by multiple `<AIMarkdown>` instances, pass the SAME `documentId` to every chunk so prefixes align. The value is passed through `encodeURIComponent` before being injected into HTML attributes, so any string is safe (React's `useId()` output, your own opaque ids, user-supplied UUIDs). Long ids (>16 chars, e.g. UUIDs) are hashed via MurmurHash3 to a short Base62 form **inside the rendered `id="…"`/`href="#…"` prefix only** to keep HTML compact; `state.documentId` itself and registry keying via `useDocumentRegistry` stay raw, so deep linking and any consumer code reading `documentId` are unaffected. |
99
- | `urlTransform` | `UrlTransform \| null` | `defaultUrlTransform` | Override the URL allowlist applied to `href`, `src`, and similar attributes. The default mirrors GitHub: `http`, `https`, `irc`, `ircs`, `mailto`, `xmpp`. Pass a function defined at module scope (or memoized) to permit additional schemes — see [Custom URL Schemes and Sanitization](#custom-url-schemes-and-sanitization). |
100
- | `sanitizeSchema` | `SanitizeSchema` | library default | Override the `rehype-sanitize` schema. Build with [`extendSanitizeSchema`](#custom-url-schemes-and-sanitization) so the library's cross-chunk tag and KaTeX className allowlists survive — hand-rolling silently drops them. |
107
+ | `urlTransform` | `UrlTransform \| null` | `defaultUrlTransform` | Override the URL allowlist applied to `href`, `src`, and similar attributes. The default mirrors GitHub: `http`, `https`, `irc`, `ircs`, `mailto`, `xmpp`. Pass a function defined at module scope (or memoized) to permit additional schemes — see [Custom URL Schemes and Sanitization](#custom-url-schemes-and-sanitization). |
108
+ | `sanitizeSchema` | `SanitizeSchema` | library default | Override the `rehype-sanitize` schema. Build with [`extendSanitizeSchema`](#custom-url-schemes-and-sanitization) so the library's cross-chunk tag and KaTeX className allowlists survive — hand-rolling silently drops them. |
101
109
 
102
110
  ## Configuration
103
111
 
@@ -202,25 +210,23 @@ By default `<AIMarkdown>` only renders links and images whose URLs use the stand
202
210
 
203
211
  Sanitization runs in **two independent gates** (defense in depth):
204
212
 
205
- 1. **`urlTransform`** — runs first, on every URL-bearing attribute, and rewrites disallowed URLs to `''`.
206
- 2. **`rehype-sanitize` schema** — runs second, and drops the entire `href`/`src`/`cite` attribute when the protocol is not in its own allowlist.
213
+ 1. **`urlTransform`** — runs first, on every URL-bearing attribute, and rewrites disallowed URLs to `''`. Called per-attribute with the attribute name (`'href'` / `'src'` / …) so key-aware transforms can discriminate (e.g. allow a scheme on `href` but not on `src` to block tracker pixels).
214
+ 2. **`rehype-sanitize` schema** — runs second, and drops the URL when the protocol is not in the schema's per-attribute allowlist (`protocols.href`, `protocols.src`, `protocols.cite`).
207
215
 
208
216
  For a private scheme to render, **both gates must permit it**. Allowing only one is the most common pitfall.
209
217
 
218
+ **Cross-chunk symmetry.** When `<AIMarkdown>` instances are wrapped in `<AIMarkdownDocuments>`, link/image references resolved across chunks (chunk A defines `[evil]: …`, chunk B writes `[click][evil]`) go through both gates as well — the same `urlTransform` and `sanitizeSchema` you pass to `<AIMarkdown>` apply at render time. The per-attribute key (`'href'` vs `'src'`) is honored: a key-aware policy that permits a scheme on `<a>` but not `<img>` will produce identical behavior whether the reference is in-chunk or cross-chunk.
219
+
210
220
  ### Allowing a Custom Scheme
211
221
 
212
222
  Define both gates at module scope so their reference identity is stable across renders (this keeps the per-block memo cache warm):
213
223
 
214
224
  ```tsx
215
- import AIMarkdown, {
216
- defaultUrlTransform,
217
- extendSanitizeSchema,
218
- } from '@ai-react-markdown/core';
225
+ import AIMarkdown, { defaultUrlTransform, extendSanitizeSchema } from '@ai-react-markdown/core';
219
226
 
220
227
  // Gate 1: compose with the default so https/mailto/etc. still work.
221
228
  const ALLOWED = /^myapp:/i;
222
- const URL_TRANSFORM = (url, key, node) =>
223
- ALLOWED.test(url) ? url : defaultUrlTransform(url, key, node);
229
+ const URL_TRANSFORM = (url, key, node) => (ALLOWED.test(url) ? url : defaultUrlTransform(url, key, node));
224
230
 
225
231
  // Gate 2: extend the library schema so it permits the scheme on href + src.
226
232
  const SCHEMA = extendSanitizeSchema((s) => {
@@ -229,13 +235,7 @@ const SCHEMA = extendSanitizeSchema((s) => {
229
235
  });
230
236
 
231
237
  function App() {
232
- return (
233
- <AIMarkdown
234
- content={markdown}
235
- urlTransform={URL_TRANSFORM}
236
- sanitizeSchema={SCHEMA}
237
- />
238
- );
238
+ return <AIMarkdown content={markdown} urlTransform={URL_TRANSFORM} sanitizeSchema={SCHEMA} />;
239
239
  }
240
240
  ```
241
241
 
@@ -245,8 +245,8 @@ Hands you a deep clone of the library's default sanitize schema. Mutate it freel
245
245
 
246
246
  ```tsx
247
247
  const SCHEMA = extendSanitizeSchema((s) => {
248
- s.tagNames.push('my-widget'); // add a tag
249
- s.protocols.href.push('myapp'); // permit a protocol
248
+ s.tagNames.push('my-widget'); // add a tag
249
+ s.protocols.href.push('myapp'); // permit a protocol
250
250
  s.attributes['my-widget'] = ['data-id', 'data-mode']; // allow attributes
251
251
  // No `return` needed — mutate-only is fine.
252
252
  });
@@ -260,36 +260,45 @@ const SCHEMA = extendSanitizeSchema((s) => {
260
260
 
261
261
  ### Reference Stability and the Cache
262
262
 
263
- Both `urlTransform` and `sanitizeSchema` are tracked by the per-block memo cache. Defining them inline:
263
+ Both `urlTransform` and `sanitizeSchema` participate in the per-block memo cache, but they are stabilized **asymmetrically**:
264
+
265
+ - **`urlTransform`** is tracked by identity only. A new function reference every render flushes the cache. Callers MUST supply a stable reference (module scope or `useMemo`).
266
+ - **`sanitizeSchema`** is tracked by identity AND additionally stabilized internally via a deep-equal safety net (`useStableValue`). An inline-but-deep-equal schema still works, just with a one-time deep compare on each render — cheaper than a cache flush but not free.
267
+
268
+ Why the asymmetry: function identity can't be deep-compared (two closures with identical bodies are always non-equal), so for `urlTransform` only the call-site can produce a stable reference. `sanitizeSchema` is plain data, so a deep compare is meaningful and serves as a guardrail for callers who forget the module-scope rule.
264
269
 
265
270
  ```tsx
266
- // 🚫 Anti-pattern — discards the entire markdown cache on every parent re-render.
271
+ // 🚫 Anti-pattern — `urlTransform` is recreated every render and discards
272
+ // the entire markdown cache. `sanitizeSchema` would too without the
273
+ // internal deep-equal safety net, but you still pay the deep-compare cost.
267
274
  <AIMarkdown
268
275
  urlTransform={(url, k, n) => /* … */}
269
276
  sanitizeSchema={extendSanitizeSchema((s) => /* … */)}
270
277
  />
278
+
279
+ // ✅ Stable — both refs are minted once at module scope.
280
+ const URL_TRANSFORM = (url, k, n) => /* … */;
281
+ const SCHEMA = extendSanitizeSchema((s) => /* … */);
282
+ <AIMarkdown urlTransform={URL_TRANSFORM} sanitizeSchema={SCHEMA} />
271
283
  ```
272
284
 
273
- … creates fresh references every render, invalidates the cache, and undermines streaming performance. In development the library will `console.warn` after detecting 3+ identity flips on either prop. The warning is dead-code-eliminated in production builds. Define both values at module scope, or memoize with `useMemo` if they depend on state.
285
+ In development the library will `console.warn` after detecting 3+ identity flips on either prop. The warning is dead-code-eliminated in production builds. Define both values at module scope, or memoize with `useMemo` if they depend on state.
274
286
 
275
287
  ### Regex Escaping for `+` / `-` / `.` in Scheme Names
276
288
 
277
289
  Per RFC 3986 scheme names may contain `+`, `-`, and `.` — all regex metacharacters. Write `/^web\+app:/i`, **not** `/^web+app:/i` (the latter would match `we`, `wee`, `weee`, …, silently broadening the allowlist).
278
290
 
279
- ### Escape Hatch: Hand-rolled Schema
291
+ ### Inspecting the Default Schema
280
292
 
281
- If you need full control, the library's default schema is also exported as `sanitizeSchema`. **Spread it** so cross-chunk and KaTeX additions survive:
293
+ `extendSanitizeSchema` hands the modifier a deep clone of the library default. That makes the helper itself the cleanest introspection path no separate export of the singleton is needed:
282
294
 
283
295
  ```tsx
284
- import AIMarkdown, { sanitizeSchema } from '@ai-react-markdown/core';
285
-
286
- const fullCustom = {
287
- ...sanitizeSchema, // ← required to keep cross-chunk + math invariants
288
- // your overrides here
289
- };
296
+ extendSanitizeSchema((s) => {
297
+ console.log('default sanitize schema:', s);
298
+ });
290
299
  ```
291
300
 
292
- The `extendSanitizeSchema` helper exists precisely because consumers tend to forget that spread. Prefer the helper unless you have a specific reason.
301
+ Why no direct `sanitizeSchema` export? Because the obvious extension pattern `{ ...sanitizeSchema, … }` — is a shallow spread. Nested arrays (`protocols.href`, `attributes.a`, `ancestors.*`, …) stay aliased to the singleton; a subsequent `.protocols.href.push(...)` mutates it, and the change leaks into every other `<AIMarkdown>` in your app that doesn't override `sanitizeSchema`. `extendSanitizeSchema` always works on a deep clone, so this class of bug is impossible by construction.
293
302
 
294
303
  ### API Stability of `UrlTransform` and `SanitizeSchema`
295
304
 
@@ -316,15 +325,15 @@ function CustomCodeBlock({ children }: PropsWithChildren) {
316
325
 
317
326
  **Returns** `AIMarkdownRenderState<TConfig>`:
318
327
 
319
- | Field | Type | Description |
320
- | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
321
- | `streaming` | `boolean` | Whether content is being streamed. |
322
- | `fontSize` | `string` | Resolved CSS font-size value. |
323
- | `variant` | `AIMarkdownVariant` | Active typography variant. |
324
- | `colorScheme` | `AIMarkdownColorScheme` | Active color scheme. |
325
- | `documentId` | `string` | Stable id for the logical markdown document — caller-supplied or auto-generated via `useId()`. |
328
+ | Field | Type | Description |
329
+ | --------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
330
+ | `streaming` | `boolean` | Whether content is being streamed. |
331
+ | `fontSize` | `string` | Resolved CSS font-size value. |
332
+ | `variant` | `AIMarkdownVariant` | Active typography variant. |
333
+ | `colorScheme` | `AIMarkdownColorScheme` | Active color scheme. |
334
+ | `documentId` | `string` | Stable id for the logical markdown document — caller-supplied or auto-generated via `useId()`. |
326
335
  | `clobberPrefix` | `string` | URI-safe id prefix derived from `documentId` (with MurmurHash3 → Base62 shortening applied for >16-char ids), used by every clobberable HTML attribute (`id=…` / `href="#…"`). Read this from the render state rather than recomputing locally when writing components that emit anchors — the prefix's exact byte form is not part of the stability contract and may shift across versions. |
327
- | `config` | `TConfig` | Active render configuration (merged with defaults). |
336
+ | `config` | `TConfig` | Active render configuration (merged with defaults). |
328
337
 
329
338
  ### `useAIMarkdownMetadata<TMetadata>()`
330
339
 
@@ -575,7 +584,6 @@ The metadata and render state providers are deliberately separated so that metad
575
584
  - `AIMarkdownRenderDisplayOptimizeAbility`
576
585
  - `defaultAIMarkdownRenderConfig`
577
586
  - `defaultUrlTransform` -- the library's built-in URL-allowlist transform; compose with this when supplying a custom `urlTransform`
578
- - `sanitizeSchema` -- the library's built-in `rehype-sanitize` schema; spread this when hand-rolling a custom schema (or use `extendSanitizeSchema` instead)
579
587
 
580
588
  ### Helpers
581
589