@zhin.js/client 2.1.8 → 2.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/client/index.ts +19 -2
  2. package/client/mediaSrc.test.ts +15 -0
  3. package/client/mediaSrc.ts +59 -12
  4. package/client/message-content/MarkdownContent.tsx +128 -0
  5. package/client/message-content/markdown.test.ts +48 -0
  6. package/client/message-content/markdown.ts +247 -0
  7. package/client/workroom/WorkroomRunsPage.tsx +248 -0
  8. package/client/{orchestration/registerOrchestrationConsole.test.ts → workroom/registerWorkroomConsole.test.ts} +8 -8
  9. package/client/workroom/registerWorkroomConsole.ts +17 -0
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/mediaSrc.d.ts.map +1 -1
  15. package/dist/mediaSrc.js +66 -12
  16. package/dist/mediaSrc.js.map +1 -1
  17. package/dist/message-content/MarkdownContent.d.ts +11 -0
  18. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  19. package/dist/message-content/MarkdownContent.js +68 -0
  20. package/dist/message-content/MarkdownContent.js.map +1 -0
  21. package/dist/message-content/markdown.d.ts +47 -0
  22. package/dist/message-content/markdown.d.ts.map +1 -0
  23. package/dist/message-content/markdown.js +219 -0
  24. package/dist/message-content/markdown.js.map +1 -0
  25. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  26. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  27. package/dist/workroom/WorkroomRunsPage.js +64 -0
  28. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  29. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  30. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  31. package/dist/workroom/registerWorkroomConsole.js +16 -0
  32. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  33. package/package.json +3 -3
  34. package/client/orchestration/OrchestrationRunsPage.tsx +0 -217
  35. package/client/orchestration/registerOrchestrationConsole.ts +0 -17
  36. package/dist/orchestration/OrchestrationRunsPage.d.ts +0 -2
  37. package/dist/orchestration/OrchestrationRunsPage.d.ts.map +0 -1
  38. package/dist/orchestration/OrchestrationRunsPage.js +0 -65
  39. package/dist/orchestration/OrchestrationRunsPage.js.map +0 -1
  40. package/dist/orchestration/registerOrchestrationConsole.d.ts +0 -4
  41. package/dist/orchestration/registerOrchestrationConsole.d.ts.map +0 -1
  42. package/dist/orchestration/registerOrchestrationConsole.js +0 -16
  43. package/dist/orchestration/registerOrchestrationConsole.js.map +0 -1
package/client/index.ts CHANGED
@@ -19,6 +19,23 @@ export {
19
19
  // Media URL resolution
20
20
  export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
21
21
 
22
+ // Safe rich message rendering (Markdown + code blocks; no raw HTML execution)
23
+ export {
24
+ MarkdownContent,
25
+ CodeBlock,
26
+ type MarkdownContentProps,
27
+ } from "./message-content/MarkdownContent.js";
28
+ export {
29
+ parseMarkdown,
30
+ parseMarkdownInline,
31
+ highlightCodeLine,
32
+ isSafeMarkdownHref,
33
+ type MarkdownBlock,
34
+ type MarkdownInline,
35
+ type CodeToken,
36
+ type CodeTokenKind,
37
+ } from "./message-content/markdown.js";
38
+
22
39
  // Segment IM visibility (inbox vs agent panel)
23
40
  export { segmentsForImDelivery, segmentsForAgentPanel } from "./segments.js";
24
41
 
@@ -67,8 +84,8 @@ export {
67
84
  type LoadConsoleEntriesOptions,
68
85
  } from "./bootstrap/loadConsoleEntries.js";
69
86
 
70
- export { registerOrchestrationConsole } from "./orchestration/registerOrchestrationConsole.js";
71
- export { default as OrchestrationRunsPage } from "./orchestration/OrchestrationRunsPage.js";
87
+ export { registerWorkroomConsole } from "./workroom/registerWorkroomConsole.js";
88
+ export { default as WorkroomRunsPage } from "./workroom/WorkroomRunsPage.js";
72
89
 
73
90
  export {
74
91
  startAgentSession,
@@ -31,4 +31,19 @@ describe('pickMediaRawUrl', () => {
31
31
  expect(pickMediaRawUrl(undefined)).toBeUndefined();
32
32
  expect(pickMediaRawUrl({})).toBeUndefined();
33
33
  });
34
+
35
+ it('rejects executable data URLs, credentials, and malformed base64', () => {
36
+ expect(resolveMediaSrc('data:text/html;base64,PHNjcmlwdD4=', 'image')).toBeUndefined();
37
+ expect(resolveMediaSrc('data:image/svg+xml;base64,PHN2Zz4=', 'image')).toBeUndefined();
38
+ expect(resolveMediaSrc('https://user:secret@example.com/a.png', 'image')).toBeUndefined();
39
+ expect(resolveMediaSrc('base64://not:base64', 'image')).toBeUndefined();
40
+ });
41
+
42
+ it('accepts only data MIME types matching the rendered media kind', () => {
43
+ expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'image')).toBe(
44
+ 'data:image/png;base64,aGVsbG8=',
45
+ );
46
+ expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'video')).toBeUndefined();
47
+ expect(resolveMediaSrc('javascript:alert(1)', 'image')).toBeUndefined();
48
+ });
34
49
  });
@@ -3,10 +3,54 @@ export type MediaKind = 'image' | 'video' | 'audio'
3
3
 
4
4
  const BASE64_PROTO = 'base64://'
5
5
 
6
- const SAFE_URL_SCHEMES = ['data:', 'blob:', 'http://', 'https://']
6
+ const SAFE_DATA_MIME: Readonly<Record<MediaKind, ReadonlySet<string>>> = Object.freeze({
7
+ image: new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']),
8
+ video: new Set(['video/mp4', 'video/ogg', 'video/webm']),
9
+ audio: new Set(['audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/webm']),
10
+ })
7
11
 
8
- function isSafeUrl(url: string): boolean {
9
- return SAFE_URL_SCHEMES.some((scheme) => url.startsWith(scheme))
12
+ function resolveSafeRemoteUrl(value: string): string | undefined {
13
+ try {
14
+ const parsed = new URL(value)
15
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.protocol !== 'blob:') {
16
+ return undefined
17
+ }
18
+ if (parsed.username || parsed.password) return undefined
19
+ return parsed.href
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ function resolveSafeDataUrl(value: string, kind: MediaKind): string | undefined {
26
+ if (!value.startsWith('data:')) return undefined
27
+ const separator = value.indexOf(',')
28
+ if (separator < 0) return undefined
29
+ const header = value.slice('data:'.length, separator).toLowerCase()
30
+ if (!header.endsWith(';base64')) return undefined
31
+ const mime = header.slice(0, -';base64'.length)
32
+ if (!SAFE_DATA_MIME[kind].has(mime)) return undefined
33
+ const payload = compactBase64(value.slice(separator + 1))
34
+ return payload ? `data:${mime};base64,${payload}` : undefined
35
+ }
36
+
37
+ function compactBase64(value: string): string | undefined {
38
+ let compact = ''
39
+ for (const character of value) {
40
+ if (character === ' ' || character === '\n' || character === '\r' || character === '\t') continue
41
+ const code = character.charCodeAt(0)
42
+ const allowed = (code >= 48 && code <= 57)
43
+ || (code >= 65 && code <= 90)
44
+ || (code >= 97 && code <= 122)
45
+ || character === '+' || character === '/' || character === '='
46
+ if (!allowed) return undefined
47
+ compact += character
48
+ }
49
+ const padding = compact.indexOf('=')
50
+ if (padding >= 0 && (compact.length - padding > 2 || compact.slice(padding).replace(/=/g, '') !== '')) {
51
+ return undefined
52
+ }
53
+ return compact || undefined
10
54
  }
11
55
 
12
56
  /**
@@ -27,26 +71,29 @@ export function resolveMediaSrc(
27
71
  const s = raw.trim()
28
72
  if (!s) return undefined
29
73
 
30
- if (isSafeUrl(s)) {
31
- return s
32
- }
74
+ const dataUrl = resolveSafeDataUrl(s, kind)
75
+ if (dataUrl) return dataUrl
76
+
77
+ const remoteUrl = resolveSafeRemoteUrl(s)
78
+ if (remoteUrl) return remoteUrl
33
79
 
34
80
  if (!s.startsWith(BASE64_PROTO)) {
35
- return isSafeUrl(s) ? s : undefined
81
+ return undefined
36
82
  }
37
83
 
38
- const payload = s.slice(BASE64_PROTO.length).replace(/^\s+/, '')
84
+ const payload = s.slice(BASE64_PROTO.length).trimStart()
39
85
  if (!payload) return undefined
40
86
 
41
87
  // 已是 type/subtype;base64, 片段(不含 data: 前缀)
42
- if (/^[\w+.-]+\/[\w+.-]+;base64,/i.test(payload)) {
43
- return `data:${payload}`
88
+ const declared = resolveSafeDataUrl(`data:${payload}`, kind)
89
+ if (declared) {
90
+ return declared
44
91
  }
45
92
 
46
93
  const defaultMime =
47
94
  kind === 'image' ? 'image/png' : kind === 'video' ? 'video/mp4' : 'audio/mpeg'
48
- const b64 = payload.replace(/\s/g, '')
49
- return `data:${defaultMime};base64,${b64}`
95
+ const b64 = compactBase64(payload)
96
+ return b64 ? `data:${defaultMime};base64,${b64}` : undefined
50
97
  }
51
98
 
52
99
  /** 从 segment.data 取常见媒体字段(优先 canonical MediaRef) */
@@ -0,0 +1,128 @@
1
+ import { Fragment, useMemo, useState, type ReactNode } from 'react';
2
+ import {
3
+ highlightCodeLine,
4
+ parseMarkdown,
5
+ type MarkdownInline,
6
+ } from './markdown.js';
7
+
8
+ export interface MarkdownContentProps {
9
+ readonly text: string;
10
+ readonly className?: string;
11
+ }
12
+
13
+ function InlineContent({ content }: { readonly content: readonly MarkdownInline[] }) {
14
+ const nodes: ReactNode[] = [];
15
+ content.forEach((token, index) => {
16
+ const key = `${token.type}-${index}`;
17
+ const value = token.value.split('\n').map((line, lineIndex, lines) => (
18
+ <Fragment key={`${key}-line-${lineIndex}`}>
19
+ {line}
20
+ {lineIndex < lines.length - 1 ? <br /> : null}
21
+ </Fragment>
22
+ ));
23
+ if (token.type === 'strong') nodes.push(<strong key={key}>{value}</strong>);
24
+ else if (token.type === 'emphasis') nodes.push(<em key={key}>{value}</em>);
25
+ else if (token.type === 'strike') nodes.push(<del key={key}>{value}</del>);
26
+ else if (token.type === 'code') nodes.push(<code key={key} className="im-inline-code">{token.value}</code>);
27
+ else if (token.type === 'link') nodes.push(
28
+ <a key={key} href={token.href} target={token.href.startsWith('#') ? undefined : '_blank'} rel="noreferrer" className="im-message-link">{value}</a>,
29
+ );
30
+ else nodes.push(<Fragment key={key}>{value}</Fragment>);
31
+ });
32
+ return <>{nodes}</>;
33
+ }
34
+
35
+ export function CodeBlock({ code, language = '', closed = true }: { readonly code: string; readonly language?: string; readonly closed?: boolean }) {
36
+ const [copied, setCopied] = useState(false);
37
+ const [wrapped, setWrapped] = useState(false);
38
+ const [expanded, setExpanded] = useState(false);
39
+ const lines = useMemo(() => code.split('\n'), [code]);
40
+ const isLong = lines.length > 18 || code.length > 1800;
41
+ const visibleLines = isLong && !expanded ? lines.slice(0, 18) : lines;
42
+
43
+ const copy = async () => {
44
+ try {
45
+ await navigator.clipboard.writeText(code);
46
+ setCopied(true);
47
+ window.setTimeout(() => setCopied(false), 1600);
48
+ } catch {
49
+ setCopied(false);
50
+ }
51
+ };
52
+
53
+ return (
54
+ <figure className="zhin-code-block">
55
+ <figcaption className="zhin-code-toolbar">
56
+ <span className="zhin-code-language">{language || 'plain text'}</span>
57
+ {!closed ? <span className="zhin-code-streaming">生成中</span> : null}
58
+ <span className="zhin-code-actions">
59
+ <button type="button" onClick={() => setWrapped((value) => !value)} aria-pressed={wrapped}>{wrapped ? '不折行' : '折行'}</button>
60
+ <button type="button" onClick={() => void copy()}>{copied ? '已复制' : '复制'}</button>
61
+ </span>
62
+ </figcaption>
63
+ <pre className={wrapped ? 'zhin-code-pre zhin-code-pre--wrap' : 'zhin-code-pre'}>
64
+ <code>
65
+ {visibleLines.map((line, lineIndex) => (
66
+ <span className="zhin-code-line" key={`${lineIndex}-${line}`}>
67
+ <span className="zhin-code-line-number" aria-hidden="true">{lineIndex + 1}</span>
68
+ <span className="zhin-code-line-content">
69
+ {highlightCodeLine(line, language).map((token, tokenIndex) => (
70
+ <span key={`${token.kind}-${tokenIndex}`} className={`zhin-code-token zhin-code-token--${token.kind}`}>{token.value}</span>
71
+ ))}
72
+ {'\n'}
73
+ </span>
74
+ </span>
75
+ ))}
76
+ </code>
77
+ </pre>
78
+ {isLong ? (
79
+ <button type="button" className="zhin-code-expand" onClick={() => setExpanded((value) => !value)}>
80
+ {expanded ? '收起代码' : `展开其余 ${lines.length - visibleLines.length} 行`}
81
+ </button>
82
+ ) : null}
83
+ </figure>
84
+ );
85
+ }
86
+
87
+ export function MarkdownContent({ text, className }: MarkdownContentProps) {
88
+ const blocks = useMemo(() => parseMarkdown(text), [text]);
89
+
90
+ return (
91
+ <div className={['im-markdown-body', 'zhin-markdown', className].filter(Boolean).join(' ')}>
92
+ {blocks.map((block, index) => {
93
+ if (block.type === 'heading') {
94
+ const Heading = `h${block.level}` as keyof React.JSX.IntrinsicElements;
95
+ return <Heading key={index} className={`im-markdown-heading im-markdown-heading-${block.level}`}><InlineContent content={block.content} /></Heading>;
96
+ }
97
+ if (block.type === 'paragraph') return <p key={index} className="im-markdown-paragraph"><InlineContent content={block.content} /></p>;
98
+ if (block.type === 'quote') return <blockquote key={index} className="im-markdown-quote"><InlineContent content={block.content} /></blockquote>;
99
+ if (block.type === 'rule') return <hr key={index} className="zhin-markdown-rule" />;
100
+ if (block.type === 'code') return <CodeBlock key={index} code={block.value} language={block.language} closed={block.closed} />;
101
+ if (block.type === 'list') {
102
+ const List = block.ordered ? 'ol' : 'ul';
103
+ return (
104
+ <List key={index} className={`im-markdown-list${block.ordered ? ' im-markdown-list-ordered' : ''}`}>
105
+ {block.items.map((item, itemIndex) => (
106
+ <li key={itemIndex} className={item.checked === undefined ? undefined : 'zhin-markdown-task'}>
107
+ {item.checked === undefined ? null : <input type="checkbox" checked={item.checked} readOnly aria-label={item.checked ? '已完成' : '未完成'} />}
108
+ <InlineContent content={item.content} />
109
+ </li>
110
+ ))}
111
+ </List>
112
+ );
113
+ }
114
+ if (block.type === 'table') {
115
+ return (
116
+ <div className="zhin-markdown-table-wrap" key={index}>
117
+ <table className="zhin-markdown-table">
118
+ <thead><tr>{block.header.map((cell, cellIndex) => <th key={cellIndex}><InlineContent content={cell} /></th>)}</tr></thead>
119
+ <tbody>{block.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}><InlineContent content={cell} /></td>)}</tr>)}</tbody>
120
+ </table>
121
+ </div>
122
+ );
123
+ }
124
+ return null;
125
+ })}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,48 @@
1
+ import { highlightCodeLine, isSafeMarkdownHref, parseMarkdown, parseMarkdownInline } from './markdown.js';
2
+
3
+ describe('message markdown', () => {
4
+ it('parses rich blocks including tables and tasks', () => {
5
+ const blocks = parseMarkdown(`# Result
6
+
7
+ - [x] inspect runtime
8
+ - [ ] publish patch
9
+
10
+ | field | value |
11
+ | --- | --- |
12
+ | status | **ready** |`);
13
+
14
+ expect(blocks.map((block) => block.type)).toEqual(['heading', 'list', 'table']);
15
+ expect(blocks[1]).toMatchObject({ type: 'list', items: [{ checked: true }, { checked: false }] });
16
+ });
17
+
18
+ it('keeps incomplete streaming fences renderable', () => {
19
+ expect(parseMarkdown('```ts\nconst ready = true')).toEqual([
20
+ { type: 'code', language: 'ts', value: 'const ready = true', closed: false },
21
+ ]);
22
+ });
23
+
24
+ it('rejects unsafe markdown links without emitting active links', () => {
25
+ expect(isSafeMarkdownHref('javascript:alert(1)')).toBe(false);
26
+ expect(isSafeMarkdownHref('https://zhin.dev/docs')).toBe(true);
27
+ expect(parseMarkdownInline('[run](javascript:alert(1))')).toEqual([
28
+ { type: 'text', value: '[run](javascript:alert(1))' },
29
+ ]);
30
+ });
31
+
32
+ it('treats repeated unmatched link openers as plain text', () => {
33
+ const value = '['.repeat(20_000);
34
+ expect(parseMarkdownInline(value)).toEqual([{ type: 'text', value }]);
35
+ });
36
+
37
+ it('tokenizes common code without producing html', () => {
38
+ expect(highlightCodeLine('const answer = "<safe>" // note', 'ts')).toEqual([
39
+ { kind: 'keyword', value: 'const' },
40
+ { kind: 'plain', value: ' ' },
41
+ { kind: 'plain', value: 'answer' },
42
+ { kind: 'plain', value: ' = ' },
43
+ { kind: 'string', value: '"<safe>"' },
44
+ { kind: 'plain', value: ' ' },
45
+ { kind: 'comment', value: '// note' },
46
+ ]);
47
+ });
48
+ });
@@ -0,0 +1,247 @@
1
+ export type MarkdownInline =
2
+ | { readonly type: 'text'; readonly value: string }
3
+ | { readonly type: 'strong' | 'emphasis' | 'strike' | 'code'; readonly value: string }
4
+ | { readonly type: 'link'; readonly value: string; readonly href: string };
5
+
6
+ export type MarkdownBlock =
7
+ | { readonly type: 'heading'; readonly level: number; readonly content: MarkdownInline[] }
8
+ | { readonly type: 'paragraph' | 'quote'; readonly content: MarkdownInline[] }
9
+ | { readonly type: 'list'; readonly ordered: boolean; readonly items: ReadonlyArray<{ readonly content: MarkdownInline[]; readonly checked?: boolean }> }
10
+ | { readonly type: 'code'; readonly language: string; readonly value: string; readonly closed: boolean }
11
+ | { readonly type: 'table'; readonly header: ReadonlyArray<MarkdownInline[]>; readonly rows: ReadonlyArray<ReadonlyArray<MarkdownInline[]>> }
12
+ | { readonly type: 'rule' };
13
+
14
+ export type CodeTokenKind = 'plain' | 'comment' | 'string' | 'number' | 'keyword' | 'literal';
15
+
16
+ export interface CodeToken {
17
+ readonly kind: CodeTokenKind;
18
+ readonly value: string;
19
+ }
20
+
21
+ // Excluding another opening bracket from link labels prevents the regexp
22
+ // engine from rescanning an attacker-controlled run of `[` characters.
23
+ const INLINE_PATTERN = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(\[[^\[\]\n]+\]\([^\s()]+(?:\s+"[^"\n]*")?\))|(https?:\/\/[^\s<>()]+)/g;
24
+ const TABLE_DIVIDER = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
25
+
26
+ export function isSafeMarkdownHref(href: string): boolean {
27
+ const value = href.trim();
28
+ if (!value) return false;
29
+ if (/^(https?:|mailto:)/i.test(value)) return true;
30
+ return /^(\/|#|\.\/|\.\.\/)/.test(value);
31
+ }
32
+
33
+ export function parseMarkdownInline(value: string): MarkdownInline[] {
34
+ const tokens: MarkdownInline[] = [];
35
+ let cursor = 0;
36
+ let match: RegExpExecArray | null;
37
+ INLINE_PATTERN.lastIndex = 0;
38
+
39
+ while ((match = INLINE_PATTERN.exec(value)) !== null) {
40
+ if (match.index > cursor) tokens.push({ type: 'text', value: value.slice(cursor, match.index) });
41
+ const raw = match[0];
42
+ if (match[1]) {
43
+ tokens.push({ type: 'code', value: raw.slice(1, -1) });
44
+ } else if (match[2]) {
45
+ tokens.push({ type: 'strong', value: raw.slice(2, -2) });
46
+ } else if (match[3]) {
47
+ tokens.push({ type: 'strike', value: raw.slice(2, -2) });
48
+ } else if (match[4]) {
49
+ tokens.push({ type: 'emphasis', value: raw.slice(1, -1) });
50
+ } else if (match[5]) {
51
+ const parsed = raw.match(/^\[([^\[\]]+)\]\(([^\s()]+)(?:\s+"[^"\n]*")?\)$/);
52
+ if (parsed && isSafeMarkdownHref(parsed[2])) {
53
+ tokens.push({ type: 'link', value: parsed[1], href: parsed[2] });
54
+ } else {
55
+ tokens.push({ type: 'text', value: raw });
56
+ }
57
+ } else if (match[6]) {
58
+ tokens.push({ type: 'link', value: raw, href: raw });
59
+ }
60
+ cursor = INLINE_PATTERN.lastIndex;
61
+ }
62
+
63
+ if (cursor < value.length) tokens.push({ type: 'text', value: value.slice(cursor) });
64
+ const merged: MarkdownInline[] = [];
65
+ for (const token of tokens) {
66
+ const previous = merged.at(-1);
67
+ if (token.type === 'text' && previous?.type === 'text') {
68
+ merged[merged.length - 1] = { type: 'text', value: previous.value + token.value };
69
+ } else {
70
+ merged.push(token);
71
+ }
72
+ }
73
+ return merged.length > 0 ? merged : [{ type: 'text', value }];
74
+ }
75
+
76
+ function splitTableRow(line: string): string[] {
77
+ return line
78
+ .trim()
79
+ .replace(/^\|/, '')
80
+ .replace(/\|$/, '')
81
+ .split(/(?<!\\)\|/)
82
+ .map((cell) => cell.trim().replace(/\\\|/g, '|'));
83
+ }
84
+
85
+ function startsBlock(lines: readonly string[], index: number): boolean {
86
+ const line = lines[index] ?? '';
87
+ return /^(?:```|~~~|#{1,6}\s+|>\s?|\s*(?:[-+*]|\d+[.)])\s+|\s*(?:-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
88
+ || (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? ''));
89
+ }
90
+
91
+ export function parseMarkdown(value: string): MarkdownBlock[] {
92
+ const lines = value.replace(/\r\n?/g, '\n').split('\n');
93
+ const blocks: MarkdownBlock[] = [];
94
+ let index = 0;
95
+
96
+ while (index < lines.length) {
97
+ const line = lines[index];
98
+ if (!line.trim()) {
99
+ index += 1;
100
+ continue;
101
+ }
102
+
103
+ const fence = line.match(/^\s*(```|~~~)\s*([^\s`]*)?.*$/);
104
+ if (fence) {
105
+ const marker = fence[1];
106
+ const language = (fence[2] ?? '').toLowerCase();
107
+ const code: string[] = [];
108
+ let closed = false;
109
+ index += 1;
110
+ while (index < lines.length) {
111
+ if (lines[index].trim() === marker) {
112
+ closed = true;
113
+ index += 1;
114
+ break;
115
+ }
116
+ code.push(lines[index]);
117
+ index += 1;
118
+ }
119
+ blocks.push({ type: 'code', language, value: code.join('\n'), closed });
120
+ continue;
121
+ }
122
+
123
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
124
+ if (heading) {
125
+ blocks.push({ type: 'heading', level: heading[1].length, content: parseMarkdownInline(heading[2]) });
126
+ index += 1;
127
+ continue;
128
+ }
129
+
130
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
131
+ blocks.push({ type: 'rule' });
132
+ index += 1;
133
+ continue;
134
+ }
135
+
136
+ if (/^>\s?/.test(line)) {
137
+ const quoted: string[] = [];
138
+ while (index < lines.length && /^>\s?/.test(lines[index])) {
139
+ quoted.push(lines[index].replace(/^>\s?/, ''));
140
+ index += 1;
141
+ }
142
+ blocks.push({ type: 'quote', content: parseMarkdownInline(quoted.join('\n')) });
143
+ continue;
144
+ }
145
+
146
+ const listMatch = line.match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
147
+ if (listMatch) {
148
+ const ordered = /^\d/.test(listMatch[1]);
149
+ const items: Array<{ content: MarkdownInline[]; checked?: boolean }> = [];
150
+ while (index < lines.length) {
151
+ const item = lines[index].match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
152
+ if (!item || /^\d/.test(item[1]) !== ordered) break;
153
+ const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
154
+ items.push({
155
+ content: parseMarkdownInline(task ? task[2] : item[2]),
156
+ ...(task ? { checked: task[1].toLowerCase() === 'x' } : {}),
157
+ });
158
+ index += 1;
159
+ }
160
+ blocks.push({ type: 'list', ordered, items });
161
+ continue;
162
+ }
163
+
164
+ if (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? '')) {
165
+ const header = splitTableRow(line).map(parseMarkdownInline);
166
+ const rows: MarkdownInline[][][] = [];
167
+ index += 2;
168
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
169
+ rows.push(splitTableRow(lines[index]).map(parseMarkdownInline));
170
+ index += 1;
171
+ }
172
+ blocks.push({ type: 'table', header, rows });
173
+ continue;
174
+ }
175
+
176
+ const paragraph = [line];
177
+ index += 1;
178
+ while (index < lines.length && lines[index].trim() && !startsBlock(lines, index)) {
179
+ paragraph.push(lines[index]);
180
+ index += 1;
181
+ }
182
+ blocks.push({ type: 'paragraph', content: parseMarkdownInline(paragraph.join('\n')) });
183
+ }
184
+
185
+ return blocks;
186
+ }
187
+
188
+ const KEYWORDS: Record<string, ReadonlySet<string>> = {
189
+ javascript: new Set(['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'switch', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'yield']),
190
+ typescript: new Set(['abstract', 'any', 'as', 'asserts', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'keyof', 'let', 'namespace', 'never', 'new', 'number', 'object', 'of', 'readonly', 'return', 'satisfies', 'static', 'string', 'switch', 'symbol', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'yield']),
191
+ python: new Set(['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']),
192
+ shell: new Set(['case', 'do', 'done', 'elif', 'else', 'esac', 'export', 'fi', 'for', 'function', 'if', 'in', 'local', 'then', 'while']),
193
+ css: new Set(['@import', '@media', '@supports', 'and', 'important', 'not', 'only']),
194
+ };
195
+
196
+ const LANGUAGE_ALIASES: Record<string, string> = {
197
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
198
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
199
+ py: 'python', bash: 'shell', sh: 'shell', zsh: 'shell', jsonc: 'json',
200
+ };
201
+
202
+ export function highlightCodeLine(line: string, language = ''): CodeToken[] {
203
+ const normalized = LANGUAGE_ALIASES[language] ?? language;
204
+ const keywords = KEYWORDS[normalized] ?? KEYWORDS.javascript;
205
+ const tokens: CodeToken[] = [];
206
+ let index = 0;
207
+
208
+ const push = (kind: CodeTokenKind, value: string) => {
209
+ if (value) tokens.push({ kind, value });
210
+ };
211
+
212
+ while (index < line.length) {
213
+ const rest = line.slice(index);
214
+ const comment = normalized === 'python' || normalized === 'shell'
215
+ ? rest.match(/^#.*/)
216
+ : rest.match(/^\/\/.*|^\/\*.*?\*\//);
217
+ if (comment) {
218
+ push('comment', comment[0]);
219
+ index += comment[0].length;
220
+ continue;
221
+ }
222
+ const string = rest.match(/^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/);
223
+ if (string) {
224
+ push('string', string[0]);
225
+ index += string[0].length;
226
+ continue;
227
+ }
228
+ const number = rest.match(/^(?:0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i);
229
+ if (number) {
230
+ push('number', number[0]);
231
+ index += number[0].length;
232
+ continue;
233
+ }
234
+ const word = rest.match(/^[A-Za-z_$@][\w$-]*/);
235
+ if (word) {
236
+ const literal = /^(?:true|false|null|undefined|None|True|False)$/.test(word[0]);
237
+ push(literal ? 'literal' : keywords.has(word[0]) ? 'keyword' : 'plain', word[0]);
238
+ index += word[0].length;
239
+ continue;
240
+ }
241
+ const plain = rest.match(/^[^A-Za-z_$@'"`\d/#]+/) ?? rest.match(/^./);
242
+ push('plain', plain?.[0] ?? '');
243
+ index += plain?.[0].length ?? 1;
244
+ }
245
+
246
+ return tokens;
247
+ }