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

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,180 @@
1
+ import type * as React from "react";
2
+
3
+ // ============ CONTENT TYPES ============
4
+
5
+ /**
6
+ * Built-in content types that FileView can render.
7
+ */
8
+ export type BuiltInContentType =
9
+ | "code"
10
+ | "markdown"
11
+ | "image"
12
+ | "pdf"
13
+ | "csv"
14
+ | "html"
15
+ | "plaintext"
16
+ | "unknown";
17
+
18
+ /**
19
+ * Content type - either a built-in type or a custom string.
20
+ * The `(string & {})` pattern preserves autocomplete for built-in types
21
+ * while accepting arbitrary strings for custom renderers.
22
+ */
23
+ export type ContentType = BuiltInContentType | (string & {});
24
+
25
+ // ============ IMAGE RESOLUTION ============
26
+
27
+ /**
28
+ * Callback to resolve image `src` values to renderable URLs.
29
+ * Called for non-remote, non-data-URI image sources in markdown content.
30
+ * Can return a blob URL, data URL, or any string that works as an `<img>` src.
31
+ */
32
+ export type ResolveImageUrl = (src: string) => Promise<string> | string;
33
+
34
+ // ============ RENDERER TYPES ============
35
+
36
+ /**
37
+ * Props passed to every renderer component.
38
+ * All renderers receive a consistent contract regardless of content type.
39
+ */
40
+ export interface FileRendererProps {
41
+ /** Text content of the file. Null when content is binary/URL-based. */
42
+ content: string | null;
43
+ /** URL for binary content (images, PDFs). Null for text-based content. */
44
+ url: string | null;
45
+ /** The file name, used for display and language detection. */
46
+ fileName: string;
47
+ /** The resolved content type. */
48
+ contentType: ContentType;
49
+ /** Optional className for the renderer container. */
50
+ className?: string;
51
+ /** Optional callback to resolve relative image URLs in markdown content. */
52
+ resolveImageUrl?: ResolveImageUrl;
53
+ }
54
+
55
+ /**
56
+ * A renderer component that can render file content.
57
+ */
58
+ export type FileRenderer = React.ComponentType<FileRendererProps>;
59
+
60
+ /**
61
+ * Registry mapping content types to renderer components.
62
+ * Users can override individual renderers by providing their own map.
63
+ */
64
+ export type RendererRegistry = Partial<Record<ContentType, FileRenderer>>;
65
+
66
+ // ============ STATE TYPES ============
67
+
68
+ /**
69
+ * Error information for the FileView.
70
+ */
71
+ export interface FileViewError {
72
+ /** Error message to display. */
73
+ message: string;
74
+ /** Optional retry callback. If provided, a retry button is shown. */
75
+ onRetry?: () => void;
76
+ }
77
+
78
+ // ============ COMPONENT PROPS ============
79
+
80
+ /**
81
+ * Props for the FileView component.
82
+ */
83
+ export interface FileViewProps {
84
+ // ============ CONTENT ============
85
+
86
+ /**
87
+ * Text content of the file.
88
+ * For text-based files (code, markdown, plaintext, csv), pass the string content.
89
+ * For binary files (images, PDFs), pass null and use `url` instead.
90
+ */
91
+ content: string | null;
92
+
93
+ /**
94
+ * URL for binary content.
95
+ * Used for images, PDFs, and other non-text content.
96
+ * Can be an object URL, data URL, or HTTP URL.
97
+ */
98
+ url?: string | null;
99
+
100
+ /**
101
+ * The file name including extension.
102
+ * Used for content type detection and display context.
103
+ * @example "README.md", "app.tsx", "photo.png"
104
+ */
105
+ fileName: string;
106
+
107
+ // ============ CONTENT TYPE ============
108
+
109
+ /**
110
+ * Explicit content type override.
111
+ * When provided, bypasses automatic detection from fileName.
112
+ */
113
+ contentType?: ContentType;
114
+
115
+ // ============ RENDERERS ============
116
+
117
+ /**
118
+ * Custom renderer overrides.
119
+ * Merges with (and overrides) the default renderer registry.
120
+ * Only the types you specify are overridden; others use defaults.
121
+ *
122
+ * @example
123
+ * renderers={{ code: MyCustomCodeRenderer }}
124
+ */
125
+ renderers?: RendererRegistry;
126
+
127
+ // ============ STATE ============
128
+
129
+ /** Loading state. When true, shows loading indicator. */
130
+ loading?: boolean;
131
+
132
+ /** Error state. When provided, shows error message with optional retry. */
133
+ error?: FileViewError | null;
134
+
135
+ // ============ CUSTOMIZATION ============
136
+
137
+ /** Custom loading component. Replaces the default spinner. */
138
+ loadingComponent?: React.ReactNode;
139
+
140
+ /** Custom empty state component. Shown when content is null/empty and not loading/error. */
141
+ emptyComponent?: React.ReactNode;
142
+
143
+ /**
144
+ * Empty state message. Used when no custom emptyComponent is provided.
145
+ * @default "No content to display"
146
+ */
147
+ emptyMessage?: string;
148
+
149
+ /** Custom error component. Replaces the default error display. */
150
+ errorComponent?: React.ComponentType<{ error: FileViewError }>;
151
+
152
+ // ============ STYLING ============
153
+
154
+ /** Additional class name for the outermost container. */
155
+ className?: string;
156
+
157
+ /** Additional class name passed to the active renderer. */
158
+ rendererClassName?: string;
159
+
160
+ // ============ IMAGE RESOLUTION ============
161
+
162
+ /**
163
+ * Optional callback to resolve image `src` values to renderable URLs.
164
+ * Used by MarkdownRenderer for relative/non-remote image paths.
165
+ * Remote URLs (http/https) and data URIs are passed through unchanged.
166
+ */
167
+ resolveImageUrl?: ResolveImageUrl;
168
+ }
169
+
170
+ // ============ DETECTION TYPES ============
171
+
172
+ /**
173
+ * Content type detection result.
174
+ */
175
+ export interface ContentTypeDetectionResult {
176
+ /** The detected content type. */
177
+ type: ContentType;
178
+ /** The file extension that was matched, if any. */
179
+ extension: string | null;
180
+ }
@@ -0,0 +1,157 @@
1
+ import type { ContentType, ContentTypeDetectionResult } from "../types";
2
+
3
+ /**
4
+ * Extension-to-content-type mapping.
5
+ */
6
+ const EXTENSION_MAP: Record<string, ContentType> = {
7
+ // Code / programming languages
8
+ ts: "code",
9
+ tsx: "code",
10
+ js: "code",
11
+ jsx: "code",
12
+ mjs: "code",
13
+ cjs: "code",
14
+ py: "code",
15
+ rb: "code",
16
+ go: "code",
17
+ rs: "code",
18
+ java: "code",
19
+ c: "code",
20
+ cpp: "code",
21
+ h: "code",
22
+ hpp: "code",
23
+ cs: "code",
24
+ php: "code",
25
+ swift: "code",
26
+ kt: "code",
27
+ scala: "code",
28
+ r: "code",
29
+ sql: "code",
30
+ sh: "code",
31
+ bash: "code",
32
+ zsh: "code",
33
+ ps1: "code",
34
+ bat: "code",
35
+ lua: "code",
36
+ perl: "code",
37
+ pl: "code",
38
+ // Config / data (code-rendered)
39
+ json: "code",
40
+ yaml: "code",
41
+ yml: "code",
42
+ toml: "code",
43
+ xml: "code",
44
+ html: "html",
45
+ htm: "html",
46
+ css: "code",
47
+ scss: "code",
48
+ sass: "code",
49
+ less: "code",
50
+ graphql: "code",
51
+ gql: "code",
52
+ // Optimization / domain-specific
53
+ lp: "code",
54
+ dat: "code",
55
+ // Markdown
56
+ md: "markdown",
57
+ mdx: "markdown",
58
+ // Images
59
+ png: "image",
60
+ jpg: "image",
61
+ jpeg: "image",
62
+ gif: "image",
63
+ svg: "image",
64
+ webp: "image",
65
+ ico: "image",
66
+ bmp: "image",
67
+ // PDF
68
+ pdf: "pdf",
69
+ // CSV
70
+ csv: "csv",
71
+ tsv: "csv",
72
+ // Plain text
73
+ txt: "plaintext",
74
+ log: "plaintext",
75
+ env: "plaintext",
76
+ };
77
+
78
+ /**
79
+ * Special file names (without extension) that map to content types.
80
+ */
81
+ const SPECIAL_FILES: Record<string, ContentType> = {
82
+ dockerfile: "code",
83
+ makefile: "code",
84
+ rakefile: "code",
85
+ gemfile: "code",
86
+ procfile: "code",
87
+ };
88
+
89
+ /**
90
+ * Extract the file extension from a file name.
91
+ * Handles dotfiles (e.g., ".gitignore" -> "gitignore") and
92
+ * compound extensions (e.g., "file.test.ts" -> "ts").
93
+ */
94
+ export function getFileExtension(fileName: string): string | null {
95
+ if (!fileName) return null;
96
+
97
+ const baseName = fileName.split("/").pop() || fileName;
98
+
99
+ // Dotfile with no other extension (e.g., ".gitignore")
100
+ if (baseName.startsWith(".") && !baseName.slice(1).includes(".")) {
101
+ return baseName.slice(1).toLowerCase();
102
+ }
103
+
104
+ const lastDot = baseName.lastIndexOf(".");
105
+ if (lastDot === -1 || lastDot === baseName.length - 1) return null;
106
+
107
+ return baseName.slice(lastDot + 1).toLowerCase();
108
+ }
109
+
110
+ /**
111
+ * Detect content type from a file name.
112
+ *
113
+ * @example
114
+ * detectContentType("app.tsx") // { type: "code", extension: "tsx" }
115
+ * detectContentType("README.md") // { type: "markdown", extension: "md" }
116
+ * detectContentType("photo.png") // { type: "image", extension: "png" }
117
+ * detectContentType("unknown.xyz") // { type: "unknown", extension: "xyz" }
118
+ */
119
+ export function detectContentType(
120
+ fileName: string,
121
+ ): ContentTypeDetectionResult {
122
+ // Check special file names first
123
+ const baseName = (fileName.split("/").pop() || fileName).toLowerCase();
124
+ const specialType = SPECIAL_FILES[baseName];
125
+ if (specialType) {
126
+ return { type: specialType, extension: baseName };
127
+ }
128
+
129
+ const extension = getFileExtension(fileName);
130
+ if (!extension) {
131
+ return { type: "unknown", extension: null };
132
+ }
133
+
134
+ const type = EXTENSION_MAP[extension] ?? "unknown";
135
+ return { type, extension };
136
+ }
137
+
138
+ /**
139
+ * Check if a content type is text-based (uses `content` string).
140
+ */
141
+ export function isTextContentType(contentType: ContentType): boolean {
142
+ return (
143
+ contentType === "code" ||
144
+ contentType === "markdown" ||
145
+ contentType === "plaintext" ||
146
+ contentType === "csv" ||
147
+ contentType === "html" ||
148
+ contentType === "unknown"
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Check if a content type is URL-based (uses `url` string).
154
+ */
155
+ export function isUrlContentType(contentType: ContentType): boolean {
156
+ return contentType === "image" || contentType === "pdf";
157
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ detectContentType,
3
+ getFileExtension,
4
+ isTextContentType,
5
+ isUrlContentType,
6
+ } from "./contentTypeDetection";
7
+
8
+ export {
9
+ DEFAULT_RENDERERS,
10
+ mergeRenderers,
11
+ resolveRenderer,
12
+ } from "./rendererRegistry";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Maps file extensions to shiki language IDs.
3
+ *
4
+ * Most extensions work directly as shiki lang IDs. This map only
5
+ * contains edge-cases where the extension differs from the shiki ID.
6
+ */
7
+ const EXTENSION_OVERRIDES: Record<string, string> = {
8
+ yml: "yaml",
9
+ htm: "html",
10
+ cjs: "javascript",
11
+ mjs: "javascript",
12
+ jsx: "jsx",
13
+ tsx: "tsx",
14
+ h: "c",
15
+ hpp: "cpp",
16
+ cs: "csharp",
17
+ rb: "ruby",
18
+ sh: "shellscript",
19
+ bash: "shellscript",
20
+ zsh: "shellscript",
21
+ ps1: "powershell",
22
+ bat: "bat",
23
+ kt: "kotlin",
24
+ rs: "rust",
25
+ gql: "graphql",
26
+ pl: "perl",
27
+ sass: "sass",
28
+ scss: "scss",
29
+ less: "less",
30
+ // Domain-specific / data formats without shiki support
31
+ lp: "text",
32
+ dat: "text",
33
+ env: "shellscript",
34
+ };
35
+
36
+ /**
37
+ * Special file names (without extension) mapped to shiki language IDs.
38
+ */
39
+ const SPECIAL_FILE_LANGUAGES: Record<string, string> = {
40
+ dockerfile: "dockerfile",
41
+ makefile: "makefile",
42
+ rakefile: "ruby",
43
+ gemfile: "ruby",
44
+ procfile: "yaml",
45
+ };
46
+
47
+ /**
48
+ * Get the shiki language ID for a given file name.
49
+ *
50
+ * @example
51
+ * getLanguageFromFileName("app.tsx") // "tsx"
52
+ * getLanguageFromFileName("config.yml") // "yaml"
53
+ * getLanguageFromFileName("Dockerfile") // "dockerfile"
54
+ * getLanguageFromFileName("unknown.xyz") // "text"
55
+ */
56
+ export function getLanguageFromFileName(fileName: string): string {
57
+ const baseName = (fileName.split("/").pop() || fileName).toLowerCase();
58
+
59
+ // Check special file names
60
+ const specialLang = SPECIAL_FILE_LANGUAGES[baseName];
61
+ if (specialLang) return specialLang;
62
+
63
+ // Extract extension
64
+ let ext: string | null = null;
65
+ if (baseName.startsWith(".") && !baseName.slice(1).includes(".")) {
66
+ ext = baseName.slice(1);
67
+ } else {
68
+ const lastDot = baseName.lastIndexOf(".");
69
+ if (lastDot !== -1 && lastDot !== baseName.length - 1) {
70
+ ext = baseName.slice(lastDot + 1);
71
+ }
72
+ }
73
+
74
+ if (!ext) return "text";
75
+
76
+ // Check overrides first, then use extension directly as shiki lang ID
77
+ return EXTENSION_OVERRIDES[ext] ?? ext;
78
+ }
@@ -0,0 +1,42 @@
1
+ import type { RendererRegistry, FileRenderer } from "../types";
2
+ import { CodeRenderer } from "../components/CodeRenderer";
3
+ import { MarkdownRenderer } from "../components/MarkdownRenderer";
4
+ import { ImageRenderer } from "../components/ImageRenderer";
5
+ import { PlainTextRenderer } from "../components/PlainTextRenderer";
6
+ import { CsvRenderer } from "../components/CsvRenderer";
7
+ import { HtmlRenderer } from "../components/HtmlRenderer";
8
+
9
+ /**
10
+ * Default renderer registry.
11
+ * Maps built-in content types to their default renderer components.
12
+ */
13
+ export const DEFAULT_RENDERERS: RendererRegistry = {
14
+ code: CodeRenderer,
15
+ markdown: MarkdownRenderer,
16
+ image: ImageRenderer,
17
+ plaintext: PlainTextRenderer,
18
+ csv: CsvRenderer,
19
+ html: HtmlRenderer,
20
+ };
21
+
22
+ /**
23
+ * Merge user-provided renderers with defaults.
24
+ * User renderers override defaults for the same content type.
25
+ */
26
+ export function mergeRenderers(
27
+ userRenderers?: RendererRegistry,
28
+ ): RendererRegistry {
29
+ if (!userRenderers) return DEFAULT_RENDERERS;
30
+ return { ...DEFAULT_RENDERERS, ...userRenderers };
31
+ }
32
+
33
+ /**
34
+ * Resolve a renderer for a given content type.
35
+ * Falls back to PlainTextRenderer if no match found.
36
+ */
37
+ export function resolveRenderer(
38
+ registry: RendererRegistry,
39
+ contentType: string,
40
+ ): FileRenderer {
41
+ return registry[contentType] ?? registry["plaintext"] ?? PlainTextRenderer;
42
+ }
package/src/index.ts CHANGED
@@ -321,6 +321,45 @@ export {
321
321
  type UseContextMenuResult,
322
322
  } from "./components/context-menu";
323
323
 
324
+ // FileView - Configurable file viewer with pluggable renderers
325
+ export {
326
+ // Main component
327
+ FileView,
328
+
329
+ // Types
330
+ type BuiltInContentType,
331
+ type ContentType,
332
+ type ContentTypeDetectionResult,
333
+ type FileRendererProps,
334
+ type FileRenderer,
335
+ type RendererRegistry,
336
+ type FileViewError,
337
+ type FileViewProps,
338
+ type ResolveImageUrl,
339
+
340
+ // Sub-components (for custom composition or standalone use)
341
+ CodeRenderer,
342
+ MarkdownRenderer,
343
+ ImageRenderer,
344
+ PlainTextRenderer,
345
+ CsvRenderer,
346
+ HtmlRenderer,
347
+
348
+ // Hooks
349
+ useContentType,
350
+ type UseContentTypeOptions,
351
+ type UseContentTypeReturn,
352
+
353
+ // Utilities
354
+ detectContentType,
355
+ getFileExtension,
356
+ isTextContentType,
357
+ isUrlContentType,
358
+ DEFAULT_RENDERERS,
359
+ mergeRenderers,
360
+ resolveRenderer,
361
+ } from "./components/file-view/index";
362
+
324
363
  // Theme system
325
364
  export {
326
365
  // Types