@startsimpli/ui 0.4.55 → 0.4.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.55",
3
+ "version": "0.4.57",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -41,8 +41,8 @@
41
41
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
42
42
  "react": "^18.0.0 || ^19.0.0",
43
43
  "react-dom": "^18.0.0 || ^19.0.0",
44
- "@startsimpli/auth": "0.4.29",
45
- "@startsimpli/api": "0.5.44"
44
+ "@startsimpli/api": "0.5.44",
45
+ "@startsimpli/auth": "0.4.30"
46
46
  },
47
47
  "peerDependenciesMeta": {
48
48
  "@startsimpli/api": {
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ const ENTITIES: Record<string, string> = {
7
+ '&amp;': '&',
8
+ '&lt;': '<',
9
+ '&gt;': '>',
10
+ '&quot;': '"',
11
+ '&#39;': "'",
12
+ '&nbsp;': ' ',
13
+ }
14
+
15
+ /**
16
+ * Flatten markdown / HTML / whitespace-noisy text into a single-line preview,
17
+ * clamped to `maxChars` on a word boundary. Board cards get a readable content
18
+ * snippet without pulling in a full markdown renderer (startsim-768w.17.3): the
19
+ * pipeline writes long article bodies to `data.content`, and a raw slice of that
20
+ * shows heading hashes, link syntax and code fences.
21
+ */
22
+ export function toPlainSnippet(
23
+ input: string | null | undefined,
24
+ maxChars = 240,
25
+ ): string {
26
+ if (!input) return ''
27
+ let s = String(input)
28
+ s = s.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
29
+ s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text
30
+ s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> link text
31
+ s = s.replace(/<[^>]+>/g, ' ') // html tags
32
+ s = s.replace(/^[ \t]*[#>\-*+]+[ \t]+/gm, ' ') // heading / list / quote markers
33
+ s = s.replace(/[*_`~]+/g, '') // inline emphasis / code marks
34
+ s = s.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
35
+ s = s.replace(/\s+/g, ' ').trim()
36
+ if (s.length <= maxChars) return s
37
+ const cut = s.slice(0, maxChars)
38
+ const lastSpace = cut.lastIndexOf(' ')
39
+ // cut back to the last whole word when that boundary is in the back half,
40
+ // otherwise hard-cut (e.g. a single very long token has no usable boundary)
41
+ const body = lastSpace > maxChars * 0.5 ? cut.slice(0, lastSpace) : cut
42
+ return body.trimEnd() + '…'
43
+ }
44
+
45
+ const LINE_CLAMP: Record<number, string> = {
46
+ 2: 'line-clamp-2',
47
+ 3: 'line-clamp-3',
48
+ 4: 'line-clamp-4',
49
+ 5: 'line-clamp-5',
50
+ 6: 'line-clamp-6',
51
+ }
52
+
53
+ export interface BoardCardMeta {
54
+ label: string
55
+ value: React.ReactNode
56
+ }
57
+
58
+ export interface BoardCardProps {
59
+ /** Card heading (record name / title). */
60
+ title: React.ReactNode
61
+ /** Raw content (markdown / HTML / plain); shown as a clamped plain-text preview. */
62
+ snippet?: string | null
63
+ /** Max plain-text characters for the snippet (default 240). */
64
+ snippetMaxChars?: number
65
+ /** Tailwind line-clamp count for the snippet (default 3). */
66
+ snippetLines?: 2 | 3 | 4 | 5 | 6
67
+ /** Source / host label or link, shown under the snippet. */
68
+ source?: React.ReactNode
69
+ /** Compact key/value chips for short declared attributes. */
70
+ meta?: BoardCardMeta[]
71
+ /** Opens the record detail; wraps the title as a button when provided. */
72
+ onOpen?: () => void
73
+ /**
74
+ * Footer slot — e.g. a status `<Select>`. Pointer/click events inside are
75
+ * stopped so interacting with a control never starts a drag on the card.
76
+ */
77
+ footer?: React.ReactNode
78
+ className?: string
79
+ }
80
+
81
+ /**
82
+ * A content-aware board card: title + readable content snippet + source + a few
83
+ * meta chips + a footer slot. Presentation-only and drag-agnostic — the
84
+ * KanbanBoard wraps it in a Draggable; this just renders the content the epic's
85
+ * News Item board was missing (title-only cards). Reusable for any entity board.
86
+ */
87
+ export function BoardCard({
88
+ title,
89
+ snippet,
90
+ snippetMaxChars = 240,
91
+ snippetLines = 3,
92
+ source,
93
+ meta,
94
+ onOpen,
95
+ footer,
96
+ className,
97
+ }: BoardCardProps) {
98
+ const preview = toPlainSnippet(snippet, snippetMaxChars)
99
+ const hasSource = source != null && source !== ''
100
+ return (
101
+ <div
102
+ className={cn(
103
+ 'm-2 rounded-md border bg-white p-3 shadow-sm dark:bg-neutral-900',
104
+ className,
105
+ )}
106
+ >
107
+ {onOpen ? (
108
+ <button
109
+ type="button"
110
+ onClick={onOpen}
111
+ className="block w-full text-left text-sm font-medium leading-snug hover:underline"
112
+ >
113
+ {title}
114
+ </button>
115
+ ) : (
116
+ <div className="text-sm font-medium leading-snug">{title}</div>
117
+ )}
118
+
119
+ {preview && (
120
+ <p
121
+ className={cn(
122
+ 'mt-1 text-xs leading-snug text-neutral-600 dark:text-neutral-300',
123
+ LINE_CLAMP[snippetLines],
124
+ )}
125
+ >
126
+ {preview}
127
+ </p>
128
+ )}
129
+
130
+ {hasSource && (
131
+ <div className="mt-1.5 truncate text-xs text-neutral-400">{source}</div>
132
+ )}
133
+
134
+ {meta && meta.length > 0 && (
135
+ <dl className="mt-1 space-y-0.5">
136
+ {meta.map((m, i) => (
137
+ <div key={i} className="flex gap-1 text-xs text-neutral-600 dark:text-neutral-300">
138
+ <dt className="capitalize text-neutral-400">{m.label}:</dt>
139
+ <dd className="truncate">{m.value}</dd>
140
+ </div>
141
+ ))}
142
+ </dl>
143
+ )}
144
+
145
+ {footer != null && (
146
+ <div
147
+ className="mt-2"
148
+ onClick={(e) => e.stopPropagation()}
149
+ onPointerDown={(e) => e.stopPropagation()}
150
+ >
151
+ {footer}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,77 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { BoardCard, toPlainSnippet } from '../BoardCard'
3
+
4
+ describe('toPlainSnippet', () => {
5
+ it('returns empty string for nullish/empty input', () => {
6
+ expect(toPlainSnippet(null)).toBe('')
7
+ expect(toPlainSnippet(undefined)).toBe('')
8
+ expect(toPlainSnippet('')).toBe('')
9
+ })
10
+
11
+ it('strips markdown headings, emphasis, links and code fences', () => {
12
+ const md = '# Title\n\nSome **bold** and _italic_ with a [link](https://x.io).\n\n```js\ncode()\n```'
13
+ const out = toPlainSnippet(md)
14
+ expect(out).toBe('Title Some bold and italic with a link.')
15
+ expect(out).not.toMatch(/[#*_`]|\]\(|```/)
16
+ })
17
+
18
+ it('strips html tags and decodes common entities', () => {
19
+ expect(toPlainSnippet('<p>Gold &amp; silver &lt;up&gt;</p>')).toBe('Gold & silver <up>')
20
+ })
21
+
22
+ it('collapses whitespace to single spaces', () => {
23
+ expect(toPlainSnippet('a\n\n b\t c')).toBe('a b c')
24
+ })
25
+
26
+ it('truncates on a word boundary with an ellipsis when over the limit', () => {
27
+ // cut(12) = "one two thre"; last boundary at 7 -> back to the whole word
28
+ expect(toPlainSnippet('one two three four five six', 12)).toBe('one two…')
29
+ })
30
+
31
+ it('hard-cuts a single long token with no usable word boundary', () => {
32
+ expect(toPlainSnippet('supercalifragilisticexpialidocious', 10)).toBe('supercalif…')
33
+ })
34
+
35
+ it('does not truncate when within the limit', () => {
36
+ expect(toPlainSnippet('short', 240)).toBe('short')
37
+ })
38
+ })
39
+
40
+ describe('BoardCard', () => {
41
+ it('renders the title and a plain-text content snippet (markdown stripped)', () => {
42
+ render(<BoardCard title="Gold hits record" snippet={'## Lead\n\nPrices **surged** today.'} />)
43
+ expect(screen.getByText('Gold hits record')).toBeInTheDocument()
44
+ expect(screen.getByText('Lead Prices surged today.')).toBeInTheDocument()
45
+ })
46
+
47
+ it('renders source and meta chips', () => {
48
+ render(
49
+ <BoardCard
50
+ title="T"
51
+ source="reuters.com"
52
+ meta={[{ label: 'market', value: 'UAE' }]}
53
+ />,
54
+ )
55
+ expect(screen.getByText('reuters.com')).toBeInTheDocument()
56
+ expect(screen.getByText('market:')).toBeInTheDocument()
57
+ expect(screen.getByText('UAE')).toBeInTheDocument()
58
+ })
59
+
60
+ it('calls onOpen when the title is clicked', () => {
61
+ const onOpen = jest.fn()
62
+ render(<BoardCard title="Open me" onOpen={onOpen} />)
63
+ fireEvent.click(screen.getByRole('button', { name: 'Open me' }))
64
+ expect(onOpen).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('stops footer clicks from bubbling (so a control never starts a card drag)', () => {
68
+ const outer = jest.fn()
69
+ render(
70
+ <div onClick={outer}>
71
+ <BoardCard title="T" footer={<button type="button">status</button>} />
72
+ </div>,
73
+ )
74
+ fireEvent.click(screen.getByRole('button', { name: 'status' }))
75
+ expect(outer).not.toHaveBeenCalled()
76
+ })
77
+ })
@@ -1,2 +1,4 @@
1
1
  export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
2
2
  export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'
3
+ export { BoardCard, toPlainSnippet } from './BoardCard'
4
+ export type { BoardCardProps, BoardCardMeta } from './BoardCard'
@@ -189,3 +189,98 @@ describe('overallStatus', () => {
189
189
  expect(overallStatus(checks)).toBe('fail');
190
190
  });
191
191
  });
192
+
193
+ describe('runContentChecks — locations (768w.16.15.3 jump-to-issue)', () => {
194
+ it('omits locations on a passing check — there is nothing to jump to', () => {
195
+ const checks = runContentChecks({ blog: words(450) });
196
+ expect(find(checks, 'blog-words')!.status).toBe('pass');
197
+ expect(find(checks, 'blog-words')!.locations).toBeUndefined();
198
+ expect(find(checks, 'no-hype')!.status).toBe('pass');
199
+ expect(find(checks, 'no-hype')!.locations).toBeUndefined();
200
+ });
201
+
202
+ it('points a failing band check at its field with no span to highlight', () => {
203
+ const checks = runContentChecks({ blog: words(10) });
204
+ const c = find(checks, 'blog-words')!;
205
+ expect(c.status).toBe('warn');
206
+ expect(c.locations).toEqual([{ field: 'blog' }]);
207
+ // a word count is not a substring — nothing to highlight inside the field
208
+ expect(c.locations![0].matches).toBeUndefined();
209
+ });
210
+
211
+ it('maps each band check to its own field', () => {
212
+ const checks = runContentChecks({
213
+ headline: words(1),
214
+ linkedin: words(2),
215
+ tags: 'a',
216
+ metaDescription: 'short',
217
+ });
218
+ expect(find(checks, 'headline-words')!.locations).toEqual([{ field: 'headline' }]);
219
+ expect(find(checks, 'linkedin-words')!.locations).toEqual([{ field: 'linkedin' }]);
220
+ expect(find(checks, 'tags-count')!.locations).toEqual([{ field: 'tags' }]);
221
+ expect(find(checks, 'meta-length')!.locations).toEqual([{ field: 'metaDescription' }]);
222
+ });
223
+
224
+ it('locates a missing [link] placeholder at the linkedin field', () => {
225
+ const checks = runContentChecks({ linkedin: words(200) });
226
+ const c = find(checks, 'link-placeholder')!;
227
+ expect(c.status).toBe('warn');
228
+ expect(c.locations).toEqual([{ field: 'linkedin' }]);
229
+ });
230
+
231
+ it('locates hype words in the SPECIFIC field they appear in, not the joined haystack', () => {
232
+ const checks = runContentChecks({
233
+ headline: 'a calm headline',
234
+ blog: 'this is revolutionary work',
235
+ linkedin: 'nothing to see [link]',
236
+ });
237
+ const c = find(checks, 'no-hype')!;
238
+ expect(c.status).toBe('fail');
239
+ // only the blog is implicated — headline/linkedin must NOT be offered as jumps
240
+ expect(c.locations).toEqual([{ field: 'blog', matches: ['revolutionary'] }]);
241
+ });
242
+
243
+ it('reports one location per implicated field when hype spans several', () => {
244
+ const checks = runContentChecks({
245
+ headline: 'groundbreaking news',
246
+ blog: 'a revolutionary idea',
247
+ });
248
+ const c = find(checks, 'no-hype')!;
249
+ expect(c.locations).toEqual([
250
+ { field: 'headline', matches: ['groundbreaking'] },
251
+ { field: 'blog', matches: ['revolutionary'] },
252
+ ]);
253
+ // detail stays the flat human sentence across all fields
254
+ expect(c.detail).toBe('revolutionary, groundbreaking');
255
+ });
256
+
257
+ it('highlights unapproved sources by URL, not bare host, so the span exists in the text', () => {
258
+ const checks = runContentChecks(
259
+ { sources: 'https://good.com/a https://reddit.com/r/x' },
260
+ { approvedHosts: ['good.com'] },
261
+ );
262
+ const c = find(checks, 'approved-sources')!;
263
+ expect(c.status).toBe('fail');
264
+ expect(c.locations).toEqual([{ field: 'sources', matches: ['https://reddit.com/r/x'] }]);
265
+ // the approved URL is not offered for highlight
266
+ expect(c.locations![0].matches).not.toContain('https://good.com/a');
267
+ });
268
+
269
+ it('locates the sources field when there are no sources to check', () => {
270
+ const checks = runContentChecks({ sources: '' }, { approvedHosts: ['good.com'] });
271
+ const c = find(checks, 'approved-sources')!;
272
+ expect(c.status).toBe('warn');
273
+ expect(c.locations).toEqual([{ field: 'sources' }]);
274
+ });
275
+
276
+ it('every non-passing check carries a location a UI can act on', () => {
277
+ const checks = runContentChecks(
278
+ { blog: words(10), linkedin: 'revolutionary', tags: 'a', sources: 'https://reddit.com/x' },
279
+ { approvedHosts: ['good.com'] },
280
+ );
281
+ const stranded = checks.filter((c) => c.status !== 'pass' && !c.locations?.length).map((c) => c.id);
282
+ // name the offenders — a bare toBeTruthy() would just say "expected truthy"
283
+ expect(stranded).toEqual([]);
284
+ expect(checks.some((c) => c.status !== 'pass')).toBe(true);
285
+ });
286
+ });
@@ -15,6 +15,22 @@
15
15
  /** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
16
16
  export type CheckStatus = 'pass' | 'warn' | 'fail';
17
17
 
18
+ /**
19
+ * Where a check's problem lives, so a reviewer UI can jump straight to it
20
+ * (768w.16.15.3) instead of making the reader hunt for what "7/8" means.
21
+ */
22
+ export interface CheckLocation {
23
+ /** The offending field — lets a UI open the right channel/tab first. */
24
+ field: keyof ContentFields;
25
+ /**
26
+ * Exact substrings to highlight within that field, in the order the check
27
+ * found them. Omitted when the whole field IS the problem (a word count is
28
+ * not a span), so `matches === undefined` means "jump to the field, don't
29
+ * highlight anything inside it".
30
+ */
31
+ matches?: string[];
32
+ }
33
+
18
34
  /** One deterministic check result. */
19
35
  export interface ContentCheck {
20
36
  /** Stable id for the check (e.g. `blog-words`, `no-hype`). */
@@ -24,6 +40,13 @@ export interface ContentCheck {
24
40
  status: CheckStatus;
25
41
  /** Short human explanation (e.g. "462 words", "unapproved: reddit.com"). */
26
42
  detail?: string;
43
+ /**
44
+ * Where the problem is. Present ONLY when the check is not passing — a green
45
+ * check has nothing to jump to. `no-hype` can span several fields, hence an
46
+ * array. `detail` stays the human sentence; this is the machine-readable
47
+ * counterpart of the same finding.
48
+ */
49
+ locations?: CheckLocation[];
27
50
  }
28
51
 
29
52
  /**
@@ -123,6 +146,15 @@ function bandStatus(n: number, [min, max]: Band): CheckStatus {
123
146
  return n >= min && n <= max ? 'pass' : 'warn';
124
147
  }
125
148
 
149
+ /**
150
+ * `locations` for a whole-field problem (word/char counts, a missing
151
+ * placeholder): the field itself is the finding, so there is no span to
152
+ * highlight. Passing checks get nothing — there is nowhere to jump to.
153
+ */
154
+ function fieldLocation(status: CheckStatus, field: keyof ContentFields): Pick<ContentCheck, 'locations'> {
155
+ return status === 'pass' ? {} : { locations: [{ field }] };
156
+ }
157
+
126
158
  /** Pull http(s) URLs out of a free-text sources string. */
127
159
  export function extractUrls(text: string | null | undefined): string[] {
128
160
  if (!text) return [];
@@ -159,69 +191,108 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
159
191
  // Word-count bands.
160
192
  if (fields.headline !== undefined) {
161
193
  const n = wordCount(fields.headline);
162
- checks.push({ id: 'headline-words', label: 'Headline length', status: bandStatus(n, headlineWords), detail: `${n} words` });
194
+ const status = bandStatus(n, headlineWords);
195
+ checks.push({ id: 'headline-words', label: 'Headline length', status, detail: `${n} words`, ...fieldLocation(status, 'headline') });
163
196
  }
164
197
  if (fields.blog !== undefined) {
165
198
  const n = wordCount(fields.blog);
166
- checks.push({ id: 'blog-words', label: 'Blog word count', status: bandStatus(n, blogWords), detail: `${n} words` });
199
+ const status = bandStatus(n, blogWords);
200
+ checks.push({ id: 'blog-words', label: 'Blog word count', status, detail: `${n} words`, ...fieldLocation(status, 'blog') });
167
201
  }
168
202
  if (fields.linkedin !== undefined) {
169
203
  const n = wordCount(fields.linkedin);
170
- checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status: bandStatus(n, linkedinWords), detail: `${n} words` });
204
+ const status = bandStatus(n, linkedinWords);
205
+ checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail: `${n} words`, ...fieldLocation(status, 'linkedin') });
171
206
  }
172
207
 
173
208
  // Tag count.
174
209
  if (fields.tags !== undefined) {
175
210
  const n = tagCount(fields.tags);
176
- checks.push({ id: 'tags-count', label: 'Tag count', status: bandStatus(n, tagsCount), detail: `${n} tags` });
211
+ const status = bandStatus(n, tagsCount);
212
+ checks.push({ id: 'tags-count', label: 'Tag count', status, detail: `${n} tags`, ...fieldLocation(status, 'tags') });
177
213
  }
178
214
 
179
215
  // Meta description length (characters).
180
216
  if (fields.metaDescription !== undefined) {
181
217
  const len = fields.metaDescription.trim().length;
182
- checks.push({ id: 'meta-length', label: 'Meta description length', status: bandStatus(len, metaChars), detail: `${len} chars` });
218
+ const status = bandStatus(len, metaChars);
219
+ checks.push({ id: 'meta-length', label: 'Meta description length', status, detail: `${len} chars`, ...fieldLocation(status, 'metaDescription') });
183
220
  }
184
221
 
185
222
  // `[link]` placeholder present in the LinkedIn post.
186
223
  if (requireLink && fields.linkedin !== undefined) {
187
224
  const has = fields.linkedin.includes('[link]');
225
+ const status: CheckStatus = has ? 'pass' : 'warn';
188
226
  checks.push({
189
227
  id: 'link-placeholder',
190
228
  label: '[link] placeholder in LinkedIn',
191
- status: has ? 'pass' : 'warn',
229
+ status,
192
230
  detail: has ? undefined : 'no [link] found',
231
+ // The problem is an ABSENCE, so there is no span to highlight — only a
232
+ // field to open.
233
+ ...fieldLocation(status, 'linkedin'),
193
234
  });
194
235
  }
195
236
 
196
237
  // No hype words — scan headline + blog + linkedin, lowercased.
197
- const hasProse = fields.headline !== undefined || fields.blog !== undefined || fields.linkedin !== undefined;
238
+ const proseFields: Array<[keyof ContentFields, string | undefined]> = [
239
+ ['headline', fields.headline],
240
+ ['blog', fields.blog],
241
+ ['linkedin', fields.linkedin],
242
+ ];
243
+ const hasProse = proseFields.some(([, text]) => text !== undefined);
198
244
  if (hasProse) {
199
- const haystack = [fields.headline, fields.blog, fields.linkedin]
245
+ const haystack = proseFields
246
+ .map(([, text]) => text)
200
247
  .filter((s): s is string => typeof s === 'string')
201
248
  .join('\n')
202
249
  .toLowerCase();
203
250
  const found = hypeWords.filter((w) => haystack.includes(w.toLowerCase()));
251
+ // Re-scan per field so a UI can jump to the RIGHT channel: the joined
252
+ // haystack above tells us a word exists, not which post it's in.
253
+ const locations: CheckLocation[] = [];
254
+ if (found.length > 0) {
255
+ for (const [field, text] of proseFields) {
256
+ if (typeof text !== 'string') continue;
257
+ const hay = text.toLowerCase();
258
+ const matches = found.filter((w) => hay.includes(w.toLowerCase()));
259
+ if (matches.length > 0) locations.push({ field, matches });
260
+ }
261
+ }
204
262
  checks.push({
205
263
  id: 'no-hype',
206
264
  label: 'No hype words',
207
265
  status: found.length > 0 ? 'fail' : 'pass',
208
266
  detail: found.length > 0 ? found.join(', ') : undefined,
267
+ ...(locations.length > 0 ? { locations } : {}),
209
268
  });
210
269
  }
211
270
 
212
271
  // Approved sources — only when the app supplied an allow-list.
213
272
  if (approvedHosts !== undefined) {
214
273
  const approved = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, '')));
215
- const hosts = extractUrls(fields.sources).map(hostOf).filter(Boolean);
274
+ const urls = extractUrls(fields.sources);
275
+ const hosts = urls.map(hostOf).filter(Boolean);
216
276
  if (hosts.length === 0) {
217
- checks.push({ id: 'approved-sources', label: 'Approved sources', status: 'warn', detail: 'no sources to check' });
277
+ checks.push({
278
+ id: 'approved-sources',
279
+ label: 'Approved sources',
280
+ status: 'warn',
281
+ detail: 'no sources to check',
282
+ locations: [{ field: 'sources' }],
283
+ });
218
284
  } else {
219
285
  const bad = Array.from(new Set(hosts.filter((h) => !approved.has(h) || FORUM_SOCIAL.test(h))));
286
+ // Highlight the offending URLs as they appear in the sources text — the
287
+ // reviewer pastes URLs, not bare hosts, so matching on the host alone
288
+ // would highlight nothing.
289
+ const badUrls = urls.filter((u) => bad.includes(hostOf(u)));
220
290
  checks.push({
221
291
  id: 'approved-sources',
222
292
  label: 'Approved sources',
223
293
  status: bad.length > 0 ? 'fail' : 'pass',
224
294
  detail: bad.length > 0 ? `unapproved: ${bad.join(', ')}` : `${hosts.length} source${hosts.length === 1 ? '' : 's'} ok`,
295
+ ...(bad.length > 0 ? { locations: [{ field: 'sources' as const, matches: badUrls }] } : {}),
225
296
  });
226
297
  }
227
298
  }
@@ -21,6 +21,7 @@ export {
21
21
  hostOf,
22
22
  DEFAULT_HYPE_WORDS,
23
23
  type ContentCheck,
24
+ type CheckLocation,
24
25
  type ContentFields,
25
26
  type ContentChecksConfig,
26
27
  type CheckStatus,