@linxin666/dsh-remote-web-ui 0.1.17 → 0.1.18

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 (36) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/lib/client.js +48 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/mobile.js +649 -45
  7. package/lib/mobile.js.map +1 -1
  8. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +11 -5
  11. package/lib/types/mobile/markdown.d.ts +26 -0
  12. package/lib/types/mobile/markdown.d.ts.map +1 -0
  13. package/lib/types/mobile/markdown.js +272 -0
  14. package/lib/types/mobile/markdown.test.d.ts +2 -0
  15. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  16. package/lib/types/mobile/markdown.test.js +67 -0
  17. package/lib/types/mobile/messages.d.ts +14 -0
  18. package/lib/types/mobile/messages.d.ts.map +1 -1
  19. package/lib/types/mobile/messages.js +54 -2
  20. package/lib/types/mobile/messages.test.js +99 -0
  21. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  22. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  23. package/lib/types/mobile/mobile-styles.js +167 -0
  24. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  25. package/lib/types/mobile/views/ChatView.js +81 -5
  26. package/lib/types/mobile/views/ChatView.test.js +105 -0
  27. package/package.json +1 -1
  28. package/src/client/PluginSettingsCard.tsx +50 -47
  29. package/src/client/settings-card.module.css +9 -0
  30. package/src/mobile/markdown.test.ts +77 -0
  31. package/src/mobile/markdown.ts +278 -0
  32. package/src/mobile/messages.test.ts +106 -0
  33. package/src/mobile/messages.ts +66 -2
  34. package/src/mobile/mobile-styles.ts +167 -0
  35. package/src/mobile/views/ChatView.test.tsx +121 -0
  36. package/src/mobile/views/ChatView.tsx +164 -6
@@ -461,3 +461,108 @@ describe('ChatView scrolling', () => {
461
461
  await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
462
462
  });
463
463
  });
464
+ describe('ChatView display toggles and context usage', () => {
465
+ /** jsdom in this setup ships a bare localStorage object; install a real fake. */
466
+ const makeStorage = () => {
467
+ const map = new Map();
468
+ return {
469
+ getItem: (key) => map.get(key) ?? null,
470
+ setItem: (key, value) => { map.set(key, value); },
471
+ removeItem: (key) => { map.delete(key); },
472
+ clear: () => { map.clear(); },
473
+ key: (index) => [...map.keys()][index] ?? null,
474
+ get length() { return map.size; },
475
+ };
476
+ };
477
+ /** A user/message whose source.kind is 'plugin' (injected system message). */
478
+ const systemEvents = () => [
479
+ makeEntry('user/message', {
480
+ id: 'u-plugin',
481
+ role: 'user',
482
+ content: [{ type: 'text', text: '系统注入消息' }],
483
+ source: { kind: 'plugin', name: 'react-extension' },
484
+ }, 0),
485
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
486
+ ];
487
+ const toolEvents = () => [
488
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
489
+ makeEntry('assistant/message', {
490
+ turn: 0,
491
+ step: 0,
492
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
493
+ }, 1),
494
+ makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
495
+ ];
496
+ beforeEach(() => {
497
+ vi.stubGlobal('localStorage', makeStorage());
498
+ });
499
+ afterEach(() => {
500
+ vi.unstubAllGlobals();
501
+ });
502
+ it('hides injected user messages by default and reveals them via the display sheet', async () => {
503
+ loadHistoryMock.mockResolvedValue(historyPage(systemEvents()));
504
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
505
+ // The plugin-injected message is hidden by default; the real user message shows.
506
+ expect(await screen.findByText('普通消息')).toBeTruthy();
507
+ expect(screen.queryByText('系统注入消息')).toBeNull();
508
+ // Open the display sheet and flip the system-message switch on.
509
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }));
510
+ const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' });
511
+ expect(systemSwitch.getAttribute('aria-checked')).toBe('false');
512
+ fireEvent.click(systemSwitch);
513
+ expect(await screen.findByText('系统注入消息')).toBeTruthy();
514
+ // Flip it back off: the injected row disappears again.
515
+ const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' });
516
+ expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true');
517
+ fireEvent.click(systemSwitchAfter);
518
+ expect(screen.queryByText('系统注入消息')).toBeNull();
519
+ });
520
+ it('hides the tool disclosure when the tool-call toggle is off', async () => {
521
+ loadHistoryMock.mockResolvedValue(historyPage(toolEvents()));
522
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
523
+ // Tool disclosure visible by default.
524
+ expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy();
525
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }));
526
+ const toolSwitch = await screen.findByRole('switch', { name: '工具调用' });
527
+ expect(toolSwitch.getAttribute('aria-checked')).toBe('true');
528
+ fireEvent.click(toolSwitch);
529
+ // The disclosure is gone while reasoning/text remain.
530
+ expect(screen.queryByRole('button', { name: /工具/ })).toBeNull();
531
+ expect(screen.getByText('完成')).toBeTruthy();
532
+ });
533
+ it('renders the context usage chip from request/context plus assistant usage', async () => {
534
+ loadHistoryMock.mockResolvedValue(historyPage([
535
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
536
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
537
+ makeEntry('assistant/message', {
538
+ turn: 0,
539
+ step: 0,
540
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
541
+ usage: { inputTokens: 30_000, outputTokens: 1_000 },
542
+ }, 2),
543
+ ]));
544
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
545
+ expect(await screen.findByText('上下文 30%')).toBeTruthy();
546
+ });
547
+ it('adds the warn class when context usage is at or above 80%', async () => {
548
+ loadHistoryMock.mockResolvedValue(historyPage([
549
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
550
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
551
+ makeEntry('assistant/message', {
552
+ turn: 0,
553
+ step: 0,
554
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
555
+ usage: { inputTokens: 80_000, outputTokens: 0 },
556
+ }, 2),
557
+ ]));
558
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
559
+ const chip = await screen.findByText('上下文 80%');
560
+ expect(chip.className).toContain('chat-context-warn');
561
+ });
562
+ it('renders no context chip when there is no usage/context data', async () => {
563
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
564
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
565
+ await screen.findByText('已完成修改');
566
+ expect(screen.queryByText(/上下文/)).toBeNull();
567
+ });
568
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
4
- "version": "0.1.17",
4
+ "version": "0.1.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
45
45
  onSave: () => void
46
46
  /** Drop every staged edit. */
47
47
  onDiscard: () => void
48
+ /**
49
+ * Render the controls expanded on arrival (still collapsible). Defaults to
50
+ * true: promoted first-level sections show their settings immediately.
51
+ */
52
+ defaultOpen?: boolean
53
+ /**
54
+ * Render without the collapse affordance: a static header and an always
55
+ * visible body. Used by first-level settings sections whose nav entry
56
+ * already provides the selection.
57
+ */
58
+ alwaysOpen?: boolean
48
59
  /** The plugin's controls. */
49
60
  children: ReactNode
50
61
  }
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
55
66
  * @returns the card, or nothing while the namespace is still loading.
56
67
  */
57
68
  export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
58
- const [open, setOpen] = useState(false)
59
- const { state } = props
69
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
70
+ const { state, alwaysOpen } = props
60
71
  if (!state.available) return null
61
72
  const title = props.t(props.titleKey)
62
73
  const description = props.t(props.descriptionKey)
63
74
  const blocked = !state.dirty || state.invalid || state.saving
64
- const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
65
- // The namespace exists but the Host does not serve it to this client (the
66
- // official settings allowlist omits third-party namespaces): show a card
67
- // that explains the gap instead of vanishing, so a missing card never
68
- // reads as a missing plugin.
69
- if (!state.exposed) {
70
- return (
71
- <li className={cardClass}>
72
- <button
73
- type="button"
74
- className={css.header}
75
- aria-expanded={open}
76
- aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
77
- onClick={() => { setOpen(!open) }}
78
- >
79
- <span className={css.headText}>
80
- <span className={css.name} title={title}>{title}</span>
81
- <span className={css.description} title={description}>{description}</span>
82
- </span>
83
- <svg
84
- width="14"
85
- height="14"
86
- viewBox="0 0 14 14"
87
- fill="none"
88
- xmlns="http://www.w3.org/2000/svg"
89
- className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
90
- >
91
- <path
92
- d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
93
- fill="currentColor"
94
- />
95
- </svg>
96
- </button>
97
- {open
98
- ? (
99
- <div className={css.body}>
100
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
101
- </div>
102
- )
103
- : null}
104
- </li>
75
+ const expanded = alwaysOpen === true || open
76
+ const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
77
+ // With alwaysOpen the nav entry already provides the selection, so the
78
+ // header is a static title row instead of a disclosure button.
79
+ const header = alwaysOpen === true
80
+ ? (
81
+ <div className={css.headerStatic}>
82
+ <span className={css.headText}>
83
+ <span className={css.name} title={title}>{title}</span>
84
+ <span className={css.description} title={description}>{description}</span>
85
+ </span>
86
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
87
+ </div>
105
88
  )
106
- }
107
- return (
108
- <li className={cardClass}>
89
+ : (
109
90
  <button
110
91
  type="button"
111
92
  className={css.header}
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
132
113
  />
133
114
  </svg>
134
115
  </button>
135
- {open
116
+ )
117
+ // The namespace exists but the Host does not serve it to this client (the
118
+ // official settings allowlist omits third-party namespaces): show a card
119
+ // that explains the gap instead of vanishing, so a missing card never
120
+ // reads as a missing plugin.
121
+ if (!state.exposed) {
122
+ return (
123
+ <li className={cardClass}>
124
+ {header}
125
+ {expanded
126
+ ? (
127
+ <div className={css.body}>
128
+ <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
129
+ </div>
130
+ )
131
+ : null}
132
+ </li>
133
+ )
134
+ }
135
+ return (
136
+ <li className={cardClass}>
137
+ {header}
138
+ {expanded
136
139
  ? (
137
140
  <div className={css.body}>
138
141
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Mobile markdown renderer tests: GFM subset coverage plus the security
3
+ * contract — raw HTML escapes, dangerous link/image protocols are
4
+ * stripped, and code fences never parse their content.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { escapeHtml, renderInline, renderMarkdown, safeUrl } from './markdown.ts'
8
+
9
+ describe('safeUrl', () => {
10
+ it('allows http/https/mailto/fragments and scheme-less targets', () => {
11
+ expect(safeUrl('https://example.com/a')).toBe('https://example.com/a')
12
+ expect(safeUrl('http://example.com')).toBe('http://example.com')
13
+ expect(safeUrl('mailto:a@b.c')).toBe('mailto:a@b.c')
14
+ expect(safeUrl('#top')).toBe('#top')
15
+ expect(safeUrl('./rel/path')).toBe('./rel/path')
16
+ })
17
+
18
+ it('rejects javascript:/data:/vbscript: and blanks', () => {
19
+ expect(safeUrl('javascript:alert(1)')).toBeNull()
20
+ expect(safeUrl('data:text/html,x')).toBeNull()
21
+ expect(safeUrl('vbscript:x')).toBeNull()
22
+ expect(safeUrl('')).toBeNull()
23
+ expect(safeUrl(' ')).toBeNull()
24
+ })
25
+ })
26
+
27
+ describe('renderInline', () => {
28
+ it('renders code, bold, italic, strikethrough and links', () => {
29
+ expect(renderInline('`a<b`')).toBe('<code>a&lt;b</code>')
30
+ expect(renderInline('**b**')).toBe('<strong>b</strong>')
31
+ expect(renderInline('*i*')).toBe('<em>i</em>')
32
+ expect(renderInline('~~d~~')).toBe('<del>d</del>')
33
+ expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>')
34
+ })
35
+
36
+ it('strips unsafe link targets and renders the label as plain text', () => {
37
+ expect(renderInline('[x](javascript:alert(1))')).toBe('x')
38
+ expect(renderInline('![a](data:text/html,x)')).toBe('a')
39
+ })
40
+
41
+ it('escapes raw HTML instead of passing it through', () => {
42
+ expect(renderInline('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;')
43
+ })
44
+ })
45
+
46
+ describe('renderMarkdown', () => {
47
+ it('renders headings, hr, paragraphs', () => {
48
+ const html = renderMarkdown('# T\n\npara\n\n---')
49
+ expect(html).toContain('<h1>T</h1>')
50
+ expect(html).toContain('<p>para</p>')
51
+ expect(html).toContain('<hr />')
52
+ })
53
+
54
+ it('renders fenced code without parsing its content', () => {
55
+ const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```')
56
+ expect(html).toBe('<pre class="language-ts"><code>const a = **bold** &lt;x&gt;</code></pre>')
57
+ })
58
+
59
+ it('renders lists, blockquotes and tables', () => {
60
+ const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |')
61
+ expect(html).toContain('<ul><li>a</li><li>b</li></ul>')
62
+ expect(html).toContain('<ol><li>x</li><li>y</li></ol>')
63
+ expect(html).toContain('<blockquote><p>quote</p></blockquote>')
64
+ expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>')
65
+ expect(html).toContain('<td>c1</td><td>c2</td>')
66
+ })
67
+
68
+ it('normalizes CRLF and keeps user-visible text intact', () => {
69
+ expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>')
70
+ })
71
+ })
72
+
73
+ describe('escapeHtml', () => {
74
+ it('escapes all five HTML-significant characters', () => {
75
+ expect(escapeHtml('<&>"\'')).toBe('&lt;&amp;&gt;&quot;&#39;')
76
+ })
77
+ })
@@ -0,0 +1,278 @@
1
+ /**
2
+ * A compact GFM-subset markdown renderer for the mobile chat: headings,
3
+ * paragraphs, fenced + inline code, bold/italic/strikethrough, links,
4
+ * images, lists, blockquotes, hr, and tables. All HTML is escaped before
5
+ * transformation — the output only ever contains the renderer's own tags.
6
+ * Dependency-free on purpose (the mobile bundle stays at ~456 KB); the
7
+ * escape-first + protocol allow-list design mirrors the desktop panel's
8
+ * preview renderer (dsh-aionui-panel/src/client/preview/markdown.ts).
9
+ * Pure and exported for tests.
10
+ * @module dsh-remote-web-ui/mobile/markdown
11
+ */
12
+
13
+ /** Escape HTML special characters. */
14
+ export function escapeHtml(text: string): string {
15
+ return text
16
+ .replace(/&/g, '&amp;')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/'/g, '&#39;')
21
+ }
22
+
23
+ /**
24
+ * Guard a raw link/image target against dangerous protocols. Returns the
25
+ * (trimmed) raw string when safe, else null. Only http:, https:, mailto:
26
+ * and fragment anchors (#...) are allowed; anything with another scheme
27
+ * — javascript:, data:, vbscript:, etc. — is rejected so the value never
28
+ * reaches dangerouslySetInnerHTML.
29
+ */
30
+ export function safeUrl(raw: string): string | null {
31
+ const trimmed = raw.trim()
32
+ if (trimmed === '') return null
33
+ if (trimmed.startsWith('#')) return trimmed
34
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
35
+ if (scheme === null) return trimmed
36
+ const name = scheme[1].toLowerCase()
37
+ return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null
38
+ }
39
+
40
+ /** Find the ')' closing a link/image target, skipping nested parens. */
41
+ function findCloseParen(text: string, from: number): number {
42
+ let depth = 0
43
+ for (let i = from; i < text.length; i += 1) {
44
+ const char = text[i]
45
+ if (char === '(') depth += 1
46
+ else if (char === ')') {
47
+ if (depth === 0) return i
48
+ depth -= 1
49
+ }
50
+ }
51
+ return -1
52
+ }
53
+
54
+ /** Inline pass: code spans, bold, italic, strikethrough, images, links. */
55
+ export function renderInline(text: string): string {
56
+ let out = ''
57
+ let i = 0
58
+ const n = text.length
59
+ while (i < n) {
60
+ const char = text[i]
61
+ // Fenced inline code first.
62
+ if (char === '`') {
63
+ const end = text.indexOf('`', i + 1)
64
+ if (end !== -1) {
65
+ out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>'
66
+ i = end + 1
67
+ continue
68
+ }
69
+ }
70
+ // Image ![alt](src)
71
+ if (char === '!' && text[i + 1] === '[') {
72
+ const close = text.indexOf('](', i + 2)
73
+ if (close !== -1) {
74
+ const parenEnd = findCloseParen(text, close + 2)
75
+ if (parenEnd !== -1) {
76
+ const alt = text.slice(i + 2, close)
77
+ const src = text.slice(close + 2, parenEnd)
78
+ const safe = safeUrl(src)
79
+ if (safe === null) {
80
+ out += escapeHtml(alt)
81
+ } else {
82
+ const srcEsc = escapeHtml(safe).replace(/\s+/g, '%20')
83
+ out += '<img alt="' + escapeHtml(alt) + '" src="' + srcEsc + '" />'
84
+ }
85
+ i = parenEnd + 1
86
+ continue
87
+ }
88
+ }
89
+ }
90
+ // Link [text](href)
91
+ if (char === '[') {
92
+ const close = text.indexOf('](', i + 1)
93
+ if (close !== -1) {
94
+ const parenEnd = findCloseParen(text, close + 2)
95
+ if (parenEnd !== -1) {
96
+ const label = text.slice(i + 1, close)
97
+ const href = text.slice(close + 2, parenEnd)
98
+ const safe = safeUrl(href)
99
+ if (safe === null) {
100
+ out += renderInline(label)
101
+ } else {
102
+ out += '<a href="' + escapeHtml(safe) + '" target="_blank" rel="noopener noreferrer">' + renderInline(label) + '</a>'
103
+ }
104
+ i = parenEnd + 1
105
+ continue
106
+ }
107
+ }
108
+ }
109
+ // Bold **text**
110
+ if (char === '*' && text[i + 1] === '*') {
111
+ const end = text.indexOf('**', i + 2)
112
+ if (end !== -1) {
113
+ out += '<strong>' + renderInline(text.slice(i + 2, end)) + '</strong>'
114
+ i = end + 2
115
+ continue
116
+ }
117
+ }
118
+ // Italic *text*
119
+ if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
120
+ const end = text.indexOf('*', i + 1)
121
+ if (end !== -1 && text[end + 1] !== '*') {
122
+ out += '<em>' + renderInline(text.slice(i + 1, end)) + '</em>'
123
+ i = end + 1
124
+ continue
125
+ }
126
+ }
127
+ // Strikethrough ~~text~~
128
+ if (char === '~' && text[i + 1] === '~') {
129
+ const end = text.indexOf('~~', i + 2)
130
+ if (end !== -1) {
131
+ out += '<del>' + renderInline(text.slice(i + 2, end)) + '</del>'
132
+ i = end + 2
133
+ continue
134
+ }
135
+ }
136
+ out += escapeHtml(char)
137
+ i += 1
138
+ }
139
+ return out
140
+ }
141
+
142
+ /** Render a markdown document to HTML (block pass). */
143
+ export function renderMarkdown(source: string): string {
144
+ const lines = source.replace(/\r\n/g, '\n').split('\n')
145
+ const out: string[] = []
146
+ let i = 0
147
+ const n = lines.length
148
+
149
+ const flushParagraph = (buffer: string[]): void => {
150
+ if (buffer.length === 0) return
151
+ out.push('<p>' + renderInline(buffer.join('\n')) + '</p>')
152
+ buffer.length = 0
153
+ }
154
+
155
+ let paragraph: string[] = []
156
+ while (i < n) {
157
+ const line = lines[i]
158
+
159
+ // Fenced code block.
160
+ const fence = /^```([\w+-]*)\s*$/.exec(line)
161
+ if (fence !== null) {
162
+ flushParagraph(paragraph)
163
+ const lang = fence[1] ?? ''
164
+ i += 1
165
+ const code: string[] = []
166
+ while (i < n && !/^```\s*$/.test(lines[i])) {
167
+ code.push(lines[i])
168
+ i += 1
169
+ }
170
+ i += 1 // closing fence
171
+ const langAttr = lang === '' ? '' : ' class="language-' + escapeHtml(lang) + '"'
172
+ out.push('<pre' + langAttr + '><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
173
+ continue
174
+ }
175
+
176
+ // Heading.
177
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
178
+ if (heading !== null) {
179
+ flushParagraph(paragraph)
180
+ const level = heading[1].length
181
+ out.push('<h' + level + '>' + renderInline(heading[2] ?? '') + '</h' + level + '>')
182
+ i += 1
183
+ continue
184
+ }
185
+
186
+ // Horizontal rule.
187
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
188
+ flushParagraph(paragraph)
189
+ out.push('<hr />')
190
+ i += 1
191
+ continue
192
+ }
193
+
194
+ // Table: header row then separator row.
195
+ if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
196
+ flushParagraph(paragraph)
197
+ const headerCells = splitTableRow(line)
198
+ i += 2
199
+ const rows: string[][] = []
200
+ while (i < n && lines[i].includes('|')) {
201
+ rows.push(splitTableRow(lines[i]))
202
+ i += 1
203
+ }
204
+ out.push('<table>')
205
+ out.push('<thead><tr>' + headerCells.map((cell) => '<th>' + renderInline(cell) + '</th>').join('') + '</tr></thead>')
206
+ if (rows.length > 0) {
207
+ out.push('<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + renderInline(cell) + '</td>').join('') + '</tr>').join('') + '</tbody>')
208
+ }
209
+ out.push('</table>')
210
+ continue
211
+ }
212
+
213
+ // Blockquote (one level).
214
+ const quote = /^>\s?(.*)$/.exec(line)
215
+ if (quote !== null) {
216
+ flushParagraph(paragraph)
217
+ const body: string[] = []
218
+ while (i < n) {
219
+ const q = /^>\s?(.*)$/.exec(lines[i])
220
+ if (q === null) break
221
+ body.push(q[1] ?? '')
222
+ i += 1
223
+ }
224
+ out.push('<blockquote><p>' + body.map((bodyLine) => renderInline(bodyLine)).join('<br />') + '</p></blockquote>')
225
+ continue
226
+ }
227
+
228
+ // Unordered list.
229
+ const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
230
+ if (ul !== null) {
231
+ flushParagraph(paragraph)
232
+ const items: string[] = []
233
+ while (i < n) {
234
+ const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i])
235
+ if (item === null) break
236
+ items.push('<li>' + renderInline(item[2] ?? '') + '</li>')
237
+ i += 1
238
+ }
239
+ out.push('<ul>' + items.join('') + '</ul>')
240
+ continue
241
+ }
242
+
243
+ // Ordered list.
244
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line)
245
+ if (ol !== null) {
246
+ flushParagraph(paragraph)
247
+ const items: string[] = []
248
+ while (i < n) {
249
+ const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
250
+ if (item === null) break
251
+ items.push('<li>' + renderInline(item[1] ?? '') + '</li>')
252
+ i += 1
253
+ }
254
+ out.push('<ol>' + items.join('') + '</ol>')
255
+ continue
256
+ }
257
+
258
+ // Blank line: flush the paragraph.
259
+ if (line.trim() === '') {
260
+ flushParagraph(paragraph)
261
+ i += 1
262
+ continue
263
+ }
264
+
265
+ paragraph.push(line)
266
+ i += 1
267
+ }
268
+ flushParagraph(paragraph)
269
+ return out.join('\n')
270
+ }
271
+
272
+ /** Split one table row into cells (respecting the leading/trailing pipes). */
273
+ function splitTableRow(line: string): string[] {
274
+ const trimmed = line.trim()
275
+ const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
276
+ const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner
277
+ return withoutTrailing.split('|').map((cell) => cell.trim())
278
+ }