@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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -2
- package/README.zh.md +2 -2
- package/lib/client.js +48 -58
- package/lib/client.js.map +1 -1
- package/lib/mobile.js +649 -45
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +11 -5
- package/lib/types/mobile/markdown.d.ts +26 -0
- package/lib/types/mobile/markdown.d.ts.map +1 -0
- package/lib/types/mobile/markdown.js +272 -0
- package/lib/types/mobile/markdown.test.d.ts +2 -0
- package/lib/types/mobile/markdown.test.d.ts.map +1 -0
- package/lib/types/mobile/markdown.test.js +67 -0
- package/lib/types/mobile/messages.d.ts +14 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +54 -2
- package/lib/types/mobile/messages.test.js +99 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +167 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +81 -5
- package/lib/types/mobile/views/ChatView.test.js +105 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/settings-card.module.css +9 -0
- package/src/mobile/markdown.test.ts +77 -0
- package/src/mobile/markdown.ts +278 -0
- package/src/mobile/messages.test.ts +106 -0
- package/src/mobile/messages.ts +66 -2
- package/src/mobile/mobile-styles.ts +167 -0
- package/src/mobile/views/ChatView.test.tsx +121 -0
- 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.
|
|
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(
|
|
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
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -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<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('')).toBe('a')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('escapes raw HTML instead of passing it through', () => {
|
|
42
|
+
expect(renderInline('<script>alert(1)</script>')).toBe('<script>alert(1)</script>')
|
|
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** <x></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('<&>"'')
|
|
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, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/'/g, ''')
|
|
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 
|
|
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
|
+
}
|