Arslan1997 commited on
Commit
9499163
·
1 Parent(s): a7da6ce

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/app/llm/signatures.py CHANGED
@@ -117,8 +117,12 @@ class ResearchArchive(dspy.Signature):
117
  If the archive does not cover the question, say so plainly and set unsupported=true.
118
  Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
119
  bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
120
- ### heading only when the answer has distinct parts. Put [n] markers right after the sentence
121
- or list item they support. Never use em dashes."""
 
 
 
 
122
 
123
  question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
124
  conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
 
117
  If the archive does not cover the question, say so plainly and set unsupported=true.
118
  Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
119
  bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
120
+ ### heading only when the answer has distinct parts. Use a table for side by side comparisons.
121
+ When the posts contain code, commands or config, quote them in fenced code blocks with a language
122
+ tag (```python, ```bash, ```json) exactly as written, never paraphrased. When a post you read has a
123
+ relevant image line (![alt](https://...)), you may include that exact line; never invent image
124
+ URLs. Put [n] markers right after the sentence or list item they support (after a code block or
125
+ image, on the line below it). Never use em dashes."""
126
 
127
  question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
128
  conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
backend/app/pipeline/ingest.py CHANGED
@@ -168,7 +168,25 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
168
  for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]):
169
  tag.decompose()
170
  sections: list[tuple[str | None, list[str]]] = [(None, [])]
171
- for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre"]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  text = el.get_text(" ", strip=True)
173
  if not text or (len(text) < 120 and _DROP.search(text)):
174
  continue
@@ -179,6 +197,9 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
179
  return [(h, "\n\n".join(p)) for h, p in sections if p]
180
 
181
 
 
 
 
182
  def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
183
  sections: list[tuple[str | None, list[str]]] = [(None, [])]
184
  para: list[str] = []
@@ -188,16 +209,32 @@ def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
188
  sections[-1][1].append(" ".join(para).strip())
189
  para.clear()
190
 
 
191
  for line in text.splitlines():
192
  stripped = line.strip()
 
 
 
 
 
 
 
 
 
 
193
  heading = re.match(r"^#{1,3}\s+(.*)", stripped)
194
- if heading:
 
 
 
195
  flush()
196
  sections.append((heading.group(1).strip(), []))
197
  elif not stripped:
198
  flush()
199
  else:
200
  para.append(stripped)
 
 
201
  flush()
202
  return [(h, "\n\n".join(p)) for h, p in sections if p]
203
 
@@ -239,13 +276,19 @@ class FeedEntry:
239
  sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
240
 
241
 
242
- _MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
243
- _MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
244
 
245
 
246
  def _clean_markdown(md: str) -> str:
247
- """Firecrawl markdown to plain prose: drop images, keep link text."""
248
- return _MD_LINK.sub(r"\1", _MD_IMAGE.sub("", md))
 
 
 
 
 
 
249
 
250
 
251
  def _firecrawl_entry(url: str) -> FeedEntry:
 
168
  for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]):
169
  tag.decompose()
170
  sections: list[tuple[str | None, list[str]]] = [(None, [])]
171
+ for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre", "img"]):
172
+ if el.name == "img":
173
+ # Keep images as Markdown so answers can show them (web images only, no tracking pixels).
174
+ src = (el.get("src") or el.get("data-src") or "").strip()
175
+ width = str(el.get("width") or "")
176
+ if src.startswith(("http://", "https://")) and not (width.isdigit() and int(width) < 50):
177
+ alt = re.sub(r"[\[\]\n]", " ", el.get("alt") or "").strip()
178
+ sections[-1][1].append(f"![{alt}]({src})")
179
+ continue
180
+ if el.name == "pre":
181
+ # Code keeps its line breaks, as a fenced block.
182
+ code = el.get_text().strip("\n")
183
+ if code.strip():
184
+ lang = next((c.split("-", 1)[1] for c in (el.find("code") or el).get("class") or []
185
+ if c.startswith(("language-", "lang-"))), "")
186
+ sections[-1][1].append(f"```{lang}\n{code}\n```")
187
+ continue
188
+ if el.find_parent("pre"):
189
+ continue
190
  text = el.get_text(" ", strip=True)
191
  if not text or (len(text) < 120 and _DROP.search(text)):
192
  continue
 
197
  return [(h, "\n\n".join(p)) for h, p in sections if p]
198
 
199
 
200
+ _MD_IMAGE_LINE = re.compile(r"^!\[[^\]]*\]\(https?://[^)\s]+[^)]*\)$")
201
+
202
+
203
  def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
204
  sections: list[tuple[str | None, list[str]]] = [(None, [])]
205
  para: list[str] = []
 
209
  sections[-1][1].append(" ".join(para).strip())
210
  para.clear()
211
 
212
+ fence: list[str] | None = None # lines of an open ``` block, kept verbatim
213
  for line in text.splitlines():
214
  stripped = line.strip()
215
+ if fence is not None:
216
+ fence.append(line)
217
+ if stripped.startswith("```"):
218
+ sections[-1][1].append("\n".join(fence))
219
+ fence = None
220
+ continue
221
+ if stripped.startswith("```"):
222
+ flush()
223
+ fence = [stripped]
224
+ continue
225
  heading = re.match(r"^#{1,3}\s+(.*)", stripped)
226
+ if _MD_IMAGE_LINE.match(stripped):
227
+ flush()
228
+ sections[-1][1].append(stripped) # images stay on their own line
229
+ elif heading:
230
  flush()
231
  sections.append((heading.group(1).strip(), []))
232
  elif not stripped:
233
  flush()
234
  else:
235
  para.append(stripped)
236
+ if fence is not None: # unclosed fence: close it so the block still renders
237
+ sections[-1][1].append("\n".join([*fence, "```"]))
238
  flush()
239
  return [(h, "\n\n".join(p)) for h, p in sections if p]
240
 
 
276
  sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
277
 
278
 
279
+ _MD_IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)\s]*)[^)]*\)")
280
+ _MD_LINK = re.compile(r"(?<!!)\[([^\]]*)\]\([^)]*\)")
281
 
282
 
283
  def _clean_markdown(md: str) -> str:
284
+ """Firecrawl markdown to prose: keep link text, keep web images on their own line (drop data: URIs
285
+ and relative paths, which would not load outside the site)."""
286
+
287
+ def image(m: re.Match) -> str:
288
+ alt, src = m.group(1).replace("\n", " ").strip(), m.group(2)
289
+ return f"\n\n![{alt}]({src})\n\n" if src.startswith(("http://", "https://")) else ""
290
+
291
+ return _MD_LINK.sub(r"\1", _MD_IMAGE.sub(image, md))
292
 
293
 
294
  def _firecrawl_entry(url: str) -> FeedEntry:
backend/tests/test_buildout.py CHANGED
@@ -178,7 +178,8 @@ def test_crawl_uses_firecrawl_when_configured(monkeypatch):
178
  assert [e.title for e in entries] == ["Rendered Post"]
179
  assert entries[0].published_at.year == 2026
180
  body = entries[0].sections[0][1]
181
- assert "See the docs." in body and "png" not in body
 
182
 
183
 
184
  def test_firecrawl_is_main_path_except_substack(feed, monkeypatch):
 
178
  assert [e.title for e in entries] == ["Rendered Post"]
179
  assert entries[0].published_at.year == 2026
180
  body = entries[0].sections[0][1]
181
+ assert "See the docs." in body and "[the docs]" not in body
182
+ assert "![hero](https://x/y.png)" in [t for _, t in entries[0].sections][0] # images kept for answers
183
 
184
 
185
  def test_firecrawl_is_main_path_except_substack(feed, monkeypatch):
frontend/src/components/Markdown.tsx CHANGED
@@ -1,14 +1,71 @@
1
- import type { ReactNode } from "react";
2
  import type { Citation } from "../api/types";
 
3
 
4
  /** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
5
  * [n] citation markers into buttons that open the cited lines.
6
- * Supports: headings, paragraphs, bullet and numbered lists (one nested level), blockquotes, fenced
7
- * code, tables, horizontal rules, bold, italic, strikethrough, inline code and links. */
 
8
 
9
  type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
10
 
11
- const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(\[[^\]]+\]\((https?:\/\/[^)\s]+)\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)]+)/g;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
14
  const out: ReactNode[] = [];
@@ -19,17 +76,25 @@ function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
19
  const idx = m.index ?? 0;
20
  if (idx > last) out.push(text.slice(last, idx));
21
  const key = `${keyBase}-${i++}`;
22
- const [whole, code, bold, italic, strike, link, href, markers, bare] = m;
23
  if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
24
  else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
25
  else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
26
  else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
27
- else if (link && href) {
28
- const label = link.slice(1, link.indexOf("]("));
 
 
 
 
29
  out.push(
30
- <a key={key} href={href} target="_blank" rel="noreferrer">
31
- {inline(label, cite, key)}
32
- </a>,
 
 
 
 
33
  );
34
  } else if (markers) {
35
  for (const n of markers.matchAll(/\[(\d+)\]/g)) {
@@ -59,14 +124,16 @@ function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
59
  return out;
60
  }
61
 
62
- type ListItem = { text: string; children: ListItem[] };
 
63
  type Block =
64
  | { kind: "h"; level: number; text: string }
65
  | { kind: "p"; text: string }
66
  | { kind: "ul" | "ol"; items: ListItem[]; start: number }
67
  | { kind: "quote"; text: string }
68
  | { kind: "code"; text: string; lang: string }
69
- | { kind: "table"; head: string[]; rows: string[][] }
 
70
  | { kind: "hr" };
71
 
72
  const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
@@ -98,7 +165,13 @@ function parse(src: string): Block[] {
98
  blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
99
  continue;
100
  }
101
- const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
 
 
 
 
 
 
102
  if (heading) {
103
  blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
104
  i++;
@@ -111,10 +184,13 @@ function parse(src: string): Block[] {
111
  }
112
  if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
113
  const head = splitRow(trimmed);
 
 
 
114
  i += 2;
115
  const rows: string[][] = [];
116
  while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
117
- blocks.push({ kind: "table", head, rows });
118
  continue;
119
  }
120
  if (trimmed.startsWith(">")) {
@@ -131,7 +207,10 @@ function parse(src: string): Block[] {
131
  while (i < lines.length) {
132
  const m = lines[i].match(LIST);
133
  if (m) {
134
- const item = { text: m[3], children: [] as ListItem[] };
 
 
 
135
  if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
136
  else items.push(item);
137
  i++;
@@ -149,7 +228,7 @@ function parse(src: string): Block[] {
149
  continue;
150
  }
151
  const para: string[] = [];
152
- while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,4}\s|>|```|\|)/.test(lines[i].trim())) {
153
  para.push(lines[i++].trim());
154
  }
155
  if (para.length === 0) {
@@ -170,7 +249,8 @@ function withBreaks(text: string, cite: Cite, key: string): ReactNode[] {
170
 
171
  function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
172
  const children = items.map((it, j) => (
173
- <li key={`${key}-${j}`}>
 
174
  {inline(it.text, cite, `${key}-${j}`)}
175
  {it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
176
  </li>
@@ -192,7 +272,7 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
192
  const key = `b${i}`;
193
  switch (b.kind) {
194
  case "h": {
195
- const Tag = (["h3", "h3", "h4", "h5"] as const)[b.level - 1];
196
  return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
197
  }
198
  case "p":
@@ -203,10 +283,13 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
203
  case "quote":
204
  return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
205
  case "code":
 
 
206
  return (
207
- <pre key={key} data-lang={b.lang || undefined}>
208
- <code>{b.text}</code>
209
- </pre>
 
210
  );
211
  case "table":
212
  return (
@@ -215,7 +298,9 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
215
  <thead>
216
  <tr>
217
  {b.head.map((h, j) => (
218
- <th key={j}>{inline(h, cite, `${key}-h${j}`)}</th>
 
 
219
  ))}
220
  </tr>
221
  </thead>
@@ -223,7 +308,9 @@ export function Markdown({ text, citations, onCite, className = "" }: { text: st
223
  {b.rows.map((r, j) => (
224
  <tr key={j}>
225
  {r.map((c, k) => (
226
- <td key={k}>{inline(c, cite, `${key}-${j}-${k}`)}</td>
 
 
227
  ))}
228
  </tr>
229
  ))}
 
1
+ import { useState, type ReactNode } from "react";
2
  import type { Citation } from "../api/types";
3
+ import { highlight, languageLabel } from "../lib/highlight";
4
 
5
  /** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
6
  * [n] citation markers into buttons that open the cited lines.
7
+ * Supports: headings, paragraphs, bullet, numbered and task lists (one nested level), blockquotes,
8
+ * fenced code (highlighted, copyable, collapsible), tables with alignment, images, horizontal rules,
9
+ * bold, italic, strikethrough, inline code, links and bare URLs. */
10
 
11
  type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
12
 
13
+ const INLINE =
14
+ /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\))|(\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)<]+[^\s)<.,;:!?'"])/g;
15
+
16
+ /** Links: web, mail and in app paths only (no javascript: and friends). Images: web and app paths. */
17
+ const safeHref = (url: string) => (/^(https?:\/\/|mailto:|\/(?!\/)|#)/i.test(url) ? url : null);
18
+ const safeSrc = (url: string) => (/^(https?:\/\/|\/(?!\/))/i.test(url) ? url : null);
19
+
20
+ function Img({ src, alt, title }: { src: string; alt: string; title?: string }) {
21
+ const [broken, setBroken] = useState(false);
22
+ if (broken) return <span className="md-img-broken mono">[image: {alt || src}]</span>;
23
+ return (
24
+ <a className="md-img" href={src} target="_blank" rel="noreferrer" title={title || alt || "Open image"}>
25
+ <img src={src} alt={alt} loading="lazy" decoding="async" referrerPolicy="no-referrer" onError={() => setBroken(true)} />
26
+ </a>
27
+ );
28
+ }
29
+
30
+ const COLLAPSE_AT = 24; // lines
31
+
32
+ function CodeBlock({ code, lang }: { code: string; lang: string }) {
33
+ const [copied, setCopied] = useState(false);
34
+ const lines = code.split("\n");
35
+ const long = lines.length > COLLAPSE_AT;
36
+ const [open, setOpen] = useState(!long);
37
+ const shown = open ? code : lines.slice(0, COLLAPSE_AT).join("\n");
38
+ return (
39
+ <div className={"md-code" + (open ? "" : " collapsed")}>
40
+ <div className="md-code-bar">
41
+ <span className="mono">{languageLabel(lang)}</span>
42
+ <button
43
+ type="button"
44
+ className="md-code-copy mono"
45
+ onClick={async () => {
46
+ try {
47
+ await navigator.clipboard.writeText(code);
48
+ setCopied(true);
49
+ setTimeout(() => setCopied(false), 1500);
50
+ } catch {
51
+ /* clipboard blocked */
52
+ }
53
+ }}
54
+ >
55
+ {copied ? "Copied" : "Copy"}
56
+ </button>
57
+ </div>
58
+ <pre data-lang={lang || undefined}>
59
+ <code>{highlight(shown, lang)}</code>
60
+ </pre>
61
+ {long && (
62
+ <button type="button" className="md-code-more mono" onClick={() => setOpen((o) => !o)}>
63
+ {open ? "Show less" : "Show all " + lines.length + " lines"}
64
+ </button>
65
+ )}
66
+ </div>
67
+ );
68
+ }
69
 
70
  function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
71
  const out: ReactNode[] = [];
 
76
  const idx = m.index ?? 0;
77
  if (idx > last) out.push(text.slice(last, idx));
78
  const key = `${keyBase}-${i++}`;
79
+ const [whole, code, bold, italic, strike, image, alt, src, title, link, label, href, markers, bare] = m;
80
  if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
81
  else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
82
  else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
83
  else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
84
+ else if (image) {
85
+ const url = safeSrc(src);
86
+ out.push(url ? <Img key={key} src={url} alt={alt} title={title} /> : alt);
87
+ } else if (link) {
88
+ const url = safeHref(href);
89
+ const external = url ? /^https?:/i.test(url) : false;
90
  out.push(
91
+ url ? (
92
+ <a key={key} href={url} {...(external ? { target: "_blank", rel: "noreferrer" } : {})}>
93
+ {inline(label, cite, key)}
94
+ </a>
95
+ ) : (
96
+ <span key={key}>{inline(label, cite, key)}</span>
97
+ ),
98
  );
99
  } else if (markers) {
100
  for (const n of markers.matchAll(/\[(\d+)\]/g)) {
 
124
  return out;
125
  }
126
 
127
+ type ListItem = { text: string; children: ListItem[]; task?: boolean; done?: boolean };
128
+ type Align = "left" | "center" | "right" | undefined;
129
  type Block =
130
  | { kind: "h"; level: number; text: string }
131
  | { kind: "p"; text: string }
132
  | { kind: "ul" | "ol"; items: ListItem[]; start: number }
133
  | { kind: "quote"; text: string }
134
  | { kind: "code"; text: string; lang: string }
135
+ | { kind: "table"; head: string[]; rows: string[][]; align: Align[] }
136
+ | { kind: "img"; src: string; alt: string; title?: string }
137
  | { kind: "hr" };
138
 
139
  const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
 
165
  blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
166
  continue;
167
  }
168
+ const image = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
169
+ if (image && safeSrc(image[2])) {
170
+ blocks.push({ kind: "img", alt: image[1], src: image[2], title: image[3] });
171
+ i++;
172
+ continue;
173
+ }
174
+ const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
175
  if (heading) {
176
  blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
177
  i++;
 
184
  }
185
  if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
186
  const head = splitRow(trimmed);
187
+ const align: Align[] = splitRow(lines[i + 1]).map((c) =>
188
+ c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : undefined,
189
+ );
190
  i += 2;
191
  const rows: string[][] = [];
192
  while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
193
+ blocks.push({ kind: "table", head, rows, align });
194
  continue;
195
  }
196
  if (trimmed.startsWith(">")) {
 
207
  while (i < lines.length) {
208
  const m = lines[i].match(LIST);
209
  if (m) {
210
+ const task = m[3].match(/^\[([ xX])\]\s+(.*)$/);
211
+ const item: ListItem = task
212
+ ? { text: task[2], children: [], task: true, done: task[1] !== " " }
213
+ : { text: m[3], children: [] };
214
  if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
215
  else items.push(item);
216
  i++;
 
228
  continue;
229
  }
230
  const para: string[] = [];
231
+ while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,6}\s|>|```|\||!\[)/.test(lines[i].trim())) {
232
  para.push(lines[i++].trim());
233
  }
234
  if (para.length === 0) {
 
249
 
250
  function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
251
  const children = items.map((it, j) => (
252
+ <li key={`${key}-${j}`} className={it.task ? `md-task${it.done ? " done" : ""}` : undefined}>
253
+ {it.task && <input type="checkbox" checked={it.done} readOnly tabIndex={-1} aria-label={it.done ? "Done" : "Not done"} />}
254
  {inline(it.text, cite, `${key}-${j}`)}
255
  {it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
256
  </li>
 
272
  const key = `b${i}`;
273
  switch (b.kind) {
274
  case "h": {
275
+ const Tag = (["h3", "h3", "h4", "h5", "h5", "h5"] as const)[b.level - 1];
276
  return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
277
  }
278
  case "p":
 
283
  case "quote":
284
  return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
285
  case "code":
286
+ return <CodeBlock key={key} code={b.text} lang={b.lang} />;
287
+ case "img":
288
  return (
289
+ <figure key={key} className="md-figure">
290
+ <Img src={b.src} alt={b.alt} title={b.title} />
291
+ {(b.title || b.alt) && <figcaption className="muted">{b.title || b.alt}</figcaption>}
292
+ </figure>
293
  );
294
  case "table":
295
  return (
 
298
  <thead>
299
  <tr>
300
  {b.head.map((h, j) => (
301
+ <th key={j} style={b.align[j] ? { textAlign: b.align[j] } : undefined}>
302
+ {inline(h, cite, `${key}-h${j}`)}
303
+ </th>
304
  ))}
305
  </tr>
306
  </thead>
 
308
  {b.rows.map((r, j) => (
309
  <tr key={j}>
310
  {r.map((c, k) => (
311
+ <td key={k} style={b.align[k] ? { textAlign: b.align[k] } : undefined}>
312
+ {inline(c, cite, `${key}-${j}-${k}`)}
313
+ </td>
314
  ))}
315
  </tr>
316
  ))}
frontend/src/styles/pages.css CHANGED
@@ -1627,6 +1627,160 @@ select.input option {
1627
  line-height: 1.6;
1628
  }
1629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  .md blockquote {
1631
  padding: 4px 0 4px 16px;
1632
  border-left: 3px solid var(--blue);
 
1627
  line-height: 1.6;
1628
  }
1629
 
1630
+ /* Code blocks: language bar, copy, highlighting, collapse */
1631
+ .md-code {
1632
+ position: relative;
1633
+ border: 1px solid var(--line);
1634
+ border-radius: var(--radius-sm);
1635
+ background: rgba(255, 255, 255, 0.03);
1636
+ overflow: hidden;
1637
+ max-width: 100%;
1638
+ }
1639
+
1640
+ .md-code-bar {
1641
+ display: flex;
1642
+ align-items: center;
1643
+ justify-content: space-between;
1644
+ gap: 8px;
1645
+ padding: 6px 8px 6px 14px;
1646
+ border-bottom: 1px solid var(--line);
1647
+ background: rgba(33, 124, 255, 0.06);
1648
+ font-size: 0.7rem;
1649
+ letter-spacing: 0.08em;
1650
+ text-transform: uppercase;
1651
+ color: var(--text-3);
1652
+ }
1653
+
1654
+ .md-code-copy,
1655
+ .md-code-more {
1656
+ border: 1px solid var(--line);
1657
+ border-radius: 999px;
1658
+ background: transparent;
1659
+ color: var(--text-2);
1660
+ font-size: 0.7rem;
1661
+ letter-spacing: 0.06em;
1662
+ padding: 3px 10px;
1663
+ cursor: pointer;
1664
+ }
1665
+
1666
+ .md-code-copy:hover,
1667
+ .md-code-more:hover {
1668
+ color: var(--white);
1669
+ border-color: var(--blue-60);
1670
+ }
1671
+
1672
+ .md-code pre {
1673
+ margin: 0;
1674
+ border: none;
1675
+ border-radius: 0;
1676
+ background: transparent;
1677
+ max-height: 520px;
1678
+ overflow: auto;
1679
+ }
1680
+
1681
+ .md-code.collapsed pre {
1682
+ mask-image: linear-gradient(180deg, #000 70%, transparent);
1683
+ }
1684
+
1685
+ .md-code-more {
1686
+ display: block;
1687
+ margin: 0 auto 10px;
1688
+ }
1689
+
1690
+ .md pre code {
1691
+ color: rgba(255, 255, 255, 0.82);
1692
+ white-space: pre;
1693
+ tab-size: 2;
1694
+ }
1695
+
1696
+ .tok-kw {
1697
+ color: var(--blue);
1698
+ font-weight: 600;
1699
+ }
1700
+
1701
+ .tok-str {
1702
+ color: var(--white);
1703
+ }
1704
+
1705
+ .tok-com {
1706
+ color: var(--text-3);
1707
+ font-style: italic;
1708
+ }
1709
+
1710
+ .tok-num,
1711
+ .tok-prop {
1712
+ color: color-mix(in srgb, var(--blue) 55%, var(--white));
1713
+ }
1714
+
1715
+ .tok-fn {
1716
+ color: var(--white);
1717
+ font-weight: 600;
1718
+ }
1719
+
1720
+ .tok-type {
1721
+ color: var(--white);
1722
+ text-decoration: underline;
1723
+ text-decoration-color: var(--blue-35);
1724
+ text-underline-offset: 3px;
1725
+ }
1726
+
1727
+ /* Images */
1728
+ .md-figure {
1729
+ margin: 0;
1730
+ display: grid;
1731
+ gap: 6px;
1732
+ }
1733
+
1734
+ .md-figure figcaption {
1735
+ font-size: 0.8rem;
1736
+ }
1737
+
1738
+ .md-img {
1739
+ display: inline-block;
1740
+ max-width: 100%;
1741
+ border-radius: var(--radius-sm);
1742
+ overflow: hidden;
1743
+ border: 1px solid var(--line);
1744
+ line-height: 0;
1745
+ vertical-align: middle;
1746
+ }
1747
+
1748
+ .md-img img {
1749
+ display: block;
1750
+ max-width: 100%;
1751
+ max-height: 420px;
1752
+ height: auto;
1753
+ object-fit: contain;
1754
+ background: rgba(255, 255, 255, 0.03);
1755
+ }
1756
+
1757
+ .md-img:hover {
1758
+ border-color: var(--blue-60);
1759
+ box-shadow: 0 0 18px var(--blue-20);
1760
+ }
1761
+
1762
+ .md-img-broken {
1763
+ font-size: 0.8rem;
1764
+ color: var(--text-3);
1765
+ }
1766
+
1767
+ /* Task lists */
1768
+ .md li.md-task {
1769
+ list-style: none;
1770
+ margin-left: -1.2em;
1771
+ }
1772
+
1773
+ .md li.md-task input {
1774
+ accent-color: var(--blue);
1775
+ margin: 0 8px 0 0;
1776
+ vertical-align: -2px;
1777
+ }
1778
+
1779
+ .md li.md-task.done {
1780
+ color: var(--text-3);
1781
+ text-decoration: line-through;
1782
+ }
1783
+
1784
  .md blockquote {
1785
  padding: 4px 0 4px 16px;
1786
  border-left: 3px solid var(--blue);
frontend/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}