Skip to content

Commit c1f1469

Browse files
committed
The end of open source? Just regurgitate some markdown parsing code.
1 parent 24acfea commit c1f1469

File tree

2 files changed

+241
-1
lines changed

2 files changed

+241
-1
lines changed

src/ifcchat/app.js

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,190 @@ function setBusy(isBusy, reason = "") {
162162
setStatus(isBusy ? (reason || "Working…") : "Ready");
163163
}
164164

165+
function escapeHtml(text) {
166+
return text
167+
.replaceAll("&", "&")
168+
.replaceAll("<", "&lt;")
169+
.replaceAll(">", "&gt;")
170+
.replaceAll('"', "&quot;")
171+
.replaceAll("'", "&#39;");
172+
}
173+
174+
function sanitizeUrl(url) {
175+
try {
176+
const parsed = new URL(url, window.location.href);
177+
if (["http:", "https:", "mailto:"].includes(parsed.protocol)) {
178+
return parsed.href;
179+
}
180+
} catch {
181+
}
182+
return null;
183+
}
184+
185+
function renderInlineMarkdown(text) {
186+
const placeholders = [];
187+
const addPlaceholder = (html) => {
188+
const token = `@@MD${placeholders.length}@@`;
189+
placeholders.push({ token, html });
190+
return token;
191+
};
192+
193+
let rendered = text;
194+
195+
rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`<code>${escapeHtml(code)}</code>`));
196+
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
197+
const href = sanitizeUrl(url);
198+
if (!href) {
199+
return `${label} (${url})`;
200+
}
201+
return addPlaceholder(
202+
`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${escapeHtml(label)}</a>`
203+
);
204+
});
205+
206+
rendered = escapeHtml(rendered);
207+
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
208+
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
209+
rendered = rendered.replace(/_([^_]+)_/g, "<em>$1</em>");
210+
211+
for (const placeholder of placeholders) {
212+
rendered = rendered.replaceAll(placeholder.token, placeholder.html);
213+
}
214+
215+
return rendered;
216+
}
217+
218+
function renderMarkdown(text) {
219+
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
220+
const html = [];
221+
let paragraphLines = [];
222+
let quoteLines = [];
223+
let listType = null;
224+
let listItems = [];
225+
226+
const flushParagraph = () => {
227+
if (!paragraphLines.length) return;
228+
html.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
229+
paragraphLines = [];
230+
};
231+
232+
const flushQuote = () => {
233+
if (!quoteLines.length) return;
234+
const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("<br />");
235+
html.push(`<blockquote><p>${quoteBody}</p></blockquote>`);
236+
quoteLines = [];
237+
};
238+
239+
const flushList = () => {
240+
if (!listItems.length || !listType) return;
241+
const items = listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("");
242+
html.push(`<${listType}>${items}</${listType}>`);
243+
listType = null;
244+
listItems = [];
245+
};
246+
247+
const flushAll = () => {
248+
flushParagraph();
249+
flushQuote();
250+
flushList();
251+
};
252+
253+
for (let index = 0; index < lines.length; index++) {
254+
const line = lines[index];
255+
const trimmed = line.trim();
256+
257+
if (trimmed.startsWith("```")) {
258+
flushAll();
259+
const language = trimmed.slice(3).trim();
260+
const codeLines = [];
261+
index += 1;
262+
while (index < lines.length && !lines[index].trim().startsWith("```")) {
263+
codeLines.push(lines[index]);
264+
index += 1;
265+
}
266+
const languageClass = language ? ` class="language-${escapeHtml(language)}"` : "";
267+
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
268+
continue;
269+
}
270+
271+
if (!trimmed) {
272+
flushAll();
273+
continue;
274+
}
275+
276+
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
277+
if (headingMatch) {
278+
flushAll();
279+
const level = headingMatch[1].length;
280+
html.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
281+
continue;
282+
}
283+
284+
const quoteMatch = trimmed.match(/^>\s?(.*)$/);
285+
if (quoteMatch) {
286+
flushParagraph();
287+
flushList();
288+
quoteLines.push(quoteMatch[1]);
289+
continue;
290+
}
291+
292+
if (quoteLines.length) {
293+
flushQuote();
294+
}
295+
296+
const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/);
297+
if (unorderedListMatch) {
298+
flushParagraph();
299+
if (listType && listType !== "ul") {
300+
flushList();
301+
}
302+
listType = "ul";
303+
listItems.push(unorderedListMatch[1]);
304+
continue;
305+
}
306+
307+
const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/);
308+
if (orderedListMatch) {
309+
flushParagraph();
310+
if (listType && listType !== "ol") {
311+
flushList();
312+
}
313+
listType = "ol";
314+
listItems.push(orderedListMatch[1]);
315+
continue;
316+
}
317+
318+
if (listItems.length) {
319+
flushList();
320+
}
321+
322+
paragraphLines.push(trimmed);
323+
}
324+
325+
flushAll();
326+
327+
return html.join("");
328+
}
329+
165330
function addMessage(role, text) {
166331
if (text.ok) {
167332
text = text.data;
168333
}
334+
if (typeof text !== "string") {
335+
text = JSON.stringify(text, null, 2);
336+
}
169337
const wrap = document.createElement("div");
170338
wrap.className = `msg ${role}`;
171339
wrap.innerHTML = `
172340
<div class="role ${role}">${role}${role === "tool" ? '<span class="chevron">▶</span>' : ''}</div>
173341
<div class="bubble"></div>`;
174342
const bubble = wrap.querySelector(".bubble");
175-
bubble.textContent = text;
343+
if (role === "assistant") {
344+
bubble.classList.add("markdown-content");
345+
bubble.innerHTML = renderMarkdown(text);
346+
} else {
347+
bubble.textContent = text;
348+
}
176349
bubble.onclick = function () {
177350
if (bubble.scrollHeight > 100 && role === "tool") {
178351
const expanded = bubble.style.maxHeight === 'none';

src/ifcchat/style.css

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,73 @@ main {
8686
white-space: pre-wrap;
8787
}
8888

89+
.msg.assistant .bubble {
90+
padding: 10px 14px;
91+
line-height: 1.5;
92+
}
93+
94+
.markdown-content > :first-child {
95+
margin-top: 0;
96+
}
97+
98+
.markdown-content > :last-child {
99+
margin-bottom: 0;
100+
}
101+
102+
.markdown-content p,
103+
.markdown-content ul,
104+
.markdown-content ol,
105+
.markdown-content blockquote,
106+
.markdown-content pre {
107+
margin: 0 0 12px 0;
108+
}
109+
110+
.markdown-content h1,
111+
.markdown-content h2,
112+
.markdown-content h3,
113+
.markdown-content h4,
114+
.markdown-content h5,
115+
.markdown-content h6 {
116+
margin: 0 0 12px 0;
117+
line-height: 1.25;
118+
}
119+
120+
.markdown-content ul,
121+
.markdown-content ol {
122+
padding-left: 24px;
123+
}
124+
125+
.markdown-content blockquote {
126+
margin-left: 0;
127+
padding-left: 12px;
128+
border-left: 3px solid #ddd;
129+
color: #555;
130+
}
131+
132+
.markdown-content code {
133+
padding: 1px 4px;
134+
border-radius: 4px;
135+
background: #f2f2f2;
136+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
137+
font-size: 90%;
138+
}
139+
140+
.markdown-content pre {
141+
overflow-x: auto;
142+
padding: 12px;
143+
border-radius: 10px;
144+
background: #f4f4f4;
145+
}
146+
147+
.markdown-content pre code {
148+
padding: 0;
149+
background: transparent;
150+
}
151+
152+
.markdown-content a {
153+
color: inherit;
154+
}
155+
89156
.msg.user .bubble {
90157
padding: 10px 20px;
91158
background: #eee;

0 commit comments

Comments
 (0)