@jxsuite/studio 0.33.0 → 0.35.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.
Files changed (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,383 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Dynamic route-param helpers for the tab-bar's $params picker.
4
+ *
5
+ * A page like `pages/products/[sku].json` declares its candidate values in a `$paths` block (spec
6
+ * §4.3). At build time the compiler expands those into concrete routes (`resolvePathEntries` in
7
+ * `@jxsuite/compiler`); in the studio the same shapes are resolved live so the author can pick one
8
+ * value to render. The chosen values are substituted into the canvas render document
9
+ * (`substitutePreviewParams`), which mirrors what the compiler's context injection produces for the
10
+ * matching built page.
11
+ */
12
+
13
+ import { getPlatform } from "./platform";
14
+ import type { JxMutableNode, JxPathsDef } from "@jxsuite/schema/types";
15
+
16
+ /** Param name → candidate values, in `$paths` declaration order. */
17
+ export type ParamValues = Record<string, string[]>;
18
+
19
+ /** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
20
+ const PARAM_SEGMENT = /\[\.\.\.(\w+)\]|\[(\w+)\]/g;
21
+
22
+ /**
23
+ * Param names declared by a document path's bracket segments, e.g. `pages/products/[sku].json` →
24
+ * `["sku"]`. Mirrors the compiler's `fileToRoute` parsing.
25
+ *
26
+ * @param {string | null | undefined} documentPath
27
+ * @returns {string[]}
28
+ */
29
+ export function dynamicRouteParams(documentPath: string | null | undefined) {
30
+ if (!documentPath) {
31
+ return [];
32
+ }
33
+ const params: string[] = [];
34
+ for (const match of documentPath.matchAll(PARAM_SEGMENT)) {
35
+ params.push((match[1] ?? match[2])!);
36
+ }
37
+ return params;
38
+ }
39
+
40
+ /**
41
+ * URL pattern for a page document path, mirroring the compiler's `fileToRoute`:
42
+ * `pages/products/[sku].json` → `/products/:sku`, `pages/index.json` → `/`.
43
+ *
44
+ * @param {string | null | undefined} documentPath
45
+ * @returns {string}
46
+ */
47
+ export function documentUrlPattern(documentPath: string | null | undefined) {
48
+ if (!documentPath) {
49
+ return "/";
50
+ }
51
+ let urlPath = documentPath
52
+ .replace(/^\.\//, "")
53
+ .replace(/^pages\//, "")
54
+ .replace(/\.[^/.]+$/, "");
55
+ if (urlPath.endsWith("/index")) {
56
+ urlPath = urlPath.slice(0, -6) || "/";
57
+ } else if (urlPath === "index") {
58
+ urlPath = "/";
59
+ }
60
+ if (!urlPath.startsWith("/")) {
61
+ urlPath = `/${urlPath}`;
62
+ }
63
+ return urlPath.replaceAll(PARAM_SEGMENT, (_match, spread: string, named: string) =>
64
+ spread ? "*" : `:${named}`,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The `$paths` declaration of an open document. JSON pages carry it on the document root;
70
+ * format-class pages (markdown) keep it in the frontmatter.
71
+ *
72
+ * @param {{ document?: unknown; frontmatter?: Record<string, unknown> }} doc
73
+ * @returns {JxPathsDef | null}
74
+ */
75
+ export function pagePathsDef(doc: {
76
+ document?: JxMutableNode | null;
77
+ frontmatter?: Record<string, unknown> | null;
78
+ }) {
79
+ const fromDoc = (doc.document as { $paths?: JxPathsDef } | null | undefined)?.$paths;
80
+ const fromFrontmatter = doc.frontmatter?.$paths as JxPathsDef | undefined;
81
+ return fromDoc ?? fromFrontmatter ?? null;
82
+ }
83
+
84
+ const valueCache = new Map<string, Promise<ParamValues>>();
85
+
86
+ /** Drop all cached enumerations (e.g. after content files change). */
87
+ export function invalidateParamValues() {
88
+ valueCache.clear();
89
+ }
90
+
91
+ /**
92
+ * Enumerate candidate values for each param declared by a `$paths` block. Results are cached per
93
+ * (documentPath, $paths) pair; failures are not cached so a transient backend error retries on the
94
+ * next call.
95
+ *
96
+ * @param {string | null | undefined} documentPath - Cache key component
97
+ * @param {JxPathsDef | null} pathsDef
98
+ * @returns {Promise<ParamValues>}
99
+ */
100
+ export function loadParamValues(
101
+ documentPath: string | null | undefined,
102
+ pathsDef: JxPathsDef | null,
103
+ ) {
104
+ if (!pathsDef) {
105
+ return Promise.resolve({} as ParamValues);
106
+ }
107
+ const key = `${documentPath ?? ""}::${JSON.stringify(pathsDef)}`;
108
+ let pending = valueCache.get(key);
109
+ if (!pending) {
110
+ pending = resolveParamValues(pathsDef).catch((error: unknown) => {
111
+ valueCache.delete(key);
112
+ console.warn("page-params: failed to enumerate $paths values:", error);
113
+ return {} as ParamValues;
114
+ });
115
+ valueCache.set(key, pending);
116
+ }
117
+ return pending;
118
+ }
119
+
120
+ /**
121
+ * Resolve one `$paths` shape to values, mirroring the compiler's `resolvePathEntries`.
122
+ *
123
+ * @param {JxPathsDef} pathsDef
124
+ * @returns {Promise<ParamValues>}
125
+ */
126
+ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
127
+ // Legacy: array of param objects — group values per param key
128
+ if (Array.isArray(pathsDef)) {
129
+ const out: ParamValues = {};
130
+ for (const entry of pathsDef) {
131
+ for (const [param, value] of Object.entries(entry)) {
132
+ pushValue(out, param, value);
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // Content type-based: { contentType: "blog", param: "slug", field: "id" } — resolved through the
139
+ // Backend's ContentCollection pipeline (the same one the canvas's ContentEntry uses), so every
140
+ // Offered value is guaranteed to resolve in preview.
141
+ if ("contentType" in pathsDef && pathsDef.contentType) {
142
+ const param = pathsDef.param ?? "slug";
143
+ const field = pathsDef.field ?? "id";
144
+ const entries = (await resolveContentCollection(pathsDef.contentType)) as {
145
+ id?: unknown;
146
+ data?: Record<string, unknown>;
147
+ }[];
148
+ const out: ParamValues = { [param]: [] };
149
+ if (!Array.isArray(entries)) {
150
+ return out;
151
+ }
152
+ for (const entry of entries) {
153
+ pushValue(out, param, field === "id" ? entry.id : (entry.data?.[field] ?? entry.id));
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // Explicit values: { values: ["en", "fr"], param: "lang" }
159
+ if ("values" in pathsDef && Array.isArray(pathsDef.values)) {
160
+ const param = pathsDef.param ?? "value";
161
+ const out: ParamValues = { [param]: [] };
162
+ for (const value of pathsDef.values) {
163
+ pushValue(out, param, value);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
169
+ if ("$ref" in pathsDef && pathsDef.$ref) {
170
+ const param = pathsDef.param ?? "id";
171
+ const field = pathsDef.field ?? "id";
172
+ const content = await getPlatform().readFile(pathsDef.$ref.replace(/^\.\//, ""));
173
+ const data = JSON.parse(content) as unknown;
174
+ const out: ParamValues = { [param]: [] };
175
+ if (!Array.isArray(data)) {
176
+ return out;
177
+ }
178
+ for (const item of data as Record<string, unknown>[]) {
179
+ pushValue(out, param, item?.[field] ?? item?.id ?? item);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ return {};
185
+ }
186
+
187
+ /**
188
+ * Resolve a ContentCollection for a content type via the PAL, falling back to a plain dev-proxy
189
+ * fetch on platforms that predate `resolveClass`.
190
+ *
191
+ * @param {string} contentType
192
+ * @returns {Promise<unknown>}
193
+ */
194
+ function resolveContentCollection(contentType: string) {
195
+ const body = {
196
+ $prototype: "ContentCollection",
197
+ $src: "@jxsuite/parser/ContentCollection.class.json",
198
+ contentType,
199
+ };
200
+ const platform = getPlatform();
201
+ if (platform.resolveClass) {
202
+ return platform.resolveClass(body);
203
+ }
204
+ return fetch("/__jx_resolve__", {
205
+ body: JSON.stringify(body),
206
+ headers: { "content-type": "application/json" },
207
+ method: "POST",
208
+ }).then((res) => {
209
+ if (!res.ok) {
210
+ throw new Error(`Class resolution failed: ${res.status}`);
211
+ }
212
+ return res.json() as Promise<unknown>;
213
+ });
214
+ }
215
+
216
+ /**
217
+ * @param {ParamValues} out
218
+ * @param {string} param
219
+ * @param {unknown} value
220
+ */
221
+ function pushValue(out: ParamValues, param: string, value: unknown) {
222
+ if (value === null || value === undefined || value === "") {
223
+ return;
224
+ }
225
+ const str = String(value);
226
+ const list = (out[param] ??= []);
227
+ if (!list.includes(str)) {
228
+ list.push(str);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Substitute chosen param values into a canvas render document:
234
+ *
235
+ * - Every `{ "$ref": "#/$params/<name>" }` value with a chosen `<name>` becomes the literal string
236
+ * (the exact dereference `ContentEntry.resolve()` performs against `route._pathParams`), and
237
+ * - `state.$page` is injected mirroring the compiler's `injectContext`, so `${state.$page.params.x}`
238
+ * template expressions render.
239
+ *
240
+ * Pure: returns a rebuilt tree. The design/edit render doc shares node references with the tab's
241
+ * source document (stripEventHandlers/prepareForEditMode shallow-rebuild), so in-place mutation
242
+ * here would corrupt the document being edited and saved.
243
+ *
244
+ * @param {JxMutableNode} renderDoc
245
+ * @param {Record<string, string>} params
246
+ * @param {string | null | undefined} documentPath
247
+ * @returns {JxMutableNode}
248
+ */
249
+ export function substitutePreviewParams(
250
+ renderDoc: JxMutableNode,
251
+ params: Record<string, string>,
252
+ documentPath: string | null | undefined,
253
+ ): JxMutableNode {
254
+ if (Object.keys(params).length === 0) {
255
+ return renderDoc;
256
+ }
257
+ const doc = substituteParamRefs(renderDoc, params) as JxMutableNode & {
258
+ state?: Record<string, unknown>;
259
+ title?: unknown;
260
+ };
261
+ doc.state ??= {};
262
+ doc.state.$page = {
263
+ params: { ...params },
264
+ title: typeof doc.title === "string" ? doc.title : "",
265
+ url: documentUrlPattern(documentPath),
266
+ };
267
+ return doc;
268
+ }
269
+
270
+ /**
271
+ * State keys whose entries are class prototypes ($prototype + $src) referencing a route param.
272
+ * These are the entries `substitutePreviewParams` rewrites, and the ones `resolveParamBoundState`
273
+ * bakes.
274
+ *
275
+ * @param {Record<string, unknown> | null | undefined} state - The ORIGINAL document state (with
276
+ * `$ref`s intact — substitution removes the marker)
277
+ * @returns {string[]}
278
+ */
279
+ export function paramBoundStateKeys(state: Record<string, unknown> | null | undefined) {
280
+ if (!state) {
281
+ return [];
282
+ }
283
+ return Object.keys(state).filter((key) => {
284
+ const entry = state[key] as Record<string, unknown> | null;
285
+ return (
286
+ entry &&
287
+ typeof entry === "object" &&
288
+ typeof entry.$prototype === "string" &&
289
+ typeof entry.$src === "string" &&
290
+ JSON.stringify(entry).includes('"#/$params/')
291
+ );
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Resolve substituted class-prototype state entries through the backend and bake the values into
297
+ * `renderDoc.state` — compiler parity: the built site resolves ContentEntry/ContentCollection
298
+ * BEFORE templates run. Without baking, the iframe runtime resolves these entries asynchronously
299
+ * (null on the first render pass), so preview templates like `${state.product.data.title}`
300
+ * dereference null and abort the whole render.
301
+ *
302
+ * Failures leave the entry in place (the runtime's own async resolution remains the fallback).
303
+ *
304
+ * @param {JxMutableNode} renderDoc - The substituted render doc (mutated in place — it is the fresh
305
+ * rebuild from substitutePreviewParams, never the source doc)
306
+ * @param {string[]} keys - From paramBoundStateKeys(originalDoc.state)
307
+ * @param {string | undefined} docBase - Document base URL for relative-$src rebasing
308
+ */
309
+ export async function resolveParamBoundState(
310
+ renderDoc: JxMutableNode,
311
+ keys: string[],
312
+ docBase?: string,
313
+ ) {
314
+ if (keys.length === 0) {
315
+ return;
316
+ }
317
+ const platform = getPlatform();
318
+ if (!platform.resolveClass) {
319
+ return;
320
+ }
321
+ const { state } = renderDoc as { state?: Record<string, unknown> };
322
+ if (!state) {
323
+ return;
324
+ }
325
+ await Promise.all(
326
+ keys.map(async (key) => {
327
+ const entry = state[key];
328
+ if (!entry || typeof entry !== "object") {
329
+ return;
330
+ }
331
+ try {
332
+ state[key] = await platform.resolveClass!({
333
+ ...(entry as Record<string, unknown>),
334
+ ...(docBase ? { $base: docBase } : {}),
335
+ });
336
+ } catch (error) {
337
+ console.warn(`page-params: failed to resolve state.${key} for preview:`, error);
338
+ }
339
+ }),
340
+ );
341
+ }
342
+
343
+ /**
344
+ * @param {unknown} node
345
+ * @param {Record<string, string>} params
346
+ * @returns {unknown} — a rebuilt copy with matched param refs replaced by literals
347
+ */
348
+ function substituteParamRefs(node: unknown, params: Record<string, string>): unknown {
349
+ const replaced = paramRefValue(node, params);
350
+ if (replaced !== null) {
351
+ return replaced;
352
+ }
353
+ if (!node || typeof node !== "object") {
354
+ return node;
355
+ }
356
+ if (Array.isArray(node)) {
357
+ return node.map((item) => substituteParamRefs(item, params));
358
+ }
359
+ const out: Record<string, unknown> = {};
360
+ for (const [key, value] of Object.entries(node)) {
361
+ out[key] = substituteParamRefs(value, params);
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /**
367
+ * The chosen literal for a `{ "$ref": "#/$params/<name>" }` value, or null when the value is not a
368
+ * param ref (or no value was chosen for it).
369
+ *
370
+ * @param {unknown} value
371
+ * @param {Record<string, string>} params
372
+ * @returns {string | null}
373
+ */
374
+ function paramRefValue(value: unknown, params: Record<string, string>) {
375
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
376
+ return null;
377
+ }
378
+ const ref = (value as { $ref?: unknown }).$ref;
379
+ if (typeof ref !== "string" || !ref.startsWith("#/$params/")) {
380
+ return null;
381
+ }
382
+ return params[ref.slice("#/$params/".length)] ?? null;
383
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Attached-context.js — the composer's "attach context" message-content convention.
3
+ *
4
+ * Attached context (current page, selected element) is embedded into the user message
5
+ * content after a delimiter line: it's the only channel the LLM sees (the streaming
6
+ * payload carries message content only) and it persists with the session for free.
7
+ * The chat view splits the content back apart to show chips instead of the raw block.
8
+ *
9
+ * The delimiter is a soft convention — a model echoing it would at worst render stray
10
+ * chips (no security impact; assistant markdown is sanitized separately).
11
+ *
12
+ * @license MIT
13
+ */
14
+
15
+ export const ATTACHED_CONTEXT_DELIMITER = "---- attached context ----";
16
+
17
+ export interface ContextChip {
18
+ kind: "page" | "selection";
19
+ /** Short chip label, e.g. the page path or `<h1>`. */
20
+ label: string;
21
+ /** The context line embedded into the message content. */
22
+ detail: string;
23
+ }
24
+
25
+ /** Serialize the composer text plus attached-context chips into one message content. */
26
+ export function buildMessageWithContext(text: string, chips: ContextChip[]): string {
27
+ if (chips.length === 0) {
28
+ return text;
29
+ }
30
+ const lines = chips.map((c) => c.detail).join("\n");
31
+ return `${text}\n\n${ATTACHED_CONTEXT_DELIMITER}\n${lines}`;
32
+ }
33
+
34
+ /** Split a user message back into its typed body and any attached-context lines. */
35
+ export function splitAttachedContext(content: string): { body: string; contextLines: string[] } {
36
+ const idx = content.indexOf(`\n\n${ATTACHED_CONTEXT_DELIMITER}\n`);
37
+ if (idx === -1) {
38
+ return { body: content, contextLines: [] };
39
+ }
40
+ const body = content.slice(0, idx);
41
+ const rest = content.slice(idx + ATTACHED_CONTEXT_DELIMITER.length + 3);
42
+ return {
43
+ body,
44
+ contextLines: rest
45
+ .split("\n")
46
+ .map((l) => l.trim())
47
+ .filter((l) => l.length > 0),
48
+ };
49
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Chat-markdown.js — memoized markdown rendering for assistant chat messages.
3
+ *
4
+ * Wraps @jxsuite/parser/md-html (sanitized markdown → HTML) with a per-message cache
5
+ * keyed by message id + content length, so re-renders during streaming only re-parse
6
+ * the message that actually grew. The HTML goes through unsafeHTML, which is safe here
7
+ * because md-html sanitizes (raw HTML dropped, javascript: URLs stripped).
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import { html } from "lit-html";
13
+ import type { TemplateResult } from "lit-html";
14
+ import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
15
+ import { markdownToHtml } from "@jxsuite/parser/md-html";
16
+
17
+ const cache = new Map<string, { len: number; html: string }>();
18
+
19
+ /**
20
+ * Render a message's markdown content, memoized by message id.
21
+ *
22
+ * @param {string} id - Stable message id (cache key).
23
+ * @param {string} content
24
+ * @returns {TemplateResult}
25
+ */
26
+ export function renderMarkdown(id: string, content: string): TemplateResult {
27
+ let entry = cache.get(id);
28
+ if (!entry || entry.len !== content.length) {
29
+ entry = { len: content.length, html: markdownToHtml(content) };
30
+ cache.set(id, entry);
31
+ }
32
+ return html`<div class="ai-msg-md">${unsafeHTML(entry.html)}</div>`;
33
+ }
34
+
35
+ /** Drop all cached renders (call on session switch / new chat). */
36
+ export function clearMarkdownCache() {
37
+ cache.clear();
38
+ }