@asteby/metacore-runtime-react 23.3.0 → 23.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - edfa5a9: Render "pro" de URLs, imágenes y archivos: una URL nunca se muestra cruda. Nuevas primitivas compartidas en `rich-url.tsx` (`MediaValue`, `UrlChip`, `FileChip`, `ImageThumbnail`, `RichText`, `linkifyText`, `classifyUrl`, …) que consumen por igual la CELDA de la tabla/kanban (`dynamic-columns.tsx`) y el DETAIL DIALOG (`dynamic-record.tsx`) — cero copy-paste.
8
+ - URL de página (ej. `github_url` con la issue completa) → chip compacto con icono `ExternalLink` + label corto (hostname, ej. "github.com", o el nombre de archivo si aplica); URL completa en el tooltip; abre en pestaña nueva. Ya no se ve la URL de 120 caracteres.
9
+ - URL de imagen (jpg/png/gif/webp/avif/svg y rutas `…/storage/media/…` sin extensión) → THUMBNAIL inline redondeado y clickeable que abre la imagen completa; `onError` degrada a link chip (nunca un icono de imagen rota). En la celda el thumbnail es chico (~h-8) para no romper la altura de fila; en el dialog es más grande.
10
+ - URL de archivo (pdf/zip/docx/mp4/…) → chip con icono `FileText` + nombre del archivo.
11
+ - URLs embebidas en texto largo (body, textarea, long-text) → se linkifican con el mismo estilo (bare o markdown `[label](url)`), recortando puntuación final y respetando paréntesis balanceados; imágenes y archivos embebidos también se renderizan inline.
12
+
13
+ Sin llamadas de red para renderizar (nada de servicio de favicons — CSP/privacidad): el icono es un glyph lucide local.
14
+
3
15
  ## 23.3.0
4
16
 
5
17
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
@@ -28,6 +28,7 @@ import { DynamicIcon, isLucideIconName } from '../dynamic-icon';
28
28
  import { humanizeToken } from '../dynamic-columns-helpers';
29
29
  import { formatDateCell } from '../dynamic-columns';
30
30
  import { OptionBadge, statusColorFor, useIsDarkTheme, } from '../display-value';
31
+ import { MediaValue, RichText } from '../rich-url';
31
32
  import { generateBadgeStyles } from '@asteby/metacore-ui/lib';
32
33
  import { CollectionCell } from '../collection-cell';
33
34
  import { ImageUrlContext, identityImageUrl } from '../image-url-context';
@@ -638,9 +639,10 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
638
639
  // carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
639
640
  // external link, opening in a new tab, truncated.
640
641
  if ((renderAs === 'url' || renderAs === 'link') && value) {
641
- const urlStr = String(value);
642
- const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`;
643
- return (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "truncate max-w-[360px]", children: urlStr })] }));
642
+ // Shared media renderer (same as the table cell): an image URL shows a
643
+ // larger inline thumbnail, a file a chip, else a compact link chip with
644
+ // a smart label never the raw 120-char URL.
645
+ return (_jsx("div", { className: "py-1", children: _jsx(MediaValue, { url: String(value), getImageUrl: getImageUrl, label: field.styleConfig?.label, icon: field.styleConfig?.icon, thumbHeight: 200, maxLabelWidth: 360 }) }));
644
646
  }
645
647
  // Money → org-currency string. Detected by the backend `cellStyle:'currency'`
646
648
  // stamp or a numeric value whose key matches the money heuristic (fallback
@@ -717,10 +719,14 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
717
719
  return (_jsx(StructuredViewValue, { value: value, field: field, locale: i18n.language, t: t }));
718
720
  }
719
721
  const display = formatDisplayValue(value, field);
720
- if (field.type === 'textarea') {
721
- return (_jsx("p", { className: "text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px]", children: display }));
722
- }
723
- return _jsx("p", { className: "text-sm py-1", children: display });
722
+ // Free text may embed URLs (a github body, notes, a long-text field). Turn
723
+ // them into rich chips / inline thumbnails with the shared linkifier instead
724
+ // of showing raw URLs — the rest of the text is preserved verbatim.
725
+ const hasUrl = display !== '—' && /(https?:\/\/|www\.)\S/i.test(display);
726
+ if (field.type === 'textarea' || renderAs === 'textarea' || renderAs === 'long-text') {
727
+ return (_jsx("p", { className: "text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px] [&_img]:my-1", children: hasUrl ? (_jsx(RichText, { text: display, getImageUrl: getImageUrl, imageHeight: 200 })) : (display) }));
728
+ }
729
+ return (_jsx("p", { className: "text-sm py-1", children: hasUrl ? (_jsx(RichText, { text: display, getImageUrl: getImageUrl, imageHeight: 160 })) : (display) }));
724
730
  }
725
731
  // IconNameViewValue — read view for a column whose value is a lucide icon name
726
732
  // (an addon's `icon` column): the glyph plus the name, so the value stays
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAsC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqFhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA8mBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAuC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqFhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAqmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -23,6 +23,7 @@ import { generateBadgeStyles, getInitials, relationChipStyles, } from '@asteby/m
23
23
  import { Progress } from './dialogs/_primitives';
24
24
  import { humanizeToken } from './dynamic-columns-helpers';
25
25
  import { OptionBadge, RelationThumbnail, statusColorFor, useIsDarkTheme, } from './display-value';
26
+ import { MediaValue } from './rich-url';
26
27
  import { OptionsContext } from './options-context';
27
28
  import { DynamicIcon, isLucideIconName } from './dynamic-icon';
28
29
  import { CollectionCell } from './collection-cell';
@@ -584,25 +585,16 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
584
585
  if (!rawUrl)
585
586
  return _jsx(EmptyCell, {});
586
587
  const urlStr = String(rawUrl);
587
- const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`;
588
- let label;
589
- if (labelField) {
590
- label = String(getNestedValue(row.original, labelField) ?? href);
591
- }
592
- else {
593
- try {
594
- label = new URL(href).hostname;
595
- }
596
- catch {
597
- label = urlStr;
598
- }
599
- }
600
- const isExternal = !/^https?:\/\/(localhost|127\.)/i.test(href);
601
- const newTab = styleCfg(col, 'new_tab', 'newTab') === true || isExternal;
602
- const iconName = styleCfg(col, 'icon') || 'ExternalLink';
603
- return (_jsxs("a", { href: href, ...(newTab
604
- ? { target: '_blank', rel: 'noopener noreferrer' }
605
- : {}), className: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(DynamicIcon, { name: iconName, className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "truncate max-w-[260px]", children: label })] }));
588
+ // Explicit label from a `label_field` wins; otherwise
589
+ // the shared primitive derives a smart label (hostname
590
+ // / file name). Images render as a small (~h-8) inline
591
+ // thumbnail, files as a chip, else a link chip. Same
592
+ // renderer as the detail dialog.
593
+ const label = labelField
594
+ ? String(getNestedValue(row.original, labelField) ?? '')
595
+ : undefined;
596
+ const iconName = styleCfg(col, 'icon');
597
+ return (_jsx(MediaValue, { url: urlStr, getImageUrl: getImageUrl, label: label || undefined, icon: iconName || undefined, thumbHeight: 32, maxLabelWidth: 260 }));
606
598
  }
607
599
  case 'email': {
608
600
  if (!value)
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ export * from './dynamic-icon';
26
26
  export type { ColumnFilterConfig, FilterOption as DynamicColumnFilterOption, GetDynamicColumns, DynamicIconComponent, } from './dynamic-columns-shim';
27
27
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, relationKeyFor, resolveRelationLabel, type DynamicColumnsHelpers, } from './dynamic-columns';
28
28
  export { humanizeToken } from './dynamic-columns-helpers';
29
+ export { UrlChip, FileChip, ImageThumbnail, MediaValue, RichText, linkifyText, classifyUrl, isImageUrl, isFileUrl, ensureHref, smartUrlLabel, fileNameFromUrl, splitTrailingPunct, type UrlKind, type LinkifyOptions, } from './rich-url';
29
30
  export { CollectionCell, formatScalar, prettifyKey, countLabel, type CollectionCellProps, type Translate as CollectionCellTranslate, } from './collection-cell';
30
31
  export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid';
31
32
  export { DynamicRecordDialog, ViewValue } from './dialogs/dynamic-record';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederation
30
30
  export * from './dynamic-icon';
31
31
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, relationKeyFor, resolveRelationLabel, } from './dynamic-columns';
32
32
  export { humanizeToken } from './dynamic-columns-helpers';
33
+ export { UrlChip, FileChip, ImageThumbnail, MediaValue, RichText, linkifyText, classifyUrl, isImageUrl, isFileUrl, ensureHref, smartUrlLabel, fileNameFromUrl, splitTrailingPunct, } from './rich-url';
33
34
  export { CollectionCell, formatScalar, prettifyKey, countLabel, } from './collection-cell';
34
35
  export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid';
35
36
  export { DynamicRecordDialog, ViewValue } from './dialogs/dynamic-record';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Rich URL & media rendering primitives.
3
+ *
4
+ * A URL is never shown raw. Depending on what it points at we render:
5
+ * - an IMAGE (jpg/png/gif/webp/… or a `link.asteby.com/storage/media/` path)
6
+ * → an inline clickable THUMBNAIL that opens the full image in a new tab,
7
+ * - a FILE (pdf/zip/docx/…) → a chip with a file icon and the file name,
8
+ * - anything else → a compact link CHIP: an external-link glyph plus a short
9
+ * smart label (hostname, e.g. "github.com", or the last path segment for a
10
+ * file-like URL), with the full URL in a native tooltip.
11
+ *
12
+ * These live here (not inline in the table/dialog) so the dynamic TABLE cell
13
+ * (`dynamic-columns.tsx`) and the read-only DETAIL DIALOG (`dynamic-record.tsx`)
14
+ * share the EXACT same rendering. Ecosystem rule: shared primitives, zero copy.
15
+ *
16
+ * No external requests are made to render a link (no favicon service — CSP /
17
+ * privacy): the icon is a local lucide glyph.
18
+ */
19
+ import React from 'react';
20
+ export type UrlKind = 'image' | 'file' | 'link';
21
+ /** `true` when the string looks like an image URL/path. */
22
+ export declare function isImageUrl(url: string): boolean;
23
+ /** `true` when the string looks like a non-image downloadable file. */
24
+ export declare function isFileUrl(url: string): boolean;
25
+ /** Classify a URL so the right primitive (thumbnail / file chip / link) renders. */
26
+ export declare function classifyUrl(url: string): UrlKind;
27
+ /** Ensure a URL is absolute so `<a href>` and `new URL()` behave. */
28
+ export declare function ensureHref(url: string): string;
29
+ /**
30
+ * A short, human label for a URL. For a file-like URL (last segment carries an
31
+ * extension) the file name wins ("report.pdf"); otherwise the bare hostname
32
+ * ("github.com"), so a 120-char issue URL never shows raw. Falls back to the
33
+ * input when it can't be parsed.
34
+ */
35
+ export declare function smartUrlLabel(url: string): string;
36
+ /** Last path segment (decoded) — the file name for a file/image URL. */
37
+ export declare function fileNameFromUrl(url: string): string;
38
+ /**
39
+ * Compact link chip: external-link glyph + smart label, full URL in the
40
+ * tooltip, opens in a new tab. The canonical render for a plain (non-media)
41
+ * URL, matching the table's `url`/`link` cell.
42
+ */
43
+ export declare const UrlChip: React.FC<{
44
+ url: string;
45
+ label?: string;
46
+ icon?: string;
47
+ /** Smaller label cap for a table/kanban cell. */
48
+ maxLabelWidth?: number;
49
+ className?: string;
50
+ }>;
51
+ /**
52
+ * Chip for a downloadable non-image file: file-text glyph + the file name,
53
+ * opens the file in a new tab.
54
+ */
55
+ export declare const FileChip: React.FC<{
56
+ url: string;
57
+ maxLabelWidth?: number;
58
+ className?: string;
59
+ }>;
60
+ /**
61
+ * Inline image thumbnail — rounded, bordered, `object-cover` — that opens the
62
+ * full image in a new tab. On load error it degrades to a normal link chip so a
63
+ * dead image URL is still reachable (and never a broken-image icon). `maxHeight`
64
+ * keeps a cell thumbnail small (~h-8) while the dialog shows a larger preview.
65
+ */
66
+ export declare const ImageThumbnail: React.FC<{
67
+ url: string;
68
+ getImageUrl?: (path: string) => string;
69
+ /** Max rendered height in px. */
70
+ maxHeight?: number;
71
+ className?: string;
72
+ }>;
73
+ /**
74
+ * One URL value → the right primitive (thumbnail / file chip / link chip).
75
+ * Single entry point used by both the table cell and the detail dialog so a URL
76
+ * renders identically everywhere.
77
+ */
78
+ export declare const MediaValue: React.FC<{
79
+ url: string;
80
+ getImageUrl?: (path: string) => string;
81
+ /** Explicit label for the link chip (from a `label_field`). */
82
+ label?: string;
83
+ /** Explicit icon for the link chip. */
84
+ icon?: string;
85
+ /** Thumbnail max height (small in a cell, larger in the dialog). */
86
+ thumbHeight?: number;
87
+ /** Link/file label truncation cap. */
88
+ maxLabelWidth?: number;
89
+ className?: string;
90
+ }>;
91
+ /**
92
+ * Peel trailing punctuation a URL shouldn't swallow (sentence period, closing
93
+ * bracket, quote). A closing paren is kept only when the URL itself opened one
94
+ * (Wikipedia-style `(…)` paths), so `(see https://x.com/a)` links `https://x.com/a`
95
+ * but `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren.
96
+ */
97
+ export declare function splitTrailingPunct(match: string): [string, string];
98
+ export interface LinkifyOptions {
99
+ getImageUrl?: (path: string) => string;
100
+ /** Max height for an inline image found in the text. */
101
+ imageHeight?: number;
102
+ }
103
+ /**
104
+ * Turn free text into React nodes, replacing every URL (bare or markdown
105
+ * `[label](url)`) with the matching rich primitive: an inline thumbnail for an
106
+ * image URL, a file chip for a file, otherwise a link chip. Plain text between
107
+ * matches is preserved verbatim (caller keeps `whitespace-pre-wrap`).
108
+ */
109
+ export declare function linkifyText(text: string, opts?: LinkifyOptions): React.ReactNode[];
110
+ /**
111
+ * Linkified free text. Renders the raw string with every URL turned into a rich
112
+ * chip/thumbnail — the read view for a long-text / textarea / body field.
113
+ */
114
+ export declare const RichText: React.FC<{
115
+ text: string;
116
+ getImageUrl?: (path: string) => string;
117
+ imageHeight?: number;
118
+ }>;
119
+ //# sourceMappingURL=rich-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-url.d.ts","sourceRoot":"","sources":["../src/rich-url.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AAkBzB,MAAM,MAAM,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAA;AAE/C,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9C;AAED,oFAAoF;AACpF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAIhD;AAED,qEAAqE;AACrE,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9C;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAYjD;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQnD;AAOD;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iDAAiD;IACjD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB,CAqBA,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB,CAoBA,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,iCAAiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB,CA6BA,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sCAAsC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB,CAwBA,CAAA;AAQD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAclE;AAED,MAAM,WAAW,cAAc;IAC3B,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACvB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,cAAmB,GAC1B,KAAK,CAAC,SAAS,EAAE,CAsDnB;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB,CAEA,CAAA"}
@@ -0,0 +1,224 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Rich URL & media rendering primitives.
4
+ *
5
+ * A URL is never shown raw. Depending on what it points at we render:
6
+ * - an IMAGE (jpg/png/gif/webp/… or a `link.asteby.com/storage/media/` path)
7
+ * → an inline clickable THUMBNAIL that opens the full image in a new tab,
8
+ * - a FILE (pdf/zip/docx/…) → a chip with a file icon and the file name,
9
+ * - anything else → a compact link CHIP: an external-link glyph plus a short
10
+ * smart label (hostname, e.g. "github.com", or the last path segment for a
11
+ * file-like URL), with the full URL in a native tooltip.
12
+ *
13
+ * These live here (not inline in the table/dialog) so the dynamic TABLE cell
14
+ * (`dynamic-columns.tsx`) and the read-only DETAIL DIALOG (`dynamic-record.tsx`)
15
+ * share the EXACT same rendering. Ecosystem rule: shared primitives, zero copy.
16
+ *
17
+ * No external requests are made to render a link (no favicon service — CSP /
18
+ * privacy): the icon is a local lucide glyph.
19
+ */
20
+ import React from 'react';
21
+ import { cn } from '@asteby/metacore-ui/lib';
22
+ import { DynamicIcon } from './dynamic-icon';
23
+ /** Image file extensions we render as an inline thumbnail. */
24
+ const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico)(?:[?#].*)?$/i;
25
+ /**
26
+ * Storage paths that serve images without a file extension (the link.asteby.com
27
+ * media CDN hands back opaque ids). Treated as images so uploaded photos still
28
+ * thumbnail.
29
+ */
30
+ const MEDIA_PATH_RE = /\/storage\/media\//i;
31
+ /** Non-image file extensions we render as a download-ish file chip. */
32
+ const FILE_EXT_RE = /\.(pdf|zip|rar|7z|tar|t?gz|docx?|xlsx?|pptx?|csv|txt|json|xml|md|rtf|odt|mp4|mov|avi|mkv|webm|mp3|wav|ogg|flac|apk|dmg|exe|pkg)(?:[?#].*)?$/i;
33
+ /** `true` when the string looks like an image URL/path. */
34
+ export function isImageUrl(url) {
35
+ return IMAGE_EXT_RE.test(url) || MEDIA_PATH_RE.test(url);
36
+ }
37
+ /** `true` when the string looks like a non-image downloadable file. */
38
+ export function isFileUrl(url) {
39
+ return FILE_EXT_RE.test(url);
40
+ }
41
+ /** Classify a URL so the right primitive (thumbnail / file chip / link) renders. */
42
+ export function classifyUrl(url) {
43
+ if (isImageUrl(url))
44
+ return 'image';
45
+ if (isFileUrl(url))
46
+ return 'file';
47
+ return 'link';
48
+ }
49
+ /** Ensure a URL is absolute so `<a href>` and `new URL()` behave. */
50
+ export function ensureHref(url) {
51
+ return /^(https?:)?\/\//i.test(url) || /^(mailto|tel):/i.test(url)
52
+ ? url
53
+ : `https://${url}`;
54
+ }
55
+ /**
56
+ * A short, human label for a URL. For a file-like URL (last segment carries an
57
+ * extension) the file name wins ("report.pdf"); otherwise the bare hostname
58
+ * ("github.com"), so a 120-char issue URL never shows raw. Falls back to the
59
+ * input when it can't be parsed.
60
+ */
61
+ export function smartUrlLabel(url) {
62
+ try {
63
+ const u = new URL(ensureHref(url));
64
+ const segs = u.pathname.split('/').filter(Boolean);
65
+ const last = segs[segs.length - 1];
66
+ if (last && /\.[a-z0-9]{1,8}$/i.test(last)) {
67
+ return decodeURIComponent(last);
68
+ }
69
+ return u.hostname.replace(/^www\./i, '');
70
+ }
71
+ catch {
72
+ return url;
73
+ }
74
+ }
75
+ /** Last path segment (decoded) — the file name for a file/image URL. */
76
+ export function fileNameFromUrl(url) {
77
+ try {
78
+ const u = new URL(ensureHref(url));
79
+ const segs = u.pathname.split('/').filter(Boolean);
80
+ return decodeURIComponent(segs[segs.length - 1] || u.hostname);
81
+ }
82
+ catch {
83
+ return url.split(/[?#]/)[0].split('/').pop() || url;
84
+ }
85
+ }
86
+ /** Stops row-click / dialog-close when a link inside them is clicked. */
87
+ function stop(e) {
88
+ e.stopPropagation();
89
+ }
90
+ /**
91
+ * Compact link chip: external-link glyph + smart label, full URL in the
92
+ * tooltip, opens in a new tab. The canonical render for a plain (non-media)
93
+ * URL, matching the table's `url`/`link` cell.
94
+ */
95
+ export const UrlChip = ({ url, label, icon, maxLabelWidth = 260, className }) => {
96
+ const href = ensureHref(url);
97
+ const text = label || smartUrlLabel(url);
98
+ return (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", title: url, onClick: stop, className: cn('inline-flex max-w-full items-center gap-1.5 align-middle text-sm font-medium text-primary hover:underline', className), children: [_jsx(DynamicIcon, { name: icon || 'ExternalLink', className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "truncate", style: { maxWidth: maxLabelWidth }, children: text })] }));
99
+ };
100
+ /**
101
+ * Chip for a downloadable non-image file: file-text glyph + the file name,
102
+ * opens the file in a new tab.
103
+ */
104
+ export const FileChip = ({ url, maxLabelWidth = 240, className }) => {
105
+ const href = ensureHref(url);
106
+ return (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", title: url, onClick: stop, className: cn('inline-flex max-w-full items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 align-middle text-sm font-medium text-foreground hover:bg-muted', className), children: [_jsx(DynamicIcon, { name: "FileText", className: "h-3.5 w-3.5 shrink-0 opacity-70" }), _jsx("span", { className: "truncate", style: { maxWidth: maxLabelWidth }, children: fileNameFromUrl(url) })] }));
107
+ };
108
+ /**
109
+ * Inline image thumbnail — rounded, bordered, `object-cover` — that opens the
110
+ * full image in a new tab. On load error it degrades to a normal link chip so a
111
+ * dead image URL is still reachable (and never a broken-image icon). `maxHeight`
112
+ * keeps a cell thumbnail small (~h-8) while the dialog shows a larger preview.
113
+ */
114
+ export const ImageThumbnail = ({ url, getImageUrl, maxHeight = 160, className }) => {
115
+ const [failed, setFailed] = React.useState(false);
116
+ const href = ensureHref(url);
117
+ // Absolute URLs are used verbatim; only relative storage paths go through
118
+ // the host's image resolver (which prefixes the media base).
119
+ const src = /^(https?:)?\/\//i.test(url) ? url : getImageUrl ? getImageUrl(url) : url;
120
+ if (failed)
121
+ return _jsx(UrlChip, { url: url, icon: "Image" });
122
+ return (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", title: url, onClick: stop, className: "inline-block max-w-full align-middle", children: _jsx("img", { src: src, alt: smartUrlLabel(url), onError: () => setFailed(true), style: { maxHeight }, className: cn('max-w-full rounded-md border object-cover', className) }) }));
123
+ };
124
+ /**
125
+ * One URL value → the right primitive (thumbnail / file chip / link chip).
126
+ * Single entry point used by both the table cell and the detail dialog so a URL
127
+ * renders identically everywhere.
128
+ */
129
+ export const MediaValue = ({ url, getImageUrl, label, icon, thumbHeight, maxLabelWidth, className }) => {
130
+ switch (classifyUrl(url)) {
131
+ case 'image':
132
+ return (_jsx(ImageThumbnail, { url: url, getImageUrl: getImageUrl, maxHeight: thumbHeight, className: className }));
133
+ case 'file':
134
+ return _jsx(FileChip, { url: url, maxLabelWidth: maxLabelWidth, className: className });
135
+ default:
136
+ return (_jsx(UrlChip, { url: url, label: label, icon: icon, maxLabelWidth: maxLabelWidth, className: className }));
137
+ }
138
+ };
139
+ // URL / markdown-link matcher used to linkify free text. Captures either a
140
+ // markdown `[label](url)` or a bare http(s)/www URL. `[^\s<]+` is greedy on
141
+ // purpose — trailing punctuation is trimmed afterwards by `splitTrailingPunct`.
142
+ const LINK_IN_TEXT_RE = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
143
+ /**
144
+ * Peel trailing punctuation a URL shouldn't swallow (sentence period, closing
145
+ * bracket, quote). A closing paren is kept only when the URL itself opened one
146
+ * (Wikipedia-style `(…)` paths), so `(see https://x.com/a)` links `https://x.com/a`
147
+ * but `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren.
148
+ */
149
+ export function splitTrailingPunct(match) {
150
+ const m = match.match(/[).,;:!?\]}'"»›]+$/);
151
+ if (!m)
152
+ return [match, ''];
153
+ let trail = m[0];
154
+ let core = match.slice(0, match.length - trail.length);
155
+ if (trail.startsWith(')')) {
156
+ const opens = (core.match(/\(/g) || []).length;
157
+ const closes = (core.match(/\)/g) || []).length;
158
+ if (opens > closes) {
159
+ core += ')';
160
+ trail = trail.slice(1);
161
+ }
162
+ }
163
+ return [core, trail];
164
+ }
165
+ /**
166
+ * Turn free text into React nodes, replacing every URL (bare or markdown
167
+ * `[label](url)`) with the matching rich primitive: an inline thumbnail for an
168
+ * image URL, a file chip for a file, otherwise a link chip. Plain text between
169
+ * matches is preserved verbatim (caller keeps `whitespace-pre-wrap`).
170
+ */
171
+ export function linkifyText(text, opts = {}) {
172
+ if (!text)
173
+ return [];
174
+ const nodes = [];
175
+ const re = new RegExp(LINK_IN_TEXT_RE.source, 'gi');
176
+ let lastIndex = 0;
177
+ let key = 0;
178
+ let m;
179
+ while ((m = re.exec(text)) !== null) {
180
+ const [full, mdLabel, mdUrl, bareUrl] = m;
181
+ const start = m.index;
182
+ if (start > lastIndex)
183
+ nodes.push(text.slice(lastIndex, start));
184
+ if (mdUrl) {
185
+ // Markdown link: honor the author's label, keep media inline.
186
+ const kind = classifyUrl(mdUrl);
187
+ if (kind === 'image') {
188
+ nodes.push(_jsx(ImageThumbnail, { url: mdUrl, getImageUrl: opts.getImageUrl, maxHeight: opts.imageHeight ?? 200 }, key));
189
+ }
190
+ else if (kind === 'file') {
191
+ nodes.push(_jsx(FileChip, { url: mdUrl }, key));
192
+ }
193
+ else {
194
+ nodes.push(_jsx(UrlChip, { url: mdUrl, label: mdLabel }, key));
195
+ }
196
+ lastIndex = start + full.length;
197
+ }
198
+ else {
199
+ const [core, trail] = splitTrailingPunct(bareUrl);
200
+ const kind = classifyUrl(core);
201
+ if (kind === 'image') {
202
+ nodes.push(_jsx(ImageThumbnail, { url: core, getImageUrl: opts.getImageUrl, maxHeight: opts.imageHeight ?? 200 }, key));
203
+ }
204
+ else if (kind === 'file') {
205
+ nodes.push(_jsx(FileChip, { url: core }, key));
206
+ }
207
+ else {
208
+ nodes.push(_jsx(UrlChip, { url: core }, key));
209
+ }
210
+ if (trail)
211
+ nodes.push(trail);
212
+ lastIndex = start + bareUrl.length;
213
+ }
214
+ key++;
215
+ }
216
+ if (lastIndex < text.length)
217
+ nodes.push(text.slice(lastIndex));
218
+ return nodes;
219
+ }
220
+ /**
221
+ * Linkified free text. Renders the raw string with every URL turned into a rich
222
+ * chip/thumbnail — the read view for a long-text / textarea / body field.
223
+ */
224
+ export const RichText = ({ text, getImageUrl, imageHeight }) => (_jsx(_Fragment, { children: linkifyText(text, { getImageUrl, imageHeight }) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.3.0",
3
+ "version": "23.4.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,196 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Rich URL & media rendering: URLs are never shown raw. The shared primitives
4
+ // classify a URL (image / file / link), derive a smart short label, and
5
+ // linkify URLs embedded in free text (bare or markdown), rendering images as
6
+ // inline thumbnails, files as chips, and everything else as a compact link.
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
9
+ import {
10
+ classifyUrl,
11
+ isImageUrl,
12
+ isFileUrl,
13
+ smartUrlLabel,
14
+ fileNameFromUrl,
15
+ ensureHref,
16
+ splitTrailingPunct,
17
+ linkifyText,
18
+ UrlChip,
19
+ FileChip,
20
+ ImageThumbnail,
21
+ MediaValue,
22
+ RichText,
23
+ } from '../rich-url'
24
+
25
+ afterEach(cleanup)
26
+
27
+ describe('classifyUrl', () => {
28
+ it('detects images by extension', () => {
29
+ for (const u of [
30
+ 'https://cdn.example.com/a.jpg',
31
+ 'https://cdn.example.com/a.JPEG',
32
+ 'http://x/y/z.png?w=200',
33
+ 'https://x/pic.webp#frag',
34
+ 'https://x/i.avif',
35
+ 'https://x/logo.svg',
36
+ ]) {
37
+ expect(isImageUrl(u)).toBe(true)
38
+ expect(classifyUrl(u)).toBe('image')
39
+ }
40
+ })
41
+
42
+ it('detects images served from a storage media path without extension', () => {
43
+ const u = 'https://link.asteby.com/storage/media/9f3ac2b1'
44
+ expect(isImageUrl(u)).toBe(true)
45
+ expect(classifyUrl(u)).toBe('image')
46
+ })
47
+
48
+ it('detects non-image files by extension', () => {
49
+ for (const u of [
50
+ 'https://x/report.pdf',
51
+ 'https://x/archive.zip',
52
+ 'https://x/sheet.xlsx',
53
+ 'https://x/doc.docx?dl=1',
54
+ 'https://x/clip.mp4',
55
+ ]) {
56
+ expect(isFileUrl(u)).toBe(true)
57
+ expect(classifyUrl(u)).toBe('file')
58
+ }
59
+ })
60
+
61
+ it('treats a plain page URL as a link', () => {
62
+ const u = 'https://github.com/asteby/doctores.lat/issues/212'
63
+ expect(isImageUrl(u)).toBe(false)
64
+ expect(isFileUrl(u)).toBe(false)
65
+ expect(classifyUrl(u)).toBe('link')
66
+ })
67
+ })
68
+
69
+ describe('smartUrlLabel / fileNameFromUrl / ensureHref', () => {
70
+ it('uses the bare hostname for a page URL (no raw long URL)', () => {
71
+ expect(
72
+ smartUrlLabel('https://github.com/asteby/doctores.lat/issues/212')
73
+ ).toBe('github.com')
74
+ expect(smartUrlLabel('https://www.example.com/path/to/thing')).toBe(
75
+ 'example.com'
76
+ )
77
+ })
78
+
79
+ it('uses the file name for a file/image URL', () => {
80
+ expect(smartUrlLabel('https://x/y/report.pdf')).toBe('report.pdf')
81
+ expect(fileNameFromUrl('https://x/y/My%20File.pdf')).toBe('My File.pdf')
82
+ })
83
+
84
+ it('makes a schemeless URL absolute', () => {
85
+ expect(ensureHref('github.com/x')).toBe('https://github.com/x')
86
+ expect(ensureHref('https://a.com')).toBe('https://a.com')
87
+ expect(ensureHref('mailto:a@b.com')).toBe('mailto:a@b.com')
88
+ })
89
+ })
90
+
91
+ describe('splitTrailingPunct', () => {
92
+ it('peels a trailing sentence period', () => {
93
+ expect(splitTrailingPunct('https://a.com/x.')).toEqual([
94
+ 'https://a.com/x',
95
+ '.',
96
+ ])
97
+ })
98
+
99
+ it('keeps a paren the URL itself opened', () => {
100
+ expect(
101
+ splitTrailingPunct('https://en.wikipedia.org/wiki/Foo_(bar)')
102
+ ).toEqual(['https://en.wikipedia.org/wiki/Foo_(bar)', ''])
103
+ })
104
+
105
+ it('peels a closing paren the URL did not open', () => {
106
+ expect(splitTrailingPunct('https://a.com/x)')).toEqual([
107
+ 'https://a.com/x',
108
+ ')',
109
+ ])
110
+ })
111
+ })
112
+
113
+ describe('linkifyText', () => {
114
+ it('linkifies a bare URL and keeps surrounding text', () => {
115
+ const nodes = linkifyText('see https://github.com/a/b for details')
116
+ render(<p>{nodes}</p>)
117
+ const link = screen.getByRole('link')
118
+ expect(link.getAttribute('href')).toBe('https://github.com/a/b')
119
+ expect(link.getAttribute('target')).toBe('_blank')
120
+ // short label, not the raw URL
121
+ expect(link.textContent).toContain('github.com')
122
+ expect(screen.getByText(/see/)).toBeTruthy()
123
+ expect(screen.getByText(/for details/)).toBeTruthy()
124
+ })
125
+
126
+ it('does not swallow the trailing period into the href', () => {
127
+ const nodes = linkifyText('go to https://a.com/x.')
128
+ render(<p>{nodes}</p>)
129
+ expect(screen.getByRole('link').getAttribute('href')).toBe('https://a.com/x')
130
+ })
131
+
132
+ it('renders an embedded image URL as an inline thumbnail', () => {
133
+ const nodes = linkifyText('pic https://cdn.x/p.png here')
134
+ const { container } = render(<p>{nodes}</p>)
135
+ const img = container.querySelector('img')
136
+ expect(img).toBeTruthy()
137
+ expect(img!.getAttribute('src')).toBe('https://cdn.x/p.png')
138
+ })
139
+
140
+ it('honors a markdown link label', () => {
141
+ const nodes = linkifyText('[the issue](https://github.com/a/b/issues/1)')
142
+ render(<p>{nodes}</p>)
143
+ const link = screen.getByRole('link')
144
+ expect(link.getAttribute('href')).toBe('https://github.com/a/b/issues/1')
145
+ expect(link.textContent).toContain('the issue')
146
+ })
147
+ })
148
+
149
+ describe('primitives render', () => {
150
+ it('UrlChip opens in a new tab with the full URL in the title', () => {
151
+ render(<UrlChip url="https://github.com/asteby/x/issues/9" />)
152
+ const link = screen.getByRole('link')
153
+ expect(link.getAttribute('href')).toBe('https://github.com/asteby/x/issues/9')
154
+ expect(link.getAttribute('title')).toBe('https://github.com/asteby/x/issues/9')
155
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
156
+ expect(link.textContent).toContain('github.com')
157
+ })
158
+
159
+ it('FileChip shows the file name', () => {
160
+ render(<FileChip url="https://x/y/quarterly-report.pdf" />)
161
+ expect(screen.getByText('quarterly-report.pdf')).toBeTruthy()
162
+ })
163
+
164
+ it('ImageThumbnail falls back to a link chip on load error', () => {
165
+ const { container } = render(
166
+ <ImageThumbnail url="https://cdn.x/broken.png" />
167
+ )
168
+ const img = container.querySelector('img') as HTMLImageElement
169
+ expect(img).toBeTruthy()
170
+ // simulate a broken image (React synthetic onError)
171
+ fireEvent.error(img)
172
+ // after error, a link chip is rendered instead
173
+ expect(screen.queryByRole('link')).toBeTruthy()
174
+ })
175
+
176
+ it('MediaValue dispatches by kind', () => {
177
+ const { rerender, container } = render(
178
+ <MediaValue url="https://cdn.x/a.png" />
179
+ )
180
+ expect(container.querySelector('img')).toBeTruthy()
181
+
182
+ rerender(<MediaValue url="https://x/a.pdf" />)
183
+ expect(screen.getByText('a.pdf')).toBeTruthy()
184
+
185
+ rerender(<MediaValue url="https://github.com/a/b" />)
186
+ expect(screen.getByRole('link').textContent).toContain('github.com')
187
+ })
188
+
189
+ it('RichText linkifies a body while preserving text', () => {
190
+ render(
191
+ <RichText text={'Fixes the bug. Ref https://github.com/a/b/pull/3'} />
192
+ )
193
+ expect(screen.getByText(/Fixes the bug/)).toBeTruthy()
194
+ expect(screen.getByRole('link').getAttribute('href')).toBe('https://github.com/a/b/pull/3')
195
+ })
196
+ })
@@ -62,6 +62,7 @@ import {
62
62
  useIsDarkTheme,
63
63
  type DisplayOption,
64
64
  } from '../display-value'
65
+ import { MediaValue, RichText } from '../rich-url'
65
66
  import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
66
67
  import { CollectionCell, type ItemField } from '../collection-cell'
67
68
  import type { ActionFieldDef, RelationMeta } from '../types'
@@ -1120,18 +1121,20 @@ export function ViewValue({
1120
1121
  // carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
1121
1122
  // external link, opening in a new tab, truncated.
1122
1123
  if ((renderAs === 'url' || renderAs === 'link') && value) {
1123
- const urlStr = String(value)
1124
- const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
1124
+ // Shared media renderer (same as the table cell): an image URL shows a
1125
+ // larger inline thumbnail, a file a chip, else a compact link chip with
1126
+ // a smart label — never the raw 120-char URL.
1125
1127
  return (
1126
- <a
1127
- href={href}
1128
- target="_blank"
1129
- rel="noopener noreferrer"
1130
- className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
1131
- >
1132
- <ExternalLink className="h-3.5 w-3.5 shrink-0" />
1133
- <span className="truncate max-w-[360px]">{urlStr}</span>
1134
- </a>
1128
+ <div className="py-1">
1129
+ <MediaValue
1130
+ url={String(value)}
1131
+ getImageUrl={getImageUrl}
1132
+ label={field.styleConfig?.label as string | undefined}
1133
+ icon={field.styleConfig?.icon as string | undefined}
1134
+ thumbHeight={200}
1135
+ maxLabelWidth={360}
1136
+ />
1137
+ </div>
1135
1138
  )
1136
1139
  }
1137
1140
 
@@ -1240,16 +1243,32 @@ export function ViewValue({
1240
1243
  }
1241
1244
 
1242
1245
  const display = formatDisplayValue(value, field)
1246
+ // Free text may embed URLs (a github body, notes, a long-text field). Turn
1247
+ // them into rich chips / inline thumbnails with the shared linkifier instead
1248
+ // of showing raw URLs — the rest of the text is preserved verbatim.
1249
+ const hasUrl = display !== '—' && /(https?:\/\/|www\.)\S/i.test(display)
1243
1250
 
1244
- if (field.type === 'textarea') {
1251
+ if (field.type === 'textarea' || renderAs === 'textarea' || renderAs === 'long-text') {
1245
1252
  return (
1246
- <p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px]">
1247
- {display}
1253
+ <p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px] [&_img]:my-1">
1254
+ {hasUrl ? (
1255
+ <RichText text={display} getImageUrl={getImageUrl} imageHeight={200} />
1256
+ ) : (
1257
+ display
1258
+ )}
1248
1259
  </p>
1249
1260
  )
1250
1261
  }
1251
1262
 
1252
- return <p className="text-sm py-1">{display}</p>
1263
+ return (
1264
+ <p className="text-sm py-1">
1265
+ {hasUrl ? (
1266
+ <RichText text={display} getImageUrl={getImageUrl} imageHeight={160} />
1267
+ ) : (
1268
+ display
1269
+ )}
1270
+ </p>
1271
+ )
1253
1272
  }
1254
1273
 
1255
1274
  // IconNameViewValue — read view for a column whose value is a lucide icon name
@@ -48,6 +48,7 @@ import {
48
48
  statusColorFor,
49
49
  useIsDarkTheme,
50
50
  } from './display-value'
51
+ import { MediaValue } from './rich-url'
51
52
  import { OptionsContext } from './options-context'
52
53
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
53
54
  import { CollectionCell } from './collection-cell'
@@ -843,33 +844,24 @@ export function makeDefaultGetDynamicColumns(
843
844
  : value
844
845
  if (!rawUrl) return <EmptyCell />
845
846
  const urlStr = String(rawUrl)
846
- const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
847
- let label: string
848
- if (labelField) {
849
- label = String(getNestedValue(row.original, labelField) ?? href)
850
- } else {
851
- try {
852
- label = new URL(href).hostname
853
- } catch {
854
- label = urlStr
855
- }
856
- }
857
- const isExternal = !/^https?:\/\/(localhost|127\.)/i.test(href)
858
- const newTab =
859
- styleCfg(col, 'new_tab', 'newTab') === true || isExternal
860
- const iconName = styleCfg(col, 'icon') || 'ExternalLink'
847
+ // Explicit label from a `label_field` wins; otherwise
848
+ // the shared primitive derives a smart label (hostname
849
+ // / file name). Images render as a small (~h-8) inline
850
+ // thumbnail, files as a chip, else a link chip. Same
851
+ // renderer as the detail dialog.
852
+ const label = labelField
853
+ ? String(getNestedValue(row.original, labelField) ?? '')
854
+ : undefined
855
+ const iconName = styleCfg(col, 'icon')
861
856
  return (
862
- <a
863
- href={href}
864
- {...(newTab
865
- ? { target: '_blank', rel: 'noopener noreferrer' }
866
- : {})}
867
- className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
868
- onClick={(e) => e.stopPropagation()}
869
- >
870
- <DynamicIcon name={iconName} className="h-3.5 w-3.5 shrink-0" />
871
- <span className="truncate max-w-[260px]">{label}</span>
872
- </a>
857
+ <MediaValue
858
+ url={urlStr}
859
+ getImageUrl={getImageUrl}
860
+ label={label || undefined}
861
+ icon={iconName || undefined}
862
+ thumbHeight={32}
863
+ maxLabelWidth={260}
864
+ />
873
865
  )
874
866
  }
875
867
 
package/src/index.ts CHANGED
@@ -129,6 +129,23 @@ export {
129
129
  type DynamicColumnsHelpers,
130
130
  } from './dynamic-columns'
131
131
  export { humanizeToken } from './dynamic-columns-helpers'
132
+ export {
133
+ UrlChip,
134
+ FileChip,
135
+ ImageThumbnail,
136
+ MediaValue,
137
+ RichText,
138
+ linkifyText,
139
+ classifyUrl,
140
+ isImageUrl,
141
+ isFileUrl,
142
+ ensureHref,
143
+ smartUrlLabel,
144
+ fileNameFromUrl,
145
+ splitTrailingPunct,
146
+ type UrlKind,
147
+ type LinkifyOptions,
148
+ } from './rich-url'
132
149
  export {
133
150
  CollectionCell,
134
151
  formatScalar,
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Rich URL & media rendering primitives.
3
+ *
4
+ * A URL is never shown raw. Depending on what it points at we render:
5
+ * - an IMAGE (jpg/png/gif/webp/… or a `link.asteby.com/storage/media/` path)
6
+ * → an inline clickable THUMBNAIL that opens the full image in a new tab,
7
+ * - a FILE (pdf/zip/docx/…) → a chip with a file icon and the file name,
8
+ * - anything else → a compact link CHIP: an external-link glyph plus a short
9
+ * smart label (hostname, e.g. "github.com", or the last path segment for a
10
+ * file-like URL), with the full URL in a native tooltip.
11
+ *
12
+ * These live here (not inline in the table/dialog) so the dynamic TABLE cell
13
+ * (`dynamic-columns.tsx`) and the read-only DETAIL DIALOG (`dynamic-record.tsx`)
14
+ * share the EXACT same rendering. Ecosystem rule: shared primitives, zero copy.
15
+ *
16
+ * No external requests are made to render a link (no favicon service — CSP /
17
+ * privacy): the icon is a local lucide glyph.
18
+ */
19
+ import React from 'react'
20
+ import { cn } from '@asteby/metacore-ui/lib'
21
+ import { DynamicIcon } from './dynamic-icon'
22
+
23
+ /** Image file extensions we render as an inline thumbnail. */
24
+ const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico)(?:[?#].*)?$/i
25
+
26
+ /**
27
+ * Storage paths that serve images without a file extension (the link.asteby.com
28
+ * media CDN hands back opaque ids). Treated as images so uploaded photos still
29
+ * thumbnail.
30
+ */
31
+ const MEDIA_PATH_RE = /\/storage\/media\//i
32
+
33
+ /** Non-image file extensions we render as a download-ish file chip. */
34
+ const FILE_EXT_RE =
35
+ /\.(pdf|zip|rar|7z|tar|t?gz|docx?|xlsx?|pptx?|csv|txt|json|xml|md|rtf|odt|mp4|mov|avi|mkv|webm|mp3|wav|ogg|flac|apk|dmg|exe|pkg)(?:[?#].*)?$/i
36
+
37
+ export type UrlKind = 'image' | 'file' | 'link'
38
+
39
+ /** `true` when the string looks like an image URL/path. */
40
+ export function isImageUrl(url: string): boolean {
41
+ return IMAGE_EXT_RE.test(url) || MEDIA_PATH_RE.test(url)
42
+ }
43
+
44
+ /** `true` when the string looks like a non-image downloadable file. */
45
+ export function isFileUrl(url: string): boolean {
46
+ return FILE_EXT_RE.test(url)
47
+ }
48
+
49
+ /** Classify a URL so the right primitive (thumbnail / file chip / link) renders. */
50
+ export function classifyUrl(url: string): UrlKind {
51
+ if (isImageUrl(url)) return 'image'
52
+ if (isFileUrl(url)) return 'file'
53
+ return 'link'
54
+ }
55
+
56
+ /** Ensure a URL is absolute so `<a href>` and `new URL()` behave. */
57
+ export function ensureHref(url: string): string {
58
+ return /^(https?:)?\/\//i.test(url) || /^(mailto|tel):/i.test(url)
59
+ ? url
60
+ : `https://${url}`
61
+ }
62
+
63
+ /**
64
+ * A short, human label for a URL. For a file-like URL (last segment carries an
65
+ * extension) the file name wins ("report.pdf"); otherwise the bare hostname
66
+ * ("github.com"), so a 120-char issue URL never shows raw. Falls back to the
67
+ * input when it can't be parsed.
68
+ */
69
+ export function smartUrlLabel(url: string): string {
70
+ try {
71
+ const u = new URL(ensureHref(url))
72
+ const segs = u.pathname.split('/').filter(Boolean)
73
+ const last = segs[segs.length - 1]
74
+ if (last && /\.[a-z0-9]{1,8}$/i.test(last)) {
75
+ return decodeURIComponent(last)
76
+ }
77
+ return u.hostname.replace(/^www\./i, '')
78
+ } catch {
79
+ return url
80
+ }
81
+ }
82
+
83
+ /** Last path segment (decoded) — the file name for a file/image URL. */
84
+ export function fileNameFromUrl(url: string): string {
85
+ try {
86
+ const u = new URL(ensureHref(url))
87
+ const segs = u.pathname.split('/').filter(Boolean)
88
+ return decodeURIComponent(segs[segs.length - 1] || u.hostname)
89
+ } catch {
90
+ return url.split(/[?#]/)[0].split('/').pop() || url
91
+ }
92
+ }
93
+
94
+ /** Stops row-click / dialog-close when a link inside them is clicked. */
95
+ function stop(e: React.MouseEvent) {
96
+ e.stopPropagation()
97
+ }
98
+
99
+ /**
100
+ * Compact link chip: external-link glyph + smart label, full URL in the
101
+ * tooltip, opens in a new tab. The canonical render for a plain (non-media)
102
+ * URL, matching the table's `url`/`link` cell.
103
+ */
104
+ export const UrlChip: React.FC<{
105
+ url: string
106
+ label?: string
107
+ icon?: string
108
+ /** Smaller label cap for a table/kanban cell. */
109
+ maxLabelWidth?: number
110
+ className?: string
111
+ }> = ({ url, label, icon, maxLabelWidth = 260, className }) => {
112
+ const href = ensureHref(url)
113
+ const text = label || smartUrlLabel(url)
114
+ return (
115
+ <a
116
+ href={href}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ title={url}
120
+ onClick={stop}
121
+ className={cn(
122
+ 'inline-flex max-w-full items-center gap-1.5 align-middle text-sm font-medium text-primary hover:underline',
123
+ className
124
+ )}
125
+ >
126
+ <DynamicIcon name={icon || 'ExternalLink'} className="h-3.5 w-3.5 shrink-0" />
127
+ <span className="truncate" style={{ maxWidth: maxLabelWidth }}>
128
+ {text}
129
+ </span>
130
+ </a>
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Chip for a downloadable non-image file: file-text glyph + the file name,
136
+ * opens the file in a new tab.
137
+ */
138
+ export const FileChip: React.FC<{
139
+ url: string
140
+ maxLabelWidth?: number
141
+ className?: string
142
+ }> = ({ url, maxLabelWidth = 240, className }) => {
143
+ const href = ensureHref(url)
144
+ return (
145
+ <a
146
+ href={href}
147
+ target="_blank"
148
+ rel="noopener noreferrer"
149
+ title={url}
150
+ onClick={stop}
151
+ className={cn(
152
+ 'inline-flex max-w-full items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 align-middle text-sm font-medium text-foreground hover:bg-muted',
153
+ className
154
+ )}
155
+ >
156
+ <DynamicIcon name="FileText" className="h-3.5 w-3.5 shrink-0 opacity-70" />
157
+ <span className="truncate" style={{ maxWidth: maxLabelWidth }}>
158
+ {fileNameFromUrl(url)}
159
+ </span>
160
+ </a>
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Inline image thumbnail — rounded, bordered, `object-cover` — that opens the
166
+ * full image in a new tab. On load error it degrades to a normal link chip so a
167
+ * dead image URL is still reachable (and never a broken-image icon). `maxHeight`
168
+ * keeps a cell thumbnail small (~h-8) while the dialog shows a larger preview.
169
+ */
170
+ export const ImageThumbnail: React.FC<{
171
+ url: string
172
+ getImageUrl?: (path: string) => string
173
+ /** Max rendered height in px. */
174
+ maxHeight?: number
175
+ className?: string
176
+ }> = ({ url, getImageUrl, maxHeight = 160, className }) => {
177
+ const [failed, setFailed] = React.useState(false)
178
+ const href = ensureHref(url)
179
+ // Absolute URLs are used verbatim; only relative storage paths go through
180
+ // the host's image resolver (which prefixes the media base).
181
+ const src =
182
+ /^(https?:)?\/\//i.test(url) ? url : getImageUrl ? getImageUrl(url) : url
183
+ if (failed) return <UrlChip url={url} icon="Image" />
184
+ return (
185
+ <a
186
+ href={href}
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ title={url}
190
+ onClick={stop}
191
+ className="inline-block max-w-full align-middle"
192
+ >
193
+ <img
194
+ src={src}
195
+ alt={smartUrlLabel(url)}
196
+ onError={() => setFailed(true)}
197
+ style={{ maxHeight }}
198
+ className={cn(
199
+ 'max-w-full rounded-md border object-cover',
200
+ className
201
+ )}
202
+ />
203
+ </a>
204
+ )
205
+ }
206
+
207
+ /**
208
+ * One URL value → the right primitive (thumbnail / file chip / link chip).
209
+ * Single entry point used by both the table cell and the detail dialog so a URL
210
+ * renders identically everywhere.
211
+ */
212
+ export const MediaValue: React.FC<{
213
+ url: string
214
+ getImageUrl?: (path: string) => string
215
+ /** Explicit label for the link chip (from a `label_field`). */
216
+ label?: string
217
+ /** Explicit icon for the link chip. */
218
+ icon?: string
219
+ /** Thumbnail max height (small in a cell, larger in the dialog). */
220
+ thumbHeight?: number
221
+ /** Link/file label truncation cap. */
222
+ maxLabelWidth?: number
223
+ className?: string
224
+ }> = ({ url, getImageUrl, label, icon, thumbHeight, maxLabelWidth, className }) => {
225
+ switch (classifyUrl(url)) {
226
+ case 'image':
227
+ return (
228
+ <ImageThumbnail
229
+ url={url}
230
+ getImageUrl={getImageUrl}
231
+ maxHeight={thumbHeight}
232
+ className={className}
233
+ />
234
+ )
235
+ case 'file':
236
+ return <FileChip url={url} maxLabelWidth={maxLabelWidth} className={className} />
237
+ default:
238
+ return (
239
+ <UrlChip
240
+ url={url}
241
+ label={label}
242
+ icon={icon}
243
+ maxLabelWidth={maxLabelWidth}
244
+ className={className}
245
+ />
246
+ )
247
+ }
248
+ }
249
+
250
+ // URL / markdown-link matcher used to linkify free text. Captures either a
251
+ // markdown `[label](url)` or a bare http(s)/www URL. `[^\s<]+` is greedy on
252
+ // purpose — trailing punctuation is trimmed afterwards by `splitTrailingPunct`.
253
+ const LINK_IN_TEXT_RE =
254
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi
255
+
256
+ /**
257
+ * Peel trailing punctuation a URL shouldn't swallow (sentence period, closing
258
+ * bracket, quote). A closing paren is kept only when the URL itself opened one
259
+ * (Wikipedia-style `(…)` paths), so `(see https://x.com/a)` links `https://x.com/a`
260
+ * but `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren.
261
+ */
262
+ export function splitTrailingPunct(match: string): [string, string] {
263
+ const m = match.match(/[).,;:!?\]}'"»›]+$/)
264
+ if (!m) return [match, '']
265
+ let trail = m[0]
266
+ let core = match.slice(0, match.length - trail.length)
267
+ if (trail.startsWith(')')) {
268
+ const opens = (core.match(/\(/g) || []).length
269
+ const closes = (core.match(/\)/g) || []).length
270
+ if (opens > closes) {
271
+ core += ')'
272
+ trail = trail.slice(1)
273
+ }
274
+ }
275
+ return [core, trail]
276
+ }
277
+
278
+ export interface LinkifyOptions {
279
+ getImageUrl?: (path: string) => string
280
+ /** Max height for an inline image found in the text. */
281
+ imageHeight?: number
282
+ }
283
+
284
+ /**
285
+ * Turn free text into React nodes, replacing every URL (bare or markdown
286
+ * `[label](url)`) with the matching rich primitive: an inline thumbnail for an
287
+ * image URL, a file chip for a file, otherwise a link chip. Plain text between
288
+ * matches is preserved verbatim (caller keeps `whitespace-pre-wrap`).
289
+ */
290
+ export function linkifyText(
291
+ text: string,
292
+ opts: LinkifyOptions = {}
293
+ ): React.ReactNode[] {
294
+ if (!text) return []
295
+ const nodes: React.ReactNode[] = []
296
+ const re = new RegExp(LINK_IN_TEXT_RE.source, 'gi')
297
+ let lastIndex = 0
298
+ let key = 0
299
+ let m: RegExpExecArray | null
300
+ while ((m = re.exec(text)) !== null) {
301
+ const [full, mdLabel, mdUrl, bareUrl] = m
302
+ const start = m.index
303
+ if (start > lastIndex) nodes.push(text.slice(lastIndex, start))
304
+
305
+ if (mdUrl) {
306
+ // Markdown link: honor the author's label, keep media inline.
307
+ const kind = classifyUrl(mdUrl)
308
+ if (kind === 'image') {
309
+ nodes.push(
310
+ <ImageThumbnail
311
+ key={key}
312
+ url={mdUrl}
313
+ getImageUrl={opts.getImageUrl}
314
+ maxHeight={opts.imageHeight ?? 200}
315
+ />
316
+ )
317
+ } else if (kind === 'file') {
318
+ nodes.push(<FileChip key={key} url={mdUrl} />)
319
+ } else {
320
+ nodes.push(<UrlChip key={key} url={mdUrl} label={mdLabel} />)
321
+ }
322
+ lastIndex = start + full.length
323
+ } else {
324
+ const [core, trail] = splitTrailingPunct(bareUrl)
325
+ const kind = classifyUrl(core)
326
+ if (kind === 'image') {
327
+ nodes.push(
328
+ <ImageThumbnail
329
+ key={key}
330
+ url={core}
331
+ getImageUrl={opts.getImageUrl}
332
+ maxHeight={opts.imageHeight ?? 200}
333
+ />
334
+ )
335
+ } else if (kind === 'file') {
336
+ nodes.push(<FileChip key={key} url={core} />)
337
+ } else {
338
+ nodes.push(<UrlChip key={key} url={core} />)
339
+ }
340
+ if (trail) nodes.push(trail)
341
+ lastIndex = start + bareUrl.length
342
+ }
343
+ key++
344
+ }
345
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex))
346
+ return nodes
347
+ }
348
+
349
+ /**
350
+ * Linkified free text. Renders the raw string with every URL turned into a rich
351
+ * chip/thumbnail — the read view for a long-text / textarea / body field.
352
+ */
353
+ export const RichText: React.FC<{
354
+ text: string
355
+ getImageUrl?: (path: string) => string
356
+ imageHeight?: number
357
+ }> = ({ text, getImageUrl, imageHeight }) => (
358
+ <>{linkifyText(text, { getImageUrl, imageHeight })}</>
359
+ )