@plannotator/ui 0.22.0

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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,1094 @@
1
+ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import hljs from "highlight.js";
4
+ import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
5
+ import { transformPlainText } from "../utils/inlineTransforms";
6
+ import { getImageSrc } from "./ImageThumbnail";
7
+ import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
8
+ import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
9
+ import { CodeFilePicker } from "./CodeFilePicker";
10
+ import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock";
11
+
12
+ export interface DocPreviewResult {
13
+ contents?: string;
14
+ filepath?: string;
15
+ }
16
+ export type DocPreviewFetcher = (path: string, base?: string) => Promise<DocPreviewResult | null>;
17
+
18
+ /**
19
+ * Default code-file hover-preview fetcher — Plannotator's `/api/doc` behavior, verbatim.
20
+ */
21
+ const defaultDocPreviewFetcher: DocPreviewFetcher = async (path, base) => {
22
+ const params = new URLSearchParams({ path });
23
+ if (base) params.set('base', base);
24
+ const res = await fetch(`/api/doc?${params}`);
25
+ return await res.json();
26
+ };
27
+
28
+ // Module-level fetcher, stable identity. Defaults to Plannotator's `/api/doc`.
29
+ // A host (e.g. Workspaces) calls setDocPreviewFetcher once at startup to load
30
+ // hover previews from its own backend.
31
+ let docPreviewFetcher: DocPreviewFetcher = defaultDocPreviewFetcher;
32
+
33
+ /** Override how code-file hover previews are fetched. Call once at app startup. */
34
+ export const setDocPreviewFetcher = (fetcher: DocPreviewFetcher): void => {
35
+ docPreviewFetcher = fetcher;
36
+ };
37
+
38
+ /** Reset to the default (Plannotator `/api/doc`) fetcher. Mainly for tests. */
39
+ export const resetDocPreviewFetcher = (): void => {
40
+ docPreviewFetcher = defaultDocPreviewFetcher;
41
+ };
42
+
43
+ /**
44
+ * Decide how a candidate code-file path should render based on validation state:
45
+ * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput)
46
+ * - 'ambiguous-link' → clickable, opens a picker over `matches`
47
+ * - 'plain' → not a link (file does not exist anywhere in the repo)
48
+ *
49
+ * No provider or `ready: false` falls back to optimistic 'link' behavior.
50
+ */
51
+ function gateCodePath(
52
+ candidate: string,
53
+ validation: CodePathValidationContextValue | null,
54
+ ): { render: 'link'; resolved?: string } | { render: 'ambiguous-link'; matches: string[] } | { render: 'plain' } {
55
+ if (!validation || !validation.ready) return { render: 'link' };
56
+ const entry = validation.validated.get(candidate);
57
+ // If the validator is ready but has no entry for this candidate, the
58
+ // extractor intentionally excluded it (e.g., inside an HTML comment or
59
+ // fenced code block). Demote rather than optimistically linking.
60
+ if (!entry) return { render: 'plain' };
61
+ switch ((entry as ValidationEntry).status) {
62
+ case 'found': return { render: 'link', resolved: (entry as Extract<ValidationEntry, { status: 'found' }>).resolved };
63
+ case 'ambiguous': return { render: 'ambiguous-link', matches: (entry as Extract<ValidationEntry, { status: 'ambiguous' }>).matches };
64
+ case 'unavailable': return { render: 'link' };
65
+ case 'missing': return { render: 'plain' };
66
+ default: return { render: 'link' }; // unknown status — degrade to optimistic
67
+ }
68
+ }
69
+
70
+ function extToLanguage(filepath: string): string | undefined {
71
+ const ext = filepath.split('.').pop()?.toLowerCase();
72
+ const map: Record<string, string> = {
73
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
74
+ py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
75
+ css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
76
+ sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
77
+ html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
78
+ };
79
+ return ext ? map[ext] : undefined;
80
+ }
81
+
82
+ const CodeSnippetPreview: React.FC<{
83
+ anchorEl: HTMLElement | null;
84
+ contents: string;
85
+ filepath: string;
86
+ line: number;
87
+ lineEnd?: number;
88
+ onMouseEnter?: () => void;
89
+ onMouseLeave?: () => void;
90
+ }> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => {
91
+ const allLines = contents.split('\n');
92
+ const start = Math.max(0, line - 1);
93
+ const end = Math.min(allLines.length, (lineEnd ?? line));
94
+ const snippet = allLines.slice(start, end).join('\n');
95
+
96
+ const highlightedLines = useMemo(() => {
97
+ const lang = extToLanguage(filepath);
98
+ const lines = snippet.split('\n');
99
+ return lines.map(line => {
100
+ try {
101
+ if (lang) return hljs.highlight(line, { language: lang }).value;
102
+ return hljs.highlightAuto(line).value;
103
+ } catch {
104
+ return line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
105
+ }
106
+ });
107
+ }, [snippet, filepath]);
108
+
109
+ if (!anchorEl) return null;
110
+
111
+ const rect = anchorEl.getBoundingClientRect();
112
+ const spaceBelow = window.innerHeight - rect.bottom;
113
+ const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
114
+ const top = showAbove ? undefined : rect.bottom + 4;
115
+ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
116
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520));
117
+
118
+ return createPortal(
119
+ <div
120
+ className="fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
121
+ style={{ top, bottom, left, maxWidth: 'min(600px, 90vw)', maxHeight: '300px' }}
122
+ onMouseEnter={onMouseEnter}
123
+ onMouseLeave={onMouseLeave}
124
+ >
125
+ <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
126
+ <span>{filepath.split('/').pop()}</span>
127
+ <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
128
+ </div>
129
+ <div className="hljs code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
130
+ <table className="border-collapse w-full">
131
+ <tbody>
132
+ {snippet.split('\n').map((_, i) => (
133
+ <tr key={start + i} className="hover:bg-white/5">
134
+ <td className="select-none text-muted-foreground/40 text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap" style={{ userSelect: 'none' }}>{start + i + 1}</td>
135
+ <td
136
+ className="font-mono pr-3 py-0 whitespace-pre"
137
+ dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
138
+ />
139
+ </tr>
140
+ ))}
141
+ </tbody>
142
+ </table>
143
+ </div>
144
+ </div>,
145
+ document.body,
146
+ );
147
+ };
148
+
149
+ const CodeFileLink: React.FC<{
150
+ candidate: string;
151
+ display: string;
152
+ onOpenCodeFile: (path: string) => void;
153
+ baseDir?: string;
154
+ }> = ({ candidate, display, onOpenCodeFile, baseDir }) => {
155
+ const validation = useCodePathValidation();
156
+ const gate = gateCodePath(candidate, validation);
157
+ const [pickerOpen, setPickerOpen] = useState(false);
158
+ const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null);
159
+ const hoverPreviewRef = useRef(hoverPreview);
160
+ hoverPreviewRef.current = hoverPreview;
161
+ const anchorRef = useRef<HTMLElement | null>(null);
162
+ const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
163
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
164
+ const parsed = parseCodePath(candidate);
165
+ const hasLineRef = parsed.line != null;
166
+
167
+ const cancelHide = useCallback(() => {
168
+ if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; }
169
+ }, []);
170
+
171
+ const scheduleHide = useCallback(() => {
172
+ cancelHide();
173
+ hideTimerRef.current = setTimeout(() => {
174
+ setHoverPreview(null);
175
+ }, 200);
176
+ }, [cancelHide]);
177
+
178
+ const handleMouseEnter = useCallback(() => {
179
+ if (!hasLineRef || gate.render === 'plain') return;
180
+ cancelHide();
181
+ if (hoverPreviewRef.current) return;
182
+ showTimerRef.current = setTimeout(async () => {
183
+ try {
184
+ const data = await docPreviewFetcher(candidate, baseDir);
185
+ if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate });
186
+ } catch {}
187
+ }, 150);
188
+ }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]);
189
+
190
+ const handleMouseLeave = useCallback(() => {
191
+ if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
192
+ scheduleHide();
193
+ }, [scheduleHide]);
194
+
195
+ const handlePreviewEnter = useCallback(() => {
196
+ cancelHide();
197
+ }, [cancelHide]);
198
+
199
+ const handlePreviewLeave = useCallback(() => {
200
+ scheduleHide();
201
+ }, [scheduleHide]);
202
+
203
+ useEffect(() => {
204
+ return () => {
205
+ if (showTimerRef.current) clearTimeout(showTimerRef.current);
206
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
207
+ };
208
+ }, []);
209
+
210
+ if (gate.render === 'plain') {
211
+ return (
212
+ <code className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono">
213
+ {display}
214
+ </code>
215
+ );
216
+ }
217
+
218
+ const isAmbiguous = gate.render === 'ambiguous-link';
219
+ const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : '';
220
+ const handleClick = () => {
221
+ handleMouseLeave();
222
+ if (isAmbiguous) {
223
+ setPickerOpen(true);
224
+ return;
225
+ }
226
+ const resolvedPath = gate.render === 'link' && gate.resolved ? gate.resolved : candidate;
227
+ onOpenCodeFile(gate.render === 'link' && gate.resolved ? resolvedPath + lineSuffix : candidate);
228
+ };
229
+
230
+ return (
231
+ <>
232
+ <code
233
+ ref={(el) => { anchorRef.current = el; }}
234
+ role="button"
235
+ tabIndex={0}
236
+ data-ambiguous={isAmbiguous ? "true" : undefined}
237
+ onClick={handleClick}
238
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}
239
+ onMouseEnter={handleMouseEnter}
240
+ onMouseLeave={handleMouseLeave}
241
+ className="code-file-link px-1.5 py-0.5 rounded bg-muted text-sm font-mono cursor-pointer hover:text-primary inline-flex items-center gap-1 transition-colors"
242
+ title={isAmbiguous ? `${display} — multiple matches` : `View: ${display}`}
243
+ >
244
+ {display}
245
+ <CodeFileIcon />
246
+ {isAmbiguous && (
247
+ <sup className="text-[0.6rem] opacity-70 -ml-0.5">{(gate as { matches: string[] }).matches.length}</sup>
248
+ )}
249
+ </code>
250
+ {hoverPreview && hasLineRef && (
251
+ <CodeSnippetPreview
252
+ anchorEl={anchorRef.current}
253
+ contents={hoverPreview.contents}
254
+ filepath={hoverPreview.filepath}
255
+ line={parsed.line!}
256
+ lineEnd={parsed.lineEnd}
257
+ onMouseEnter={handlePreviewEnter}
258
+ onMouseLeave={handlePreviewLeave}
259
+ />
260
+ )}
261
+ {pickerOpen && isAmbiguous && (
262
+ <CodeFilePicker
263
+ anchorEl={anchorRef.current}
264
+ matches={(gate as { matches: string[] }).matches}
265
+ onPick={(path) => { setPickerOpen(false); onOpenCodeFile(path + lineSuffix); }}
266
+ onDismiss={() => setPickerOpen(false)}
267
+ />
268
+ )}
269
+ </>
270
+ );
271
+ };
272
+
273
+ const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i;
274
+ function sanitizeLinkUrl(url: string): string | null {
275
+ if (DANGEROUS_PROTOCOL.test(url)) return null;
276
+ return url;
277
+ }
278
+
279
+ const CodeFileIcon = () => (
280
+ <svg
281
+ className="w-3 h-3 opacity-50 flex-shrink-0"
282
+ fill="none"
283
+ viewBox="0 0 24 24"
284
+ stroke="currentColor"
285
+ strokeWidth={2}
286
+ aria-hidden="true"
287
+ >
288
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
289
+ </svg>
290
+ );
291
+
292
+ const InlineMath: React.FC<{ tex: string }> = ({ tex }) => {
293
+ const normalizedTex = normalizeMathTex(tex);
294
+ const html = useMemo(() => renderMathToHtml(normalizedTex, false), [normalizedTex]);
295
+
296
+ return (
297
+ <span
298
+ className="math-inline math-annotatable text-foreground"
299
+ data-math-tex={normalizedTex}
300
+ data-math-display="false"
301
+ aria-label={normalizedTex}
302
+ dangerouslySetInnerHTML={{ __html: html }}
303
+ />
304
+ );
305
+ };
306
+
307
+ function canRenderSpacedDollarMath(tex: string): boolean {
308
+ const trimmed = normalizeMathTex(tex);
309
+ if (!trimmed) return false;
310
+ if (!/^\s|\s$/.test(tex)) return true;
311
+ return (
312
+ /\\[a-zA-Z]+/.test(trimmed) ||
313
+ /[=^_{}+\-*/<>|]/.test(trimmed) ||
314
+ /^[a-zA-Z]$/.test(trimmed)
315
+ );
316
+ }
317
+
318
+ // Trim trailing sentence punctuation from a bare URL, but keep closing
319
+ // brackets when they balance an opener inside the URL (Wikipedia-style
320
+ // https://…/Function_(mathematics) should keep its closing paren).
321
+ export function trimUrlTail(url: string): string {
322
+ const balanced = (u: string, close: string, open: string): boolean => {
323
+ let opens = 0, closes = 0;
324
+ for (const c of u) {
325
+ if (c === open) opens++;
326
+ else if (c === close) closes++;
327
+ }
328
+ return opens >= closes;
329
+ };
330
+ while (url.length > 0) {
331
+ const last = url[url.length - 1];
332
+ if (!/[.,;:!?)\]}>"']/.test(last)) break;
333
+ if (last === ')' && balanced(url, ')', '(')) break;
334
+ if (last === ']' && balanced(url, ']', '[')) break;
335
+ if (last === '}' && balanced(url, '}', '{')) break;
336
+ url = url.slice(0, -1);
337
+ }
338
+ return url;
339
+ }
340
+
341
+ // Scan a plain-text chunk for bare https?:// URLs and bare code file paths
342
+ // at word boundaries, emitting them as interactive nodes. Surrounding text
343
+ // passes through transformPlainText for emoji shortcodes + smart punctuation.
344
+ function emitPlainTextWithBareUrls(
345
+ text: string,
346
+ previousChar: string,
347
+ parts: React.ReactNode[],
348
+ nextKey: () => number,
349
+ onOpenCodeFile?: (path: string) => void,
350
+ validation?: CodePathValidationContextValue | null,
351
+ baseDir?: string,
352
+ ): void {
353
+ if (text.length === 0) return;
354
+
355
+ type Span = { start: number; end: number; kind: 'url'; value: string }
356
+ | { start: number; end: number; kind: 'path'; value: string };
357
+ const spans: Span[] = [];
358
+
359
+ // Collect bare URLs
360
+ const urlRe = /https?:\/\/[^\s<>"']+/g;
361
+ let m: RegExpExecArray | null;
362
+ while ((m = urlRe.exec(text)) !== null) {
363
+ const before = m.index === 0 ? previousChar : text[m.index - 1];
364
+ if (/\w/.test(before)) continue;
365
+ const url = trimUrlTail(m[0]);
366
+ const safe = url.length > 0 ? sanitizeLinkUrl(url) : null;
367
+ if (!safe) continue;
368
+ spans.push({ start: m.index, end: m.index + url.length, kind: 'url', value: url });
369
+ urlRe.lastIndex = m.index + url.length;
370
+ }
371
+
372
+ // Collect bare code file paths (require /)
373
+ if (onOpenCodeFile) {
374
+ const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, 'g');
375
+ while ((m = pathRe.exec(text)) !== null) {
376
+ const before = m.index === 0 ? previousChar : text[m.index - 1];
377
+ if (/\w/.test(before)) continue;
378
+ const candidate = m[0];
379
+ if (!isCodeFilePathStrict(candidate)) continue;
380
+ const overlaps = spans.some(s => m!.index < s.end && m!.index + candidate.length > s.start);
381
+ if (overlaps) continue;
382
+ spans.push({ start: m.index, end: m.index + candidate.length, kind: 'path', value: candidate });
383
+ }
384
+ }
385
+
386
+ if (spans.length === 0) {
387
+ parts.push(transformPlainText(text));
388
+ return;
389
+ }
390
+
391
+ spans.sort((a, b) => a.start - b.start);
392
+
393
+ let last = 0;
394
+ for (const span of spans) {
395
+ if (span.start > last) {
396
+ parts.push(transformPlainText(text.slice(last, span.start)));
397
+ }
398
+ if (span.kind === 'url') {
399
+ parts.push(
400
+ <a
401
+ key={nextKey()}
402
+ href={span.value}
403
+ target="_blank"
404
+ rel="noopener noreferrer"
405
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
406
+ >
407
+ {span.value}
408
+ </a>,
409
+ );
410
+ } else {
411
+ const cleanPath = span.value.replace(/#.*$/, '');
412
+ const gate = gateCodePath(cleanPath, validation ?? null);
413
+ if (gate.render === 'plain') {
414
+ // Bare prose, file doesn't exist — emit as plain text, no link styling.
415
+ parts.push(transformPlainText(span.value));
416
+ } else {
417
+ parts.push(
418
+ <CodeFileLink
419
+ key={nextKey()}
420
+ candidate={cleanPath}
421
+ display={span.value}
422
+ onOpenCodeFile={onOpenCodeFile!}
423
+ baseDir={baseDir}
424
+ />,
425
+ );
426
+ }
427
+ }
428
+ last = span.end;
429
+ }
430
+ if (last < text.length) {
431
+ parts.push(transformPlainText(text.slice(last)));
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Scanner that walks a text string and emits React nodes for inline markdown:
437
+ * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
438
+ * [label](url) / ![alt](src) / <autolink>, bare https:// URLs, [[wiki-links]],
439
+ * inline math ($...$), hex color swatches (#fff / #123abc), @mentions, #issue-refs, and backslash
440
+ * escapes. Plain-text chunks outside these patterns pass through
441
+ * `transformPlainText` for emoji shortcodes + smart punctuation.
442
+ */
443
+ export const InlineMarkdown: React.FC<{
444
+ text: string;
445
+ onOpenLinkedDoc?: (path: string) => void;
446
+ onOpenCodeFile?: (path: string) => void;
447
+ onNavigateAnchor?: (hash: string) => void;
448
+ imageBaseDir?: string;
449
+ onImageClick?: (src: string, alt: string) => void;
450
+ githubRepo?: string;
451
+ }> = ({ text, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
452
+ const validation = useCodePathValidation();
453
+ const parts: React.ReactNode[] = [];
454
+ let remaining = text;
455
+ let key = 0;
456
+ let previousChar = "";
457
+
458
+ while (remaining.length > 0) {
459
+ // HTML comments: skip entirely — they should be invisible per CommonMark.
460
+ // The parser doesn't recognize <!-- --> as a block-level construct, so
461
+ // comments inside paragraphs land here. Without this, path detection
462
+ // would linkify paths inside comments.
463
+ let match = remaining.match(/^<!--[\s\S]*?-->/);
464
+ if (match) {
465
+ remaining = remaining.slice(match[0].length);
466
+ previousChar = ">";
467
+ continue;
468
+ }
469
+
470
+ // Inline math: \( ... \) or $...$ — require non-space content and avoid
471
+ // display fences.
472
+ if (remaining.startsWith('\\(')) {
473
+ let end = -1;
474
+ for (let i = 2; i < remaining.length - 1; i++) {
475
+ if (remaining[i] === '\\' && remaining[i + 1] === ')') {
476
+ if (i > 0 && remaining[i - 1] === '\\') continue;
477
+ end = i;
478
+ break;
479
+ }
480
+ }
481
+ if (end > 1) {
482
+ const tex = remaining.slice(2, end);
483
+ if (tex.trim()) {
484
+ parts.push(<InlineMath key={key++} tex={tex} />);
485
+ remaining = remaining.slice(end + 2);
486
+ previousChar = ')';
487
+ continue;
488
+ }
489
+ }
490
+ }
491
+
492
+ // Backslash escaping: \. \* \_ \` \[ \~ \$ etc. — emit literal char, hide backslash
493
+ match = remaining.match(/^\\([\\*_`\[\]~!.()\-#>+|{}&$])/);
494
+ if (match) {
495
+ parts.push(match[1]);
496
+ remaining = remaining.slice(2);
497
+ previousChar = match[1];
498
+ continue;
499
+ }
500
+
501
+ // Inline math: $...$ — require non-space content and avoid $$ display fences.
502
+ if (remaining.startsWith('$') && previousChar !== '$' && !remaining.startsWith('$$')) {
503
+ let end = -1;
504
+ for (let i = 1; i < remaining.length; i++) {
505
+ if (remaining[i] === '\\') {
506
+ i++;
507
+ continue;
508
+ }
509
+ if (remaining[i] === '$') {
510
+ end = i;
511
+ break;
512
+ }
513
+ }
514
+ if (end > 1) {
515
+ const tex = remaining.slice(1, end);
516
+ // Money guard: `$A$B` where a digit immediately follows the closing `$`
517
+ // is almost always two currency amounts ("$5-$10", "$50,000-$100,000",
518
+ // "$5/mo and tier B is $10/mo"), not inline math — real inline math never
519
+ // abuts a digit across the closing delimiter. Leave such text literal.
520
+ const afterClose = remaining[end + 1];
521
+ const looksLikeMoney = /^\s*\d/.test(tex) && afterClose !== undefined && /\d/.test(afterClose);
522
+ if (!looksLikeMoney && canRenderSpacedDollarMath(tex)) {
523
+ parts.push(<InlineMath key={key++} tex={tex} />);
524
+ remaining = remaining.slice(end + 1);
525
+ previousChar = '$';
526
+ continue;
527
+ }
528
+ }
529
+ }
530
+
531
+ // Bare URL autolink: https://… preceded by word boundary.
532
+ // Trailing sentence punctuation is trimmed so "See https://x.com."
533
+ // renders the period outside the link. Closing brackets are kept when
534
+ // they balance an earlier opener inside the URL (e.g. Wikipedia's
535
+ // https://…/Function_(mathematics) keeps its trailing paren).
536
+ if (!/\w/.test(previousChar)) {
537
+ const bareMatch = remaining.match(/^https?:\/\/[^\s<>"']+/);
538
+ if (bareMatch) {
539
+ const url = trimUrlTail(bareMatch[0]);
540
+ const safe = url.length > 0 ? sanitizeLinkUrl(url) : null;
541
+ if (safe) {
542
+ parts.push(
543
+ <a
544
+ key={key++}
545
+ href={safe}
546
+ target="_blank"
547
+ rel="noopener noreferrer"
548
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
549
+ >
550
+ {url}
551
+ </a>,
552
+ );
553
+ remaining = remaining.slice(url.length);
554
+ previousChar = url[url.length - 1];
555
+ continue;
556
+ }
557
+ }
558
+ }
559
+
560
+ // Autolinks: <https://url> or <email@domain.com>
561
+ match = remaining.match(/^<(https?:\/\/[^>]+)>/);
562
+ if (match) {
563
+ const url = match[1];
564
+ parts.push(
565
+ <a
566
+ key={key++}
567
+ href={url}
568
+ target="_blank"
569
+ rel="noopener noreferrer"
570
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
571
+ >
572
+ {url}
573
+ </a>,
574
+ );
575
+ remaining = remaining.slice(match[0].length);
576
+ previousChar = ">";
577
+ continue;
578
+ }
579
+ match = remaining.match(/^<([^@>\s]+@[^>\s]+)>/);
580
+ if (match) {
581
+ const email = match[1];
582
+ parts.push(
583
+ <a
584
+ key={key++}
585
+ href={`mailto:${email}`}
586
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
587
+ >
588
+ {email}
589
+ </a>,
590
+ );
591
+ remaining = remaining.slice(match[0].length);
592
+ previousChar = ">";
593
+ continue;
594
+ }
595
+
596
+ // Keep custom autolinks literal; their contents should not
597
+ // recurse into markdown parsing.
598
+ match = remaining.match(/^<([A-Za-z][A-Za-z0-9.+-]{0,31}:[^\s<>]*)>/);
599
+ if (match) {
600
+ const content = match[1];
601
+ parts.push(<span key={key++}>{`<${content}>`}</span>);
602
+ remaining = remaining.slice(match[0].length);
603
+ previousChar = ">";
604
+ continue;
605
+ }
606
+
607
+ // Strikethrough: ~~text~~
608
+ match = remaining.match(/^~~([\s\S]+?)~~/);
609
+ if (match) {
610
+ parts.push(
611
+ <del key={key++}>
612
+ <InlineMarkdown
613
+ imageBaseDir={imageBaseDir}
614
+ onImageClick={onImageClick}
615
+ text={match[1]}
616
+ onOpenLinkedDoc={onOpenLinkedDoc}
617
+ onOpenCodeFile={onOpenCodeFile}
618
+ onNavigateAnchor={onNavigateAnchor}
619
+ githubRepo={githubRepo}
620
+ />
621
+ </del>,
622
+ );
623
+ remaining = remaining.slice(match[0].length);
624
+ previousChar = match[0][match[0].length - 1] || previousChar;
625
+ continue;
626
+ }
627
+
628
+ // Bold + italic: ***text***
629
+ match = remaining.match(/^\*\*\*([\s\S]+?)\*\*\*/);
630
+ if (match) {
631
+ parts.push(
632
+ <strong key={key++} className="font-semibold">
633
+ <em>
634
+ <InlineMarkdown
635
+ imageBaseDir={imageBaseDir}
636
+ onImageClick={onImageClick}
637
+ text={match[1]}
638
+ onOpenLinkedDoc={onOpenLinkedDoc}
639
+ onOpenCodeFile={onOpenCodeFile}
640
+ onNavigateAnchor={onNavigateAnchor}
641
+ githubRepo={githubRepo}
642
+ />
643
+ </em>
644
+ </strong>,
645
+ );
646
+ remaining = remaining.slice(match[0].length);
647
+ previousChar = match[0][match[0].length - 1] || previousChar;
648
+ continue;
649
+ }
650
+
651
+ // Bold: **text** ([\s\S]+? allows matching across hard line breaks)
652
+ match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
653
+ if (match) {
654
+ parts.push(
655
+ <strong key={key++} className="font-semibold">
656
+ <InlineMarkdown
657
+ imageBaseDir={imageBaseDir}
658
+ onImageClick={onImageClick}
659
+ text={match[1]}
660
+ onOpenLinkedDoc={onOpenLinkedDoc}
661
+ onOpenCodeFile={onOpenCodeFile}
662
+ onNavigateAnchor={onNavigateAnchor}
663
+ githubRepo={githubRepo}
664
+ />
665
+ </strong>,
666
+ );
667
+ remaining = remaining.slice(match[0].length);
668
+ previousChar = match[0][match[0].length - 1] || previousChar;
669
+ continue;
670
+ }
671
+
672
+ // Italic: *text* or _text_ (avoid intraword underscores)
673
+ match = remaining.match(/^\*([\s\S]+?)\*/);
674
+ if (match) {
675
+ parts.push(
676
+ <em key={key++}>
677
+ <InlineMarkdown
678
+ imageBaseDir={imageBaseDir}
679
+ onImageClick={onImageClick}
680
+ text={match[1]}
681
+ onOpenLinkedDoc={onOpenLinkedDoc}
682
+ onOpenCodeFile={onOpenCodeFile}
683
+ onNavigateAnchor={onNavigateAnchor}
684
+ githubRepo={githubRepo}
685
+ />
686
+ </em>,
687
+ );
688
+ remaining = remaining.slice(match[0].length);
689
+ previousChar = match[0][match[0].length - 1] || previousChar;
690
+ continue;
691
+ }
692
+
693
+ match = !/\w/.test(previousChar)
694
+ ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
695
+ : null;
696
+ if (match) {
697
+ parts.push(
698
+ <em key={key++}>
699
+ <InlineMarkdown
700
+ imageBaseDir={imageBaseDir}
701
+ onImageClick={onImageClick}
702
+ text={match[1]}
703
+ onOpenLinkedDoc={onOpenLinkedDoc}
704
+ onOpenCodeFile={onOpenCodeFile}
705
+ onNavigateAnchor={onNavigateAnchor}
706
+ githubRepo={githubRepo}
707
+ />
708
+ </em>,
709
+ );
710
+ remaining = remaining.slice(match[0].length);
711
+ previousChar = match[0][match[0].length - 1] || previousChar;
712
+ continue;
713
+ }
714
+
715
+ // Inline code: `code` — when the content is a code file path, render as clickable
716
+ match = remaining.match(/^`([^`]+)`/);
717
+ if (match) {
718
+ const codeContent = match[1];
719
+ if (isCodeFilePath(codeContent) && onOpenCodeFile) {
720
+ const cleanPath = codeContent.replace(/#.*$/, '');
721
+ parts.push(
722
+ <CodeFileLink
723
+ key={key++}
724
+ candidate={cleanPath}
725
+ display={codeContent}
726
+ onOpenCodeFile={onOpenCodeFile}
727
+ baseDir={imageBaseDir}
728
+ />,
729
+ );
730
+ } else {
731
+ parts.push(
732
+ <code
733
+ key={key++}
734
+ className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono"
735
+ >
736
+ {codeContent}
737
+ </code>,
738
+ );
739
+ }
740
+ remaining = remaining.slice(match[0].length);
741
+ previousChar = match[0][match[0].length - 1] || previousChar;
742
+ continue;
743
+ }
744
+
745
+ // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123.
746
+ match = remaining.match(
747
+ /^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/,
748
+ );
749
+ if (match) {
750
+ const hex = match[1];
751
+ parts.push(
752
+ <span
753
+ key={key++}
754
+ className="inline-flex items-center gap-1 align-middle"
755
+ >
756
+ <span
757
+ className="inline-block w-3.5 h-3.5 rounded-sm border border-black/20 dark:border-white/20 flex-shrink-0"
758
+ style={{ backgroundColor: hex }}
759
+ title={hex}
760
+ />
761
+ <code className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono">
762
+ {hex}
763
+ </code>
764
+ </span>,
765
+ );
766
+ remaining = remaining.slice(match[0].length);
767
+ previousChar = match[0][match[0].length - 1] || previousChar;
768
+ continue;
769
+ }
770
+
771
+ // Issue / PR reference: #123 — only at word boundary, digits only.
772
+ // Hex-swatch branch above already consumed #fff / #123abc etc., so a bare
773
+ // #\d+ here is safe to treat as an issue ref.
774
+ if (!/\w/.test(previousChar)) {
775
+ match = remaining.match(/^#(\d+)(?!\w)/);
776
+ if (match) {
777
+ const num = match[1];
778
+ const href = githubRepo && githubRepo.includes('/')
779
+ ? `https://github.com/${githubRepo}/issues/${num}`
780
+ : null;
781
+ const label = `#${num}`;
782
+ parts.push(
783
+ href ? (
784
+ <a
785
+ key={key++}
786
+ href={href}
787
+ target="_blank"
788
+ rel="noopener noreferrer"
789
+ className="text-primary font-medium hover:underline"
790
+ >
791
+ {label}
792
+ </a>
793
+ ) : (
794
+ <span key={key++} className="text-primary font-medium">{label}</span>
795
+ ),
796
+ );
797
+ remaining = remaining.slice(match[0].length);
798
+ previousChar = match[0][match[0].length - 1];
799
+ continue;
800
+ }
801
+ }
802
+
803
+ // @mention — only at word boundary. GitHub-style handle pattern.
804
+ if (!/\w/.test(previousChar)) {
805
+ match = remaining.match(/^@([a-zA-Z][a-zA-Z0-9_-]{0,38})(?!\w)/);
806
+ if (match) {
807
+ const handle = match[1];
808
+ const href = githubRepo && githubRepo.includes('/')
809
+ ? `https://github.com/${handle}`
810
+ : null;
811
+ const label = `@${handle}`;
812
+ parts.push(
813
+ href ? (
814
+ <a
815
+ key={key++}
816
+ href={href}
817
+ target="_blank"
818
+ rel="noopener noreferrer"
819
+ className="text-primary font-medium hover:underline"
820
+ >
821
+ {label}
822
+ </a>
823
+ ) : (
824
+ <span key={key++} className="text-primary font-medium">{label}</span>
825
+ ),
826
+ );
827
+ remaining = remaining.slice(match[0].length);
828
+ previousChar = match[0][match[0].length - 1];
829
+ continue;
830
+ }
831
+ }
832
+
833
+ // Wikilinks: [[filename]] or [[filename|display text]]
834
+ match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
835
+ if (match) {
836
+ const target = match[1].trim();
837
+ const display = match[2]?.trim() || target;
838
+ const targetPath = /\.(mdx?|txt|html?)$/i.test(target)
839
+ ? target
840
+ : `${target}.md`;
841
+
842
+ if (onOpenLinkedDoc) {
843
+ parts.push(
844
+ <a
845
+ key={key++}
846
+ href={targetPath}
847
+ onClick={(e) => {
848
+ e.preventDefault();
849
+ onOpenLinkedDoc(targetPath);
850
+ }}
851
+ className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
852
+ title={`Open: ${target}`}
853
+ >
854
+ {display}
855
+ <svg
856
+ className="w-3 h-3 opacity-50 flex-shrink-0"
857
+ fill="none"
858
+ viewBox="0 0 24 24"
859
+ stroke="currentColor"
860
+ strokeWidth={2}
861
+ aria-hidden="true"
862
+ >
863
+ <path
864
+ strokeLinecap="round"
865
+ strokeLinejoin="round"
866
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
867
+ />
868
+ </svg>
869
+ </a>,
870
+ );
871
+ } else {
872
+ parts.push(
873
+ <span key={key++} className="text-primary">
874
+ {display}
875
+ </span>,
876
+ );
877
+ }
878
+ remaining = remaining.slice(match[0].length);
879
+ previousChar = match[0][match[0].length - 1] || previousChar;
880
+ continue;
881
+ }
882
+
883
+ // Images: ![alt](path)
884
+ match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
885
+ if (match) {
886
+ const alt = match[1];
887
+ const src = match[2];
888
+ const imgSrc = /^(https?:\/\/|data:|blob:)/i.test(src)
889
+ ? src
890
+ : getImageSrc(src, imageBaseDir);
891
+ parts.push(
892
+ <img
893
+ key={key++}
894
+ src={imgSrc}
895
+ alt={alt}
896
+ className="max-w-full rounded my-2 cursor-zoom-in"
897
+ loading="lazy"
898
+ onClick={(e) => {
899
+ e.stopPropagation();
900
+ onImageClick?.(imgSrc, alt);
901
+ }}
902
+ />,
903
+ );
904
+ remaining = remaining.slice(match[0].length);
905
+ previousChar = match[0][match[0].length - 1] || previousChar;
906
+ continue;
907
+ }
908
+
909
+ // Links: [text](url) — url may contain balanced parens (e.g. Wikipedia
910
+ // /Function_(mathematics)). Plain `[^)]+` would truncate at the first
911
+ // inner close-paren, so we scan the destination manually tracking depth.
912
+ const linkParsed = (() => {
913
+ if (remaining[0] !== '[') return null;
914
+ let i = 1;
915
+ let depth = 1;
916
+ while (i < remaining.length && depth > 0) {
917
+ const ch = remaining[i];
918
+ if (ch === '\\' && i + 1 < remaining.length) { i += 2; continue; }
919
+ if (ch === '[') depth++;
920
+ else if (ch === ']') depth--;
921
+ if (depth === 0) break;
922
+ i++;
923
+ }
924
+ if (depth !== 0 || remaining[i + 1] !== '(') return null;
925
+ const textEnd = i;
926
+ let j = i + 2;
927
+ let parenDepth = 1;
928
+ while (j < remaining.length && parenDepth > 0) {
929
+ const ch = remaining[j];
930
+ if (ch === '\\' && j + 1 < remaining.length) { j += 2; continue; }
931
+ if (ch === '(') parenDepth++;
932
+ else if (ch === ')') { parenDepth--; if (parenDepth === 0) break; }
933
+ else if (ch === '\n') return null;
934
+ j++;
935
+ }
936
+ if (parenDepth !== 0) return null;
937
+ const linkText = remaining.slice(1, textEnd);
938
+ const linkUrl = remaining.slice(i + 2, j);
939
+ if (!linkText || !linkUrl) return null;
940
+ return { linkText, linkUrl, consumed: j + 1 };
941
+ })();
942
+ if (linkParsed) {
943
+ const { linkText, linkUrl, consumed } = linkParsed;
944
+ const safeLinkUrl = sanitizeLinkUrl(linkUrl);
945
+
946
+ // Dangerous protocol stripped — render as plain text, not a dead link
947
+ if (safeLinkUrl === null) {
948
+ parts.push(<span key={key++}>{linkText}</span>);
949
+ remaining = remaining.slice(consumed);
950
+ previousChar = ')';
951
+ continue;
952
+ }
953
+
954
+ // Local doc: .md / .mdx / .txt / .html / .htm, optionally with #fragment.
955
+ // Fragment is stripped before handing to onOpenLinkedDoc (overlay has
956
+ // no anchor-scroll support today).
957
+ const isLocalDoc =
958
+ /\.(mdx?|txt|html?)(#.*)?$/i.test(linkUrl) &&
959
+ !linkUrl.startsWith("http://") &&
960
+ !linkUrl.startsWith("https://");
961
+ const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
962
+ const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
963
+ const codeFilePath = isCodeFile ? linkUrl.replace(/#.*$/, '') : linkUrl;
964
+ const isInPageAnchor = safeLinkUrl.startsWith('#');
965
+
966
+ if (isInPageAnchor) {
967
+ parts.push(
968
+ <a
969
+ key={key++}
970
+ href={safeLinkUrl}
971
+ onClick={onNavigateAnchor ? (e) => {
972
+ e.preventDefault();
973
+ onNavigateAnchor(safeLinkUrl);
974
+ } : undefined}
975
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
976
+ >
977
+ {linkText}
978
+ </a>,
979
+ );
980
+ } else if (isLocalDoc && onOpenLinkedDoc) {
981
+ parts.push(
982
+ <a
983
+ key={key++}
984
+ href={safeLinkUrl}
985
+ onClick={(e) => {
986
+ e.preventDefault();
987
+ onOpenLinkedDoc(linkedDocPath);
988
+ }}
989
+ className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
990
+ title={`Open: ${linkUrl}`}
991
+ >
992
+ {linkText}
993
+ <svg
994
+ className="w-3 h-3 opacity-50 flex-shrink-0"
995
+ fill="none"
996
+ viewBox="0 0 24 24"
997
+ stroke="currentColor"
998
+ strokeWidth={2}
999
+ aria-hidden="true"
1000
+ >
1001
+ <path
1002
+ strokeLinecap="round"
1003
+ strokeLinejoin="round"
1004
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
1005
+ />
1006
+ </svg>
1007
+ </a>,
1008
+ );
1009
+ } else if (isCodeFile && onOpenCodeFile) {
1010
+ parts.push(
1011
+ <a
1012
+ key={key++}
1013
+ href={safeLinkUrl}
1014
+ onClick={(e) => {
1015
+ e.preventDefault();
1016
+ onOpenCodeFile(codeFilePath);
1017
+ }}
1018
+ className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
1019
+ title={`View: ${linkUrl}`}
1020
+ >
1021
+ {linkText}
1022
+ <CodeFileIcon />
1023
+ </a>,
1024
+ );
1025
+ } else if (isLocalDoc) {
1026
+ // No handler — render as plain link (e.g., in shared/portal views)
1027
+ parts.push(
1028
+ <a
1029
+ key={key++}
1030
+ href={safeLinkUrl}
1031
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
1032
+ >
1033
+ {linkText}
1034
+ </a>,
1035
+ );
1036
+ } else {
1037
+ parts.push(
1038
+ <a
1039
+ key={key++}
1040
+ href={safeLinkUrl}
1041
+ target="_blank"
1042
+ rel="noopener noreferrer"
1043
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
1044
+ >
1045
+ {linkText}
1046
+ </a>,
1047
+ );
1048
+ }
1049
+ remaining = remaining.slice(consumed);
1050
+ previousChar = ')';
1051
+ continue;
1052
+ }
1053
+
1054
+ // Hard line break: two+ trailing spaces + newline, or backslash + newline
1055
+ match = remaining.match(/ {2,}\n|\\\n/);
1056
+ if (match && match.index !== undefined) {
1057
+ const before = remaining.slice(0, match.index);
1058
+ if (before) {
1059
+ parts.push(
1060
+ <InlineMarkdown
1061
+ key={key++}
1062
+ text={before}
1063
+ onOpenLinkedDoc={onOpenLinkedDoc}
1064
+ onOpenCodeFile={onOpenCodeFile}
1065
+ onNavigateAnchor={onNavigateAnchor}
1066
+ githubRepo={githubRepo}
1067
+ imageBaseDir={imageBaseDir}
1068
+ onImageClick={onImageClick}
1069
+ />,
1070
+ );
1071
+ }
1072
+ parts.push(<br key={key++} />);
1073
+ remaining = remaining.slice(match.index + match[0].length);
1074
+ previousChar = "\n";
1075
+ continue;
1076
+ }
1077
+
1078
+ // Find next special character or consume one regular character.
1079
+ // `h` is intentionally NOT in this class — plain-text chunks may contain
1080
+ // `h` mid-word (e.g. ":heart:", "hello"), and splitting on it breaks
1081
+ // multi-char patterns like emoji shortcodes. Bare URLs are instead
1082
+ // detected inline via emitPlainTextWithBareUrls() below.
1083
+ const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/);
1084
+ const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1);
1085
+ emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
1086
+ previousChar = plainText[plainText.length - 1] || previousChar;
1087
+ if (nextSpecial === -1) {
1088
+ break;
1089
+ }
1090
+ remaining = remaining.slice(nextSpecial + 1);
1091
+ }
1092
+
1093
+ return <>{parts}</>;
1094
+ };