@optilogic/core 1.0.0-beta.15 → 1.0.0-beta.16

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.
@@ -0,0 +1,304 @@
1
+ import * as React from "react";
2
+ import ReactMarkdownImport from "react-markdown";
3
+ import remarkGfmImport from "remark-gfm";
4
+ import { cn } from "../../../utils/cn";
5
+ import { useHighlightedTokens } from "../hooks/useHighlightedTokens";
6
+ import type { FileRendererProps, ResolveImageUrl } from "../types";
7
+
8
+ /**
9
+ * Renders a fenced code block with shiki syntax highlighting.
10
+ * Falls back to plain text if shiki is unavailable or the language isn't supported.
11
+ */
12
+ function HighlightedCodeBlock({
13
+ children,
14
+ className: codeClassName,
15
+ ...props
16
+ }: React.HTMLAttributes<HTMLElement>) {
17
+ // Extract language from className (e.g., "language-javascript" -> "javascript")
18
+ const language = React.useMemo(() => {
19
+ if (!codeClassName) return "text";
20
+ const match = codeClassName.match(/language-(\S+)/);
21
+ return match ? match[1] : "text";
22
+ }, [codeClassName]);
23
+
24
+ const code = React.useMemo(() => {
25
+ const raw = String(children);
26
+ // react-markdown adds a trailing newline to code blocks — strip it
27
+ return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
28
+ }, [children]);
29
+
30
+ const { lines } = useHighlightedTokens(code, language);
31
+
32
+ if (lines) {
33
+ return (
34
+ <code className={cn("bg-transparent p-0", codeClassName)} {...props}>
35
+ {lines.map((tokens, i) => (
36
+ <React.Fragment key={i}>
37
+ {i > 0 && "\n"}
38
+ {tokens.map((token, ti) => (
39
+ <span key={ti} style={{ color: token.color }}>
40
+ {token.content}
41
+ </span>
42
+ ))}
43
+ </React.Fragment>
44
+ ))}
45
+ </code>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <code className={cn("bg-transparent p-0", codeClassName)} {...props}>
51
+ {children}
52
+ </code>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Renders an image in markdown, with optional async URL resolution
58
+ * for relative paths via the `resolveImageUrl` callback.
59
+ *
60
+ * - Remote URLs (http/https) and data URIs render directly.
61
+ * - If no `resolveImageUrl` is provided, renders the src as-is.
62
+ * - Otherwise, calls `resolveImageUrl(src)` and manages loading/error/resolved states.
63
+ * - Cleans up blob URLs on unmount.
64
+ */
65
+ function MarkdownImage({
66
+ src,
67
+ alt,
68
+ resolveImageUrl,
69
+ ...props
70
+ }: React.ImgHTMLAttributes<HTMLImageElement> & {
71
+ resolveImageUrl?: ResolveImageUrl;
72
+ }) {
73
+ const needsResolution =
74
+ !!src &&
75
+ !!resolveImageUrl &&
76
+ !src.startsWith("http://") &&
77
+ !src.startsWith("https://") &&
78
+ !src.startsWith("data:");
79
+
80
+ const [resolvedUrl, setResolvedUrl] = React.useState<string | null>(null);
81
+ const [error, setError] = React.useState(false);
82
+ const [loading, setLoading] = React.useState(needsResolution);
83
+
84
+ React.useEffect(() => {
85
+ if (!needsResolution || !src || !resolveImageUrl) return;
86
+
87
+ let cancelled = false;
88
+ let blobUrl: string | null = null;
89
+
90
+ setLoading(true);
91
+ setError(false);
92
+ setResolvedUrl(null);
93
+
94
+ Promise.resolve(resolveImageUrl(src))
95
+ .then((url) => {
96
+ if (cancelled) {
97
+ // Revoke immediately if we're already unmounted/stale
98
+ if (url.startsWith("blob:")) URL.revokeObjectURL(url);
99
+ return;
100
+ }
101
+ if (url.startsWith("blob:")) blobUrl = url;
102
+ setResolvedUrl(url);
103
+ setLoading(false);
104
+ })
105
+ .catch(() => {
106
+ if (!cancelled) {
107
+ setError(true);
108
+ setLoading(false);
109
+ }
110
+ });
111
+
112
+ return () => {
113
+ cancelled = true;
114
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
115
+ };
116
+ }, [needsResolution, src, resolveImageUrl]);
117
+
118
+ // No resolution needed — render directly (current behavior)
119
+ if (!needsResolution) {
120
+ return (
121
+ <img src={src} alt={alt} className="max-w-full rounded" {...props} />
122
+ );
123
+ }
124
+
125
+ // Loading
126
+ if (loading) {
127
+ return (
128
+ <span className="my-2 inline-block h-24 w-40 animate-pulse rounded bg-muted" />
129
+ );
130
+ }
131
+
132
+ // Error — fall back to raw src (browser's broken image)
133
+ if (error || !resolvedUrl) {
134
+ return (
135
+ <img src={src} alt={alt} className="max-w-full rounded" {...props} />
136
+ );
137
+ }
138
+
139
+ // Resolved
140
+ return (
141
+ <img
142
+ src={resolvedUrl}
143
+ alt={alt}
144
+ className="max-w-full rounded"
145
+ {...props}
146
+ />
147
+ );
148
+ }
149
+
150
+ /**
151
+ * MarkdownRenderer
152
+ *
153
+ * Renders markdown content using react-markdown + remark-gfm.
154
+ * Fenced code blocks use shiki syntax highlighting when available.
155
+ * Styled with Tailwind using semantic CSS variables for theme support.
156
+ *
157
+ * Requires `react-markdown` and `remark-gfm` as peer dependencies of @optilogic/core.
158
+ */
159
+ export function MarkdownRenderer({
160
+ content,
161
+ className,
162
+ resolveImageUrl,
163
+ }: FileRendererProps) {
164
+ const markdownComponents = React.useMemo(
165
+ () => ({
166
+ code: ({
167
+ children,
168
+ className: codeClassName,
169
+ ...props
170
+ }: React.HTMLAttributes<HTMLElement> & {
171
+ inline?: boolean;
172
+ node?: unknown;
173
+ }) => {
174
+ // Detect inline vs block: block code is wrapped in <pre> by react-markdown
175
+ const isInline = !codeClassName;
176
+ if (isInline) {
177
+ return (
178
+ <code
179
+ className="rounded bg-muted px-1.5 py-0.5 font-mono text-[85%]"
180
+ {...props}
181
+ >
182
+ {children}
183
+ </code>
184
+ );
185
+ }
186
+ return (
187
+ <HighlightedCodeBlock className={codeClassName} {...props}>
188
+ {children}
189
+ </HighlightedCodeBlock>
190
+ );
191
+ },
192
+ pre: ({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) => (
193
+ <pre
194
+ className="my-4 overflow-auto rounded-md border border-border bg-muted p-4 font-mono text-sm leading-relaxed"
195
+ {...props}
196
+ >
197
+ {children}
198
+ </pre>
199
+ ),
200
+ blockquote: ({
201
+ children,
202
+ ...props
203
+ }: React.HTMLAttributes<HTMLQuoteElement>) => (
204
+ <blockquote
205
+ className="my-4 border-l-4 border-border pl-4 text-muted-foreground"
206
+ {...props}
207
+ >
208
+ {children}
209
+ </blockquote>
210
+ ),
211
+ a: ({
212
+ children,
213
+ href,
214
+ ...props
215
+ }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
216
+ <a
217
+ href={href}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ className="text-primary hover:underline"
221
+ {...props}
222
+ >
223
+ {children}
224
+ </a>
225
+ ),
226
+ table: ({
227
+ children,
228
+ ...props
229
+ }: React.HTMLAttributes<HTMLTableElement>) => (
230
+ <div className="my-4 overflow-auto">
231
+ <table
232
+ className="w-full border-collapse border border-border text-sm"
233
+ {...props}
234
+ >
235
+ {children}
236
+ </table>
237
+ </div>
238
+ ),
239
+ th: ({
240
+ children,
241
+ ...props
242
+ }: React.HTMLAttributes<HTMLTableCellElement>) => (
243
+ <th
244
+ className="border border-border bg-muted px-3 py-2 text-left font-semibold"
245
+ {...props}
246
+ >
247
+ {children}
248
+ </th>
249
+ ),
250
+ td: ({
251
+ children,
252
+ ...props
253
+ }: React.HTMLAttributes<HTMLTableCellElement>) => (
254
+ <td className="border border-border px-3 py-2" {...props}>
255
+ {children}
256
+ </td>
257
+ ),
258
+ img: ({
259
+ src,
260
+ alt,
261
+ ...props
262
+ }: React.ImgHTMLAttributes<HTMLImageElement>) => (
263
+ <MarkdownImage
264
+ src={src}
265
+ alt={alt}
266
+ resolveImageUrl={resolveImageUrl}
267
+ {...props}
268
+ />
269
+ ),
270
+ }),
271
+ [resolveImageUrl],
272
+ );
273
+
274
+ return (
275
+ <div
276
+ className={cn(
277
+ "h-full w-full overflow-auto bg-background p-6 text-sm leading-relaxed text-foreground",
278
+ // Heading styles
279
+ "[&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:border-b [&_h1]:border-border [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-bold",
280
+ "[&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-2 [&_h2]:text-xl [&_h2]:font-semibold",
281
+ "[&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold",
282
+ "[&_h4]:mb-3 [&_h4]:mt-4 [&_h4]:text-base [&_h4]:font-semibold",
283
+ // Paragraph and list styles
284
+ "[&_p]:mb-4 [&_p]:leading-relaxed",
285
+ "[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
286
+ "[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
287
+ "[&_li]:mb-1",
288
+ // Horizontal rule
289
+ "[&_hr]:my-6 [&_hr]:border-border",
290
+ "scrollbar-thin",
291
+ className,
292
+ )}
293
+ >
294
+ <ReactMarkdownImport
295
+ remarkPlugins={[remarkGfmImport]}
296
+ components={markdownComponents}
297
+ >
298
+ {content ?? ""}
299
+ </ReactMarkdownImport>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ MarkdownRenderer.displayName = "MarkdownRenderer";
@@ -0,0 +1,27 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import type { FileRendererProps } from "../types";
4
+
5
+ /**
6
+ * PlainTextRenderer
7
+ *
8
+ * Renders plain text content without line numbers.
9
+ * Used for .txt, .log, and as the ultimate fallback renderer.
10
+ */
11
+ export function PlainTextRenderer({ content, className }: FileRendererProps) {
12
+ return (
13
+ <div
14
+ className={cn(
15
+ "h-full w-full overflow-auto rounded-md border border-border bg-background p-4",
16
+ "scrollbar-thin",
17
+ className,
18
+ )}
19
+ >
20
+ <pre className="m-0 whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
21
+ {content ?? ""}
22
+ </pre>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ PlainTextRenderer.displayName = "PlainTextRenderer";
@@ -0,0 +1,4 @@
1
+ export { CodeRenderer } from "./CodeRenderer";
2
+ export { MarkdownRenderer } from "./MarkdownRenderer";
3
+ export { ImageRenderer } from "./ImageRenderer";
4
+ export { PlainTextRenderer } from "./PlainTextRenderer";
@@ -0,0 +1,5 @@
1
+ export { useContentType } from "./useContentType";
2
+ export type {
3
+ UseContentTypeOptions,
4
+ UseContentTypeReturn,
5
+ } from "./useContentType";
@@ -0,0 +1,34 @@
1
+ import { useMemo } from "react";
2
+ import type { ContentType, ContentTypeDetectionResult } from "../types";
3
+ import { detectContentType } from "../utils/contentTypeDetection";
4
+
5
+ export interface UseContentTypeOptions {
6
+ fileName: string;
7
+ contentTypeOverride?: ContentType;
8
+ }
9
+
10
+ export interface UseContentTypeReturn extends ContentTypeDetectionResult {
11
+ /** Whether the type was explicitly overridden. */
12
+ isOverridden: boolean;
13
+ }
14
+
15
+ /**
16
+ * Hook to determine the content type for a file.
17
+ * Uses explicit override if provided, otherwise auto-detects from fileName.
18
+ */
19
+ export function useContentType({
20
+ fileName,
21
+ contentTypeOverride,
22
+ }: UseContentTypeOptions): UseContentTypeReturn {
23
+ return useMemo(() => {
24
+ if (contentTypeOverride) {
25
+ return {
26
+ type: contentTypeOverride,
27
+ extension: null,
28
+ isOverridden: true,
29
+ };
30
+ }
31
+ const detected = detectContentType(fileName);
32
+ return { ...detected, isOverridden: false };
33
+ }, [fileName, contentTypeOverride]);
34
+ }
@@ -0,0 +1,62 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ /**
4
+ * Parse the lightness percentage from a `--background` CSS variable value.
5
+ * Expects HSL format like `"222 84% 5%"` or `"0 0% 100%"`.
6
+ * Returns the lightness as a number (0-100), or `null` if unparseable.
7
+ */
8
+ function getBackgroundLightness(el: HTMLElement): number | null {
9
+ const value = getComputedStyle(el).getPropertyValue("--background").trim();
10
+ if (!value) return null;
11
+
12
+ // Match the last percentage in the HSL string (lightness)
13
+ const match = value.match(/(\d+(?:\.\d+)?)%\s*$/);
14
+ return match ? parseFloat(match[1]) : null;
15
+ }
16
+
17
+ /**
18
+ * Detects whether the current theme is "dark" by examining the
19
+ * `--background` CSS variable's lightness.
20
+ *
21
+ * Works with:
22
+ * - The theme system (`applyTheme` sets `--background` as inline style)
23
+ * - The `.dark` class (which also changes `--background` via CSS)
24
+ * - Any mechanism that updates `--background`
25
+ *
26
+ * SSR-safe: defaults to `false` when `document` is unavailable.
27
+ */
28
+ export function useDarkMode(): boolean {
29
+ const checkDark = useCallback((): boolean => {
30
+ if (typeof document === "undefined") return false;
31
+ const lightness = getBackgroundLightness(document.documentElement);
32
+ // If we can't read the variable, fall back to checking .dark class
33
+ if (lightness === null) {
34
+ return document.documentElement.classList.contains("dark");
35
+ }
36
+ return lightness < 50;
37
+ }, []);
38
+
39
+ const [isDark, setIsDark] = useState(checkDark);
40
+
41
+ useEffect(() => {
42
+ const el = document.documentElement;
43
+
44
+ const update = () => {
45
+ setIsDark(checkDark());
46
+ };
47
+
48
+ // Watch both class and style attribute changes
49
+ const observer = new MutationObserver(update);
50
+ observer.observe(el, {
51
+ attributes: true,
52
+ attributeFilter: ["class", "style"],
53
+ });
54
+
55
+ // Sync in case the value changed between render and effect
56
+ update();
57
+
58
+ return () => observer.disconnect();
59
+ }, [checkDark]);
60
+
61
+ return isDark;
62
+ }
@@ -0,0 +1,83 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useShikiHighlighter } from "./useShikiHighlighter";
3
+ import { useDarkMode } from "./useDarkMode";
4
+
5
+ export interface HighlightToken {
6
+ content: string;
7
+ color?: string;
8
+ }
9
+
10
+ export interface UseHighlightedTokensResult {
11
+ lines: HighlightToken[][] | null;
12
+ }
13
+
14
+ /**
15
+ * Combines the shiki highlighter + dark mode detection to produce
16
+ * highlighted tokens for a given code string and language.
17
+ *
18
+ * - Loads the language on demand (with try/catch for unsupported languages)
19
+ * - Returns `null` while loading or if shiki is unavailable (signals "use plain text")
20
+ * - Re-tokenizes when dark mode toggles
21
+ */
22
+ export function useHighlightedTokens(
23
+ code: string,
24
+ language: string,
25
+ ): UseHighlightedTokensResult {
26
+ const { highlighter, isReady } = useShikiHighlighter();
27
+ const isDark = useDarkMode();
28
+ const [lines, setLines] = useState<HighlightToken[][] | null>(null);
29
+
30
+ useEffect(() => {
31
+ if (!isReady || !highlighter || !code) {
32
+ setLines(null);
33
+ return;
34
+ }
35
+
36
+ let cancelled = false;
37
+ const theme = isDark ? "github-dark" : "github-light";
38
+
39
+ (async () => {
40
+ try {
41
+ // Load language on demand — skip if it's plaintext
42
+ if (language !== "text") {
43
+ const loadedLangs = highlighter.getLoadedLanguages();
44
+ if (!loadedLangs.includes(language as never)) {
45
+ await highlighter.loadLanguage(language as Parameters<typeof highlighter.loadLanguage>[0]);
46
+ }
47
+ }
48
+ } catch {
49
+ // Language not supported by shiki — fall back to plain text
50
+ if (!cancelled) setLines(null);
51
+ return;
52
+ }
53
+
54
+ if (cancelled) return;
55
+
56
+ try {
57
+ const result = highlighter.codeToTokens(code, {
58
+ lang: language as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
59
+ theme,
60
+ });
61
+
62
+ if (!cancelled) {
63
+ setLines(
64
+ result.tokens.map((line) =>
65
+ line.map((token) => ({
66
+ content: token.content,
67
+ color: token.color,
68
+ })),
69
+ ),
70
+ );
71
+ }
72
+ } catch {
73
+ if (!cancelled) setLines(null);
74
+ }
75
+ })();
76
+
77
+ return () => {
78
+ cancelled = true;
79
+ };
80
+ }, [highlighter, isReady, code, language, isDark]);
81
+
82
+ return { lines };
83
+ }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ type Highlighter = Awaited<ReturnType<typeof import("shiki")["createHighlighter"]>>;
4
+
5
+ interface ShikiHighlighterState {
6
+ highlighter: Highlighter | null;
7
+ isReady: boolean;
8
+ }
9
+
10
+ /** Singleton: one global highlighter instance shared across all components. */
11
+ let globalHighlighter: Highlighter | null = null;
12
+ let initPromise: Promise<Highlighter | null> | null = null;
13
+
14
+ async function getOrCreateHighlighter(): Promise<Highlighter | null> {
15
+ if (globalHighlighter) return globalHighlighter;
16
+
17
+ if (!initPromise) {
18
+ initPromise = (async () => {
19
+ try {
20
+ const { createHighlighter } = await import("shiki");
21
+ globalHighlighter = await createHighlighter({
22
+ themes: ["github-light", "github-dark"],
23
+ langs: [],
24
+ });
25
+ return globalHighlighter;
26
+ } catch {
27
+ // shiki not installed or failed to load — fall back to plain text
28
+ return null;
29
+ }
30
+ })();
31
+ }
32
+
33
+ return initPromise;
34
+ }
35
+
36
+ /**
37
+ * Singleton async initialization of the shiki highlighter.
38
+ *
39
+ * - Dynamically imports shiki so it's an optional peer dependency
40
+ * - Creates one global `Highlighter` instance with github-light and github-dark themes
41
+ * - Languages are loaded on demand per-highlight call (not at init)
42
+ * - Returns `{ highlighter, isReady }`
43
+ */
44
+ export function useShikiHighlighter(): ShikiHighlighterState {
45
+ const [state, setState] = useState<ShikiHighlighterState>({
46
+ highlighter: globalHighlighter,
47
+ isReady: globalHighlighter !== null,
48
+ });
49
+
50
+ useEffect(() => {
51
+ if (globalHighlighter) {
52
+ setState({ highlighter: globalHighlighter, isReady: true });
53
+ return;
54
+ }
55
+
56
+ let cancelled = false;
57
+ getOrCreateHighlighter().then((h) => {
58
+ if (!cancelled) {
59
+ setState({ highlighter: h, isReady: true });
60
+ }
61
+ });
62
+
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, []);
67
+
68
+ return state;
69
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * FileView Module
3
+ *
4
+ * A configurable file viewer with pluggable renderers.
5
+ */
6
+
7
+ // Main component
8
+ export { FileView } from "./FileView";
9
+
10
+ // Types
11
+ export type {
12
+ BuiltInContentType,
13
+ ContentType,
14
+ ContentTypeDetectionResult,
15
+ FileRendererProps,
16
+ FileRenderer,
17
+ RendererRegistry,
18
+ FileViewError,
19
+ FileViewProps,
20
+ ResolveImageUrl,
21
+ } from "./types";
22
+
23
+ // Sub-components (for custom composition or standalone use)
24
+ export { CodeRenderer } from "./components/CodeRenderer";
25
+ export { MarkdownRenderer } from "./components/MarkdownRenderer";
26
+ export { ImageRenderer } from "./components/ImageRenderer";
27
+ export { PlainTextRenderer } from "./components/PlainTextRenderer";
28
+ export { CsvRenderer } from "./components/CsvRenderer";
29
+ export { HtmlRenderer } from "./components/HtmlRenderer";
30
+
31
+ // Hooks
32
+ export { useContentType } from "./hooks/useContentType";
33
+ export type {
34
+ UseContentTypeOptions,
35
+ UseContentTypeReturn,
36
+ } from "./hooks/useContentType";
37
+
38
+ // Utilities
39
+ export {
40
+ detectContentType,
41
+ getFileExtension,
42
+ isTextContentType,
43
+ isUrlContentType,
44
+ DEFAULT_RENDERERS,
45
+ mergeRenderers,
46
+ resolveRenderer,
47
+ } from "./utils";