tekivex-ui 2.5.13 → 2.5.15

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 (239) hide show
  1. package/dist/{TkxForm-9-W3Nl6m.js → TkxForm-BaO4wvXt.js} +109 -103
  2. package/dist/{TkxForm-1KDzoniV.cjs → TkxForm-M6ttomry.cjs} +1 -1
  3. package/dist/charts.cjs +1 -1
  4. package/dist/charts.js +1 -0
  5. package/dist/headless.cjs +1 -1
  6. package/dist/headless.js +3 -2
  7. package/dist/index.cjs +20 -15
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1716 -1066
  11. package/dist/quantum.cjs +9 -9
  12. package/dist/quantum.js +160 -115
  13. package/dist/realtime.cjs +1 -1
  14. package/dist/realtime.js +1 -1
  15. package/dist/src/charts/TkxAreaChart.d.ts.map +1 -1
  16. package/dist/src/charts/TkxBarChart.d.ts.map +1 -1
  17. package/dist/src/charts/TkxDonutChart.d.ts.map +1 -1
  18. package/dist/src/charts/TkxLineChart.d.ts.map +1 -1
  19. package/dist/src/charts/TkxPieChart.d.ts.map +1 -1
  20. package/dist/src/charts/TkxRadarChart.d.ts.map +1 -1
  21. package/dist/src/charts/TkxScatterChart.d.ts.map +1 -1
  22. package/dist/src/charts/index.d.ts.map +1 -1
  23. package/dist/src/charts/shared.d.ts.map +1 -1
  24. package/dist/src/components/TkxAIChatBubble.d.ts.map +1 -1
  25. package/dist/src/components/TkxAIConfidenceBar.d.ts.map +1 -1
  26. package/dist/src/components/TkxAIThinking.d.ts.map +1 -1
  27. package/dist/src/components/TkxAccordion.d.ts.map +1 -1
  28. package/dist/src/components/TkxAffix.d.ts.map +1 -1
  29. package/dist/src/components/TkxAlert.d.ts.map +1 -1
  30. package/dist/src/components/TkxAnchor.d.ts.map +1 -1
  31. package/dist/src/components/TkxAppBar.d.ts.map +1 -1
  32. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -1
  33. package/dist/src/components/TkxAvatar.d.ts.map +1 -1
  34. package/dist/src/components/TkxBadge.d.ts.map +1 -1
  35. package/dist/src/components/TkxBottomNav.d.ts.map +1 -1
  36. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -1
  37. package/dist/src/components/TkxButton.d.ts.map +1 -1
  38. package/dist/src/components/TkxCard.d.ts.map +1 -1
  39. package/dist/src/components/TkxCarousel.d.ts.map +1 -1
  40. package/dist/src/components/TkxCascader.d.ts.map +1 -1
  41. package/dist/src/components/TkxChat.d.ts.map +1 -1
  42. package/dist/src/components/TkxCheckbox.d.ts.map +1 -1
  43. package/dist/src/components/TkxClock.d.ts.map +1 -1
  44. package/dist/src/components/TkxColorPicker.d.ts.map +1 -1
  45. package/dist/src/components/TkxCommand.d.ts.map +1 -1
  46. package/dist/src/components/TkxConfigProvider.d.ts.map +1 -1
  47. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  48. package/dist/src/components/TkxDatePicker.d.ts +1 -1
  49. package/dist/src/components/TkxDatePicker.d.ts.map +1 -1
  50. package/dist/src/components/TkxDivider.d.ts.map +1 -1
  51. package/dist/src/components/TkxDrawer.d.ts.map +1 -1
  52. package/dist/src/components/TkxDropdown.d.ts.map +1 -1
  53. package/dist/src/components/TkxEmpty.d.ts.map +1 -1
  54. package/dist/src/components/TkxFileUpload.d.ts.map +1 -1
  55. package/dist/src/components/TkxForm.d.ts.map +1 -1
  56. package/dist/src/components/TkxIcon.d.ts.map +1 -1
  57. package/dist/src/components/TkxImage.d.ts.map +1 -1
  58. package/dist/src/components/TkxInput.d.ts.map +1 -1
  59. package/dist/src/components/TkxLayout.d.ts.map +1 -1
  60. package/dist/src/components/TkxList.d.ts.map +1 -1
  61. package/dist/src/components/TkxLiveFeed.d.ts.map +1 -1
  62. package/dist/src/components/TkxLiveLog.d.ts.map +1 -1
  63. package/dist/src/components/TkxLiveMetrics.d.ts.map +1 -1
  64. package/dist/src/components/TkxLogo.d.ts.map +1 -1
  65. package/dist/src/components/TkxMarkdown.d.ts +29 -0
  66. package/dist/src/components/TkxMarkdown.d.ts.map +1 -0
  67. package/dist/src/components/TkxMasonry.d.ts.map +1 -1
  68. package/dist/src/components/TkxMentions.d.ts.map +1 -1
  69. package/dist/src/components/TkxMenu.d.ts.map +1 -1
  70. package/dist/src/components/TkxModal.d.ts.map +1 -1
  71. package/dist/src/components/TkxNumberInput.d.ts.map +1 -1
  72. package/dist/src/components/TkxOTP.d.ts.map +1 -1
  73. package/dist/src/components/TkxPagination.d.ts.map +1 -1
  74. package/dist/src/components/TkxPlayground.d.ts +15 -1
  75. package/dist/src/components/TkxPlayground.d.ts.map +1 -1
  76. package/dist/src/components/TkxPopover.d.ts.map +1 -1
  77. package/dist/src/components/TkxProgress.d.ts.map +1 -1
  78. package/dist/src/components/TkxQRCode.d.ts.map +1 -1
  79. package/dist/src/components/TkxQuantumForm.d.ts.map +1 -1
  80. package/dist/src/components/TkxRadio.d.ts.map +1 -1
  81. package/dist/src/components/TkxRating.d.ts.map +1 -1
  82. package/dist/src/components/TkxRealTimeChart.d.ts.map +1 -1
  83. package/dist/src/components/TkxResult.d.ts.map +1 -1
  84. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -1
  85. package/dist/src/components/TkxSegmented.d.ts.map +1 -1
  86. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  87. package/dist/src/components/TkxSkeleton.d.ts.map +1 -1
  88. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  89. package/dist/src/components/TkxSnackbar.d.ts.map +1 -1
  90. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -1
  91. package/dist/src/components/TkxSpin.d.ts.map +1 -1
  92. package/dist/src/components/TkxStatistic.d.ts.map +1 -1
  93. package/dist/src/components/TkxStepper.d.ts.map +1 -1
  94. package/dist/src/components/TkxTable.d.ts.map +1 -1
  95. package/dist/src/components/TkxTabs.d.ts.map +1 -1
  96. package/dist/src/components/TkxTag.d.ts.map +1 -1
  97. package/dist/src/components/TkxThemeBuilder.d.ts.map +1 -1
  98. package/dist/src/components/TkxTimeline.d.ts.map +1 -1
  99. package/dist/src/components/TkxToast.d.ts.map +1 -1
  100. package/dist/src/components/TkxToggle.d.ts.map +1 -1
  101. package/dist/src/components/TkxToolbar.d.ts.map +1 -1
  102. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  103. package/dist/src/components/TkxTour.d.ts.map +1 -1
  104. package/dist/src/components/TkxTransferList.d.ts.map +1 -1
  105. package/dist/src/components/TkxTreeView.d.ts.map +1 -1
  106. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  107. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -1
  108. package/dist/src/components/TkxWatermark.d.ts.map +1 -1
  109. package/dist/src/components/index.d.ts +1 -0
  110. package/dist/src/components/index.d.ts.map +1 -1
  111. package/dist/src/engine/security.d.ts +8 -0
  112. package/dist/src/engine/security.d.ts.map +1 -1
  113. package/dist/src/headless/index.d.ts.map +1 -1
  114. package/dist/src/headless/useControllable.d.ts.map +1 -1
  115. package/dist/src/headless/useDebounce.d.ts.map +1 -1
  116. package/dist/src/headless/useDisclosure.d.ts.map +1 -1
  117. package/dist/src/headless/useFormState.d.ts.map +1 -1
  118. package/dist/src/headless/useInfiniteQuery.d.ts.map +1 -1
  119. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -1
  120. package/dist/src/headless/useListSelection.d.ts.map +1 -1
  121. package/dist/src/headless/useLocalStorage.d.ts.map +1 -1
  122. package/dist/src/headless/useMediaQuery.d.ts.map +1 -1
  123. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -1
  124. package/dist/src/headless/useSSE.d.ts.map +1 -1
  125. package/dist/src/headless/useThrottle.d.ts.map +1 -1
  126. package/dist/src/headless/useWebSocket.d.ts.map +1 -1
  127. package/dist/src/hooks/index.d.ts.map +1 -1
  128. package/dist/tkx-BoP5V5zy.cjs +4 -0
  129. package/dist/{tkx-DQkvHdE5.js → tkx-mOKMzvUK.js} +4 -2
  130. package/package.json +1 -1
  131. package/src/charts/TkxAreaChart.tsx +3 -1
  132. package/src/charts/TkxBarChart.tsx +3 -1
  133. package/src/charts/TkxDonutChart.tsx +3 -1
  134. package/src/charts/TkxLineChart.tsx +3 -1
  135. package/src/charts/TkxPieChart.tsx +3 -1
  136. package/src/charts/TkxRadarChart.tsx +3 -1
  137. package/src/charts/TkxScatterChart.tsx +3 -1
  138. package/src/charts/index.ts +3 -1
  139. package/src/charts/shared.ts +3 -1
  140. package/src/components/TkxAIChatBubble.tsx +55 -8
  141. package/src/components/TkxAIConfidenceBar.tsx +3 -1
  142. package/src/components/TkxAIThinking.tsx +3 -1
  143. package/src/components/TkxAccordion.tsx +3 -1
  144. package/src/components/TkxAffix.tsx +3 -1
  145. package/src/components/TkxAlert.tsx +3 -1
  146. package/src/components/TkxAnchor.tsx +3 -1
  147. package/src/components/TkxAppBar.tsx +3 -1
  148. package/src/components/TkxAutocomplete.tsx +3 -1
  149. package/src/components/TkxAvatar.tsx +3 -1
  150. package/src/components/TkxBadge.tsx +3 -1
  151. package/src/components/TkxBottomNav.tsx +3 -1
  152. package/src/components/TkxBreadcrumb.tsx +3 -1
  153. package/src/components/TkxButton.tsx +3 -1
  154. package/src/components/TkxCard.tsx +3 -1
  155. package/src/components/TkxCarousel.tsx +3 -1
  156. package/src/components/TkxCascader.tsx +3 -1
  157. package/src/components/TkxChat.tsx +3 -1
  158. package/src/components/TkxCheckbox.tsx +3 -1
  159. package/src/components/TkxClock.tsx +3 -1
  160. package/src/components/TkxColorPicker.tsx +3 -1
  161. package/src/components/TkxCommand.tsx +3 -1
  162. package/src/components/TkxConfigProvider.tsx +3 -1
  163. package/src/components/TkxDataGrid.tsx +3 -1
  164. package/src/components/TkxDatePicker.tsx +76 -18
  165. package/src/components/TkxDivider.tsx +3 -1
  166. package/src/components/TkxDrawer.tsx +3 -1
  167. package/src/components/TkxDropdown.tsx +3 -1
  168. package/src/components/TkxEmpty.tsx +3 -1
  169. package/src/components/TkxFileUpload.tsx +3 -1
  170. package/src/components/TkxForm.tsx +22 -3
  171. package/src/components/TkxIcon.tsx +3 -1
  172. package/src/components/TkxImage.tsx +3 -1
  173. package/src/components/TkxInput.tsx +3 -1
  174. package/src/components/TkxLayout.tsx +3 -1
  175. package/src/components/TkxList.tsx +3 -1
  176. package/src/components/TkxLiveFeed.tsx +3 -1
  177. package/src/components/TkxLiveLog.tsx +3 -1
  178. package/src/components/TkxLiveMetrics.tsx +3 -1
  179. package/src/components/TkxLogo.tsx +3 -1
  180. package/src/components/TkxMarkdown.tsx +855 -0
  181. package/src/components/TkxMasonry.tsx +3 -1
  182. package/src/components/TkxMentions.tsx +3 -1
  183. package/src/components/TkxMenu.tsx +3 -1
  184. package/src/components/TkxModal.tsx +3 -1
  185. package/src/components/TkxNumberInput.tsx +3 -1
  186. package/src/components/TkxOTP.tsx +3 -1
  187. package/src/components/TkxPagination.tsx +3 -1
  188. package/src/components/TkxPlayground.tsx +77 -3
  189. package/src/components/TkxPopover.tsx +3 -1
  190. package/src/components/TkxProgress.tsx +3 -1
  191. package/src/components/TkxQRCode.tsx +3 -1
  192. package/src/components/TkxQuantumForm.tsx +3 -1
  193. package/src/components/TkxRadio.tsx +3 -1
  194. package/src/components/TkxRating.tsx +3 -1
  195. package/src/components/TkxRealTimeChart.tsx +3 -1
  196. package/src/components/TkxResult.tsx +3 -1
  197. package/src/components/TkxRichTextDisplay.tsx +3 -1
  198. package/src/components/TkxSegmented.tsx +3 -1
  199. package/src/components/TkxSelect.tsx +3 -1
  200. package/src/components/TkxSkeleton.tsx +3 -1
  201. package/src/components/TkxSlider.tsx +3 -1
  202. package/src/components/TkxSnackbar.tsx +3 -1
  203. package/src/components/TkxSpeedDial.tsx +3 -1
  204. package/src/components/TkxSpin.tsx +3 -1
  205. package/src/components/TkxStatistic.tsx +3 -1
  206. package/src/components/TkxStepper.tsx +3 -1
  207. package/src/components/TkxTable.tsx +3 -1
  208. package/src/components/TkxTabs.tsx +3 -1
  209. package/src/components/TkxTag.tsx +3 -1
  210. package/src/components/TkxThemeBuilder.tsx +3 -1
  211. package/src/components/TkxTimeline.tsx +3 -1
  212. package/src/components/TkxToast.tsx +3 -1
  213. package/src/components/TkxToggle.tsx +3 -1
  214. package/src/components/TkxToolbar.tsx +3 -1
  215. package/src/components/TkxTooltip.tsx +15 -2
  216. package/src/components/TkxTour.tsx +3 -1
  217. package/src/components/TkxTransferList.tsx +3 -1
  218. package/src/components/TkxTreeView.tsx +3 -1
  219. package/src/components/TkxTypography.tsx +3 -1
  220. package/src/components/TkxVideoPlayer.tsx +3 -1
  221. package/src/components/TkxWatermark.tsx +3 -1
  222. package/src/components/index.ts +1 -0
  223. package/src/engine/security.ts +16 -1
  224. package/src/headless/index.ts +3 -1
  225. package/src/headless/useControllable.ts +3 -1
  226. package/src/headless/useDebounce.ts +3 -1
  227. package/src/headless/useDisclosure.ts +3 -1
  228. package/src/headless/useFormState.ts +3 -1
  229. package/src/headless/useInfiniteQuery.ts +3 -1
  230. package/src/headless/useIntersectionObserver.ts +3 -1
  231. package/src/headless/useListSelection.ts +3 -1
  232. package/src/headless/useLocalStorage.ts +3 -1
  233. package/src/headless/useMediaQuery.ts +3 -1
  234. package/src/headless/useRovingTabIndex.ts +3 -1
  235. package/src/headless/useSSE.ts +3 -1
  236. package/src/headless/useThrottle.ts +3 -1
  237. package/src/headless/useWebSocket.ts +3 -1
  238. package/src/hooks/index.ts +3 -1
  239. package/dist/tkx-0oAwZqgL.cjs +0 -4
@@ -0,0 +1,855 @@
1
+ 'use client';
2
+
3
+ // ══════════════════════════════════════════════════════════════════════════════
4
+ // TkxMarkdown — Zero-dependency, responsive Markdown renderer
5
+ //
6
+ // Renders a Markdown string or a remote .md file on any device. Parses a
7
+ // CommonMark-compatible subset without shelling out to `marked`/`remark` so the
8
+ // library bundle stays lean (< 7 KB gzip).
9
+ //
10
+ // Supports:
11
+ // • Headings (# .. ######)
12
+ // • Paragraphs
13
+ // • Bold **x** / *y* / _y_, italics, inline code, ~~strike~~
14
+ // • Links [text](url) + autolinks <https://…>
15
+ // • Images ![alt](src)
16
+ // • Unordered / ordered lists (arbitrary nesting)
17
+ // • Blockquotes
18
+ // • Fenced code blocks ```lang … ```
19
+ // • Horizontal rules (---, ***)
20
+ // • GFM tables (| a | b |)
21
+ // • Task list items - [ ] / - [x]
22
+ //
23
+ // All output is text-only — no dangerouslySetInnerHTML. Every string passes
24
+ // through sanitizeString() before rendering. External links get
25
+ // rel="noopener noreferrer" by default.
26
+ //
27
+ // Responsive behavior:
28
+ // • Fluid container width (max-width: 100%).
29
+ // • Images scale to parent width; never overflow.
30
+ // • Tables become horizontally scrollable on narrow viewports.
31
+ // • Code blocks wrap soft-breaks on mobile, scroll on desktop.
32
+ // ══════════════════════════════════════════════════════════════════════════════
33
+
34
+ import {
35
+ useEffect,
36
+ useMemo,
37
+ useRef,
38
+ useState,
39
+ type CSSProperties,
40
+ type ReactNode,
41
+ } from 'react';
42
+ import { useTheme } from '../themes';
43
+ import { sanitizeString } from '../engine/security';
44
+
45
+ // ── Public API ───────────────────────────────────────────────────────────────
46
+
47
+ export interface TkxMarkdownProps {
48
+ /** Markdown source string. Takes precedence over `src`. */
49
+ source?: string;
50
+ /** Remote URL (or relative path) to a .md file. Fetched on mount. */
51
+ src?: string;
52
+ /** Max content width. Default: '100%'. */
53
+ maxWidth?: number | string;
54
+ /** Render in a visually compact variant (smaller headings/line-heights). */
55
+ compact?: boolean;
56
+ /** Show a copy button on fenced code blocks. Default: true. */
57
+ copyableCode?: boolean;
58
+ /** Called when a link is clicked. Return false to suppress navigation. */
59
+ onLinkClick?: (href: string, ev: React.MouseEvent<HTMLAnchorElement>) => boolean | void;
60
+ /** Custom rel= value for external links. Default: 'noopener noreferrer'. */
61
+ externalLinkRel?: string;
62
+ /** target= for external links. Default: '_blank'. */
63
+ externalLinkTarget?: '_blank' | '_self' | '_parent' | '_top';
64
+ /** Replace the loading / error fallback. */
65
+ loadingFallback?: ReactNode;
66
+ errorFallback?: (error: string) => ReactNode;
67
+ className?: string;
68
+ style?: CSSProperties;
69
+ }
70
+
71
+ // ── AST types ────────────────────────────────────────────────────────────────
72
+
73
+ type InlineNode =
74
+ | { kind: 'text'; value: string }
75
+ | { kind: 'bold'; children: InlineNode[] }
76
+ | { kind: 'italic'; children: InlineNode[] }
77
+ | { kind: 'strike'; children: InlineNode[] }
78
+ | { kind: 'code'; value: string }
79
+ | { kind: 'link'; href: string; children: InlineNode[] }
80
+ | { kind: 'image'; src: string; alt: string };
81
+
82
+ type Block =
83
+ | { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; inline: InlineNode[] }
84
+ | { kind: 'paragraph'; inline: InlineNode[] }
85
+ | { kind: 'blockquote'; children: Block[] }
86
+ | { kind: 'code'; language: string; value: string }
87
+ | { kind: 'list'; ordered: boolean; items: ListItem[] }
88
+ | { kind: 'hr' }
89
+ | { kind: 'table'; headers: InlineNode[][]; rows: InlineNode[][][]; align: Array<'left' | 'center' | 'right' | null> };
90
+
91
+ interface ListItem {
92
+ blocks: Block[];
93
+ task: null | 'unchecked' | 'checked';
94
+ }
95
+
96
+ // ── Inline parser ────────────────────────────────────────────────────────────
97
+
98
+ function parseInline(src: string): InlineNode[] {
99
+ const out: InlineNode[] = [];
100
+ let i = 0;
101
+ let buf = '';
102
+
103
+ const flush = () => {
104
+ if (buf) {
105
+ out.push({ kind: 'text', value: buf });
106
+ buf = '';
107
+ }
108
+ };
109
+
110
+ while (i < src.length) {
111
+ const ch = src[i];
112
+
113
+ // Escape: \char → literal
114
+ if (ch === '\\' && i + 1 < src.length) {
115
+ buf += src[i + 1];
116
+ i += 2;
117
+ continue;
118
+ }
119
+
120
+ // Image: ![alt](src)
121
+ if (ch === '!' && src[i + 1] === '[') {
122
+ const close = src.indexOf(']', i + 2);
123
+ if (close !== -1 && src[close + 1] === '(') {
124
+ const endParen = src.indexOf(')', close + 2);
125
+ if (endParen !== -1) {
126
+ flush();
127
+ out.push({
128
+ kind: 'image',
129
+ alt: src.slice(i + 2, close),
130
+ src: src.slice(close + 2, endParen),
131
+ });
132
+ i = endParen + 1;
133
+ continue;
134
+ }
135
+ }
136
+ }
137
+
138
+ // Link: [text](href)
139
+ if (ch === '[') {
140
+ const close = findMatching(src, i, '[', ']');
141
+ if (close !== -1 && src[close + 1] === '(') {
142
+ const endParen = src.indexOf(')', close + 2);
143
+ if (endParen !== -1) {
144
+ flush();
145
+ out.push({
146
+ kind: 'link',
147
+ href: src.slice(close + 2, endParen).trim(),
148
+ children: parseInline(src.slice(i + 1, close)),
149
+ });
150
+ i = endParen + 1;
151
+ continue;
152
+ }
153
+ }
154
+ }
155
+
156
+ // Autolink: <https://…> or <email@x.y>
157
+ if (ch === '<') {
158
+ const close = src.indexOf('>', i + 1);
159
+ if (close !== -1) {
160
+ const inner = src.slice(i + 1, close);
161
+ if (/^(https?:\/\/|mailto:)/i.test(inner) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inner)) {
162
+ flush();
163
+ const href = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inner) ? `mailto:${inner}` : inner;
164
+ out.push({ kind: 'link', href, children: [{ kind: 'text', value: inner }] });
165
+ i = close + 1;
166
+ continue;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Inline code: `...`
172
+ if (ch === '`') {
173
+ const close = src.indexOf('`', i + 1);
174
+ if (close !== -1) {
175
+ flush();
176
+ out.push({ kind: 'code', value: src.slice(i + 1, close) });
177
+ i = close + 1;
178
+ continue;
179
+ }
180
+ }
181
+
182
+ // Strike: ~~text~~
183
+ if (ch === '~' && src[i + 1] === '~') {
184
+ const close = src.indexOf('~~', i + 2);
185
+ if (close !== -1) {
186
+ flush();
187
+ out.push({ kind: 'strike', children: parseInline(src.slice(i + 2, close)) });
188
+ i = close + 2;
189
+ continue;
190
+ }
191
+ }
192
+
193
+ // Bold: **text** or __text__
194
+ if ((ch === '*' && src[i + 1] === '*') || (ch === '_' && src[i + 1] === '_')) {
195
+ const marker = ch + ch;
196
+ const close = src.indexOf(marker, i + 2);
197
+ if (close !== -1) {
198
+ flush();
199
+ out.push({ kind: 'bold', children: parseInline(src.slice(i + 2, close)) });
200
+ i = close + 2;
201
+ continue;
202
+ }
203
+ }
204
+
205
+ // Italic: *text* or _text_
206
+ if ((ch === '*' || ch === '_') && src[i + 1] !== ch) {
207
+ const close = src.indexOf(ch, i + 1);
208
+ if (close !== -1 && src.slice(i + 1, close).trim().length > 0) {
209
+ flush();
210
+ out.push({ kind: 'italic', children: parseInline(src.slice(i + 1, close)) });
211
+ i = close + 1;
212
+ continue;
213
+ }
214
+ }
215
+
216
+ buf += ch;
217
+ i++;
218
+ }
219
+
220
+ flush();
221
+ return out;
222
+ }
223
+
224
+ function findMatching(src: string, start: number, open: string, close: string): number {
225
+ let depth = 0;
226
+ for (let i = start; i < src.length; i++) {
227
+ if (src[i] === '\\') { i++; continue; }
228
+ if (src[i] === open) depth++;
229
+ else if (src[i] === close) {
230
+ depth--;
231
+ if (depth === 0) return i;
232
+ }
233
+ }
234
+ return -1;
235
+ }
236
+
237
+ // ── Block parser ─────────────────────────────────────────────────────────────
238
+
239
+ function parseBlocks(src: string): Block[] {
240
+ // Normalize line endings; strip a leading BOM.
241
+ const text = src.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
242
+ const lines = text.split('\n');
243
+ const blocks: Block[] = [];
244
+ let i = 0;
245
+
246
+ while (i < lines.length) {
247
+ const line = lines[i];
248
+
249
+ // Blank line
250
+ if (/^\s*$/.test(line)) { i++; continue; }
251
+
252
+ // Fenced code block
253
+ const fence = line.match(/^\s*```(.*)$/);
254
+ if (fence) {
255
+ const lang = fence[1].trim();
256
+ i++;
257
+ const codeLines: string[] = [];
258
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
259
+ codeLines.push(lines[i]);
260
+ i++;
261
+ }
262
+ if (i < lines.length) i++; // consume closing fence
263
+ blocks.push({ kind: 'code', language: lang, value: codeLines.join('\n') });
264
+ continue;
265
+ }
266
+
267
+ // ATX heading
268
+ const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
269
+ if (h) {
270
+ blocks.push({
271
+ kind: 'heading',
272
+ level: h[1].length as 1 | 2 | 3 | 4 | 5 | 6,
273
+ inline: parseInline(h[2]),
274
+ });
275
+ i++;
276
+ continue;
277
+ }
278
+
279
+ // Horizontal rule
280
+ if (/^\s*([-*_])\s*(?:\1\s*){2,}$/.test(line)) {
281
+ blocks.push({ kind: 'hr' });
282
+ i++;
283
+ continue;
284
+ }
285
+
286
+ // GFM table: header row + separator row
287
+ if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
288
+ const headers = splitTableRow(line).map(parseInline);
289
+ const align = parseTableAlignment(lines[i + 1]);
290
+ i += 2;
291
+ const rows: InlineNode[][][] = [];
292
+ while (i < lines.length && isTableRow(lines[i])) {
293
+ rows.push(splitTableRow(lines[i]).map(parseInline));
294
+ i++;
295
+ }
296
+ blocks.push({ kind: 'table', headers, rows, align });
297
+ continue;
298
+ }
299
+
300
+ // Blockquote
301
+ if (/^\s*>/.test(line)) {
302
+ const quoteLines: string[] = [];
303
+ while (i < lines.length && /^\s*>/.test(lines[i])) {
304
+ quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
305
+ i++;
306
+ }
307
+ blocks.push({ kind: 'blockquote', children: parseBlocks(quoteLines.join('\n')) });
308
+ continue;
309
+ }
310
+
311
+ // List
312
+ if (isListLine(line)) {
313
+ const [list, consumed] = parseList(lines, i);
314
+ blocks.push(list);
315
+ i = consumed;
316
+ continue;
317
+ }
318
+
319
+ // Paragraph (accumulate until blank/non-paragraph line)
320
+ const paraLines: string[] = [line];
321
+ i++;
322
+ while (
323
+ i < lines.length &&
324
+ !/^\s*$/.test(lines[i]) &&
325
+ !/^#{1,6}\s/.test(lines[i]) &&
326
+ !/^\s*```/.test(lines[i]) &&
327
+ !/^\s*>/.test(lines[i]) &&
328
+ !isListLine(lines[i]) &&
329
+ !/^\s*([-*_])\s*(?:\1\s*){2,}$/.test(lines[i])
330
+ ) {
331
+ paraLines.push(lines[i]);
332
+ i++;
333
+ }
334
+ blocks.push({ kind: 'paragraph', inline: parseInline(paraLines.join(' ')) });
335
+ }
336
+
337
+ return blocks;
338
+ }
339
+
340
+ // ── List parser (supports nesting via indentation) ──────────────────────────
341
+
342
+ function isListLine(line: string): boolean {
343
+ return /^(\s*)([-*+]|\d+[.)])\s+/.test(line);
344
+ }
345
+
346
+ function parseList(lines: string[], start: number): [Block, number] {
347
+ const first = lines[start].match(/^(\s*)([-*+]|\d+[.)])\s+/)!;
348
+ const baseIndent = first[1].length;
349
+ const ordered = /\d/.test(first[2]);
350
+ const items: ListItem[] = [];
351
+ let i = start;
352
+
353
+ while (i < lines.length) {
354
+ const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
355
+ if (!m || m[1].length !== baseIndent) break;
356
+
357
+ // Task checkbox marker
358
+ const rest = m[3];
359
+ let task: ListItem['task'] = null;
360
+ let content = rest;
361
+ const taskMatch = rest.match(/^\[([ xX])\]\s+(.*)$/);
362
+ if (taskMatch) {
363
+ task = taskMatch[1].toLowerCase() === 'x' ? 'checked' : 'unchecked';
364
+ content = taskMatch[2];
365
+ }
366
+
367
+ const itemLines: string[] = [content];
368
+ i++;
369
+
370
+ // Gather continuation + nested lines (indented beyond the marker)
371
+ while (i < lines.length) {
372
+ if (/^\s*$/.test(lines[i])) {
373
+ // Blank line — peek next to decide
374
+ if (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
375
+ itemLines.push('');
376
+ i++;
377
+ continue;
378
+ }
379
+ break;
380
+ }
381
+ const childMatch = lines[i].match(/^(\s*)(\S)/);
382
+ if (childMatch && childMatch[1].length > baseIndent) {
383
+ itemLines.push(lines[i].slice(baseIndent + 2));
384
+ i++;
385
+ continue;
386
+ }
387
+ break;
388
+ }
389
+
390
+ items.push({ blocks: parseBlocks(itemLines.join('\n')), task });
391
+ }
392
+
393
+ return [{ kind: 'list', ordered, items }, i];
394
+ }
395
+
396
+ // ── Table helpers ───────────────────────────────────────────────────────────
397
+
398
+ function isTableRow(line: string): boolean {
399
+ return /^\s*\|.+\|\s*$/.test(line.trim()) || (line.includes('|') && line.trim().startsWith('|'));
400
+ }
401
+
402
+ function isTableSeparator(line: string): boolean {
403
+ return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
404
+ }
405
+
406
+ function splitTableRow(line: string): string[] {
407
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
408
+ return trimmed.split('|').map((c) => c.trim());
409
+ }
410
+
411
+ function parseTableAlignment(sepLine: string): Array<'left' | 'center' | 'right' | null> {
412
+ return splitTableRow(sepLine).map((cell) => {
413
+ const l = cell.startsWith(':');
414
+ const r = cell.endsWith(':');
415
+ if (l && r) return 'center';
416
+ if (r) return 'right';
417
+ if (l) return 'left';
418
+ return null;
419
+ });
420
+ }
421
+
422
+ // ── Renderer ────────────────────────────────────────────────────────────────
423
+
424
+ interface RenderCtx {
425
+ theme: ReturnType<typeof useTheme>;
426
+ compact: boolean;
427
+ copyableCode: boolean;
428
+ externalLinkRel: string;
429
+ externalLinkTarget: string;
430
+ onLinkClick?: TkxMarkdownProps['onLinkClick'];
431
+ }
432
+
433
+ function renderInline(nodes: InlineNode[], ctx: RenderCtx, keyPrefix = 'i'): ReactNode[] {
434
+ return nodes.map((n, idx) => {
435
+ const k = `${keyPrefix}-${idx}`;
436
+ switch (n.kind) {
437
+ case 'text':
438
+ return <span key={k}>{sanitizeString(n.value)}</span>;
439
+ case 'bold':
440
+ return <strong key={k}>{renderInline(n.children, ctx, k)}</strong>;
441
+ case 'italic':
442
+ return <em key={k}>{renderInline(n.children, ctx, k)}</em>;
443
+ case 'strike':
444
+ return <s key={k}>{renderInline(n.children, ctx, k)}</s>;
445
+ case 'code':
446
+ return (
447
+ <code
448
+ key={k}
449
+ style={{
450
+ background: ctx.theme.surfaceAlt,
451
+ border: `1px solid ${ctx.theme.border}`,
452
+ borderRadius: 4,
453
+ padding: '0.15em 0.4em',
454
+ fontSize: '0.9em',
455
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
456
+ wordBreak: 'break-word',
457
+ }}
458
+ >
459
+ {sanitizeString(n.value)}
460
+ </code>
461
+ );
462
+ case 'link': {
463
+ const href = sanitizeHref(n.href);
464
+ if (!href) return <span key={k}>{renderInline(n.children, ctx, k)}</span>;
465
+ const isExternal = /^(https?:)?\/\//i.test(href) || href.startsWith('mailto:');
466
+ return (
467
+ <a
468
+ key={k}
469
+ href={href}
470
+ target={isExternal ? ctx.externalLinkTarget : undefined}
471
+ rel={isExternal ? ctx.externalLinkRel : undefined}
472
+ onClick={(ev) => {
473
+ if (ctx.onLinkClick?.(href, ev) === false) ev.preventDefault();
474
+ }}
475
+ style={{ color: ctx.theme.primary, textDecoration: 'underline', wordBreak: 'break-word' }}
476
+ >
477
+ {renderInline(n.children, ctx, k)}
478
+ </a>
479
+ );
480
+ }
481
+ case 'image':
482
+ return (
483
+ <img
484
+ key={k}
485
+ src={sanitizeHref(n.src) || ''}
486
+ alt={sanitizeString(n.alt)}
487
+ loading="lazy"
488
+ decoding="async"
489
+ style={{ maxWidth: '100%', height: 'auto', borderRadius: 6, display: 'inline-block' }}
490
+ />
491
+ );
492
+ }
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Only allow http/https/mailto/relative URLs. Anything else (javascript:,
498
+ * data:, vbscript:) is stripped. Prevents XSS through user-authored Markdown.
499
+ */
500
+ function sanitizeHref(raw: string): string | null {
501
+ const t = raw.trim();
502
+ if (!t) return null;
503
+ if (/^javascript:/i.test(t)) return null;
504
+ if (/^data:/i.test(t) && !/^data:image\//i.test(t)) return null;
505
+ if (/^vbscript:/i.test(t)) return null;
506
+ return t;
507
+ }
508
+
509
+ function CopyCodeButton({ text, theme }: { text: string; theme: ReturnType<typeof useTheme> }) {
510
+ const [copied, setCopied] = useState(false);
511
+ return (
512
+ <button
513
+ type="button"
514
+ onClick={() => {
515
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
516
+ navigator.clipboard.writeText(text).then(() => {
517
+ setCopied(true);
518
+ setTimeout(() => setCopied(false), 1500);
519
+ });
520
+ }
521
+ }}
522
+ aria-label={copied ? 'Copied to clipboard' : 'Copy code'}
523
+ style={{
524
+ position: 'absolute',
525
+ top: 6,
526
+ right: 6,
527
+ padding: '2px 8px',
528
+ fontSize: 11,
529
+ background: theme.surface,
530
+ color: copied ? theme.success : theme.textMuted,
531
+ border: `1px solid ${theme.border}`,
532
+ borderRadius: 4,
533
+ cursor: 'pointer',
534
+ fontFamily: 'inherit',
535
+ transition: 'color 120ms',
536
+ }}
537
+ >
538
+ {copied ? 'Copied' : 'Copy'}
539
+ </button>
540
+ );
541
+ }
542
+
543
+ function renderBlocks(blocks: Block[], ctx: RenderCtx, keyPrefix = 'b'): ReactNode[] {
544
+ return blocks.map((b, idx) => {
545
+ const k = `${keyPrefix}-${idx}`;
546
+ switch (b.kind) {
547
+ case 'heading': {
548
+ const Tag = `h${b.level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
549
+ const sizes: Record<number, { fs: string; lh: string; mt: string; mb: string; fw: number }> = {
550
+ 1: { fs: ctx.compact ? '1.75rem' : '2rem', lh: '1.2', mt: '0.6em', mb: '0.5em', fw: 700 },
551
+ 2: { fs: ctx.compact ? '1.4rem' : '1.6rem', lh: '1.25', mt: '1em', mb: '0.5em', fw: 700 },
552
+ 3: { fs: ctx.compact ? '1.2rem' : '1.3rem', lh: '1.3', mt: '1em', mb: '0.4em', fw: 600 },
553
+ 4: { fs: '1.1rem', lh: '1.35', mt: '1em', mb: '0.4em', fw: 600 },
554
+ 5: { fs: '1rem', lh: '1.4', mt: '1em', mb: '0.3em', fw: 600 },
555
+ 6: { fs: '0.9rem', lh: '1.4', mt: '1em', mb: '0.3em', fw: 600 },
556
+ };
557
+ const s = sizes[b.level];
558
+ return (
559
+ <Tag
560
+ key={k}
561
+ style={{
562
+ fontSize: s.fs,
563
+ lineHeight: s.lh,
564
+ marginTop: s.mt,
565
+ marginBottom: s.mb,
566
+ fontWeight: s.fw,
567
+ color: ctx.theme.text,
568
+ scrollMarginTop: '1em',
569
+ }}
570
+ >
571
+ {renderInline(b.inline, ctx, k)}
572
+ </Tag>
573
+ );
574
+ }
575
+
576
+ case 'paragraph':
577
+ return (
578
+ <p
579
+ key={k}
580
+ style={{
581
+ margin: `0 0 ${ctx.compact ? '0.7em' : '1em'}`,
582
+ lineHeight: 1.65,
583
+ color: ctx.theme.text,
584
+ wordBreak: 'break-word',
585
+ }}
586
+ >
587
+ {renderInline(b.inline, ctx, k)}
588
+ </p>
589
+ );
590
+
591
+ case 'blockquote':
592
+ return (
593
+ <blockquote
594
+ key={k}
595
+ style={{
596
+ margin: '0 0 1em',
597
+ padding: '0.5em 1em',
598
+ borderLeft: `4px solid ${ctx.theme.primary}`,
599
+ background: `${ctx.theme.surfaceAlt}`,
600
+ color: ctx.theme.textMuted,
601
+ borderRadius: '0 6px 6px 0',
602
+ }}
603
+ >
604
+ {renderBlocks(b.children, ctx, k)}
605
+ </blockquote>
606
+ );
607
+
608
+ case 'code': {
609
+ const raw = b.value;
610
+ return (
611
+ <div
612
+ key={k}
613
+ style={{
614
+ position: 'relative',
615
+ margin: '0 0 1em',
616
+ border: `1px solid ${ctx.theme.border}`,
617
+ borderRadius: 8,
618
+ background: ctx.theme.surfaceAlt,
619
+ overflow: 'hidden',
620
+ }}
621
+ >
622
+ {b.language && (
623
+ <div
624
+ style={{
625
+ padding: '4px 10px',
626
+ fontSize: 11,
627
+ color: ctx.theme.textMuted,
628
+ borderBottom: `1px solid ${ctx.theme.border}`,
629
+ textTransform: 'uppercase',
630
+ letterSpacing: '0.05em',
631
+ }}
632
+ >
633
+ {sanitizeString(b.language)}
634
+ </div>
635
+ )}
636
+ {ctx.copyableCode && <CopyCodeButton text={raw} theme={ctx.theme} />}
637
+ <pre
638
+ style={{
639
+ margin: 0,
640
+ padding: 12,
641
+ overflowX: 'auto',
642
+ fontSize: 13,
643
+ lineHeight: 1.55,
644
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
645
+ color: ctx.theme.text,
646
+ whiteSpace: 'pre',
647
+ }}
648
+ >
649
+ <code>{sanitizeString(raw)}</code>
650
+ </pre>
651
+ </div>
652
+ );
653
+ }
654
+
655
+ case 'list': {
656
+ const Tag = b.ordered ? 'ol' : 'ul';
657
+ return (
658
+ <Tag
659
+ key={k}
660
+ style={{
661
+ margin: '0 0 1em',
662
+ paddingLeft: '1.5em',
663
+ color: ctx.theme.text,
664
+ lineHeight: 1.65,
665
+ }}
666
+ >
667
+ {b.items.map((item, ii) => (
668
+ <li key={`${k}-${ii}`} style={{ listStyle: item.task !== null ? 'none' : undefined, marginLeft: item.task !== null ? '-1.25em' : undefined }}>
669
+ {item.task !== null && (
670
+ <input
671
+ type="checkbox"
672
+ disabled
673
+ checked={item.task === 'checked'}
674
+ readOnly
675
+ style={{ marginRight: 6, verticalAlign: 'middle' }}
676
+ aria-label={item.task === 'checked' ? 'Completed task' : 'Uncompleted task'}
677
+ />
678
+ )}
679
+ {renderBlocks(item.blocks, ctx, `${k}-${ii}`)}
680
+ </li>
681
+ ))}
682
+ </Tag>
683
+ );
684
+ }
685
+
686
+ case 'hr':
687
+ return (
688
+ <hr
689
+ key={k}
690
+ style={{
691
+ border: 'none',
692
+ borderTop: `1px solid ${ctx.theme.border}`,
693
+ margin: '1.5em 0',
694
+ }}
695
+ />
696
+ );
697
+
698
+ case 'table':
699
+ return (
700
+ <div
701
+ key={k}
702
+ style={{
703
+ overflowX: 'auto',
704
+ margin: '0 0 1em',
705
+ border: `1px solid ${ctx.theme.border}`,
706
+ borderRadius: 6,
707
+ WebkitOverflowScrolling: 'touch',
708
+ }}
709
+ >
710
+ <table
711
+ style={{
712
+ width: '100%',
713
+ borderCollapse: 'collapse',
714
+ fontSize: 14,
715
+ color: ctx.theme.text,
716
+ }}
717
+ >
718
+ <thead>
719
+ <tr>
720
+ {b.headers.map((cell, ci) => (
721
+ <th
722
+ key={ci}
723
+ style={{
724
+ padding: '8px 12px',
725
+ textAlign: b.align[ci] ?? 'left',
726
+ borderBottom: `2px solid ${ctx.theme.border}`,
727
+ background: ctx.theme.surfaceAlt,
728
+ fontWeight: 600,
729
+ }}
730
+ >
731
+ {renderInline(cell, ctx, `${k}-h-${ci}`)}
732
+ </th>
733
+ ))}
734
+ </tr>
735
+ </thead>
736
+ <tbody>
737
+ {b.rows.map((row, ri) => (
738
+ <tr key={ri}>
739
+ {row.map((cell, ci) => (
740
+ <td
741
+ key={ci}
742
+ style={{
743
+ padding: '8px 12px',
744
+ textAlign: b.align[ci] ?? 'left',
745
+ borderBottom: `1px solid ${ctx.theme.border}`,
746
+ }}
747
+ >
748
+ {renderInline(cell, ctx, `${k}-r-${ri}-${ci}`)}
749
+ </td>
750
+ ))}
751
+ </tr>
752
+ ))}
753
+ </tbody>
754
+ </table>
755
+ </div>
756
+ );
757
+ }
758
+ });
759
+ }
760
+
761
+ // ── Component ────────────────────────────────────────────────────────────────
762
+
763
+ export function TkxMarkdown({
764
+ source,
765
+ src,
766
+ maxWidth = '100%',
767
+ compact = false,
768
+ copyableCode = true,
769
+ onLinkClick,
770
+ externalLinkRel = 'noopener noreferrer',
771
+ externalLinkTarget = '_blank',
772
+ loadingFallback,
773
+ errorFallback,
774
+ className,
775
+ style,
776
+ }: TkxMarkdownProps) {
777
+ const theme = useTheme();
778
+ const [remote, setRemote] = useState<{ text: string | null; error: string | null; loading: boolean }>({
779
+ text: null,
780
+ error: null,
781
+ loading: !!src && source === undefined,
782
+ });
783
+ const abortRef = useRef<AbortController | null>(null);
784
+
785
+ useEffect(() => {
786
+ if (source !== undefined || !src) return;
787
+ abortRef.current?.abort();
788
+ const ctrl = new AbortController();
789
+ abortRef.current = ctrl;
790
+ setRemote({ text: null, error: null, loading: true });
791
+ fetch(src, { signal: ctrl.signal })
792
+ .then(async (res) => {
793
+ if (!res.ok) throw new Error(`Failed to load markdown (HTTP ${res.status})`);
794
+ const text = await res.text();
795
+ setRemote({ text, error: null, loading: false });
796
+ })
797
+ .catch((err: Error) => {
798
+ if (err.name === 'AbortError') return;
799
+ setRemote({ text: null, error: err.message, loading: false });
800
+ });
801
+ return () => ctrl.abort();
802
+ }, [src, source]);
803
+
804
+ const effectiveSource = source ?? remote.text ?? '';
805
+ const blocks = useMemo(() => (effectiveSource ? parseBlocks(effectiveSource) : []), [effectiveSource]);
806
+
807
+ const ctx: RenderCtx = {
808
+ theme,
809
+ compact,
810
+ copyableCode,
811
+ externalLinkRel,
812
+ externalLinkTarget,
813
+ onLinkClick,
814
+ };
815
+
816
+ const wrapperStyle: CSSProperties = {
817
+ maxWidth,
818
+ width: '100%',
819
+ color: theme.text,
820
+ fontFamily: 'inherit',
821
+ fontSize: compact ? 14 : 15,
822
+ lineHeight: 1.65,
823
+ wordBreak: 'break-word',
824
+ overflowWrap: 'anywhere',
825
+ boxSizing: 'border-box',
826
+ ...style,
827
+ };
828
+
829
+ if (source === undefined && src) {
830
+ if (remote.loading) {
831
+ return (
832
+ <div className={className} style={wrapperStyle} aria-busy="true" aria-live="polite">
833
+ {loadingFallback ?? <span style={{ color: theme.textMuted }}>Loading…</span>}
834
+ </div>
835
+ );
836
+ }
837
+ if (remote.error) {
838
+ return (
839
+ <div className={className} style={wrapperStyle} role="alert">
840
+ {errorFallback
841
+ ? errorFallback(remote.error)
842
+ : <span style={{ color: theme.danger }}>{sanitizeString(remote.error)}</span>}
843
+ </div>
844
+ );
845
+ }
846
+ }
847
+
848
+ return (
849
+ <div className={className} style={wrapperStyle}>
850
+ {renderBlocks(blocks, ctx)}
851
+ </div>
852
+ );
853
+ }
854
+
855
+ TkxMarkdown.displayName = 'TkxMarkdown';