@aparte/plugin-marked 0.19.3 → 0.21.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
@@ -15,6 +15,21 @@ setupMarkedProvider({ gfm: true, breaks: true }); // options are optional
15
15
  ```
16
16
 
17
17
  `@aparte/core` and `marked` are **peer dependencies**. For token-by-token incremental rendering during
18
- streaming, add `@aparte/plugin-streaming-markdown` alongside this.
18
+ streaming, add `@aparte/plugin-streaming-markdown` alongside this — or use marked for the stream too:
19
+
20
+ ## Stream with marked
21
+
22
+ ```ts
23
+ import { setupMarkedStreamingProvider } from '@aparte/plugin-marked/streaming';
24
+
25
+ setupMarkedStreamingProvider(); // replaces setupMarkedProvider(); same options
26
+ ```
27
+
28
+ marked formats the reply while it streams and once it is done, so a finished reply looks exactly as
29
+ it did at its last chunk. The block being written is completed by Vercel's
30
+ [`remend`](https://www.npmjs.com/package/remend) before marked reads it — `**bold` shows bold before
31
+ its closing `**`, a link shows as a link from its first character — and drawn again at each chunk;
32
+ the blocks above it are not. Every block goes through the config's sanitizer. `remend` is a
33
+ dependency of this plugin, imported by the `/streaming` entry only.
19
34
 
20
35
  > ESM-only. Part of the aparté monorepo.
@@ -0,0 +1,30 @@
1
+ import { type AparteConfig } from '@aparte/core';
2
+ import { type MarkedExtension } from 'marked';
3
+ /**
4
+ * Register [marked](https://marked.js.org) as aparté's Markdown renderer for a reply
5
+ * WHILE it streams and once it is finished — one parser on both sides, so a reply
6
+ * looks the same at its last chunk as when it settles.
7
+ *
8
+ * While a reply streams, the block being written is completed by Vercel's
9
+ * [`remend`](https://www.npmjs.com/package/remend) before marked parses it:
10
+ * `**bold` shows bold before its closing `**` arrives, and a link shows as a link
11
+ * from its first character, clickable once its URL is complete. Blocks already
12
+ * written are not drawn again, so a selection in them survives the stream. Every
13
+ * block goes through the config's sanitizer before it reaches the page.
14
+ *
15
+ * It takes both of aparté's Markdown seams — `setMarkdownProvider` and
16
+ * `setStreamingMarkdownProvider` — so it replaces `setupMarkedProvider` and
17
+ * `@aparte/plugin-streaming-markdown` rather than going beside them.
18
+ *
19
+ * Call once at application startup.
20
+ *
21
+ * @param options Optional marked extension(s), as for `setupMarkedProvider`: applied
22
+ * to a private marked instance, to the streaming and the finished render alike.
23
+ * @param config The config to register on. Defaults to the global one.
24
+ *
25
+ * @example
26
+ * import { setupMarkedStreamingProvider } from '@aparte/plugin-marked/streaming';
27
+ * setupMarkedStreamingProvider({ gfm: true });
28
+ */
29
+ export declare function setupMarkedStreamingProvider(options?: MarkedExtension, config?: AparteConfig): void;
30
+ //# sourceMappingURL=streaming.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streaming.d.ts","sourceRoot":"","sources":["../src/streaming.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,YAAY,EAAwC,MAAM,cAAc,CAAC;AAC3G,OAAO,EAAkB,KAAK,eAAe,EAAE,MAAM,QAAQ,CAAC;AAqF9D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,CAAC,EAAE,eAAe,EAAE,MAAM,GAAE,YAAiC,GAAG,IAAI,CAKvH"}
@@ -0,0 +1,61 @@
1
+ import { aparteGlobalConfig } from "@aparte/core";
2
+ import { Marked, marked } from "marked";
3
+ import remend from "remend";
4
+ const PENDING = "streamdown:";
5
+ const BARE_OPENER = /(^|[ \t])[*_~`]+$/;
6
+ function heal(raw) {
7
+ return remend(raw.replace(BARE_OPENER, "$1"));
8
+ }
9
+ function blockNodes(html, healed, config) {
10
+ const template = document.createElement("template");
11
+ template.innerHTML = html;
12
+ if (healed) {
13
+ for (const a of template.content.querySelectorAll(`a[href^="${PENDING}"]`)) a.removeAttribute("href");
14
+ for (const img of template.content.querySelectorAll(`img[src^="${PENDING}"]`)) img.remove();
15
+ }
16
+ template.innerHTML = config.sanitizeHtml(template.innerHTML);
17
+ return Array.from(template.content.childNodes);
18
+ }
19
+ function createRenderer(target, parser, config) {
20
+ let source = "";
21
+ let drawn = [];
22
+ const draw = (healLast) => {
23
+ const blocks = parser.lexer(source);
24
+ let from = 0;
25
+ while (from < blocks.length && from < drawn.length) {
26
+ const healed = healLast && from === blocks.length - 1;
27
+ const old = drawn[from];
28
+ if (old.raw !== blocks[from].raw || old.healed !== healed) break;
29
+ from++;
30
+ }
31
+ for (const old of drawn.slice(from)) for (const node of old.nodes) node.remove();
32
+ const next = drawn.slice(0, from);
33
+ for (let i = from; i < blocks.length; i++) {
34
+ const raw = blocks[i].raw;
35
+ const healed = healLast && i === blocks.length - 1;
36
+ const html = parser.parse(healed ? heal(raw) : raw, { async: false });
37
+ const nodes = blockNodes(html, healed, config);
38
+ target.append(...nodes);
39
+ next.push({ raw, healed, nodes });
40
+ }
41
+ drawn = next;
42
+ };
43
+ return {
44
+ write(chunk) {
45
+ source += chunk;
46
+ draw(true);
47
+ },
48
+ end() {
49
+ draw(false);
50
+ }
51
+ };
52
+ }
53
+ function setupMarkedStreamingProvider(options, config = aparteGlobalConfig) {
54
+ const parser = options ? new Marked(options) : marked;
55
+ config.setMarkdownProvider((raw) => parser.parse(raw, { async: false }));
56
+ config.setStreamingMarkdownProvider((target) => createRenderer(target, parser, config));
57
+ }
58
+ export {
59
+ setupMarkedStreamingProvider
60
+ };
61
+ //# sourceMappingURL=streaming.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streaming.js","sources":["../src/streaming.ts"],"sourcesContent":["import { aparteGlobalConfig, type AparteConfig, type AparteStreamingMarkdownRenderer } from '@aparte/core';\nimport { Marked, marked, type MarkedExtension } from 'marked';\nimport remend from 'remend';\n\n/*\n * What remend writes where a link or an image has no destination yet. Not a URL\n * anyone can follow: a pending link loses its `href`, so it reads as a link from its\n * first character without going anywhere, and a pending image is left out, since\n * there is nothing to show until its URL is complete.\n */\nconst PENDING = 'streamdown:';\n\n/*\n * A marker that opens and has nothing after it yet — `I am a **`. remend completes\n * `**scr` but has nothing to complete here, so the marker would show as typed until\n * the next chunk. After a space it can only open (a closer follows a character, not\n * a space), so holding it back never hides one that closes.\n */\nconst BARE_OPENER = /(^|[ \\t])[*_~`]+$/;\n\n/** The block being written, completed so that marked reads it as it will end. */\nfunction heal(raw: string): string {\n return remend(raw.replace(BARE_OPENER, '$1'));\n}\n\n/** One top-level Markdown block as it was last drawn. */\ninterface DrawnBlock {\n /** The block's source, which is what decides whether it has to be drawn again. */\n raw: string;\n /** Whether it was drawn completed by remend, as the block still being written is. */\n healed: boolean;\n /** The nodes it put in the target, in order. */\n nodes: ChildNode[];\n}\n\nfunction blockNodes(html: string, healed: boolean, config: AparteConfig): ChildNode[] {\n const template = document.createElement('template');\n template.innerHTML = html;\n if (healed) {\n for (const a of template.content.querySelectorAll(`a[href^=\"${PENDING}\"]`)) a.removeAttribute('href');\n for (const img of template.content.querySelectorAll(`img[src^=\"${PENDING}\"]`)) img.remove();\n }\n // Model-authored, like everything a Markdown provider returns: the config's own\n // sanitizer runs before any of it reaches the page, external links included.\n template.innerHTML = config.sanitizeHtml(template.innerHTML);\n return Array.from(template.content.childNodes);\n}\n\nfunction createRenderer(target: HTMLElement, parser: Marked, config: AparteConfig): AparteStreamingMarkdownRenderer {\n let source = '';\n let drawn: DrawnBlock[] = [];\n\n const draw = (healLast: boolean): void => {\n const blocks = parser.lexer(source);\n // The first block whose source moved; everything before it keeps its DOM.\n let from = 0;\n while (from < blocks.length && from < drawn.length) {\n const healed = healLast && from === blocks.length - 1;\n const old = drawn[from]!;\n if (old.raw !== blocks[from]!.raw || old.healed !== healed) break;\n from++;\n }\n for (const old of drawn.slice(from)) for (const node of old.nodes) node.remove();\n const next = drawn.slice(0, from);\n for (let i = from; i < blocks.length; i++) {\n const raw = blocks[i]!.raw;\n const healed = healLast && i === blocks.length - 1;\n const html = parser.parse(healed ? heal(raw) : raw, { async: false }) as string;\n const nodes = blockNodes(html, healed, config);\n target.append(...nodes);\n next.push({ raw, healed, nodes });\n }\n drawn = next;\n };\n\n return {\n write(chunk: string): void {\n source += chunk;\n draw(true);\n },\n end(): void {\n draw(false);\n },\n };\n}\n\n/**\n * Register [marked](https://marked.js.org) as aparté's Markdown renderer for a reply\n * WHILE it streams and once it is finished — one parser on both sides, so a reply\n * looks the same at its last chunk as when it settles.\n *\n * While a reply streams, the block being written is completed by Vercel's\n * [`remend`](https://www.npmjs.com/package/remend) before marked parses it:\n * `**bold` shows bold before its closing `**` arrives, and a link shows as a link\n * from its first character, clickable once its URL is complete. Blocks already\n * written are not drawn again, so a selection in them survives the stream. Every\n * block goes through the config's sanitizer before it reaches the page.\n *\n * It takes both of aparté's Markdown seams — `setMarkdownProvider` and\n * `setStreamingMarkdownProvider` — so it replaces `setupMarkedProvider` and\n * `@aparte/plugin-streaming-markdown` rather than going beside them.\n *\n * Call once at application startup.\n *\n * @param options Optional marked extension(s), as for `setupMarkedProvider`: applied\n * to a private marked instance, to the streaming and the finished render alike.\n * @param config The config to register on. Defaults to the global one.\n *\n * @example\n * import { setupMarkedStreamingProvider } from '@aparte/plugin-marked/streaming';\n * setupMarkedStreamingProvider({ gfm: true });\n */\nexport function setupMarkedStreamingProvider(options?: MarkedExtension, config: AparteConfig = aparteGlobalConfig): void {\n // Private instance when options are given, for the reason `setupMarkedProvider` gives.\n const parser = options ? new Marked(options) : marked;\n config.setMarkdownProvider((raw) => parser.parse(raw, { async: false }) as string);\n config.setStreamingMarkdownProvider((target) => createRenderer(target, parser as Marked, config));\n}\n"],"names":[],"mappings":";;;AAUA,MAAM,UAAU;AAQhB,MAAM,cAAc;AAGpB,SAAS,KAAK,KAAqB;AAC/B,SAAO,OAAO,IAAI,QAAQ,aAAa,IAAI,CAAC;AAChD;AAYA,SAAS,WAAW,MAAc,QAAiB,QAAmC;AAClF,QAAM,WAAW,SAAS,cAAc,UAAU;AAClD,WAAS,YAAY;AACrB,MAAI,QAAQ;AACR,eAAW,KAAK,SAAS,QAAQ,iBAAiB,YAAY,OAAO,IAAI,EAAG,GAAE,gBAAgB,MAAM;AACpG,eAAW,OAAO,SAAS,QAAQ,iBAAiB,aAAa,OAAO,IAAI,EAAG,KAAI,OAAA;AAAA,EACvF;AAGA,WAAS,YAAY,OAAO,aAAa,SAAS,SAAS;AAC3D,SAAO,MAAM,KAAK,SAAS,QAAQ,UAAU;AACjD;AAEA,SAAS,eAAe,QAAqB,QAAgB,QAAuD;AAChH,MAAI,SAAS;AACb,MAAI,QAAsB,CAAA;AAE1B,QAAM,OAAO,CAAC,aAA4B;AACtC,UAAM,SAAS,OAAO,MAAM,MAAM;AAElC,QAAI,OAAO;AACX,WAAO,OAAO,OAAO,UAAU,OAAO,MAAM,QAAQ;AAChD,YAAM,SAAS,YAAY,SAAS,OAAO,SAAS;AACpD,YAAM,MAAM,MAAM,IAAI;AACtB,UAAI,IAAI,QAAQ,OAAO,IAAI,EAAG,OAAO,IAAI,WAAW,OAAQ;AAC5D;AAAA,IACJ;AACA,eAAW,OAAO,MAAM,MAAM,IAAI,cAAc,QAAQ,IAAI,MAAO,MAAK,OAAA;AACxE,UAAM,OAAO,MAAM,MAAM,GAAG,IAAI;AAChC,aAAS,IAAI,MAAM,IAAI,OAAO,QAAQ,KAAK;AACvC,YAAM,MAAM,OAAO,CAAC,EAAG;AACvB,YAAM,SAAS,YAAY,MAAM,OAAO,SAAS;AACjD,YAAM,OAAO,OAAO,MAAM,SAAS,KAAK,GAAG,IAAI,KAAK,EAAE,OAAO,MAAA,CAAO;AACpE,YAAM,QAAQ,WAAW,MAAM,QAAQ,MAAM;AAC7C,aAAO,OAAO,GAAG,KAAK;AACtB,WAAK,KAAK,EAAE,KAAK,QAAQ,OAAO;AAAA,IACpC;AACA,YAAQ;AAAA,EACZ;AAEA,SAAO;AAAA,IACH,MAAM,OAAqB;AACvB,gBAAU;AACV,WAAK,IAAI;AAAA,IACb;AAAA,IACA,MAAY;AACR,WAAK,KAAK;AAAA,IACd;AAAA,EAAA;AAER;AA4BO,SAAS,6BAA6B,SAA2B,SAAuB,oBAA0B;AAErH,QAAM,SAAS,UAAU,IAAI,OAAO,OAAO,IAAI;AAC/C,SAAO,oBAAoB,CAAC,QAAQ,OAAO,MAAM,KAAK,EAAE,OAAO,MAAA,CAAO,CAAW;AACjF,SAAO,6BAA6B,CAAC,WAAW,eAAe,QAAQ,QAAkB,MAAM,CAAC;AACpG;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aparte/plugin-marked",
3
- "version": "0.19.3",
3
+ "version": "0.21.0",
4
4
  "homepage": "https://apartejs.dev/plugins/marked/",
5
5
  "description": "Markdown rendering for aparté via marked — register it as the one-shot Markdown provider.",
6
6
  "type": "module",
@@ -14,6 +14,12 @@
14
14
  "types": "./dist/index.d.ts",
15
15
  "import": "./dist/index.js",
16
16
  "default": "./dist/index.js"
17
+ },
18
+ "./streaming": {
19
+ "@aparte-workspace/source": "./src/streaming.ts",
20
+ "types": "./dist/streaming.d.ts",
21
+ "import": "./dist/streaming.js",
22
+ "default": "./dist/streaming.js"
17
23
  }
18
24
  },
19
25
  "files": [
@@ -24,21 +30,25 @@
24
30
  "engines": {
25
31
  "node": ">=18"
26
32
  },
33
+ "dependencies": {
34
+ "remend": "^1.4.0"
35
+ },
27
36
  "peerDependencies": {
28
- "@aparte/core": ">=0.19.3 <1.0.0",
37
+ "@aparte/core": ">=0.21.0 <1.0.0",
29
38
  "marked": "^18.0.0"
30
39
  },
31
40
  "devDependencies": {
32
41
  "marked": "^18.0.0",
33
42
  "typescript": "^5.4.0",
34
43
  "vite": "^6.0.0",
35
- "@aparte/core": "0.19.3"
44
+ "@aparte/core": "0.21.0"
36
45
  },
37
46
  "keywords": [
38
47
  "aparte",
39
48
  "plugin",
40
49
  "markdown",
41
- "marked"
50
+ "marked",
51
+ "streaming"
42
52
  ],
43
53
  "license": "MIT",
44
54
  "repository": {
@@ -51,7 +61,7 @@
51
61
  },
52
62
  "scripts": {
53
63
  "dev": "vite",
54
- "build": "vite build && tsc -b --emitDeclarationOnly --force",
64
+ "build": "vite build && tsc -b --emitDeclarationOnly --force && node ../../../scripts/stamp-dist.mjs",
55
65
  "preview": "vite preview",
56
66
  "test": "vitest",
57
67
  "test:run": "vitest run",