@asteby/metacore-runtime-react 23.3.0 → 23.5.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.
@@ -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.5.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -131,6 +131,15 @@ function stageCalls(api: ApiClient, stage: string) {
131
131
  (c: any[]) => c[1]?.params?.f_stage === stage,
132
132
  )
133
133
  }
134
+ // A lane's real page-load (per_page 25), as opposed to the eager per_page=1
135
+ // total probe fired on the initial board load.
136
+ function stagePageCalls(api: ApiClient, stage: string) {
137
+ return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 25)
138
+ }
139
+ // The eager total probe (per_page 1) for a stage.
140
+ function stageTotalCalls(api: ApiClient, stage: string) {
141
+ return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 1)
142
+ }
134
143
  function globalDataCalls(api: ApiClient) {
135
144
  return (api.get as any).mock.calls.filter(
136
145
  (c: any[]) =>
@@ -171,15 +180,20 @@ describe('DynamicKanban per-lane infinite scroll', () => {
171
180
  )
172
181
  // Initial board page painted its single backlog card.
173
182
  expect(await screen.findByText('Backlog One')).toBeTruthy()
174
- expect(stageCalls(api, 'backlog')).toHaveLength(0)
183
+ // No real page-load fired yet — only the eager per_page=1 total probe.
184
+ expect(stagePageCalls(api, 'backlog')).toHaveLength(0)
185
+ // The eager total lands, so the header shows the real stage count (1/5)
186
+ // BEFORE the user scrolls.
187
+ await waitFor(() => expect(stageTotalCalls(api, 'backlog').length).toBeGreaterThan(0))
188
+ expect(await screen.findByText('1/5')).toBeTruthy()
175
189
 
176
190
  // Sentinels enter view → each declared lane tops up its OWN stage.
177
191
  expect(observers.length).toBeGreaterThan(0)
178
192
  observers.forEach((o) => o.fire(true))
179
193
 
180
194
  // The backlog top-up ran with the right scoped params...
181
- await waitFor(() => expect(stageCalls(api, 'backlog').length).toBeGreaterThan(0))
182
- const call = stageCalls(api, 'backlog')[0]
195
+ await waitFor(() => expect(stagePageCalls(api, 'backlog').length).toBeGreaterThan(0))
196
+ const call = stagePageCalls(api, 'backlog')[0]
183
197
  expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
184
198
 
185
199
  // ...its rows were appended and the header shows count/serverTotal (3/5).
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicKanban eager lane totals (#521 follow-up): every lane header shows the
4
+ // REAL stage count on first render — before the user scrolls — by firing one
5
+ // lightweight `per_page=1` probe per declared stage after the initial board
6
+ // page. Covers:
7
+ // 1. formatLaneCount — the pure `N` vs `N/M` header formatter.
8
+ // 2. Totals visible without scrolling (partial `N/M` and complete `N`).
9
+ // 3. Totals refetch (scoped by the new filters) when the filters change.
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ useNavigate: () => () => {},
15
+ }))
16
+ const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
17
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
18
+
19
+ import { formatLaneCount, DynamicKanban } from '../dynamic-kanban'
20
+ import { ApiProvider, type ApiClient } from '../api-context'
21
+ import { useMetadataCache } from '../metadata-cache'
22
+ import type { TableMetadata } from '../types'
23
+
24
+ afterEach(cleanup)
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 1. Pure formatter
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('formatLaneCount', () => {
31
+ it('shows just the total when everything is loaded (shown === total)', () => {
32
+ expect(formatLaneCount(41, 41, 41, false)).toBe('41')
33
+ })
34
+ it('shows shown/total when the lane is partial', () => {
35
+ expect(formatLaneCount(22, 22, 41, false)).toBe('22/41')
36
+ })
37
+ it('shows just the loaded count when the total is unknown', () => {
38
+ expect(formatLaneCount(7, 7, null, false)).toBe('7')
39
+ })
40
+ it('shows shown/loaded when a lane filter is active', () => {
41
+ expect(formatLaneCount(3, 22, 41, true)).toBe('3/22')
42
+ })
43
+ it('collapses to the total once shown catches up (never N/N)', () => {
44
+ expect(formatLaneCount(5, 5, 5, false)).toBe('5')
45
+ })
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 2 + 3. Component: eager totals on load and on filter change
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const STAGES = [
53
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
54
+ { key: 'done', label: 'Done', color: 'green', order: 1 },
55
+ ]
56
+
57
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
58
+ return {
59
+ title: 'Issues',
60
+ endpoint: '/data/issue',
61
+ view_type: 'kanban',
62
+ group_by: 'stage',
63
+ stages: STAGES,
64
+ columns: [
65
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
66
+ { key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
67
+ ],
68
+ actions: [],
69
+ perPageOptions: [50],
70
+ defaultPerPage: 50,
71
+ searchPlaceholder: 'Buscar...',
72
+ enableCRUDActions: false,
73
+ hasActions: false,
74
+ ...over,
75
+ }
76
+ }
77
+
78
+ // Initial global page: one backlog card + two done cards. The eager per_page=1
79
+ // probes report a backlog total of 5 (partial) and a done total of 2 (complete
80
+ // — the two done cards are already loaded).
81
+ function fakeApi(): ApiClient {
82
+ const ok = (data: unknown, m?: Record<string, unknown>) => ({
83
+ data: { success: true, data, ...(m ? { meta: m } : {}) },
84
+ })
85
+ return {
86
+ get: vi.fn(async (url: string, cfg?: any) => {
87
+ if (url.startsWith('/metadata/table/')) return ok(meta())
88
+ const stage = cfg?.params?.f_stage
89
+ if (stage === 'backlog') return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }], { total: 5 })
90
+ if (stage === 'done') return ok([{ id: 2, title: 'Done Two', stage: 'done' }], { total: 2 })
91
+ // initial global board page
92
+ return ok([
93
+ { id: 1, title: 'Backlog One', stage: 'backlog' },
94
+ { id: 2, title: 'Done Two', stage: 'done' },
95
+ { id: 3, title: 'Done Three', stage: 'done' },
96
+ ])
97
+ }),
98
+ post: vi.fn(async () => ok(null)),
99
+ put: vi.fn(async () => ok(null)),
100
+ delete: vi.fn(async () => ok(null)),
101
+ }
102
+ }
103
+
104
+ function totalProbeCalls(api: ApiClient) {
105
+ return (api.get as any).mock.calls.filter(
106
+ (c: any[]) => c[1]?.params?.f_stage !== undefined && c[1]?.params?.per_page === 1,
107
+ )
108
+ }
109
+
110
+ describe('DynamicKanban eager lane totals', () => {
111
+ beforeEach(() => {
112
+ // No IntersectionObserver: nothing scrolls, so any total shown is proof
113
+ // it arrived from the eager probe, not from a lane top-up.
114
+ ;(globalThis as any).IntersectionObserver = class {
115
+ observe() {}
116
+ disconnect() {}
117
+ }
118
+ })
119
+ afterEach(() => {
120
+ delete (globalThis as any).IntersectionObserver
121
+ })
122
+
123
+ it('shows the real stage total on first render without scrolling', async () => {
124
+ useMetadataCache.getState().setMetadata('issue', meta())
125
+ const api = fakeApi()
126
+ render(
127
+ <ApiProvider client={api}>
128
+ <DynamicKanban model="issue" />
129
+ </ApiProvider>,
130
+ )
131
+ await screen.findByText('Backlog One')
132
+ // Backlog: 1 loaded of 5 → partial. Done: 2 loaded of 2 → collapses to "2".
133
+ expect(await screen.findByText('1/5')).toBeTruthy()
134
+ expect(await screen.findByText('2')).toBeTruthy()
135
+ // One probe per declared stage.
136
+ await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
137
+ })
138
+
139
+ it('refetches the totals (scoped by the new filters) when the filters change', async () => {
140
+ useMetadataCache.getState().setMetadata('issue', meta())
141
+ const api = fakeApi()
142
+ const { rerender } = render(
143
+ <ApiProvider client={api}>
144
+ <DynamicKanban model="issue" />
145
+ </ApiProvider>,
146
+ )
147
+ await screen.findByText('1/5')
148
+ await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
149
+
150
+ rerender(
151
+ <ApiProvider client={api}>
152
+ <DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
153
+ </ApiProvider>,
154
+ )
155
+
156
+ // The filter change re-fires the eager probes (now carrying the filter).
157
+ await waitFor(() => expect(totalProbeCalls(api).length).toBeGreaterThan(2))
158
+ const probe = totalProbeCalls(api).at(-1)!
159
+ expect(probe[1].params).toMatchObject({ f_priority: 'high', per_page: 1 })
160
+ })
161
+ })
@@ -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
+ })