@things-factory/ai-assistant 10.1.6
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/client/components/board-ai-chat.test.ts +149 -0
- package/client/components/board-ai-chat.ts +3228 -0
- package/client/components/chat-defaults.ts +27 -0
- package/client/components/chat-echo-dedup.test.ts +230 -0
- package/client/components/chat-echo-dedup.ts +155 -0
- package/client/components/chat-input-builder.ts +69 -0
- package/client/components/markdown.test.ts +518 -0
- package/client/components/markdown.ts +508 -0
- package/client/components/mention-popup-helpers.ts +433 -0
- package/client/components/mention-popup.test.ts +588 -0
- package/client/components/mention-popup.ts +238 -0
- package/client/index.ts +14 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/board-edit-patch.ts +374 -0
- package/dist-client/components/board-ai-chat.d.ts +423 -0
- package/dist-client/components/board-ai-chat.js +3034 -0
- package/dist-client/components/board-ai-chat.js.map +1 -0
- package/dist-client/components/board-ai-chat.test.d.ts +1 -0
- package/dist-client/components/board-ai-chat.test.js +138 -0
- package/dist-client/components/board-ai-chat.test.js.map +1 -0
- package/dist-client/components/chat-defaults.d.ts +8 -0
- package/dist-client/components/chat-defaults.js +22 -0
- package/dist-client/components/chat-defaults.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.d.ts +54 -0
- package/dist-client/components/chat-echo-dedup.js +115 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
- package/dist-client/components/chat-echo-dedup.test.js +203 -0
- package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.d.ts +55 -0
- package/dist-client/components/chat-input-builder.js +32 -0
- package/dist-client/components/chat-input-builder.js.map +1 -0
- package/dist-client/components/markdown.d.ts +60 -0
- package/dist-client/components/markdown.js +422 -0
- package/dist-client/components/markdown.js.map +1 -0
- package/dist-client/components/markdown.test.d.ts +1 -0
- package/dist-client/components/markdown.test.js +418 -0
- package/dist-client/components/markdown.test.js.map +1 -0
- package/dist-client/components/mention-popup-helpers.d.ts +165 -0
- package/dist-client/components/mention-popup-helpers.js +330 -0
- package/dist-client/components/mention-popup-helpers.js.map +1 -0
- package/dist-client/components/mention-popup.d.ts +35 -0
- package/dist-client/components/mention-popup.js +223 -0
- package/dist-client/components/mention-popup.js.map +1 -0
- package/dist-client/components/mention-popup.test.d.ts +1 -0
- package/dist-client/components/mention-popup.test.js +474 -0
- package/dist-client/components/mention-popup.test.js.map +1 -0
- package/dist-client/index.d.ts +14 -0
- package/dist-client/index.js +15 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/board-edit-patch.d.ts +157 -0
- package/dist-client/utils/board-edit-patch.js +284 -0
- package/dist-client/utils/board-edit-patch.js.map +1 -0
- package/dist-server/index.d.ts +28 -0
- package/dist-server/index.js +32 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message-publish.js +27 -0
- package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
- package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
- package/dist-server/service/chat-message/chat-message.d.ts +68 -0
- package/dist-server/service/chat-message/chat-message.js +172 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -0
- package/dist-server/service/chat-message/fold-history.d.ts +43 -0
- package/dist-server/service/chat-message/fold-history.js +33 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +5 -0
- package/dist-server/service/chat-message/index.js +12 -0
- package/dist-server/service/chat-message/index.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +46 -0
- package/dist-server/service/chat-message/llm-history.js +87 -0
- package/dist-server/service/chat-message/llm-history.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +60 -0
- package/dist-server/service/chat-session/chat-session.js +159 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -0
- package/dist-server/service/chat-session/index.d.ts +3 -0
- package/dist-server/service/chat-session/index.js +7 -0
- package/dist-server/service/chat-session/index.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
- package/dist-server/service/chat-session/session-activity-publish.js +37 -0
- package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
- package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
- package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
- package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
- package/dist-server/service/chat-session/session-anchor.js +100 -0
- package/dist-server/service/chat-session/session-anchor.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
- package/dist-server/service/chat-session/session-inbox.js +133 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
- package/dist-server/service/chat-session-participant/index.d.ts +4 -0
- package/dist-server/service/chat-session-participant/index.js +10 -0
- package/dist-server/service/chat-session-participant/index.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
- package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
- package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
- package/dist-server/service/chat-session-participant/presence.js +22 -0
- package/dist-server/service/chat-session-participant/presence.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +93 -0
- package/dist-server/service/chat-session-resolver.js +706 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -0
- package/dist-server/service/index.d.ts +24 -0
- package/dist-server/service/index.js +39 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/subscribe-auth.d.ts +14 -0
- package/dist-server/service/subscribe-auth.js +33 -0
- package/dist-server/service/subscribe-auth.js.map +1 -0
- package/dist-server/service/types.d.ts +5 -0
- package/dist-server/service/types.js +12 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/server/index.ts +28 -0
- package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
- package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
- package/server/service/chat-message/chat-message-broadcast.ts +27 -0
- package/server/service/chat-message/chat-message-publish.ts +22 -0
- package/server/service/chat-message/chat-message-subscription.ts +45 -0
- package/server/service/chat-message/chat-message.ts +195 -0
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +77 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/index.ts +12 -0
- package/server/service/chat-message/llm-history.test.ts +194 -0
- package/server/service/chat-message/llm-history.ts +163 -0
- package/server/service/chat-session/chat-session.ts +193 -0
- package/server/service/chat-session/index.ts +5 -0
- package/server/service/chat-session/session-activity-broadcast.ts +57 -0
- package/server/service/chat-session/session-activity-publish.ts +44 -0
- package/server/service/chat-session/session-activity-subscription.ts +67 -0
- package/server/service/chat-session/session-anchor.ts +106 -0
- package/server/service/chat-session/session-inbox.test.ts +185 -0
- package/server/service/chat-session/session-inbox.ts +161 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
- package/server/service/chat-session-participant/index.ts +6 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
- package/server/service/chat-session-participant/invite-notification.ts +48 -0
- package/server/service/chat-session-participant/presence.test.ts +39 -0
- package/server/service/chat-session-participant/presence.ts +21 -0
- package/server/service/chat-session-resolver.ts +644 -0
- package/server/service/index.ts +37 -0
- package/server/service/session-anchor.test.ts +133 -0
- package/server/service/subscribe-auth.test.ts +57 -0
- package/server/service/subscribe-auth.ts +34 -0
- package/server/service/types.ts +15 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board AI 채팅 메시지의 Markdown 렌더링.
|
|
3
|
+
*
|
|
4
|
+
* 보안 (defense-in-depth):
|
|
5
|
+
* 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.
|
|
6
|
+
* 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
|
|
7
|
+
* 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
|
|
8
|
+
* (이미지 onerror, span style="javascript:..." 등) 까지 차단.
|
|
9
|
+
*
|
|
10
|
+
* 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
|
|
11
|
+
* - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
|
|
12
|
+
* 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
|
|
13
|
+
* - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
|
|
14
|
+
* allowlist 기반 정화 필요
|
|
15
|
+
* - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
|
|
16
|
+
*
|
|
17
|
+
* CJK 보강:
|
|
18
|
+
* - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
|
|
19
|
+
* ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.
|
|
20
|
+
* 예) "**'rect'**의" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패
|
|
21
|
+
* - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked
|
|
22
|
+
* 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.
|
|
23
|
+
* - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
|
|
24
|
+
* escapeHtml 로 항상 escape).
|
|
25
|
+
*/
|
|
26
|
+
import DOMPurify from 'dompurify';
|
|
27
|
+
import { marked } from 'marked';
|
|
28
|
+
/**
|
|
29
|
+
* DOMPurify 정화 정책.
|
|
30
|
+
*
|
|
31
|
+
* 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
|
|
32
|
+
* - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
|
|
33
|
+
* - mention span (class + data-refid + data-userid)
|
|
34
|
+
*
|
|
35
|
+
* 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
|
|
36
|
+
* URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
|
|
37
|
+
*/
|
|
38
|
+
const PURIFY_CONFIG = {
|
|
39
|
+
ALLOWED_TAGS: [
|
|
40
|
+
'p', 'br', 'hr',
|
|
41
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
42
|
+
'strong', 'em', 'del', 's', 'u',
|
|
43
|
+
'ul', 'ol', 'li',
|
|
44
|
+
'a', 'img',
|
|
45
|
+
'code', 'pre',
|
|
46
|
+
'blockquote',
|
|
47
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
48
|
+
'span'
|
|
49
|
+
],
|
|
50
|
+
ALLOWED_ATTR: [
|
|
51
|
+
'href', 'title', 'alt', 'src', 'class',
|
|
52
|
+
'data-refid', 'data-userid'
|
|
53
|
+
],
|
|
54
|
+
// javascript: / data: / vbscript: 등 위험 스킴 자동 차단
|
|
55
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
|
56
|
+
// target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
|
|
57
|
+
ADD_ATTR: ['target', 'rel'],
|
|
58
|
+
// raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
|
|
59
|
+
// dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
|
|
60
|
+
KEEP_CONTENT: true
|
|
61
|
+
};
|
|
62
|
+
const markdownRenderer = new marked.Renderer();
|
|
63
|
+
markdownRenderer.html = () => '';
|
|
64
|
+
const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i;
|
|
65
|
+
const origLink = markdownRenderer.link.bind(markdownRenderer);
|
|
66
|
+
markdownRenderer.link = (token) => {
|
|
67
|
+
const href = typeof token?.href === 'string' ? token.href.trim() : '';
|
|
68
|
+
if (DANGEROUS_SCHEME_RE.test(href)) {
|
|
69
|
+
return token?.text ?? '';
|
|
70
|
+
}
|
|
71
|
+
return origLink(token);
|
|
72
|
+
};
|
|
73
|
+
// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
|
|
74
|
+
const origImage = markdownRenderer.image.bind(markdownRenderer);
|
|
75
|
+
markdownRenderer.image = (token) => {
|
|
76
|
+
const src = typeof token?.href === 'string' ? token.href.trim() : '';
|
|
77
|
+
if (DANGEROUS_SCHEME_RE.test(src)) {
|
|
78
|
+
// 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
|
|
79
|
+
return escapeHtml(token?.text ?? '');
|
|
80
|
+
}
|
|
81
|
+
return origImage(token);
|
|
82
|
+
};
|
|
83
|
+
marked.setOptions({
|
|
84
|
+
gfm: true,
|
|
85
|
+
breaks: true,
|
|
86
|
+
pedantic: false,
|
|
87
|
+
renderer: markdownRenderer,
|
|
88
|
+
async: false
|
|
89
|
+
});
|
|
90
|
+
export function escapeHtml(text) {
|
|
91
|
+
return text
|
|
92
|
+
.replace(/&/g, '&')
|
|
93
|
+
.replace(/</g, '<')
|
|
94
|
+
.replace(/>/g, '>')
|
|
95
|
+
.replace(/"/g, '"')
|
|
96
|
+
.replace(/'/g, ''');
|
|
97
|
+
}
|
|
98
|
+
// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각
|
|
99
|
+
const CJK_RE = /[-ヿ㐀-䶿一-鿿가--]/;
|
|
100
|
+
const PUNCT_RE = /\p{P}/u;
|
|
101
|
+
// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)
|
|
102
|
+
const PLACEHOLDER_PREFIX = 'BAICJKSTRONG';
|
|
103
|
+
const PLACEHOLDER_SUFFIX = 'ENDMARK';
|
|
104
|
+
const MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION';
|
|
105
|
+
const MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION';
|
|
106
|
+
// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.
|
|
107
|
+
const MENTION_WORD_RE = /[\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u;
|
|
108
|
+
/**
|
|
109
|
+
* `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는
|
|
110
|
+
* 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.
|
|
111
|
+
*
|
|
112
|
+
* 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):
|
|
113
|
+
* - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter
|
|
114
|
+
* - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점
|
|
115
|
+
*/
|
|
116
|
+
export function extractCjkStrongs(text) {
|
|
117
|
+
const stored = [];
|
|
118
|
+
const lines = text.split('\n');
|
|
119
|
+
const outLines = [];
|
|
120
|
+
let inFence = false;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (/^\s{0,3}```/.test(line)) {
|
|
123
|
+
inFence = !inFence;
|
|
124
|
+
outLines.push(line);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (inFence) {
|
|
128
|
+
outLines.push(line);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''));
|
|
132
|
+
}
|
|
133
|
+
return { processed: outLines.join('\n'), stored };
|
|
134
|
+
}
|
|
135
|
+
function processLine(line, stored, prevLine) {
|
|
136
|
+
let i = 0;
|
|
137
|
+
let result = '';
|
|
138
|
+
while (i < line.length) {
|
|
139
|
+
const ch = line[i];
|
|
140
|
+
if (ch === '`') {
|
|
141
|
+
const end = line.indexOf('`', i + 1);
|
|
142
|
+
if (end === -1) {
|
|
143
|
+
result += line.slice(i);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
result += line.slice(i, end + 1);
|
|
147
|
+
i = end + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '*' && line[i + 1] === '*') {
|
|
151
|
+
const close = findCloseStrong(line, i + 2);
|
|
152
|
+
if (close === -1) {
|
|
153
|
+
result += '**';
|
|
154
|
+
i += 2;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const inner = line.slice(i + 2, close);
|
|
158
|
+
if (inner.length === 0) {
|
|
159
|
+
result += '**';
|
|
160
|
+
i += 2;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1);
|
|
164
|
+
const after = close + 2 < line.length ? line[close + 2] : '';
|
|
165
|
+
if (needsCjkFix(before, after, inner)) {
|
|
166
|
+
stored.push(inner);
|
|
167
|
+
result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
result += line.slice(i, close + 2);
|
|
171
|
+
}
|
|
172
|
+
i = close + 2;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
result += ch;
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
function findCloseStrong(line, start) {
|
|
181
|
+
let i = start;
|
|
182
|
+
while (i < line.length - 1) {
|
|
183
|
+
if (line[i] === '`') {
|
|
184
|
+
const end = line.indexOf('`', i + 1);
|
|
185
|
+
if (end === -1)
|
|
186
|
+
return -1;
|
|
187
|
+
i = end + 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (line[i] === '*' && line[i + 1] === '*')
|
|
191
|
+
return i;
|
|
192
|
+
i++;
|
|
193
|
+
}
|
|
194
|
+
return -1;
|
|
195
|
+
}
|
|
196
|
+
function needsCjkFix(before, after, inner) {
|
|
197
|
+
const innerStart = inner[0];
|
|
198
|
+
const innerEnd = inner[inner.length - 1];
|
|
199
|
+
return ((before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||
|
|
200
|
+
(after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd)));
|
|
201
|
+
}
|
|
202
|
+
function restoreCjkStrongs(html, stored) {
|
|
203
|
+
let out = html;
|
|
204
|
+
for (let n = 0; n < stored.length; n++) {
|
|
205
|
+
const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`;
|
|
206
|
+
const replacement = `<strong>${escapeHtml(stored[n])}</strong>`;
|
|
207
|
+
out = out.split(token).join(replacement);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* `#token` mention 패턴을 추출 → placeholder 로 치환.
|
|
213
|
+
*
|
|
214
|
+
* 활성 조건 (chat textarea 의 extractMentionContext 와 일관):
|
|
215
|
+
* - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)
|
|
216
|
+
* - `#` 직후가 word char (영숫자/한글/일본/중국어)
|
|
217
|
+
* - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음
|
|
218
|
+
*
|
|
219
|
+
* Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로
|
|
220
|
+
* 본 정규식 (직후가 word char) 매칭 안 됨.
|
|
221
|
+
*/
|
|
222
|
+
export function extractMentions(text) {
|
|
223
|
+
const stored = [];
|
|
224
|
+
const lines = text.split('\n');
|
|
225
|
+
const outLines = [];
|
|
226
|
+
let inFence = false;
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
if (/^\s{0,3}```/.test(line)) {
|
|
229
|
+
inFence = !inFence;
|
|
230
|
+
outLines.push(line);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (inFence) {
|
|
234
|
+
outLines.push(line);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
outLines.push(processMentionLine(line, stored));
|
|
238
|
+
}
|
|
239
|
+
return { processed: outLines.join('\n'), stored };
|
|
240
|
+
}
|
|
241
|
+
function processMentionLine(line, stored) {
|
|
242
|
+
let result = '';
|
|
243
|
+
let i = 0;
|
|
244
|
+
while (i < line.length) {
|
|
245
|
+
const ch = line[i];
|
|
246
|
+
if (ch === '`') {
|
|
247
|
+
// 인라인 코드 — 닫는 backtick 까지 그대로 통과
|
|
248
|
+
const end = line.indexOf('`', i + 1);
|
|
249
|
+
if (end === -1) {
|
|
250
|
+
result += line.slice(i);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
result += line.slice(i, end + 1);
|
|
254
|
+
i = end + 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (ch === '#' || ch === '@') {
|
|
258
|
+
const prev = i > 0 ? line[i - 1] : '';
|
|
259
|
+
const next = line[i + 1] ?? '';
|
|
260
|
+
if ((prev === '' || !MENTION_WORD_RE.test(prev)) &&
|
|
261
|
+
next !== '' &&
|
|
262
|
+
MENTION_WORD_RE.test(next)) {
|
|
263
|
+
// 토큰 끝 위치 찾기
|
|
264
|
+
let j = i + 1;
|
|
265
|
+
while (j < line.length && MENTION_WORD_RE.test(line[j]))
|
|
266
|
+
j++;
|
|
267
|
+
const token = line.slice(i + 1, j);
|
|
268
|
+
stored.push({ trigger: ch, token });
|
|
269
|
+
result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`;
|
|
270
|
+
i = j;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
result += ch;
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* placeholder 를 mention span 으로 복원. trigger 별로 다른 span:
|
|
281
|
+
* - `#component` → `<span class="mention" data-refid="N">#token</span>`
|
|
282
|
+
* - `@user` → `<span class="user-mention" data-userid="UUID">@token</span>`
|
|
283
|
+
* 매핑 없으면 data-* 속성 없이 단순 span.
|
|
284
|
+
*/
|
|
285
|
+
function restoreMentions(html, stored, maps) {
|
|
286
|
+
let out = html;
|
|
287
|
+
for (let n = 0; n < stored.length; n++) {
|
|
288
|
+
const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`;
|
|
289
|
+
const { trigger, token } = stored[n];
|
|
290
|
+
let replacement;
|
|
291
|
+
if (trigger === '#') {
|
|
292
|
+
const refid = maps.refidByToken.get(token);
|
|
293
|
+
const attr = typeof refid === 'number' && Number.isFinite(refid)
|
|
294
|
+
? ` data-refid="${refid}"`
|
|
295
|
+
: '';
|
|
296
|
+
replacement = `<span class="mention"${attr}>#${escapeHtml(token)}</span>`;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const userId = maps.userIdByToken.get(token);
|
|
300
|
+
const attr = userId ? ` data-userid="${escapeHtml(userId)}"` : '';
|
|
301
|
+
replacement = `<span class="user-mention"${attr}>@${escapeHtml(token)}</span>`;
|
|
302
|
+
}
|
|
303
|
+
out = out.split(placeholder).join(replacement);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:
|
|
309
|
+
* - `{refid:N}` — `#component` 멘션
|
|
310
|
+
* - `{userId:UUID}` — `@user` 멘션
|
|
311
|
+
* 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.
|
|
312
|
+
*/
|
|
313
|
+
const MENTION_MARKER_RE = /\{[a-zA-Z][a-zA-Z0-9]*:[^}\s]+\}/g;
|
|
314
|
+
const WORD_CLASS = '\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}';
|
|
315
|
+
/**
|
|
316
|
+
* 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.
|
|
317
|
+
* 토큰 (#component / @user) 자체는 보존.
|
|
318
|
+
*/
|
|
319
|
+
export function stripMentionRefids(text) {
|
|
320
|
+
if (!text)
|
|
321
|
+
return '';
|
|
322
|
+
return text.replace(MENTION_MARKER_RE, '');
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.
|
|
326
|
+
* 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.
|
|
327
|
+
*/
|
|
328
|
+
function extractInlineMentions(text) {
|
|
329
|
+
const refidByToken = new Map();
|
|
330
|
+
const userIdByToken = new Map();
|
|
331
|
+
const componentRe = new RegExp(`(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\{refid:(\\d+)\\}`, 'gu');
|
|
332
|
+
const userRe = new RegExp(`(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\{userId:([^}\\s]+)\\}`, 'gu');
|
|
333
|
+
let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {
|
|
334
|
+
const refid = Number(refidStr);
|
|
335
|
+
if (Number.isFinite(refid) && !refidByToken.has(token))
|
|
336
|
+
refidByToken.set(token, refid);
|
|
337
|
+
return `${leading}#${token}`;
|
|
338
|
+
});
|
|
339
|
+
cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {
|
|
340
|
+
if (!userIdByToken.has(token))
|
|
341
|
+
userIdByToken.set(token, userId);
|
|
342
|
+
return `${leading}@${token}`;
|
|
343
|
+
});
|
|
344
|
+
return { cleaned, maps: { refidByToken, userIdByToken } };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 일반화된 inline marker 주입.
|
|
348
|
+
* injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])
|
|
349
|
+
* injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])
|
|
350
|
+
*/
|
|
351
|
+
function injectInlineMarkers(text, trigger, markerKey, picks) {
|
|
352
|
+
if (!text || !picks || picks.length === 0)
|
|
353
|
+
return text ?? '';
|
|
354
|
+
const map = new Map();
|
|
355
|
+
for (const p of picks)
|
|
356
|
+
map.set(p.token, p.value);
|
|
357
|
+
const escapedTrig = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
358
|
+
const re = new RegExp(`(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\{${markerKey}:[^}\\s]+\\})?`, 'gu');
|
|
359
|
+
return text.replace(re, (match, leading, trig, token, existingMarker) => {
|
|
360
|
+
if (existingMarker)
|
|
361
|
+
return match;
|
|
362
|
+
const value = map.get(token);
|
|
363
|
+
if (value === undefined)
|
|
364
|
+
return match;
|
|
365
|
+
return `${leading}${trig}${token}{${markerKey}:${value}}`;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */
|
|
369
|
+
export function injectMentionRefids(text, picks) {
|
|
370
|
+
return injectInlineMarkers(text, '#', 'refid', picks?.map(p => ({ token: p.token, value: p.refid })));
|
|
371
|
+
}
|
|
372
|
+
/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */
|
|
373
|
+
export function injectMentionUserIds(text, picks) {
|
|
374
|
+
return injectInlineMarkers(text, '@', 'userId', picks?.map(p => ({ token: p.token, value: p.userId })));
|
|
375
|
+
}
|
|
376
|
+
export function renderMarkdown(text) {
|
|
377
|
+
if (!text)
|
|
378
|
+
return '';
|
|
379
|
+
try {
|
|
380
|
+
// 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화
|
|
381
|
+
const { cleaned, maps } = extractInlineMentions(text);
|
|
382
|
+
// 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에
|
|
383
|
+
// 휩쓸리지 않도록 영숫자 placeholder 로 치환.
|
|
384
|
+
const m = extractMentions(cleaned);
|
|
385
|
+
// 2) CJK strong placeholder
|
|
386
|
+
const c = extractCjkStrongs(m.processed);
|
|
387
|
+
const out = marked.parse(c.processed);
|
|
388
|
+
if (typeof out !== 'string') {
|
|
389
|
+
// marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback
|
|
390
|
+
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
|
|
391
|
+
}
|
|
392
|
+
// 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
|
|
393
|
+
const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps);
|
|
394
|
+
// 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
|
|
395
|
+
return sanitizeHtml(restored);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
|
|
403
|
+
*
|
|
404
|
+
* 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
|
|
405
|
+
* Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
|
|
406
|
+
* (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
|
|
407
|
+
*/
|
|
408
|
+
export function sanitizeHtml(html) {
|
|
409
|
+
try {
|
|
410
|
+
if (typeof window === 'undefined' || !DOMPurify.sanitize) {
|
|
411
|
+
return html;
|
|
412
|
+
}
|
|
413
|
+
const result = DOMPurify.sanitize(html, PURIFY_CONFIG);
|
|
414
|
+
return typeof result === 'string' ? result : String(result);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
|
|
418
|
+
// (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
|
|
419
|
+
return html;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
//# sourceMappingURL=markdown.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../client/components/markdown.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Board AI 채팅 메시지의 Markdown 렌더링.\n *\n * 보안 (defense-in-depth):\n * 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.\n * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.\n * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML\n * (이미지 onerror, span style=\"javascript:...\" 등) 까지 차단.\n *\n * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:\n * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서\n * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음\n * - mention span (`<span class=\"mention\" ...>`) 이 우리 손으로 만드는 element 라\n * allowlist 기반 정화 필요\n * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑\n *\n * CJK 보강:\n * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와\n * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.\n * 예) \"**'rect'**의\" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패\n * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked\n * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.\n * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은\n * escapeHtml 로 항상 escape).\n */\nimport DOMPurify from 'dompurify'\nimport { marked } from 'marked'\n\n/**\n * DOMPurify 정화 정책.\n *\n * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:\n * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)\n * - mention span (class + data-refid + data-userid)\n *\n * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:\n * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.\n */\nconst PURIFY_CONFIG: any = {\n ALLOWED_TAGS: [\n 'p', 'br', 'hr',\n 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',\n 'strong', 'em', 'del', 's', 'u',\n 'ul', 'ol', 'li',\n 'a', 'img',\n 'code', 'pre',\n 'blockquote',\n 'table', 'thead', 'tbody', 'tr', 'th', 'td',\n 'span'\n ],\n ALLOWED_ATTR: [\n 'href', 'title', 'alt', 'src', 'class',\n 'data-refid', 'data-userid'\n ],\n // javascript: / data: / vbscript: 등 위험 스킴 자동 차단\n ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i,\n // target=\"_blank\" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록\n ADD_ATTR: ['target', 'rel'],\n // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,\n // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단\n KEEP_CONTENT: true\n}\n\nconst markdownRenderer = new marked.Renderer()\nmarkdownRenderer.html = () => ''\n\nconst DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i\n\nconst origLink = markdownRenderer.link.bind(markdownRenderer)\nmarkdownRenderer.link = (token: any) => {\n const href = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(href)) {\n return token?.text ?? ''\n }\n return origLink(token)\n}\n\n// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape\nconst origImage = markdownRenderer.image.bind(markdownRenderer)\nmarkdownRenderer.image = (token: any) => {\n const src = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(src)) {\n // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출\n return escapeHtml(token?.text ?? '')\n }\n return origImage(token)\n}\n\nmarked.setOptions({\n gfm: true,\n breaks: true,\n pedantic: false,\n renderer: markdownRenderer,\n async: false\n} as any)\n\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각\nconst CJK_RE = /[-ヿ㐀-䶿一-鿿가--]/\nconst PUNCT_RE = /\\p{P}/u\n\n// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)\nconst PLACEHOLDER_PREFIX = 'BAICJKSTRONG'\nconst PLACEHOLDER_SUFFIX = 'ENDMARK'\nconst MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'\nconst MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'\n\n// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.\nconst MENTION_WORD_RE = /[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]/u\n\ninterface ExtractResult {\n processed: string\n stored: string[]\n}\n\ninterface MentionExtractResult {\n processed: string\n stored: StoredMention[]\n}\n\n/**\n * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는\n * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.\n *\n * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):\n * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter\n * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점\n */\nexport function extractCjkStrongs(text: string): ExtractResult {\n const stored: string[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\nfunction processLine(line: string, stored: string[], prevLine: () => string): string {\n let i = 0\n let result = ''\n while (i < line.length) {\n const ch = line[i]\n\n if (ch === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n\n if (ch === '*' && line[i + 1] === '*') {\n const close = findCloseStrong(line, i + 2)\n if (close === -1) {\n result += '**'\n i += 2\n continue\n }\n const inner = line.slice(i + 2, close)\n if (inner.length === 0) {\n result += '**'\n i += 2\n continue\n }\n const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)\n const after = close + 2 < line.length ? line[close + 2] : ''\n if (needsCjkFix(before, after, inner)) {\n stored.push(inner)\n result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`\n } else {\n result += line.slice(i, close + 2)\n }\n i = close + 2\n continue\n }\n\n result += ch\n i++\n }\n return result\n}\n\nfunction findCloseStrong(line: string, start: number): number {\n let i = start\n while (i < line.length - 1) {\n if (line[i] === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) return -1\n i = end + 1\n continue\n }\n if (line[i] === '*' && line[i + 1] === '*') return i\n i++\n }\n return -1\n}\n\nfunction needsCjkFix(before: string, after: string, inner: string): boolean {\n const innerStart = inner[0]\n const innerEnd = inner[inner.length - 1]\n return (\n (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||\n (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))\n )\n}\n\nfunction restoreCjkStrongs(html: string, stored: string[]): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`\n const replacement = `<strong>${escapeHtml(stored[n])}</strong>`\n out = out.split(token).join(replacement)\n }\n return out\n}\n\n/**\n * `#token` mention 패턴을 추출 → placeholder 로 치환.\n *\n * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):\n * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)\n * - `#` 직후가 word char (영숫자/한글/일본/중국어)\n * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음\n *\n * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로\n * 본 정규식 (직후가 word char) 매칭 안 됨.\n */\nexport function extractMentions(text: string): MentionExtractResult {\n const stored: StoredMention[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processMentionLine(line, stored))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\n/** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */\ninterface StoredMention {\n trigger: '#' | '@'\n token: string\n}\n\nfunction processMentionLine(line: string, stored: StoredMention[]): string {\n let result = ''\n let i = 0\n while (i < line.length) {\n const ch = line[i]\n if (ch === '`') {\n // 인라인 코드 — 닫는 backtick 까지 그대로 통과\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n if (ch === '#' || ch === '@') {\n const prev = i > 0 ? line[i - 1] : ''\n const next = line[i + 1] ?? ''\n if (\n (prev === '' || !MENTION_WORD_RE.test(prev)) &&\n next !== '' &&\n MENTION_WORD_RE.test(next)\n ) {\n // 토큰 끝 위치 찾기\n let j = i + 1\n while (j < line.length && MENTION_WORD_RE.test(line[j])) j++\n const token = line.slice(i + 1, j)\n stored.push({ trigger: ch as '#' | '@', token })\n result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`\n i = j\n continue\n }\n }\n result += ch\n i++\n }\n return result\n}\n\n/**\n * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:\n * - `#component` → `<span class=\"mention\" data-refid=\"N\">#token</span>`\n * - `@user` → `<span class=\"user-mention\" data-userid=\"UUID\">@token</span>`\n * 매핑 없으면 data-* 속성 없이 단순 span.\n */\nfunction restoreMentions(\n html: string,\n stored: StoredMention[],\n maps: InlineMentionMaps\n): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`\n const { trigger, token } = stored[n]\n let replacement: string\n if (trigger === '#') {\n const refid = maps.refidByToken.get(token)\n const attr =\n typeof refid === 'number' && Number.isFinite(refid)\n ? ` data-refid=\"${refid}\"`\n : ''\n replacement = `<span class=\"mention\"${attr}>#${escapeHtml(token)}</span>`\n } else {\n const userId = maps.userIdByToken.get(token)\n const attr = userId ? ` data-userid=\"${escapeHtml(userId)}\"` : ''\n replacement = `<span class=\"user-mention\"${attr}>@${escapeHtml(token)}</span>`\n }\n out = out.split(placeholder).join(replacement)\n }\n return out\n}\n\n/**\n * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:\n * - `{refid:N}` — `#component` 멘션\n * - `{userId:UUID}` — `@user` 멘션\n * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.\n */\nconst MENTION_MARKER_RE = /\\{[a-zA-Z][a-zA-Z0-9]*:[^}\\s]+\\}/g\n\nconst WORD_CLASS =\n '\\\\w\\\\p{Script=Hangul}\\\\p{Script=Hiragana}\\\\p{Script=Katakana}\\\\p{Script=Han}'\n\n/**\n * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.\n * 토큰 (#component / @user) 자체는 보존.\n */\nexport function stripMentionRefids(text: string | null | undefined): string {\n if (!text) return ''\n return text.replace(MENTION_MARKER_RE, '')\n}\n\ninterface InlineMentionMaps {\n /** trigger='#' 의 token → refid (component) */\n refidByToken: Map<string, number>\n /** trigger='@' 의 token → userId (user) */\n userIdByToken: Map<string, string>\n}\n\n/**\n * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.\n * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.\n */\nfunction extractInlineMentions(text: string): {\n cleaned: string\n maps: InlineMentionMaps\n} {\n const refidByToken = new Map<string, number>()\n const userIdByToken = new Map<string, string>()\n\n const componentRe = new RegExp(\n `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\\\{refid:(\\\\d+)\\\\}`,\n 'gu'\n )\n const userRe = new RegExp(\n `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\\\{userId:([^}\\\\s]+)\\\\}`,\n 'gu'\n )\n\n let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {\n const refid = Number(refidStr)\n if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)\n return `${leading}#${token}`\n })\n cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {\n if (!userIdByToken.has(token)) userIdByToken.set(token, userId)\n return `${leading}@${token}`\n })\n return { cleaned, maps: { refidByToken, userIdByToken } }\n}\n\n/**\n * 일반화된 inline marker 주입.\n * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])\n * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])\n */\nfunction injectInlineMarkers(\n text: string,\n trigger: string,\n markerKey: string,\n picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined\n): string {\n if (!text || !picks || picks.length === 0) return text ?? ''\n const map = new Map<string, string | number>()\n for (const p of picks) map.set(p.token, p.value)\n const escapedTrig = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const re = new RegExp(\n `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\\\{${markerKey}:[^}\\\\s]+\\\\})?`,\n 'gu'\n )\n return text.replace(re, (match, leading, trig, token, existingMarker) => {\n if (existingMarker) return match\n const value = map.get(token)\n if (value === undefined) return match\n return `${leading}${trig}${token}{${markerKey}:${value}}`\n })\n}\n\n/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */\nexport function injectMentionRefids(\n text: string,\n picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '#',\n 'refid',\n picks?.map(p => ({ token: p.token, value: p.refid }))\n )\n}\n\n/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */\nexport function injectMentionUserIds(\n text: string,\n picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '@',\n 'userId',\n picks?.map(p => ({ token: p.token, value: p.userId }))\n )\n}\n\nexport function renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화\n const { cleaned, maps } = extractInlineMentions(text)\n // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에\n // 휩쓸리지 않도록 영숫자 placeholder 로 치환.\n const m = extractMentions(cleaned)\n // 2) CJK strong placeholder\n const c = extractCjkStrongs(m.processed)\n const out = marked.parse(c.processed)\n if (typeof out !== 'string') {\n // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.\n const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)\n // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.\n return sanitizeHtml(restored)\n } catch {\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n}\n\n/**\n * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.\n *\n * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.\n * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback\n * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).\n */\nexport function sanitizeHtml(html: string): string {\n try {\n if (typeof window === 'undefined' || !DOMPurify.sanitize) {\n return html\n }\n const result = DOMPurify.sanitize(html, PURIFY_CONFIG)\n return typeof result === 'string' ? result : String(result)\n } catch {\n // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과\n // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)\n return html\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|