@aparte/plugin-streaming-markdown 0.15.1 → 0.16.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.
package/dist/index.d.ts CHANGED
@@ -16,8 +16,11 @@ import { type AparteConfig } from '@aparte/core';
16
16
  * live (an attacker-controlled `[x](javascript:…)` streamed token would produce
17
17
  * a clickable `javascript:` link before the final re-render sanitises it), the
18
18
  * renderer's `set_attr` is wrapped to drop any `href`/`src` whose scheme fails
19
- * {@link isSafeUrl}. The one-shot re-render at `end()` remains the full-fidelity
20
- * re-sanitisation.
19
+ * {@link isSafeUrl}, and to send an external `href` to its own tab
20
+ * (`target="_blank" rel="noopener noreferrer"`) as the one-shot path does — a
21
+ * link is clickable from the moment it streams in, and a bare anchor navigates
22
+ * the frame the chat lives in. The one-shot re-render at `end()` remains the
23
+ * full-fidelity re-sanitisation.
21
24
  *
22
25
  * Call once at application startup.
23
26
  */
@@ -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;AAGhF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,GAAE,YAAiC,GAAG,IAAI,CAe9F"}
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"}
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { aparteGlobalConfig, isSafeUrl } from "@aparte/core";
2
2
  import { default_renderer, HREF, SRC, parser, parser_end, parser_write } from "streaming-markdown";
3
+ const EXTERNAL_URL = /^(?:https?:)?[/\\]{2}|^https?:[/\\]/i;
4
+ const CONTROL_WS = /[\u0000-\u0020]+/g;
3
5
  function setupStreamingMarkdownProvider(config = aparteGlobalConfig) {
4
6
  config.setStreamingMarkdownProvider((target) => {
5
7
  const renderer = default_renderer(target);
@@ -8,6 +10,13 @@ function setupStreamingMarkdownProvider(config = aparteGlobalConfig) {
8
10
  if (type === HREF && !isSafeUrl(value, "a")) return;
9
11
  if (type === SRC && !isSafeUrl(value, "img")) return;
10
12
  originalSetAttr(data, type, value);
13
+ if (type === HREF && EXTERNAL_URL.test(value.replace(CONTROL_WS, ""))) {
14
+ const node = data.nodes[data.index];
15
+ if (node && node.tagName === "A") {
16
+ node.setAttribute("target", "_blank");
17
+ node.setAttribute("rel", "noopener noreferrer");
18
+ }
19
+ }
11
20
  };
12
21
  const p = parser(renderer);
13
22
  return {
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 * 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}. The one-shot re-render at `end()` remains the full-fidelity\n * 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 };\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":";;AAyBO,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;AAAA,IACrC;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 } 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;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aparte/plugin-streaming-markdown",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Incremental (token-by-token) Markdown rendering for aparté via streaming-markdown, with a live URL-scheme guard.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -24,14 +24,14 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "peerDependencies": {
27
- "@aparte/core": ">=0.15.1 <1.0.0",
27
+ "@aparte/core": ">=0.16.1 <1.0.0",
28
28
  "streaming-markdown": "^0.2.15"
29
29
  },
30
30
  "devDependencies": {
31
31
  "streaming-markdown": "^0.2.15",
32
32
  "typescript": "^5.4.0",
33
33
  "vite": "^6.0.0",
34
- "@aparte/core": "0.15.1"
34
+ "@aparte/core": "0.16.1"
35
35
  },
36
36
  "keywords": [
37
37
  "aparte",