@aparte/plugin-streaming-markdown 0.20.0 → 0.22.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.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # @aparte/plugin-streaming-markdown
2
2
 
3
3
  Incremental (token-by-token) Markdown rendering for [aparté](https://github.com/apartejs/aparte) via
4
- [`streaming-markdown`](https://github.com/thetarnav/streaming-markdown). Parses only the new text and
5
- **appends** DOM nodes per streamed chunk — no per-token re-parse or `innerHTML` rebuild.
4
+ [`@paulrichez/streaming-markdown`](https://github.com/PaulRichez/streaming-markdown), a maintained fork of
5
+ Damian Tarnawski's [`streaming-markdown`](https://github.com/thetarnav/streaming-markdown), which has had no
6
+ release since May 2025. Parses only the new text and **appends** DOM nodes per streamed chunk — no
7
+ per-token re-parse or `innerHTML` rebuild.
6
8
 
7
9
  ```bash
8
- npm install @aparte/plugin-streaming-markdown @aparte/core streaming-markdown
10
+ npm install @aparte/plugin-streaming-markdown @aparte/core
9
11
  ```
10
12
 
11
13
  ```ts
@@ -14,7 +16,8 @@ import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdow
14
16
  setupStreamingMarkdownProvider();
15
17
  ```
16
18
 
17
- `@aparte/core` and `streaming-markdown` are **peer dependencies**.
19
+ `@aparte/core` is a **peer dependency**; the parser installs with the plugin. Coming from a version that used
20
+ `streaming-markdown`, you can remove it.
18
21
 
19
22
  This plugin is enough on its own: a reply renders the same whether it streams or arrives complete — a
20
23
  conversation restored after a reload, a non-streaming provider's answer — because with no one-shot
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type AparteConfig } from '@aparte/core';
2
2
  /**
3
- * Register `streaming-markdown` as aparté's incremental (streaming) Markdown
3
+ * Register `@paulrichez/streaming-markdown` as aparté's incremental (streaming) Markdown
4
4
  * renderer.
5
5
  *
6
6
  * Once registered, the chat bubble feeds each streamed token chunk to a
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { aparteGlobalConfig, isSafeUrl } from "@aparte/core";
2
- import { default_renderer, HREF, SRC, LANG, parser, parser_end, parser_write } from "streaming-markdown";
2
+ import { default_renderer, HREF, SRC, LANG, parser, parser_end, parser_write } from "@paulrichez/streaming-markdown";
3
3
  const EXTERNAL_URL = /^(?:https?:)?[/\\]{2}|^https?:[/\\]/i;
4
4
  const CONTROL_WS = /[\u0000-\u0020]+/g;
5
5
  const LANG_TAIL = /[^A-Za-z0-9_+#.-].*$/s;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { aparteGlobalConfig, isSafeUrl , type AparteConfig} from '@aparte/core';\nimport { parser, parser_write, parser_end, default_renderer, HREF, SRC, LANG } from 'streaming-markdown';\n\n/*\n * The external-link rule, copied — deliberately and minimally — from its OWNER,\n * core's `config/sanitize.ts`. Core exports `isSafeUrl` (used above) but not this\n * pair, and the streaming path needs the same answer live: the one-shot\n * re-sanitisation at settle sends a model's external link to its own tab, so\n * without this every streamed link is a bare, clickable, frame-navigating anchor\n * for the whole length of the reply. If core ever exports the predicate, this\n * goes and the import comes back.\n *\n * `//host` and a value written with leading whitespace resolve off-site just the\n * same, which is why the value is normalised before the test — the same\n * normalisation the accept path (`isSafeUrl`) applies. So do two spellings a\n * reader does not expect: a BACKSLASH is a slash to a URL parser on a special\n * scheme, and a SINGLE slash after an explicit scheme differing from the page's\n * enters authority state. Measured with Node's WHATWG URL against base\n * `https://site.example/chat/`: `/\\evil.example` → `https://evil.example/`,\n * `http:/evil.example` and `http:/\\evil.example` → `http://evil.example/`.\n */\nconst EXTERNAL_URL = /^(?:https?:)?[/\\\\]{2}|^https?:[/\\\\]/i;\n// eslint-disable-next-line no-control-regex -- stripping C0 control chars is intentional (anti-obfuscation)\nconst CONTROL_WS = /[\\u0000-\\u0020]+/g;\n\n/*\n * The leading run of a fence's info string that may become a language name.\n *\n * `smd` maps its LANG attribute to `class` and hands the info string over\n * VERBATIM — everything after the three backticks up to the newline, spaces\n * included. So a legal fence dressed model text in core's own recipes\n * (```aparte-approval-option aparte-btn aparte-btn--solid), which is the forgery\n * core's sanitizer comment was written for, and a `position: fixed` one\n * (`.aparte-select-dropdown`, `.aparte-sidebar__scrim`) repainted the page around\n * the chat.\n *\n * Truncated rather than filtered: the first character a language name cannot hold\n * ends the token, so `py<script>` is `py` and never `pyscript`. `+`, `#` and `.`\n * stay because `c++`, `f#` and `asp.net` are languages a highlighter knows. Spelled\n * as the TAIL to remove, so the token is one expression with no branch to miss.\n */\nconst LANG_TAIL = /[^A-Za-z0-9_+#.-].*$/s;\n\n/**\n * Register `streaming-markdown` as aparté's incremental (streaming) Markdown\n * renderer.\n *\n * Once registered, the chat bubble feeds each streamed token chunk to a\n * persistent incremental parser that parses only the new text and APPENDS DOM\n * nodes — O(n) over the whole message, with no per-token re-parse or\n * `innerHTML` rebuild. Finished / re-rendered messages still go through the\n * one-shot `setMarkdownProvider` (e.g. `@aparte/plugin-marked`).\n *\n * Framework-agnostic — vanilla DOM, no framework imports.\n *\n * **Security**: the streaming path writes DOM nodes directly and therefore\n * bypasses the one-shot `aparteGlobalConfig.sanitizeHtml`. To keep the same URL policy\n * live (an attacker-controlled `[x](javascript:…)` streamed token would produce\n * a clickable `javascript:` link before the final re-render sanitises it), the\n * renderer's `set_attr` is wrapped to drop any `href`/`src` whose scheme fails\n * {@link isSafeUrl}, and to send an external `href` to its own tab\n * (`target=\"_blank\" rel=\"noopener noreferrer\"`) as the one-shot path does — a\n * link is clickable from the moment it streams in, and a bare anchor navigates\n * the frame the chat lives in. The one-shot re-render at `end()` remains the\n * full-fidelity re-sanitisation.\n *\n * Call once at application startup.\n */\nexport function setupStreamingMarkdownProvider(config: AparteConfig = aparteGlobalConfig): void {\n config.setStreamingMarkdownProvider((target: HTMLElement) => {\n const renderer = default_renderer(target);\n const originalSetAttr = renderer.set_attr;\n renderer.set_attr = (data, type, value) => {\n if (type === HREF && !isSafeUrl(value, 'a')) return;\n if (type === SRC && !isSafeUrl(value, 'img')) return;\n if (type === LANG) {\n // `language-<token>` is what every one-shot renderer emits for a\n // fence, so this is not merely a filter: the streamed DOM and the\n // settled DOM stop diverging, `language-*` is the one prefix the\n // sanitizer's class policy deliberately exempts, and\n // `highlightMarkdownFences` can read a streamed fence's language.\n const token = value.trim().replace(LANG_TAIL, '');\n if (token) originalSetAttr(data, type, `language-${token}`);\n return;\n }\n originalSetAttr(data, type, value);\n if (type === HREF && EXTERNAL_URL.test(value.replace(CONTROL_WS, ''))) {\n const node = data.nodes[data.index];\n if (node && node.tagName === 'A') {\n node.setAttribute('target', '_blank');\n node.setAttribute('rel', 'noopener noreferrer');\n }\n }\n };\n const p = parser(renderer);\n return {\n write: (chunk: string): void => { parser_write(p, chunk); },\n end: (): void => { parser_end(p); },\n };\n });\n}\n"],"names":[],"mappings":";;AAqBA,MAAM,eAAe;AAErB,MAAM,aAAa;AAkBnB,MAAM,YAAY;AA2BX,SAAS,+BAA+B,SAAuB,oBAA0B;AAC5F,SAAO,6BAA6B,CAAC,WAAwB;AACzD,UAAM,WAAW,iBAAiB,MAAM;AACxC,UAAM,kBAAkB,SAAS;AACjC,aAAS,WAAW,CAAC,MAAM,MAAM,UAAU;AACvC,UAAI,SAAS,QAAQ,CAAC,UAAU,OAAO,GAAG,EAAG;AAC7C,UAAI,SAAS,OAAO,CAAC,UAAU,OAAO,KAAK,EAAG;AAC9C,UAAI,SAAS,MAAM;AAMf,cAAM,QAAQ,MAAM,KAAA,EAAO,QAAQ,WAAW,EAAE;AAChD,YAAI,MAAO,iBAAgB,MAAM,MAAM,YAAY,KAAK,EAAE;AAC1D;AAAA,MACJ;AACA,sBAAgB,MAAM,MAAM,KAAK;AACjC,UAAI,SAAS,QAAQ,aAAa,KAAK,MAAM,QAAQ,YAAY,EAAE,CAAC,GAAG;AACnE,cAAM,OAAO,KAAK,MAAM,KAAK,KAAK;AAClC,YAAI,QAAQ,KAAK,YAAY,KAAK;AAC9B,eAAK,aAAa,UAAU,QAAQ;AACpC,eAAK,aAAa,OAAO,qBAAqB;AAAA,QAClD;AAAA,MACJ;AAAA,IACJ;AACA,UAAM,IAAI,OAAO,QAAQ;AACzB,WAAO;AAAA,MACH,OAAO,CAAC,UAAwB;AAAE,qBAAa,GAAG,KAAK;AAAA,MAAG;AAAA,MAC1D,KAAK,MAAY;AAAE,mBAAW,CAAC;AAAA,MAAG;AAAA,IAAA;AAAA,EAE1C,CAAC;AACL;"}
1
+ {"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { aparteGlobalConfig, isSafeUrl , type AparteConfig} from '@aparte/core';\nimport { parser, parser_write, parser_end, default_renderer, HREF, SRC, LANG } from '@paulrichez/streaming-markdown';\n\n/*\n * The external-link rule, copied — deliberately and minimally — from its OWNER,\n * core's `config/sanitize.ts`. Core exports `isSafeUrl` (used above) but not this\n * pair, and the streaming path needs the same answer live: the one-shot\n * re-sanitisation at settle sends a model's external link to its own tab, so\n * without this every streamed link is a bare, clickable, frame-navigating anchor\n * for the whole length of the reply. If core ever exports the predicate, this\n * goes and the import comes back.\n *\n * `//host` and a value written with leading whitespace resolve off-site just the\n * same, which is why the value is normalised before the test — the same\n * normalisation the accept path (`isSafeUrl`) applies. So do two spellings a\n * reader does not expect: a BACKSLASH is a slash to a URL parser on a special\n * scheme, and a SINGLE slash after an explicit scheme differing from the page's\n * enters authority state. Measured with Node's WHATWG URL against base\n * `https://site.example/chat/`: `/\\evil.example` → `https://evil.example/`,\n * `http:/evil.example` and `http:/\\evil.example` → `http://evil.example/`.\n */\nconst EXTERNAL_URL = /^(?:https?:)?[/\\\\]{2}|^https?:[/\\\\]/i;\n// eslint-disable-next-line no-control-regex -- stripping C0 control chars is intentional (anti-obfuscation)\nconst CONTROL_WS = /[\\u0000-\\u0020]+/g;\n\n/*\n * The leading run of a fence's info string that may become a language name.\n *\n * `smd` maps its LANG attribute to `class` and hands the info string over\n * VERBATIM — everything after the three backticks up to the newline, spaces\n * included. So a legal fence dressed model text in core's own recipes\n * (```aparte-approval-option aparte-btn aparte-btn--solid), which is the forgery\n * core's sanitizer comment was written for, and a `position: fixed` one\n * (`.aparte-select-dropdown`, `.aparte-sidebar__scrim`) repainted the page around\n * the chat.\n *\n * Truncated rather than filtered: the first character a language name cannot hold\n * ends the token, so `py<script>` is `py` and never `pyscript`. `+`, `#` and `.`\n * stay because `c++`, `f#` and `asp.net` are languages a highlighter knows. Spelled\n * as the TAIL to remove, so the token is one expression with no branch to miss.\n */\nconst LANG_TAIL = /[^A-Za-z0-9_+#.-].*$/s;\n\n/**\n * Register `@paulrichez/streaming-markdown` as aparté's incremental (streaming) Markdown\n * renderer.\n *\n * Once registered, the chat bubble feeds each streamed token chunk to a\n * persistent incremental parser that parses only the new text and APPENDS DOM\n * nodes — O(n) over the whole message, with no per-token re-parse or\n * `innerHTML` rebuild. Finished / re-rendered messages still go through the\n * one-shot `setMarkdownProvider` (e.g. `@aparte/plugin-marked`).\n *\n * Framework-agnostic — vanilla DOM, no framework imports.\n *\n * **Security**: the streaming path writes DOM nodes directly and therefore\n * bypasses the one-shot `aparteGlobalConfig.sanitizeHtml`. To keep the same URL policy\n * live (an attacker-controlled `[x](javascript:…)` streamed token would produce\n * a clickable `javascript:` link before the final re-render sanitises it), the\n * renderer's `set_attr` is wrapped to drop any `href`/`src` whose scheme fails\n * {@link isSafeUrl}, and to send an external `href` to its own tab\n * (`target=\"_blank\" rel=\"noopener noreferrer\"`) as the one-shot path does — a\n * link is clickable from the moment it streams in, and a bare anchor navigates\n * the frame the chat lives in. The one-shot re-render at `end()` remains the\n * full-fidelity re-sanitisation.\n *\n * Call once at application startup.\n */\nexport function setupStreamingMarkdownProvider(config: AparteConfig = aparteGlobalConfig): void {\n config.setStreamingMarkdownProvider((target: HTMLElement) => {\n const renderer = default_renderer(target);\n const originalSetAttr = renderer.set_attr;\n renderer.set_attr = (data, type, value) => {\n if (type === HREF && !isSafeUrl(value, 'a')) return;\n if (type === SRC && !isSafeUrl(value, 'img')) return;\n if (type === LANG) {\n // `language-<token>` is what every one-shot renderer emits for a\n // fence, so this is not merely a filter: the streamed DOM and the\n // settled DOM stop diverging, `language-*` is the one prefix the\n // sanitizer's class policy deliberately exempts, and\n // `highlightMarkdownFences` can read a streamed fence's language.\n const token = value.trim().replace(LANG_TAIL, '');\n if (token) originalSetAttr(data, type, `language-${token}`);\n return;\n }\n originalSetAttr(data, type, value);\n if (type === HREF && EXTERNAL_URL.test(value.replace(CONTROL_WS, ''))) {\n const node = data.nodes[data.index];\n if (node && node.tagName === 'A') {\n node.setAttribute('target', '_blank');\n node.setAttribute('rel', 'noopener noreferrer');\n }\n }\n };\n const p = parser(renderer);\n return {\n write: (chunk: string): void => { parser_write(p, chunk); },\n end: (): void => { parser_end(p); },\n };\n });\n}\n"],"names":[],"mappings":";;AAqBA,MAAM,eAAe;AAErB,MAAM,aAAa;AAkBnB,MAAM,YAAY;AA2BX,SAAS,+BAA+B,SAAuB,oBAA0B;AAC5F,SAAO,6BAA6B,CAAC,WAAwB;AACzD,UAAM,WAAW,iBAAiB,MAAM;AACxC,UAAM,kBAAkB,SAAS;AACjC,aAAS,WAAW,CAAC,MAAM,MAAM,UAAU;AACvC,UAAI,SAAS,QAAQ,CAAC,UAAU,OAAO,GAAG,EAAG;AAC7C,UAAI,SAAS,OAAO,CAAC,UAAU,OAAO,KAAK,EAAG;AAC9C,UAAI,SAAS,MAAM;AAMf,cAAM,QAAQ,MAAM,KAAA,EAAO,QAAQ,WAAW,EAAE;AAChD,YAAI,MAAO,iBAAgB,MAAM,MAAM,YAAY,KAAK,EAAE;AAC1D;AAAA,MACJ;AACA,sBAAgB,MAAM,MAAM,KAAK;AACjC,UAAI,SAAS,QAAQ,aAAa,KAAK,MAAM,QAAQ,YAAY,EAAE,CAAC,GAAG;AACnE,cAAM,OAAO,KAAK,MAAM,KAAK,KAAK;AAClC,YAAI,QAAQ,KAAK,YAAY,KAAK;AAC9B,eAAK,aAAa,UAAU,QAAQ;AACpC,eAAK,aAAa,OAAO,qBAAqB;AAAA,QAClD;AAAA,MACJ;AAAA,IACJ;AACA,UAAM,IAAI,OAAO,QAAQ;AACzB,WAAO;AAAA,MACH,OAAO,CAAC,UAAwB;AAAE,qBAAa,GAAG,KAAK;AAAA,MAAG;AAAA,MAC1D,KAAK,MAAY;AAAE,mBAAW,CAAC;AAAA,MAAG;AAAA,IAAA;AAAA,EAE1C,CAAC;AACL;"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@aparte/plugin-streaming-markdown",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "homepage": "https://apartejs.dev/plugins/streaming-markdown/",
5
- "description": "Incremental (token-by-token) Markdown rendering for aparté via streaming-markdown, with a live URL-scheme guard.",
5
+ "description": "Incremental (token-by-token) Markdown rendering for aparté via @paulrichez/streaming-markdown, with a live URL-scheme guard.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "main": "./dist/index.js",
@@ -24,15 +24,16 @@
24
24
  "engines": {
25
25
  "node": ">=18"
26
26
  },
27
+ "dependencies": {
28
+ "@paulrichez/streaming-markdown": "^0.3.0"
29
+ },
27
30
  "peerDependencies": {
28
- "@aparte/core": ">=0.20.0 <1.0.0",
29
- "streaming-markdown": "^0.2.15"
31
+ "@aparte/core": ">=0.22.0 <1.0.0"
30
32
  },
31
33
  "devDependencies": {
32
- "streaming-markdown": "^0.2.15",
33
34
  "typescript": "^5.4.0",
34
35
  "vite": "^6.0.0",
35
- "@aparte/core": "0.20.0"
36
+ "@aparte/core": "0.22.0"
36
37
  },
37
38
  "keywords": [
38
39
  "aparte",