@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 +3 -3
- package/src/components/kanban/BoardCard.tsx +156 -0
- package/src/components/kanban/__tests__/board-card.test.tsx +77 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/validation/__tests__/checks.test.ts +95 -0
- package/src/components/validation/checks.ts +81 -10
- package/src/components/validation/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
45
|
-
"@startsimpli/
|
|
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
|
+
'&': '&',
|
|
8
|
+
'<': '<',
|
|
9
|
+
'>': '>',
|
|
10
|
+
'"': '"',
|
|
11
|
+
''': "'",
|
|
12
|
+
' ': ' ',
|
|
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 & silver <up></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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
274
|
+
const urls = extractUrls(fields.sources);
|
|
275
|
+
const hosts = urls.map(hostOf).filter(Boolean);
|
|
216
276
|
if (hosts.length === 0) {
|
|
217
|
-
checks.push({
|
|
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
|
}
|