@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17621
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -677
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,91 +0,0 @@
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
- it('rejects protocol-relative targets that escape the origin', () => {
27
- expect(safeUrl('//attacker.example/x')).toBeNull()
28
- expect(safeUrl(' //attacker.example/track.png')).toBeNull()
29
- })
30
- })
31
-
32
- describe('renderInline', () => {
33
- it('renders code, bold, italic, strikethrough and links', () => {
34
- expect(renderInline('`a<b`')).toBe('<code>a&lt;b</code>')
35
- expect(renderInline('**b**')).toBe('<strong>b</strong>')
36
- expect(renderInline('*i*')).toBe('<em>i</em>')
37
- expect(renderInline('~~d~~')).toBe('<del>d</del>')
38
- expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>')
39
- })
40
-
41
- it('strips unsafe link targets and renders the label as plain text', () => {
42
- expect(renderInline('[x](javascript:alert(1))')).toBe('x')
43
- expect(renderInline('![a](data:text/html,x)')).toBe('a')
44
- })
45
-
46
- it('escapes raw HTML instead of passing it through', () => {
47
- expect(renderInline('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;')
48
- })
49
- })
50
-
51
- describe('renderMarkdown', () => {
52
- it('renders headings, hr, paragraphs', () => {
53
- const html = renderMarkdown('# T\n\npara\n\n---')
54
- expect(html).toContain('<h1>T</h1>')
55
- expect(html).toContain('<p>para</p>')
56
- expect(html).toContain('<hr />')
57
- })
58
-
59
- it('renders fenced code without parsing its content', () => {
60
- const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```')
61
- expect(html).toBe('<pre class="language-ts"><code>const a = **bold** &lt;x&gt;</code></pre>')
62
- })
63
-
64
- it('renders lists, blockquotes and tables', () => {
65
- const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |')
66
- expect(html).toContain('<ul><li>a</li><li>b</li></ul>')
67
- expect(html).toContain('<ol><li>x</li><li>y</li></ol>')
68
- expect(html).toContain('<blockquote><p>quote</p></blockquote>')
69
- expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>')
70
- expect(html).toContain('<td>c1</td><td>c2</td>')
71
- })
72
-
73
- it('normalizes CRLF and keeps user-visible text intact', () => {
74
- expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>')
75
- })
76
- })
77
-
78
- describe('escapeHtml', () => {
79
- it('escapes all five HTML-significant characters', () => {
80
- expect(escapeHtml('<&>"\'')).toBe('&lt;&amp;&gt;&quot;&#39;')
81
- })
82
-
83
- it('equals the five sequential passes on mixed, pre-escaped and plain input', () => {
84
- // Single-pass map replacement must never diverge from the sequential
85
- // five-pass escape (including double-escaping of literal entities).
86
- expect(escapeHtml('a & b < c > d " e \' f')).toBe('a &amp; b &lt; c &gt; d &quot; e &#39; f')
87
- expect(escapeHtml('&amp; &lt; &gt;')).toBe('&amp;amp; &amp;lt; &amp;gt;')
88
- expect(escapeHtml('plain text 123')).toBe('plain text 123')
89
- expect(escapeHtml('')).toBe('')
90
- })
91
- })
@@ -1,291 +0,0 @@
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
- /** HTML special-character map for {@link escapeHtml}. */
14
- const HTML_ESCAPE_MAP: Record<string, string> = {
15
- '&': '&amp;',
16
- '<': '&lt;',
17
- '>': '&gt;',
18
- '"': '&quot;',
19
- "'": '&#39;',
20
- }
21
-
22
- /**
23
- * Escape HTML special characters. One regex pass replaces all five
24
- * characters through the map; the output is identical to five sequential
25
- * passes (each replacement string contains none of the escaped characters),
26
- * and the common no-special-character case scans the string only once
27
- * instead of five times.
28
- */
29
- export function escapeHtml(text: string): string {
30
- return text.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char] ?? char)
31
- }
32
-
33
- /**
34
- * Guard a raw link/image target against dangerous protocols. Returns the
35
- * (trimmed) raw string when safe, else null. Only http:, https:, mailto:,
36
- * fragment anchors (#...) and strictly relative paths are allowed; anything
37
- * with another scheme — javascript:, data:, vbscript:, etc. — or a
38
- * protocol-relative //host target (the browser resolves it against the
39
- * current scheme, reaching an arbitrary origin) is rejected so the value
40
- * never reaches dangerouslySetInnerHTML.
41
- */
42
- export function safeUrl(raw: string): string | null {
43
- const trimmed = raw.trim()
44
- if (trimmed === '') return null
45
- if (trimmed.startsWith('#')) return trimmed
46
- if (trimmed.startsWith('//')) return null
47
- const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
48
- if (scheme === null) return trimmed
49
- const name = scheme[1].toLowerCase()
50
- return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null
51
- }
52
-
53
- /** Find the ')' closing a link/image target, skipping nested parens. */
54
- function findCloseParen(text: string, from: number): number {
55
- let depth = 0
56
- for (let i = from; i < text.length; i += 1) {
57
- const char = text[i]
58
- if (char === '(') depth += 1
59
- else if (char === ')') {
60
- if (depth === 0) return i
61
- depth -= 1
62
- }
63
- }
64
- return -1
65
- }
66
-
67
- /** Inline pass: code spans, bold, italic, strikethrough, images, links. */
68
- export function renderInline(text: string): string {
69
- let out = ''
70
- let i = 0
71
- const n = text.length
72
- while (i < n) {
73
- const char = text[i]
74
- // Fenced inline code first.
75
- if (char === '`') {
76
- const end = text.indexOf('`', i + 1)
77
- if (end !== -1) {
78
- out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>'
79
- i = end + 1
80
- continue
81
- }
82
- }
83
- // Image ![alt](src)
84
- if (char === '!' && text[i + 1] === '[') {
85
- const close = text.indexOf('](', i + 2)
86
- if (close !== -1) {
87
- const parenEnd = findCloseParen(text, close + 2)
88
- if (parenEnd !== -1) {
89
- const alt = text.slice(i + 2, close)
90
- const src = text.slice(close + 2, parenEnd)
91
- const safe = safeUrl(src)
92
- if (safe === null) {
93
- out += escapeHtml(alt)
94
- } else {
95
- const srcEsc = escapeHtml(safe).replace(/\s+/g, '%20')
96
- out += '<img alt="' + escapeHtml(alt) + '" src="' + srcEsc + '" />'
97
- }
98
- i = parenEnd + 1
99
- continue
100
- }
101
- }
102
- }
103
- // Link [text](href)
104
- if (char === '[') {
105
- const close = text.indexOf('](', i + 1)
106
- if (close !== -1) {
107
- const parenEnd = findCloseParen(text, close + 2)
108
- if (parenEnd !== -1) {
109
- const label = text.slice(i + 1, close)
110
- const href = text.slice(close + 2, parenEnd)
111
- const safe = safeUrl(href)
112
- if (safe === null) {
113
- out += renderInline(label)
114
- } else {
115
- out += '<a href="' + escapeHtml(safe) + '" target="_blank" rel="noopener noreferrer">' + renderInline(label) + '</a>'
116
- }
117
- i = parenEnd + 1
118
- continue
119
- }
120
- }
121
- }
122
- // Bold **text**
123
- if (char === '*' && text[i + 1] === '*') {
124
- const end = text.indexOf('**', i + 2)
125
- if (end !== -1) {
126
- out += '<strong>' + renderInline(text.slice(i + 2, end)) + '</strong>'
127
- i = end + 2
128
- continue
129
- }
130
- }
131
- // Italic *text*
132
- if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
133
- const end = text.indexOf('*', i + 1)
134
- if (end !== -1 && text[end + 1] !== '*') {
135
- out += '<em>' + renderInline(text.slice(i + 1, end)) + '</em>'
136
- i = end + 1
137
- continue
138
- }
139
- }
140
- // Strikethrough ~~text~~
141
- if (char === '~' && text[i + 1] === '~') {
142
- const end = text.indexOf('~~', i + 2)
143
- if (end !== -1) {
144
- out += '<del>' + renderInline(text.slice(i + 2, end)) + '</del>'
145
- i = end + 2
146
- continue
147
- }
148
- }
149
- out += escapeHtml(char)
150
- i += 1
151
- }
152
- return out
153
- }
154
-
155
- /** Render a markdown document to HTML (block pass). */
156
- export function renderMarkdown(source: string): string {
157
- const lines = source.replace(/\r\n/g, '\n').split('\n')
158
- const out: string[] = []
159
- let i = 0
160
- const n = lines.length
161
-
162
- const flushParagraph = (buffer: string[]): void => {
163
- if (buffer.length === 0) return
164
- out.push('<p>' + renderInline(buffer.join('\n')) + '</p>')
165
- buffer.length = 0
166
- }
167
-
168
- let paragraph: string[] = []
169
- while (i < n) {
170
- const line = lines[i]
171
-
172
- // Fenced code block.
173
- const fence = /^```([\w+-]*)\s*$/.exec(line)
174
- if (fence !== null) {
175
- flushParagraph(paragraph)
176
- const lang = fence[1] ?? ''
177
- i += 1
178
- const code: string[] = []
179
- while (i < n && !/^```\s*$/.test(lines[i])) {
180
- code.push(lines[i])
181
- i += 1
182
- }
183
- i += 1 // closing fence
184
- const langAttr = lang === '' ? '' : ' class="language-' + escapeHtml(lang) + '"'
185
- out.push('<pre' + langAttr + '><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
186
- continue
187
- }
188
-
189
- // Heading.
190
- const heading = /^(#{1,6})\s+(.*)$/.exec(line)
191
- if (heading !== null) {
192
- flushParagraph(paragraph)
193
- const level = heading[1].length
194
- out.push('<h' + level + '>' + renderInline(heading[2] ?? '') + '</h' + level + '>')
195
- i += 1
196
- continue
197
- }
198
-
199
- // Horizontal rule.
200
- if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
201
- flushParagraph(paragraph)
202
- out.push('<hr />')
203
- i += 1
204
- continue
205
- }
206
-
207
- // Table: header row then separator row.
208
- if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
209
- flushParagraph(paragraph)
210
- const headerCells = splitTableRow(line)
211
- i += 2
212
- const rows: string[][] = []
213
- while (i < n && lines[i].includes('|')) {
214
- rows.push(splitTableRow(lines[i]))
215
- i += 1
216
- }
217
- out.push('<table>')
218
- out.push('<thead><tr>' + headerCells.map((cell) => '<th>' + renderInline(cell) + '</th>').join('') + '</tr></thead>')
219
- if (rows.length > 0) {
220
- out.push('<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + renderInline(cell) + '</td>').join('') + '</tr>').join('') + '</tbody>')
221
- }
222
- out.push('</table>')
223
- continue
224
- }
225
-
226
- // Blockquote (one level).
227
- const quote = /^>\s?(.*)$/.exec(line)
228
- if (quote !== null) {
229
- flushParagraph(paragraph)
230
- const body: string[] = []
231
- while (i < n) {
232
- const q = /^>\s?(.*)$/.exec(lines[i])
233
- if (q === null) break
234
- body.push(q[1] ?? '')
235
- i += 1
236
- }
237
- out.push('<blockquote><p>' + body.map((bodyLine) => renderInline(bodyLine)).join('<br />') + '</p></blockquote>')
238
- continue
239
- }
240
-
241
- // Unordered list.
242
- const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
243
- if (ul !== null) {
244
- flushParagraph(paragraph)
245
- const items: string[] = []
246
- while (i < n) {
247
- const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i])
248
- if (item === null) break
249
- items.push('<li>' + renderInline(item[2] ?? '') + '</li>')
250
- i += 1
251
- }
252
- out.push('<ul>' + items.join('') + '</ul>')
253
- continue
254
- }
255
-
256
- // Ordered list.
257
- const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line)
258
- if (ol !== null) {
259
- flushParagraph(paragraph)
260
- const items: string[] = []
261
- while (i < n) {
262
- const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
263
- if (item === null) break
264
- items.push('<li>' + renderInline(item[1] ?? '') + '</li>')
265
- i += 1
266
- }
267
- out.push('<ol>' + items.join('') + '</ol>')
268
- continue
269
- }
270
-
271
- // Blank line: flush the paragraph.
272
- if (line.trim() === '') {
273
- flushParagraph(paragraph)
274
- i += 1
275
- continue
276
- }
277
-
278
- paragraph.push(line)
279
- i += 1
280
- }
281
- flushParagraph(paragraph)
282
- return out.join('\n')
283
- }
284
-
285
- /** Split one table row into cells (respecting the leading/trailing pipes). */
286
- function splitTableRow(line: string): string[] {
287
- const trimmed = line.trim()
288
- const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
289
- const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner
290
- return withoutTrailing.split('|').map((cell) => cell.trim())
291
- }