@startsimpli/ui 0.4.52 → 0.4.54

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,240 @@
1
+ /**
2
+ * Deterministic content checks (768w.16.10.1) — pure + framework-free.
3
+ *
4
+ * A Foundry tenant reviews AI-generated content before accepting it. The n8n
5
+ * pipeline that produced the content also ran a set of deterministic
6
+ * auto-checks (word-count bands, hype-word scan, approved-source allow-list)
7
+ * and stored their result on the record. This module re-implements the SAME
8
+ * logic so the client can RECOMPUTE the checks against the reviewer's *edited*
9
+ * content — validation reflects the current draft, not the stale original.
10
+ *
11
+ * Keep this identical to the n8n judge's deterministic logic so client +
12
+ * pipeline always agree. No React, no DOM — importable from a Node/LLM route.
13
+ */
14
+
15
+ /** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
16
+ export type CheckStatus = 'pass' | 'warn' | 'fail';
17
+
18
+ /** One deterministic check result. */
19
+ export interface ContentCheck {
20
+ /** Stable id for the check (e.g. `blog-words`, `no-hype`). */
21
+ id: string;
22
+ /** Human label shown in the checklist. */
23
+ label: string;
24
+ status: CheckStatus;
25
+ /** Short human explanation (e.g. "462 words", "unapproved: reddit.com"). */
26
+ detail?: string;
27
+ }
28
+
29
+ /**
30
+ * The content fields a record can expose for checking. All optional — a check
31
+ * is only emitted for a field that is present (an app without a `blog` field
32
+ * never gets a spurious blog check). `tags` and `sources` are flat strings
33
+ * (comma-separated tags, whitespace/URL-bearing sources) to match how the
34
+ * pipeline stores them.
35
+ */
36
+ export interface ContentFields {
37
+ blog?: string;
38
+ linkedin?: string;
39
+ headline?: string;
40
+ tags?: string;
41
+ metaDescription?: string;
42
+ sources?: string;
43
+ }
44
+
45
+ /** An inclusive `[min, max]` band. */
46
+ export type Band = [min: number, max: number];
47
+
48
+ /**
49
+ * Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`:
50
+ * the source allow-list is deliberately app-injected (no hardcoded list here),
51
+ * so the approved-sources check is skipped entirely when it is omitted.
52
+ */
53
+ export interface ContentChecksConfig {
54
+ /** Blog word-count band (default 400–500). */
55
+ blogWords?: Band;
56
+ /** LinkedIn word-count band (default 150–250). */
57
+ linkedinWords?: Band;
58
+ /** Headline word-count band (default 8–12). */
59
+ headlineWords?: Band;
60
+ /** Tag-count band (default 4–6). */
61
+ tagsCount?: Band;
62
+ /** Meta-description character band (default 150–160). */
63
+ metaChars?: Band;
64
+ /** Require a `[link]` placeholder in the LinkedIn post (default true). */
65
+ requireLink?: boolean;
66
+ /** Override the hype-word blocklist. */
67
+ hypeWords?: string[];
68
+ /** Approved source hosts (no `www.`). REQUIRED to run the source check. */
69
+ approvedHosts?: string[];
70
+ }
71
+
72
+ /**
73
+ * The default hype/marketing-fluff blocklist. If ANY appears in the content the
74
+ * hype check FAILS. Matches the n8n judge's list — keep the two in sync.
75
+ */
76
+ export const DEFAULT_HYPE_WORDS: string[] = [
77
+ 'game-changing',
78
+ 'gamechanging',
79
+ 'revolutionary',
80
+ 'world-leading',
81
+ 'world leading',
82
+ 'unprecedented',
83
+ 'undisputed',
84
+ 'transformational',
85
+ 'groundbreaking',
86
+ 'ground-breaking',
87
+ 'best-in-class',
88
+ 'unmatched',
89
+ 'unrivalled',
90
+ 'unrivaled',
91
+ 'on earth',
92
+ 'most attractive',
93
+ ];
94
+
95
+ /** Forum / social hosts that are never an acceptable source, allow-list or not. */
96
+ const FORUM_SOCIAL = /reddit|quora|facebook|twitter|forum/i;
97
+
98
+ const DEFAULTS = {
99
+ blogWords: [400, 500] as Band,
100
+ linkedinWords: [150, 250] as Band,
101
+ headlineWords: [8, 12] as Band,
102
+ tagsCount: [4, 6] as Band,
103
+ metaChars: [150, 160] as Band,
104
+ };
105
+
106
+ /** Count words in a string — whitespace-delimited, empty → 0. */
107
+ export function wordCount(text: string | null | undefined): number {
108
+ if (!text) return 0;
109
+ return text.trim().split(/\s+/).filter(Boolean).length;
110
+ }
111
+
112
+ /** Count comma-separated tags in a flat string, ignoring empties. */
113
+ export function tagCount(tags: string | null | undefined): number {
114
+ if (!tags) return 0;
115
+ return tags
116
+ .split(',')
117
+ .map((t) => t.trim())
118
+ .filter(Boolean).length;
119
+ }
120
+
121
+ /** pass when `n` is inside the inclusive band, else warn. */
122
+ function bandStatus(n: number, [min, max]: Band): CheckStatus {
123
+ return n >= min && n <= max ? 'pass' : 'warn';
124
+ }
125
+
126
+ /** Pull http(s) URLs out of a free-text sources string. */
127
+ export function extractUrls(text: string | null | undefined): string[] {
128
+ if (!text) return [];
129
+ return text.match(/https?:\/\/[^\s"'<>)\]]+/gi) ?? [];
130
+ }
131
+
132
+ /** Normalise a URL to its bare host (lowercased, `www.` stripped). '' if unparseable. */
133
+ export function hostOf(url: string): string {
134
+ try {
135
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
136
+ } catch {
137
+ return '';
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Recompute every deterministic content check over `fields`. Pure. A check is
143
+ * emitted only when its underlying field is present; the approved-sources check
144
+ * is emitted only when `config.approvedHosts` is supplied.
145
+ */
146
+ export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] {
147
+ const checks: ContentCheck[] = [];
148
+ const {
149
+ blogWords = DEFAULTS.blogWords,
150
+ linkedinWords = DEFAULTS.linkedinWords,
151
+ headlineWords = DEFAULTS.headlineWords,
152
+ tagsCount = DEFAULTS.tagsCount,
153
+ metaChars = DEFAULTS.metaChars,
154
+ requireLink = true,
155
+ hypeWords = DEFAULT_HYPE_WORDS,
156
+ approvedHosts,
157
+ } = config;
158
+
159
+ // Word-count bands.
160
+ if (fields.headline !== undefined) {
161
+ const n = wordCount(fields.headline);
162
+ checks.push({ id: 'headline-words', label: 'Headline length', status: bandStatus(n, headlineWords), detail: `${n} words` });
163
+ }
164
+ if (fields.blog !== undefined) {
165
+ const n = wordCount(fields.blog);
166
+ checks.push({ id: 'blog-words', label: 'Blog word count', status: bandStatus(n, blogWords), detail: `${n} words` });
167
+ }
168
+ if (fields.linkedin !== undefined) {
169
+ const n = wordCount(fields.linkedin);
170
+ checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status: bandStatus(n, linkedinWords), detail: `${n} words` });
171
+ }
172
+
173
+ // Tag count.
174
+ if (fields.tags !== undefined) {
175
+ const n = tagCount(fields.tags);
176
+ checks.push({ id: 'tags-count', label: 'Tag count', status: bandStatus(n, tagsCount), detail: `${n} tags` });
177
+ }
178
+
179
+ // Meta description length (characters).
180
+ if (fields.metaDescription !== undefined) {
181
+ const len = fields.metaDescription.trim().length;
182
+ checks.push({ id: 'meta-length', label: 'Meta description length', status: bandStatus(len, metaChars), detail: `${len} chars` });
183
+ }
184
+
185
+ // `[link]` placeholder present in the LinkedIn post.
186
+ if (requireLink && fields.linkedin !== undefined) {
187
+ const has = fields.linkedin.includes('[link]');
188
+ checks.push({
189
+ id: 'link-placeholder',
190
+ label: '[link] placeholder in LinkedIn',
191
+ status: has ? 'pass' : 'warn',
192
+ detail: has ? undefined : 'no [link] found',
193
+ });
194
+ }
195
+
196
+ // No hype words — scan headline + blog + linkedin, lowercased.
197
+ const hasProse = fields.headline !== undefined || fields.blog !== undefined || fields.linkedin !== undefined;
198
+ if (hasProse) {
199
+ const haystack = [fields.headline, fields.blog, fields.linkedin]
200
+ .filter((s): s is string => typeof s === 'string')
201
+ .join('\n')
202
+ .toLowerCase();
203
+ const found = hypeWords.filter((w) => haystack.includes(w.toLowerCase()));
204
+ checks.push({
205
+ id: 'no-hype',
206
+ label: 'No hype words',
207
+ status: found.length > 0 ? 'fail' : 'pass',
208
+ detail: found.length > 0 ? found.join(', ') : undefined,
209
+ });
210
+ }
211
+
212
+ // Approved sources — only when the app supplied an allow-list.
213
+ if (approvedHosts !== undefined) {
214
+ const approved = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, '')));
215
+ const hosts = extractUrls(fields.sources).map(hostOf).filter(Boolean);
216
+ if (hosts.length === 0) {
217
+ checks.push({ id: 'approved-sources', label: 'Approved sources', status: 'warn', detail: 'no sources to check' });
218
+ } else {
219
+ const bad = Array.from(new Set(hosts.filter((h) => !approved.has(h) || FORUM_SOCIAL.test(h))));
220
+ checks.push({
221
+ id: 'approved-sources',
222
+ label: 'Approved sources',
223
+ status: bad.length > 0 ? 'fail' : 'pass',
224
+ detail: bad.length > 0 ? `unapproved: ${bad.join(', ')}` : `${hosts.length} source${hosts.length === 1 ? '' : 's'} ok`,
225
+ });
226
+ }
227
+ }
228
+
229
+ return checks;
230
+ }
231
+
232
+ /**
233
+ * Roll a list of checks up to one status: fail if any fails, else warn if any
234
+ * warns, else pass (empty → pass).
235
+ */
236
+ export function overallStatus(checks: ContentCheck[]): CheckStatus {
237
+ if (checks.some((c) => c.status === 'fail')) return 'fail';
238
+ if (checks.some((c) => c.status === 'warn')) return 'warn';
239
+ return 'pass';
240
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @startsimpli/ui validation toolkit (768w.16.10.1).
3
+ *
4
+ * Surfaces a Foundry tenant's machine review of AI-generated content — the AI
5
+ * judge verdict plus deterministic auto-checks — as a pass/warn/fail checklist,
6
+ * and RECOMPUTES the deterministic checks over the reviewer's *edited* content
7
+ * (so validation tracks the current draft, not the stale original).
8
+ *
9
+ * `checks.ts` is pure + framework-free (importable in a Node/LLM/n8n context);
10
+ * `ValidationChecklist` is presentational + BYO-data. Light-weight (React +
11
+ * lucide only), so it rides the main components barrel like the health blocks —
12
+ * no dedicated subpath (unlike document-editor, which is subpathed only because
13
+ * it drags react-markdown).
14
+ */
15
+ export {
16
+ runContentChecks,
17
+ overallStatus,
18
+ wordCount,
19
+ tagCount,
20
+ extractUrls,
21
+ hostOf,
22
+ DEFAULT_HYPE_WORDS,
23
+ type ContentCheck,
24
+ type ContentFields,
25
+ type ContentChecksConfig,
26
+ type CheckStatus,
27
+ type Band,
28
+ } from './checks';
29
+
30
+ export { ValidationChecklist } from './ValidationChecklist';
31
+ export type {
32
+ ValidationChecklistProps,
33
+ JudgeVerdict,
34
+ JudgeIssue,
35
+ ValidationOverride,
36
+ } from './ValidationChecklist';