@makolabs/ripple 3.8.1 → 3.9.2

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 (41) hide show
  1. package/dist/ai/MessageBox.svelte +2 -1
  2. package/dist/button/Button.svelte +89 -7
  3. package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
  4. package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
  5. package/dist/elements/file-viewer/FileViewer.svelte +96 -0
  6. package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
  7. package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
  8. package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
  9. package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
  10. package/dist/elements/file-viewer/file-viewer-types.js +127 -0
  11. package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
  12. package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
  13. package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
  14. package/dist/elements/image-viewer/image-viewer-types.js +1 -0
  15. package/dist/elements/pdf-viewer/PdfViewer.svelte +225 -34
  16. package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
  17. package/dist/elements/text-viewer/TextViewer.svelte +141 -0
  18. package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
  19. package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
  20. package/dist/elements/text-viewer/text-viewer-types.js +1 -0
  21. package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
  22. package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
  23. package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
  24. package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
  25. package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
  26. package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
  27. package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
  28. package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
  29. package/dist/helper/is-safe-http-url.d.ts +33 -0
  30. package/dist/helper/is-safe-http-url.js +214 -0
  31. package/dist/helper/pdf-assets.d.ts +42 -0
  32. package/dist/helper/pdf-assets.js +57 -0
  33. package/dist/helper/pdf-safety.d.ts +38 -0
  34. package/dist/helper/pdf-safety.js +85 -0
  35. package/dist/helper/resource-limits.d.ts +13 -0
  36. package/dist/helper/resource-limits.js +16 -0
  37. package/dist/helper/sanitize-html.d.ts +5 -0
  38. package/dist/helper/sanitize-html.js +12 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.js +8 -0
  41. package/package.json +4 -2
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { formatTime } from '../helper/date.js';
4
4
  import { marked } from 'marked';
5
+ import { sanitizeHtml } from '../helper/sanitize-html.js';
5
6
  import { tv, type ClassValue } from 'tailwind-variants';
6
7
  import type { VariantColors } from '../index.js';
7
8
  import { parseContentSegments } from './content-detector.js';
@@ -69,7 +70,7 @@
69
70
 
70
71
  function renderMarkdown(text: string): string {
71
72
  const result = marked(text);
72
- return typeof result === 'string' ? result : '';
73
+ return sanitizeHtml(typeof result === 'string' ? result : '');
73
74
  }
74
75
 
75
76
  // Parse content into segments for enhanced rendering
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
+ import { isSafeLinkHref } from '../helper/is-safe-http-url.js';
4
5
  import { buttonVariants } from './button.js';
5
6
  import type { ButtonProps } from '../index.js';
7
+ import type { HTMLAttributeAnchorTarget } from 'svelte/elements';
6
8
  import { Color, Size } from '../variants.js';
7
9
 
8
10
  let {
@@ -33,20 +35,100 @@
33
35
  className
34
36
  )
35
37
  );
38
+
39
+ // Drop dangerous spread attributes: string event handlers (function
40
+ // handlers like onclick={fn} are kept) and unsafe formaction.
41
+ function dropDangerous(props: Record<string, unknown>): Record<string, unknown> {
42
+ const out: Record<string, unknown> = {};
43
+ for (const [key, value] of Object.entries(props)) {
44
+ const lower = key.toLowerCase();
45
+ if (lower.startsWith('on') && typeof value === 'string') continue;
46
+ if (lower === 'formaction' && (typeof value !== 'string' || !isSafeLinkHref(value))) continue;
47
+ out[key] = value;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const ANCHOR_CONTROLLED = new Set(['href', 'target', 'rel', 'referrerpolicy']);
53
+
54
+ // restProps is a discriminated union; read anchor-only attrs via a record view.
55
+ const rest = $derived(restProps as Record<string, unknown>);
56
+
57
+ const rawHref = $derived(rest['href'] as string | undefined);
58
+ const isAnchor = $derived(typeof rawHref === 'string');
59
+ const safeHref = $derived(
60
+ typeof rawHref === 'string' && isSafeLinkHref(rawHref) ? rawHref : undefined
61
+ );
62
+ const anchorTarget = $derived(rest['target'] as HTMLAttributeAnchorTarget | undefined);
63
+
64
+ // Reverse-tabnabbing protection: opening a new browsing context requires
65
+ // `noopener`. A consumer `rel` (e.g. "nofollow") must not be able to silently
66
+ // drop it, so we merge the token in rather than treating rel as all-or-nothing.
67
+ // `noreferrer` already implies `noopener`, so either token satisfies the guard.
68
+ function hardenRel(
69
+ callerRel: string | undefined,
70
+ target: string | undefined
71
+ ): string | undefined {
72
+ if (target !== '_blank') return callerRel;
73
+ if (callerRel === undefined) return 'noopener noreferrer';
74
+ const tokens = callerRel.split(/\s+/).filter(Boolean);
75
+ if (!tokens.some((t) => t === 'noopener' || t === 'noreferrer')) tokens.push('noopener');
76
+ return tokens.join(' ');
77
+ }
78
+ const anchorRel = $derived(hardenRel(rest['rel'] as string | undefined, anchorTarget));
79
+ const anchorReferrerPolicy = $derived(
80
+ (rest['referrerpolicy'] as ReferrerPolicy | undefined) ??
81
+ (anchorTarget === '_blank' && rest['rel'] === undefined
82
+ ? ('no-referrer' as ReferrerPolicy)
83
+ : undefined)
84
+ );
85
+
86
+ const buttonAttrs = $derived(dropDangerous(rest));
87
+ const anchorAttrs = $derived(
88
+ Object.fromEntries(
89
+ Object.entries(dropDangerous(rest)).filter(([k]) => !ANCHOR_CONTROLLED.has(k.toLowerCase()))
90
+ )
91
+ );
36
92
  </script>
37
93
 
38
- {#if restProps['href']}
39
- <a class={buttonClasses} data-testid={buildTestId('button', undefined, testId)} {...restProps}>
40
- {#if children}
41
- {@render children()}
42
- {/if}
43
- </a>
94
+ <!--
95
+ Button renders arbitrary, scheme-guarded hrefs (including external URLs), so
96
+ the SvelteKit resolve() route helper does not apply here. safeHref is vetted by
97
+ isSafeLinkHref before it ever reaches the DOM.
98
+ -->
99
+ <!-- eslint-disable svelte/no-navigation-without-resolve -->
100
+ {#if isAnchor}
101
+ {#if safeHref && !disabled && !loading}
102
+ <a
103
+ class={buttonClasses}
104
+ data-testid={buildTestId('button', undefined, testId)}
105
+ {...anchorAttrs}
106
+ href={safeHref}
107
+ target={anchorTarget}
108
+ rel={anchorRel}
109
+ referrerpolicy={anchorReferrerPolicy}
110
+ >
111
+ {#if children}
112
+ {@render children()}
113
+ {/if}
114
+ </a>
115
+ {:else}
116
+ <span
117
+ class={cn(buttonClasses, 'pointer-events-none cursor-not-allowed opacity-50')}
118
+ data-testid={buildTestId('button', undefined, testId)}
119
+ aria-disabled="true"
120
+ >
121
+ {#if children}
122
+ {@render children()}
123
+ {/if}
124
+ </span>
125
+ {/if}
44
126
  {:else}
45
127
  <button
46
128
  class={buttonClasses}
47
129
  data-testid={buildTestId('button', undefined, testId)}
48
130
  disabled={disabled || loading}
49
- {...restProps}
131
+ {...buttonAttrs}
50
132
  >
51
133
  {#if children}
52
134
  {@render children()}
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import Modal from '../../modal/Modal.svelte';
4
+ import Button from '../../button/Button.svelte';
5
+ import FileViewer from './FileViewer.svelte';
6
+ import { Color, Size } from '../../variants.js';
7
+ import type { FilePreviewModalProps } from './file-preview-modal-types.js';
8
+
9
+ let {
10
+ open = $bindable(false),
11
+ url,
12
+ kind,
13
+ mimeType,
14
+ fileName,
15
+ height = '70vh',
16
+ size = Size.XL,
17
+ showDownload = true,
18
+ downloadUrl,
19
+ downloadLabel = 'Download',
20
+ closeLabel = 'Close',
21
+ onclose,
22
+ showToolbar,
23
+ initialScale,
24
+ minScale,
25
+ maxScale,
26
+ scaleStep,
27
+ maxPages,
28
+ maxImagePixels,
29
+ maxBytes,
30
+ loadingLabel,
31
+ errorTitle,
32
+ errorActionLabel,
33
+ class: className,
34
+ testId,
35
+ actions
36
+ }: FilePreviewModalProps = $props();
37
+
38
+ function handleClose() {
39
+ open = false;
40
+ onclose?.();
41
+ }
42
+ </script>
43
+
44
+ <Modal
45
+ bind:open
46
+ title={fileName}
47
+ {size}
48
+ onclose={handleClose}
49
+ bodyClass="overflow-hidden p-0"
50
+ {testId}
51
+ >
52
+ <!--
53
+ Explicit height + non-scrolling body: gives the viewer a genuinely definite
54
+ height so its toolbar stays pinned and its own content area scrolls (a
55
+ flex-derived height would collapse through FileViewer's h-full).
56
+ -->
57
+ <div style="height: {height}">
58
+ <FileViewer
59
+ {url}
60
+ {kind}
61
+ {mimeType}
62
+ {fileName}
63
+ class={cn('h-full', className)}
64
+ {showToolbar}
65
+ {initialScale}
66
+ {minScale}
67
+ {maxScale}
68
+ {scaleStep}
69
+ {maxPages}
70
+ {maxImagePixels}
71
+ {maxBytes}
72
+ {loadingLabel}
73
+ {errorTitle}
74
+ {errorActionLabel}
75
+ {testId}
76
+ />
77
+ </div>
78
+
79
+ {#snippet footer()}
80
+ {@render actions?.()}
81
+ {#if showDownload}
82
+ <Button
83
+ variant="outline"
84
+ color={Color.DEFAULT}
85
+ href={downloadUrl ?? url}
86
+ target="_blank"
87
+ download={fileName ?? true}
88
+ data-testid="file-preview-download"
89
+ >
90
+ {downloadLabel}
91
+ </Button>
92
+ {/if}
93
+ <Button
94
+ variant="outline"
95
+ color={Color.DEFAULT}
96
+ onclick={handleClose}
97
+ data-testid="file-preview-close"
98
+ >
99
+ {closeLabel}
100
+ </Button>
101
+ {/snippet}
102
+ </Modal>
@@ -0,0 +1,4 @@
1
+ import type { FilePreviewModalProps } from './file-preview-modal-types.js';
2
+ declare const FilePreviewModal: import("svelte").Component<FilePreviewModalProps, {}, "open">;
3
+ type FilePreviewModal = ReturnType<typeof FilePreviewModal>;
4
+ export default FilePreviewModal;
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import { buildTestId } from '../../helper/testid.js';
4
+ import PdfViewer from '../pdf-viewer/PdfViewer.svelte';
5
+ import ImageViewer from '../image-viewer/ImageViewer.svelte';
6
+ import TextViewer from '../text-viewer/TextViewer.svelte';
7
+ import UnsupportedPreview from '../viewer-shared/UnsupportedPreview.svelte';
8
+ import {
9
+ FileKind,
10
+ fileKindFromMime,
11
+ fileKindFromFileName,
12
+ type FileViewerProps
13
+ } from './file-viewer-types.js';
14
+
15
+ let {
16
+ url,
17
+ kind,
18
+ mimeType,
19
+ fileName,
20
+ showToolbar = true,
21
+ initialScale,
22
+ minScale,
23
+ maxScale,
24
+ scaleStep,
25
+ maxPages,
26
+ maxImagePixels,
27
+ maxBytes,
28
+ loadingLabel,
29
+ errorTitle,
30
+ errorActionLabel,
31
+ class: className = '',
32
+ testId
33
+ }: FileViewerProps = $props();
34
+
35
+ // Resolution order: explicit kind → mimeType → fileName → UNKNOWN. Each source
36
+ // only wins if it yields a known kind, so a junk mimeType still lets fileName
37
+ // decide.
38
+ const resolvedKind = $derived.by(() => {
39
+ if (kind) return kind;
40
+ if (mimeType) {
41
+ const fromMime = fileKindFromMime(mimeType);
42
+ if (fromMime !== FileKind.UNKNOWN) return fromMime;
43
+ }
44
+ if (fileName) {
45
+ const fromName = fileKindFromFileName(fileName);
46
+ if (fromName !== FileKind.UNKNOWN) return fromName;
47
+ }
48
+ return FileKind.UNKNOWN;
49
+ });
50
+ </script>
51
+
52
+ <div
53
+ class={cn('h-full w-full', className)}
54
+ data-testid={buildTestId('file-viewer', undefined, testId)}
55
+ >
56
+ {#if resolvedKind === FileKind.PDF}
57
+ <PdfViewer
58
+ {url}
59
+ {showToolbar}
60
+ {initialScale}
61
+ {minScale}
62
+ {maxScale}
63
+ {scaleStep}
64
+ {maxPages}
65
+ {loadingLabel}
66
+ {errorTitle}
67
+ {errorActionLabel}
68
+ {testId}
69
+ />
70
+ {:else if resolvedKind === FileKind.IMAGE}
71
+ <ImageViewer
72
+ {url}
73
+ {showToolbar}
74
+ initialScale={typeof initialScale === 'number' ? initialScale : undefined}
75
+ {minScale}
76
+ {maxScale}
77
+ {scaleStep}
78
+ {maxImagePixels}
79
+ {loadingLabel}
80
+ {errorTitle}
81
+ {errorActionLabel}
82
+ {testId}
83
+ />
84
+ {:else if resolvedKind === FileKind.TEXT}
85
+ <TextViewer {url} {maxBytes} {loadingLabel} {errorTitle} {errorActionLabel} {testId} />
86
+ {:else}
87
+ <UnsupportedPreview
88
+ {url}
89
+ {fileName}
90
+ title={errorTitle ?? 'Preview not available'}
91
+ message="This file type can't be previewed."
92
+ actionLabel={errorActionLabel}
93
+ {testId}
94
+ />
95
+ {/if}
96
+ </div>
@@ -0,0 +1,4 @@
1
+ import { type FileViewerProps } from './file-viewer-types.js';
2
+ declare const FileViewer: import("svelte").Component<FileViewerProps, {}, "">;
3
+ type FileViewer = ReturnType<typeof FileViewer>;
4
+ export default FileViewer;
@@ -0,0 +1,62 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ import type { Snippet } from 'svelte';
3
+ import type { VariantSizes } from '../../index.js';
4
+ import type { FileKindValue } from './file-viewer-types.js';
5
+ import type { FitMode } from '../../helper/pdf-safety.js';
6
+ /**
7
+ * Props for `<FilePreviewModal>` — a `<Modal>` that previews a file with the
8
+ * unified `<FileViewer>`, correctly sized (toolbar pinned, content scrolls) with
9
+ * an optional download action and a close button.
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <FilePreviewModal
14
+ * bind:open
15
+ * url={file.url}
16
+ * mimeType={file.mimeType}
17
+ * fileName={file.name}
18
+ * />
19
+ * ```
20
+ */
21
+ export type FilePreviewModalProps = {
22
+ /** Bindable open state. @default false */
23
+ open?: boolean;
24
+ /** URL of the file. */
25
+ url: string;
26
+ /** Explicit file kind. Optional — derived from `mimeType`/`fileName` otherwise. */
27
+ kind?: FileKindValue;
28
+ /** MIME type used to derive `kind`. */
29
+ mimeType?: string;
30
+ /** File name — the modal title, and used to derive `kind` and name the download. */
31
+ fileName?: string;
32
+ /** Height of the viewer inside the modal (any CSS length). @default '70vh' */
33
+ height?: string;
34
+ /** Modal size. @default 'xl' */
35
+ size?: VariantSizes;
36
+ /** Show a download action in the footer. @default true */
37
+ showDownload?: boolean;
38
+ /** URL for the download action. @default `url` */
39
+ downloadUrl?: string;
40
+ /** @default 'Download' */
41
+ downloadLabel?: string;
42
+ /** @default 'Close' */
43
+ closeLabel?: string;
44
+ /** Fires when the modal is dismissed (close button, × , backdrop, Escape). */
45
+ onclose?: () => void;
46
+ showToolbar?: boolean;
47
+ /** PDF initial zoom: a fixed scale, or `'fit-width'` / `'fit-page'`. @default 'fit-width' */
48
+ initialScale?: number | FitMode;
49
+ minScale?: number;
50
+ maxScale?: number;
51
+ scaleStep?: number;
52
+ maxPages?: number;
53
+ maxImagePixels?: number;
54
+ maxBytes?: number;
55
+ loadingLabel?: string;
56
+ errorTitle?: string;
57
+ errorActionLabel?: string;
58
+ class?: ClassValue;
59
+ testId?: string;
60
+ /** Extra footer content, rendered left of the Download/Close buttons. */
61
+ actions?: Snippet;
62
+ };
@@ -0,0 +1,51 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ import type { FitMode } from '../../helper/pdf-safety.js';
3
+ /** Coarse file category that selects which viewer renders the file. */
4
+ export declare const FileKind: {
5
+ readonly PDF: "pdf";
6
+ readonly IMAGE: "image";
7
+ readonly TEXT: "text";
8
+ readonly OFFICE: "office";
9
+ readonly UNKNOWN: "unknown";
10
+ };
11
+ export type FileKindValue = (typeof FileKind)[keyof typeof FileKind];
12
+ /** Map a MIME type to a FileKind. Unknown/junk maps to UNKNOWN (never a render path). */
13
+ export declare function fileKindFromMime(mime: string): FileKindValue;
14
+ /**
15
+ * Map a file name (or URL/path) to a FileKind by its extension. Query strings,
16
+ * fragments, and directories are ignored. Unknown/missing extensions map to
17
+ * UNKNOWN. Use when you don't have a MIME type; prefer `fileKindFromMime` when
18
+ * you do.
19
+ */
20
+ export declare function fileKindFromFileName(name: string): FileKindValue;
21
+ /** Props for the `<FileViewer>` router component. */
22
+ export type FileViewerProps = {
23
+ /** URL of the file. */
24
+ url: string;
25
+ /**
26
+ * Explicit file kind. Optional — if omitted, it is derived from `mimeType`,
27
+ * then from `fileName`, falling back to `UNKNOWN` (the download fallback).
28
+ */
29
+ kind?: FileKindValue;
30
+ /** MIME type used to derive `kind` when `kind` is not given. */
31
+ mimeType?: string;
32
+ /**
33
+ * File name, shown in the unsupported/download fallback and used to derive
34
+ * `kind` (by extension) when neither `kind` nor `mimeType` is given.
35
+ */
36
+ fileName?: string;
37
+ showToolbar?: boolean;
38
+ /** PDF initial zoom: a fixed scale, or `'fit-width'` / `'fit-page'`. @default 'fit-width' */
39
+ initialScale?: number | FitMode;
40
+ minScale?: number;
41
+ maxScale?: number;
42
+ scaleStep?: number;
43
+ maxPages?: number;
44
+ maxImagePixels?: number;
45
+ maxBytes?: number;
46
+ loadingLabel?: string;
47
+ errorTitle?: string;
48
+ errorActionLabel?: string;
49
+ class?: ClassValue;
50
+ testId?: string;
51
+ };
@@ -0,0 +1,127 @@
1
+ /** Coarse file category that selects which viewer renders the file. */
2
+ export const FileKind = {
3
+ PDF: 'pdf',
4
+ IMAGE: 'image',
5
+ TEXT: 'text',
6
+ OFFICE: 'office',
7
+ UNKNOWN: 'unknown'
8
+ };
9
+ const TEXT_APPLICATION_TYPES = new Set([
10
+ 'application/json',
11
+ 'application/xml',
12
+ 'application/javascript',
13
+ 'application/typescript',
14
+ 'application/x-ndjson',
15
+ 'application/csv'
16
+ ]);
17
+ const OFFICE_TYPES = new Set([
18
+ 'application/msword',
19
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ 'application/vnd.ms-excel',
21
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22
+ 'application/vnd.ms-powerpoint',
23
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
24
+ ]);
25
+ /** Map a MIME type to a FileKind. Unknown/junk maps to UNKNOWN (never a render path). */
26
+ export function fileKindFromMime(mime) {
27
+ if (typeof mime !== 'string')
28
+ return FileKind.UNKNOWN;
29
+ const type = mime.toLowerCase().split(';')[0].trim();
30
+ if (!type)
31
+ return FileKind.UNKNOWN;
32
+ if (type === 'application/pdf')
33
+ return FileKind.PDF;
34
+ if (type.startsWith('image/'))
35
+ return FileKind.IMAGE;
36
+ if (type.startsWith('text/'))
37
+ return FileKind.TEXT;
38
+ if (TEXT_APPLICATION_TYPES.has(type))
39
+ return FileKind.TEXT;
40
+ if (OFFICE_TYPES.has(type))
41
+ return FileKind.OFFICE;
42
+ return FileKind.UNKNOWN;
43
+ }
44
+ const IMAGE_EXTENSIONS = new Set([
45
+ 'png',
46
+ 'jpg',
47
+ 'jpeg',
48
+ 'gif',
49
+ 'webp',
50
+ 'svg',
51
+ 'bmp',
52
+ 'avif',
53
+ 'ico',
54
+ 'tiff',
55
+ 'tif'
56
+ ]);
57
+ // Rendered as plain text (textContent), so source-y formats are safe here.
58
+ const TEXT_EXTENSIONS = new Set([
59
+ 'txt',
60
+ 'text',
61
+ 'md',
62
+ 'markdown',
63
+ 'csv',
64
+ 'tsv',
65
+ 'json',
66
+ 'ndjson',
67
+ 'xml',
68
+ 'yml',
69
+ 'yaml',
70
+ 'log',
71
+ 'js',
72
+ 'mjs',
73
+ 'cjs',
74
+ 'ts',
75
+ 'tsx',
76
+ 'jsx',
77
+ 'css',
78
+ 'scss',
79
+ 'less',
80
+ 'html',
81
+ 'htm',
82
+ 'py',
83
+ 'rb',
84
+ 'go',
85
+ 'rs',
86
+ 'java',
87
+ 'kt',
88
+ 'c',
89
+ 'h',
90
+ 'cc',
91
+ 'cpp',
92
+ 'hpp',
93
+ 'sh',
94
+ 'bash',
95
+ 'zsh',
96
+ 'sql',
97
+ 'ini',
98
+ 'toml',
99
+ 'conf',
100
+ 'env'
101
+ ]);
102
+ const OFFICE_EXTENSIONS = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']);
103
+ /**
104
+ * Map a file name (or URL/path) to a FileKind by its extension. Query strings,
105
+ * fragments, and directories are ignored. Unknown/missing extensions map to
106
+ * UNKNOWN. Use when you don't have a MIME type; prefer `fileKindFromMime` when
107
+ * you do.
108
+ */
109
+ export function fileKindFromFileName(name) {
110
+ if (typeof name !== 'string')
111
+ return FileKind.UNKNOWN;
112
+ // Drop query/fragment, then isolate the final path segment.
113
+ const base = name.split(/[?#]/)[0].split('/').pop() ?? '';
114
+ const dot = base.lastIndexOf('.');
115
+ const ext = dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
116
+ if (!ext)
117
+ return FileKind.UNKNOWN;
118
+ if (ext === 'pdf')
119
+ return FileKind.PDF;
120
+ if (IMAGE_EXTENSIONS.has(ext))
121
+ return FileKind.IMAGE;
122
+ if (TEXT_EXTENSIONS.has(ext))
123
+ return FileKind.TEXT;
124
+ if (OFFICE_EXTENSIONS.has(ext))
125
+ return FileKind.OFFICE;
126
+ return FileKind.UNKNOWN;
127
+ }