@aparte/plugin-streaming-markdown 0.16.11 → 0.17.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
@@ -14,10 +14,14 @@ import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdow
14
14
  setupStreamingMarkdownProvider();
15
15
  ```
16
16
 
17
- `@aparte/core` and `streaming-markdown` are **peer dependencies**. Pair it with a one-shot provider
18
- (e.g. `@aparte/plugin-marked`) for finished / re-rendered messages.
17
+ `@aparte/core` and `streaming-markdown` are **peer dependencies**.
18
+
19
+ This plugin is enough on its own: when a turn completes, what it rendered stays on the page. A one-shot
20
+ provider (e.g. `@aparte/plugin-marked`) is optional — register one and the finished message is
21
+ re-rendered through it, at that renderer's full fidelity.
19
22
 
20
23
  **Security**: the streaming path writes DOM directly, bypassing the one-shot sanitizer, so it enforces
21
- the URL policy live — a streamed `[x](javascript:…)` never produces a clickable `javascript:` link.
24
+ the URL policy live — a streamed `[x](javascript:…)` never produces a clickable `javascript:` link — and
25
+ a finished message is passed through core's sanitizer either way.
22
26
 
23
27
  > ESM-only. Part of the aparté monorepo.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,YAAY,EAAC,MAAM,cAAc,CAAC;AAyBhF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,GAAE,YAAiC,GAAG,IAAI,CAsB9F"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,YAAY,EAAC,MAAM,cAAc,CAAC;AA2ChF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,GAAE,YAAiC,GAAG,IAAI,CAgC9F"}
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { aparteGlobalConfig, isSafeUrl } from "@aparte/core";
2
- import { default_renderer, HREF, SRC, parser, parser_end, parser_write } from "streaming-markdown";
2
+ import { default_renderer, HREF, SRC, LANG, parser, parser_end, parser_write } from "streaming-markdown";
3
3
  const EXTERNAL_URL = /^(?:https?:)?[/\\]{2}|^https?:[/\\]/i;
4
4
  const CONTROL_WS = /[\u0000-\u0020]+/g;
5
+ const LANG_TAIL = /[^A-Za-z0-9_+#.-].*$/s;
5
6
  function setupStreamingMarkdownProvider(config = aparteGlobalConfig) {
6
7
  config.setStreamingMarkdownProvider((target) => {
7
8
  const renderer = default_renderer(target);
@@ -9,6 +10,11 @@ function setupStreamingMarkdownProvider(config = aparteGlobalConfig) {
9
10
  renderer.set_attr = (data, type, value) => {
10
11
  if (type === HREF && !isSafeUrl(value, "a")) return;
11
12
  if (type === SRC && !isSafeUrl(value, "img")) return;
13
+ if (type === LANG) {
14
+ const token = value.trim().replace(LANG_TAIL, "");
15
+ if (token) originalSetAttr(data, type, `language-${token}`);
16
+ return;
17
+ }
12
18
  originalSetAttr(data, type, value);
13
19
  if (type === HREF && EXTERNAL_URL.test(value.replace(CONTROL_WS, ""))) {
14
20
  const node = data.nodes[data.index];
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 } 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 * 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 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;AA2BZ,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,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 '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;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aparte/plugin-streaming-markdown",
3
- "version": "0.16.11",
3
+ "version": "0.17.0",
4
4
  "homepage": "https://apartejs.dev/plugins/streaming-markdown/",
5
5
  "description": "Incremental (token-by-token) Markdown rendering for aparté via streaming-markdown, with a live URL-scheme guard.",
6
6
  "type": "module",
@@ -25,14 +25,14 @@
25
25
  "node": ">=18"
26
26
  },
27
27
  "peerDependencies": {
28
- "@aparte/core": ">=0.16.11 <1.0.0",
28
+ "@aparte/core": ">=0.17.0 <1.0.0",
29
29
  "streaming-markdown": "^0.2.15"
30
30
  },
31
31
  "devDependencies": {
32
32
  "streaming-markdown": "^0.2.15",
33
33
  "typescript": "^5.4.0",
34
34
  "vite": "^6.0.0",
35
- "@aparte/core": "0.16.11"
35
+ "@aparte/core": "0.17.0"
36
36
  },
37
37
  "keywords": [
38
38
  "aparte",