@joewinke/jatui 0.17.0 → 0.17.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.
@@ -26,7 +26,7 @@
26
26
  displayText: string;
27
27
  /** Data attributes to set on the chip element */
28
28
  dataAttrs: Record<string, string>;
29
- /** Optional HTML content (overrides displayText) */
29
+ /** Optional HTML content (overrides displayText). TRUSTED markup, rendered unescaped — never pass user-authored data. */
30
30
  html?: string;
31
31
  /** If true, insert as plain text instead of a chip element */
32
32
  insertAsText?: boolean;
@@ -20,7 +20,7 @@ export interface ChipInfo {
20
20
  displayText: string;
21
21
  /** Data attributes to set on the chip element */
22
22
  dataAttrs: Record<string, string>;
23
- /** Optional HTML content (overrides displayText) */
23
+ /** Optional HTML content (overrides displayText). TRUSTED markup, rendered unescaped — never pass user-authored data. */
24
24
  html?: string;
25
25
  /** If true, insert as plain text instead of a chip element */
26
26
  insertAsText?: boolean;
@@ -21,6 +21,7 @@
21
21
  import { untrack } from 'svelte'
22
22
  import UserAvatar from './UserAvatar.svelte'
23
23
  import { formatRelativeTime, formatFullDate } from '../utils/dateFormatters'
24
+ import { renderCommentBody } from '../utils/commentBody'
24
25
  import type {
25
26
  Comment,
26
27
  CommentCallbacks,
@@ -130,36 +131,8 @@
130
131
  return !!c.deleted_at || c.body === '[deleted]'
131
132
  }
132
133
 
133
- // ── Body rendering: escape → @mention highlight → newlines ────────────
134
- // Mention rule mirrors the server's parseMentions regex exactly, so what
135
- // the UI highlights == what the server notifies. Email addresses
136
- // (`foo@bar.com`) are not mentions because `@` follows a word char.
137
- function escapeHtml(s: string): string {
138
- return s
139
- .replace(/&/g, '&amp;')
140
- .replace(/</g, '&lt;')
141
- .replace(/>/g, '&gt;')
142
- .replace(/"/g, '&quot;')
143
- .replace(/'/g, '&#039;')
144
- }
145
-
146
- function renderBody(body: string): string {
147
- const re = /(^|[^\w@])@([a-zA-Z0-9_.-]+)/g
148
- let out = ''
149
- let last = 0
150
- let m: RegExpExecArray | null
151
- while ((m = re.exec(body)) !== null) {
152
- const handle = m[2].replace(/[.\-_]+$/, '')
153
- if (!handle) continue
154
- out += escapeHtml(body.slice(last, m.index))
155
- out += escapeHtml(m[1])
156
- out += `<span class="text-primary font-medium">@${escapeHtml(handle)}</span>`
157
- out += escapeHtml(m[2].slice(handle.length))
158
- last = m.index + m[0].length
159
- }
160
- out += escapeHtml(body.slice(last))
161
- return out.replace(/\n/g, '<br>')
162
- }
134
+ // Body rendering (escape → @mention → <br>) lives in utils/commentBody so
135
+ // its XSS contract is unit-tested — the result goes into {@html}.
163
136
 
164
137
  function findTop(id: string): Comment | undefined {
165
138
  return items.find((c) => c.id === id)
@@ -705,10 +678,10 @@
705
678
  {:else if isDeleted(c)}
706
679
  <p class="text-base-content/40 mt-0.5 text-sm italic">[deleted]</p>
707
680
  {:else}
708
- <!-- Body is escaped in renderBody() before interpolation. -->
681
+ <!-- Body is escaped in renderCommentBody() before interpolation. -->
709
682
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
710
683
  <div class="mt-0.5 text-sm break-words whitespace-pre-wrap">
711
- {@html renderBody(c.body)}
684
+ {@html renderCommentBody(c.body)}
712
685
  </div>
713
686
  {/if}
714
687
 
@@ -20,7 +20,7 @@
20
20
  id: string;
21
21
  /** Button label */
22
22
  label: string;
23
- /** SVG path string for a 24×24 icon (optional) */
23
+ /** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
24
24
  icon?: string;
25
25
  /** Color variant */
26
26
  variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
@@ -44,7 +44,7 @@
44
44
  id: string;
45
45
  /** Button label */
46
46
  label: string;
47
- /** SVG path string for a 24×24 icon (optional) */
47
+ /** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
48
48
  icon?: string;
49
49
  /** Color variant */
50
50
  variant?: 'default' | 'primary' | 'info' | 'purple';
@@ -3,7 +3,7 @@ export interface ActionButton {
3
3
  id: string;
4
4
  /** Button label */
5
5
  label: string;
6
- /** SVG path string for a 24×24 icon (optional) */
6
+ /** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
7
7
  icon?: string;
8
8
  /** Color variant */
9
9
  variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
@@ -25,7 +25,7 @@ export interface ActionDropdown {
25
25
  id: string;
26
26
  /** Button label */
27
27
  label: string;
28
- /** SVG path string for a 24×24 icon (optional) */
28
+ /** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
29
29
  icon?: string;
30
30
  /** Color variant */
31
31
  variant?: 'default' | 'primary' | 'info' | 'purple';
@@ -23,6 +23,8 @@
23
23
  import { loadGoogleMapsAPI, isGoogleMapsLoaded } from '../utils/googleMapsLoader';
24
24
  import UserAvatar from './UserAvatar.svelte';
25
25
  import type { MapJob, MapAgent } from '../types/maps';
26
+ // InfoWindow content is parsed as HTML — the builders escape every field (mos-qqyy6).
27
+ import { STATUS_COLORS, buildJobInfo, buildAgentInfo } from '../utils/mapInfo';
26
28
 
27
29
  export type { MapJob, MapAgent };
28
30
 
@@ -47,15 +49,6 @@
47
49
  onAgentClick
48
50
  }: Props = $props();
49
51
 
50
- // Job status → hex color (hardcoded by design — not themed, maps sit outside DaisyUI theming)
51
- const STATUS_COLORS: Record<string, string> = {
52
- pending: '#f59e0b',
53
- scheduled: '#3b82f6',
54
- in_progress: '#10b981',
55
- completed: '#6b7280',
56
- cancelled: '#ef4444'
57
- };
58
-
59
52
  let mapContainer: HTMLElement;
60
53
  let map: google.maps.Map;
61
54
  let jobMarkers: any[] = [];
@@ -234,39 +227,6 @@
234
227
  });
235
228
  }
236
229
 
237
- function buildJobInfo(job: MapJob): string {
238
- const statusBadge = `<span class="badge badge-sm" style="background:${STATUS_COLORS[job.status] ?? '#888'};color:#fff">${job.status.replace(/_/g, ' ')}</span>`;
239
- const priority =
240
- job.priority === 'urgent'
241
- ? `<span class="badge badge-error badge-sm">urgent</span>`
242
- : '';
243
- return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
244
- <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;font-weight:600">
245
- ${job.customer?.name ?? 'Unknown Customer'} ${statusBadge}
246
- </div>
247
- ${job.type ? `<div><b>Service:</b> ${job.type.replace(/_/g, ' ')}</div>` : ''}
248
- ${job.customer?.phone ? `<div><b>Phone:</b> ${job.customer.phone}</div>` : ''}
249
- ${job.location?.address ? `<div><b>Address:</b> ${job.location.address}</div>` : ''}
250
- ${job.scheduled_at ? `<div><b>Scheduled:</b> ${new Date(job.scheduled_at).toLocaleString()}</div>` : ''}
251
- ${priority}
252
- ${job.description ? `<div style="margin-top:4px"><b>Notes:</b> ${job.description}</div>` : ''}
253
- </div>`;
254
- }
255
-
256
- function buildAgentInfo(agent: MapAgent): string {
257
- const gpsAge = agent.location?.age_seconds ?? 0;
258
- const gpsMins = Math.floor(gpsAge / 60);
259
- const hasGps = !!(agent.location?.latitude && agent.location?.longitude);
260
- return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
261
- <div style="font-weight:600;margin-bottom:6px">${agent.name}</div>
262
- ${agent.status ? `<div><b>Status:</b> ${agent.status}</div>` : ''}
263
- ${agent.capacity ? `<div><b>Capacity:</b> ${agent.capacity}</div>` : ''}
264
- ${hasGps ? `<div><b>GPS:</b> <span style="color:${gpsMins < 5 ? '#16a34a' : '#d97706'}">${gpsMins < 1 ? 'Live' : `${gpsMins}m ago`}</span></div>` : ''}
265
- ${agent.specializations?.length ? `<div><b>Specializations:</b> ${agent.specializations.join(', ')}</div>` : ''}
266
- ${agent.vehicle?.name ? `<div><b>Vehicle:</b> ${agent.vehicle.name}</div>` : ''}
267
- </div>`;
268
- }
269
-
270
230
  function fitMapToMarkers() {
271
231
  if (!map) return;
272
232
  const all = [...jobMarkers, ...agentMarkers];
@@ -12,7 +12,7 @@
12
12
  findPartialMention,
13
13
  createMentionString,
14
14
  replaceMentionInContent,
15
- renderMentionsAsHTML,
15
+ renderMentionsForComposer,
16
16
  extractPlainText,
17
17
  parseMentions,
18
18
  } from '../../utils/mentionParser'
@@ -111,7 +111,7 @@
111
111
  const hasMentions = inputText.includes('[@')
112
112
  if (hasMentions) {
113
113
  isInternalUpdate = true
114
- const renderedHTML = renderMentionsWithDegradedStyling(inputText)
114
+ const renderedHTML = renderMentionsForComposer(inputText)
115
115
  if (contentEditableDiv.innerHTML !== renderedHTML) {
116
116
  const selection = window.getSelection()
117
117
  const cursorOffset = selection?.rangeCount ? getAbsoluteCursorPosition(selection.getRangeAt(0)) : 0
@@ -187,13 +187,6 @@
187
187
  }
188
188
  }
189
189
 
190
- function renderMentionsWithDegradedStyling(content: string): string {
191
- let result = renderMentionsAsHTML(content)
192
- const degradedMentionRegex = /(?<!>)[@$#!~>/+]\w+(?![^<]*<\/a>)/g
193
- result = result.replace(degradedMentionRegex, (match) => `<span class="degraded-mention">${match}</span>`)
194
- return result
195
- }
196
-
197
190
  function updateAutocompletePosition() {
198
191
  if (!contentEditableDiv) return
199
192
  const selection = window.getSelection()
@@ -222,7 +215,7 @@
222
215
  isInternalUpdate = true
223
216
  inputText = newContent
224
217
  if (contentEditableDiv) {
225
- contentEditableDiv.innerHTML = renderMentionsWithDegradedStyling(inputText)
218
+ contentEditableDiv.innerHTML = renderMentionsForComposer(inputText)
226
219
  setTimeout(() => {
227
220
  setContentEditableCursor(newCursorPosition)
228
221
  contentEditableDiv.focus()
@@ -3,7 +3,7 @@
3
3
 
4
4
  export interface DockItem {
5
5
  label: string;
6
- /** Inline SVG string for the icon */
6
+ /** Inline SVG string for the icon. TRUSTED markup, rendered unescaped — never pass user-authored data. */
7
7
  iconSvg?: string;
8
8
  onclick?: () => void;
9
9
  }
@@ -1,6 +1,6 @@
1
1
  export interface DockItem {
2
2
  label: string;
3
- /** Inline SVG string for the icon */
3
+ /** Inline SVG string for the icon. TRUSTED markup, rendered unescaped — never pass user-authored data. */
4
4
  iconSvg?: string;
5
5
  onclick?: () => void;
6
6
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Comment body rendering for <CommentThread> — escape → @mention highlight →
3
+ * newlines. Extracted from the component (mos-3xvn1) so the XSS contract is
4
+ * unit-tested: the result goes into `{@html}`, and bodies are user-authored
5
+ * across teams, users and portal clients.
6
+ *
7
+ * Bodies are PLAIN TEXT, never HTML. Every character of the body is escaped;
8
+ * the only markup this emits is a fixed `<span>` around a mention handle
9
+ * (itself restricted to [a-zA-Z0-9_.-] and escaped anyway) and `<br>`. That
10
+ * makes it safe by construction without a sanitizer: there is no input for
11
+ * which a caller-supplied tag or attribute reaches the output. Do not add
12
+ * markdown or HTML pass-through here without routing it through a vetted
13
+ * sanitizer (DOMPurify) first.
14
+ */
15
+ export declare function escapeHtml(s: string): string;
16
+ /**
17
+ * Mention rule mirrors the server's parseMentions regex exactly, so what the
18
+ * UI highlights == what the server notifies. Email addresses (`foo@bar.com`)
19
+ * are not mentions because `@` follows a word char.
20
+ */
21
+ export declare function renderCommentBody(body: string): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Comment body rendering for <CommentThread> — escape → @mention highlight →
3
+ * newlines. Extracted from the component (mos-3xvn1) so the XSS contract is
4
+ * unit-tested: the result goes into `{@html}`, and bodies are user-authored
5
+ * across teams, users and portal clients.
6
+ *
7
+ * Bodies are PLAIN TEXT, never HTML. Every character of the body is escaped;
8
+ * the only markup this emits is a fixed `<span>` around a mention handle
9
+ * (itself restricted to [a-zA-Z0-9_.-] and escaped anyway) and `<br>`. That
10
+ * makes it safe by construction without a sanitizer: there is no input for
11
+ * which a caller-supplied tag or attribute reaches the output. Do not add
12
+ * markdown or HTML pass-through here without routing it through a vetted
13
+ * sanitizer (DOMPurify) first.
14
+ */
15
+ export function escapeHtml(s) {
16
+ return s
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#039;');
22
+ }
23
+ /**
24
+ * Mention rule mirrors the server's parseMentions regex exactly, so what the
25
+ * UI highlights == what the server notifies. Email addresses (`foo@bar.com`)
26
+ * are not mentions because `@` follows a word char.
27
+ */
28
+ export function renderCommentBody(body) {
29
+ const re = /(^|[^\w@])@([a-zA-Z0-9_.-]+)/g;
30
+ let out = '';
31
+ let last = 0;
32
+ let m;
33
+ while ((m = re.exec(body)) !== null) {
34
+ const handle = m[2].replace(/[.\-_]+$/, '');
35
+ if (!handle)
36
+ continue;
37
+ out += escapeHtml(body.slice(last, m.index));
38
+ out += escapeHtml(m[1]);
39
+ out += `<span class="text-primary font-medium">@${escapeHtml(handle)}</span>`;
40
+ out += escapeHtml(m[2].slice(handle.length));
41
+ last = m.index + m[0].length;
42
+ }
43
+ out += escapeHtml(body.slice(last));
44
+ return out.replace(/\n/g, '<br>');
45
+ }
@@ -0,0 +1,4 @@
1
+ import type { MapJob, MapAgent } from '../types/maps';
2
+ export declare const STATUS_COLORS: Record<string, string>;
3
+ export declare function buildJobInfo(job: MapJob): string;
4
+ export declare function buildAgentInfo(agent: MapAgent): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * InfoWindow content for <MapView> (mos-qqyy6). Google Maps parses a string
3
+ * `content` as HTML, and the fields here are customer/job/agent data — often
4
+ * typed by a customer on a public booking form — so every interpolated value
5
+ * is escaped. Extracted from the component so that contract is unit-tested.
6
+ *
7
+ * The only markup emitted is the fixed layout below; colors come from a fixed
8
+ * status map, never from the data.
9
+ */
10
+ import { escapeHtml } from './commentBody';
11
+ // Job status → hex color (hardcoded by design — not themed, maps sit outside DaisyUI theming)
12
+ export const STATUS_COLORS = {
13
+ pending: '#f59e0b',
14
+ scheduled: '#3b82f6',
15
+ in_progress: '#10b981',
16
+ completed: '#6b7280',
17
+ cancelled: '#ef4444'
18
+ };
19
+ const e = (v) => escapeHtml(String(v));
20
+ export function buildJobInfo(job) {
21
+ const color = Object.prototype.hasOwnProperty.call(STATUS_COLORS, job.status) ? STATUS_COLORS[job.status] : '#888';
22
+ const statusBadge = `<span class="badge badge-sm" style="background:${color};color:#fff">${e(job.status.replace(/_/g, ' '))}</span>`;
23
+ const priority = job.priority === 'urgent'
24
+ ? `<span class="badge badge-error badge-sm">urgent</span>`
25
+ : '';
26
+ return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
27
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;font-weight:600">
28
+ ${e(job.customer?.name ?? 'Unknown Customer')} ${statusBadge}
29
+ </div>
30
+ ${job.type ? `<div><b>Service:</b> ${e(job.type.replace(/_/g, ' '))}</div>` : ''}
31
+ ${job.customer?.phone ? `<div><b>Phone:</b> ${e(job.customer.phone)}</div>` : ''}
32
+ ${job.location?.address ? `<div><b>Address:</b> ${e(job.location.address)}</div>` : ''}
33
+ ${job.scheduled_at ? `<div><b>Scheduled:</b> ${e(new Date(job.scheduled_at).toLocaleString())}</div>` : ''}
34
+ ${priority}
35
+ ${job.description ? `<div style="margin-top:4px"><b>Notes:</b> ${e(job.description)}</div>` : ''}
36
+ </div>`;
37
+ }
38
+ export function buildAgentInfo(agent) {
39
+ const gpsAge = agent.location?.age_seconds ?? 0;
40
+ const gpsMins = Math.floor(gpsAge / 60);
41
+ const hasGps = !!(agent.location?.latitude && agent.location?.longitude);
42
+ return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
43
+ <div style="font-weight:600;margin-bottom:6px">${e(agent.name)}</div>
44
+ ${agent.status ? `<div><b>Status:</b> ${e(agent.status)}</div>` : ''}
45
+ ${agent.capacity ? `<div><b>Capacity:</b> ${e(agent.capacity)}</div>` : ''}
46
+ ${hasGps ? `<div><b>GPS:</b> <span style="color:${gpsMins < 5 ? '#16a34a' : '#d97706'}">${gpsMins < 1 ? 'Live' : `${gpsMins}m ago`}</span></div>` : ''}
47
+ ${agent.specializations?.length ? `<div><b>Specializations:</b> ${e(agent.specializations.join(', '))}</div>` : ''}
48
+ ${agent.vehicle?.name ? `<div><b>Vehicle:</b> ${e(agent.vehicle.name)}</div>` : ''}
49
+ </div>`;
50
+ }
@@ -27,9 +27,25 @@ export declare function setMentionUrlGenerator(generator: MentionUrlGenerator):
27
27
  */
28
28
  export declare function parseMentions(content: string, urlGenerator?: MentionUrlGenerator): MentionMatch[];
29
29
  /**
30
- * Convert message content with mentions to HTML
30
+ * Convert message content with mentions to HTML.
31
+ *
32
+ * The result is meant for `{@html}` and message content is user-authored,
33
+ * so every interpolated value (url, type, id, display name) is escaped and
34
+ * the href is scheme-checked — see mos-3xvn1.
31
35
  */
32
36
  export declare function renderMentionsAsHTML(content: string, urlGenerator?: MentionUrlGenerator): string;
37
+ /**
38
+ * The <MessageInput> composer's contenteditable HTML: renderMentionsAsHTML,
39
+ * plus un-resolved `@name`/`#tag`-style tokens wrapped in a fixed
40
+ * `.degraded-mention` span. The wrap runs over already-escaped HTML, so it
41
+ * must skip anything inside a tag (`(?<![<>])` + the `</a>` lookahead) and
42
+ * inside a character reference (`(?<!&)`). Before mos-qqyy6 it did neither:
43
+ * every resolved mention's `</a>` became `<<span>/a</span>>`, and `don't`
44
+ * (escaped to `don&#039;t`) became `&<span>#039</span>;`, so the composer
45
+ * showed raw markup. The wrapped token is `[@$#!~>/+]\w+`,
46
+ * so it can carry no markup of its own.
47
+ */
48
+ export declare function renderMentionsForComposer(content: string): string;
33
49
  /**
34
50
  * Create a mention string for insertion into message input
35
51
  */
@@ -8,9 +8,14 @@
8
8
  * routing via the `urlGenerator` parameter or by setting a default.
9
9
  */
10
10
  // Regex to match mention pattern: [@Display Name](type:id)
11
- const MENTION_REGEX = /\[@([^\]]+)\]\(([^:]+):([^)]+)\)/g;
11
+ //
12
+ // `type` is an identifier token, never free text (mos-3xvn1): it lands in
13
+ // class/data attributes, and the old `[^:]+` let a typed
14
+ // `[@x](a"><img onerror=…>:y)` break out of the attribute. Rendering escapes
15
+ // every interpolated value as well — the token is the first wall only.
16
+ const MENTION_REGEX = /\[@([^\]]+)\]\(([A-Za-z0-9_-]+):([^)]+)\)/g;
12
17
  // Default URL generator (apps can override)
13
- let defaultUrlGenerator = (type, id) => `#${type}/${id}`;
18
+ let defaultUrlGenerator = (type, id) => `#${type}/${encodeURIComponent(id)}`;
14
19
  /**
15
20
  * Set the default URL generator for mentions.
16
21
  * Call this once at app startup to configure mention link routing.
@@ -41,7 +46,22 @@ export function parseMentions(content, urlGenerator) {
41
46
  return mentions;
42
47
  }
43
48
  /**
44
- * Convert message content with mentions to HTML
49
+ * A URL generator receives the sender-typed id, so its output is untrusted:
50
+ * a generator that echoes the id (`(t, id) => id`) would hand a sender a
51
+ * `javascript:` href. Script-capable schemes collapse to `#`. Browsers
52
+ * ignore ASCII whitespace/control chars inside a scheme, so they are
53
+ * stripped before the check.
54
+ */
55
+ function safeHref(url) {
56
+ const scheme = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
57
+ return /^(javascript|data|vbscript):/.test(scheme) ? '#' : url;
58
+ }
59
+ /**
60
+ * Convert message content with mentions to HTML.
61
+ *
62
+ * The result is meant for `{@html}` and message content is user-authored,
63
+ * so every interpolated value (url, type, id, display name) is escaped and
64
+ * the href is scheme-checked — see mos-3xvn1.
45
65
  */
46
66
  export function renderMentionsAsHTML(content, urlGenerator) {
47
67
  const mentions = parseMentions(content, urlGenerator);
@@ -52,12 +72,26 @@ export function renderMentionsAsHTML(content, urlGenerator) {
52
72
  let lastIndex = 0;
53
73
  mentions.forEach((mention) => {
54
74
  result += escapeHTML(content.substring(lastIndex, mention.startIndex));
55
- result += `<a href="${mention.url}" class="mention-link mention-${mention.type}" data-mention-type="${mention.type}" data-mention-id="${mention.id}">@${escapeHTML(mention.displayName)}</a>`;
75
+ result += `<a href="${escapeHTML(safeHref(mention.url))}" class="mention-link mention-${escapeHTML(mention.type)}" data-mention-type="${escapeHTML(mention.type)}" data-mention-id="${escapeHTML(mention.id)}">@${escapeHTML(mention.displayName)}</a>`;
56
76
  lastIndex = mention.endIndex;
57
77
  });
58
78
  result += escapeHTML(content.substring(lastIndex));
59
79
  return result;
60
80
  }
81
+ /**
82
+ * The <MessageInput> composer's contenteditable HTML: renderMentionsAsHTML,
83
+ * plus un-resolved `@name`/`#tag`-style tokens wrapped in a fixed
84
+ * `.degraded-mention` span. The wrap runs over already-escaped HTML, so it
85
+ * must skip anything inside a tag (`(?<![<>])` + the `</a>` lookahead) and
86
+ * inside a character reference (`(?<!&)`). Before mos-qqyy6 it did neither:
87
+ * every resolved mention's `</a>` became `<<span>/a</span>>`, and `don't`
88
+ * (escaped to `don&#039;t`) became `&<span>#039</span>;`, so the composer
89
+ * showed raw markup. The wrapped token is `[@$#!~>/+]\w+`,
90
+ * so it can carry no markup of its own.
91
+ */
92
+ export function renderMentionsForComposer(content) {
93
+ return renderMentionsAsHTML(content).replace(/(?<![<>&])[@$#!~>/+]\w+(?![^<]*<\/a>)/g, (match) => `<span class="degraded-mention">${match}</span>`);
94
+ }
61
95
  /**
62
96
  * Create a mention string for insertion into message input
63
97
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",