@startsimpli/ui 0.4.102 → 0.4.103

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.102",
3
+ "version": "0.4.103",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -44,9 +44,9 @@
44
44
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
45
45
  "react": "^18.0.0 || ^19.0.0",
46
46
  "react-dom": "^18.0.0 || ^19.0.0",
47
+ "@startsimpli/api": "^0.5.56",
47
48
  "@startsimpli/auth": "^0.4.35",
48
- "@startsimpli/i18n": "^0.1.3",
49
- "@startsimpli/api": "^0.5.56"
49
+ "@startsimpli/i18n": "^0.1.3"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@startsimpli/api": {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Counting words in a language that does not use spaces (bd startsim-wn2p.28).
3
+ *
4
+ * OBSERVED in the browser on the live OGMC Chinese draft, 2026-08-24. The review
5
+ * workspace greeted its reviewer with "4 ISSUES TO LOOK AT" and CHECKS 4/8, and
6
+ * three of the four were arithmetic, not editing:
7
+ *
8
+ * Headline length 1 words (the headline is 19 Chinese characters)
9
+ * Blog word count 16 words (the blog is 855 Chinese characters)
10
+ * LinkedIn word count 7 words (the post is 364 Chinese characters)
11
+ *
12
+ * `split(/\s+/)` counts whitespace runs. Chinese does not put spaces between
13
+ * words, so an entire article counted as however many Latin fragments happened
14
+ * to be embedded in it. The rail whose job is to say what to fix was saying
15
+ * nothing at all, on exactly the language the CN review stage exists for.
16
+ *
17
+ * MEASURED against the three real translation pairs in the tenant — whitespace
18
+ * count vs `Intl.Segmenter`, with the English source's count for scale:
19
+ *
20
+ * zh #1 headline EN 9 ws 1 segmented 13
21
+ * blog EN 507 ws 26 segmented 609
22
+ * linkedin EN 190 ws 11 segmented 234
23
+ * zh #2 headline EN 8 ws 1 segmented 11
24
+ * blog EN 463 ws 16 segmented 524
25
+ * ar blog EN 497 ws 479 segmented 476 <- Arabic already worked
26
+ *
27
+ * So segmentation turns nonsense into a number an editor can act on, and leaves
28
+ * space-delimited scripts where they were.
29
+ */
30
+ import { wordCount } from '../checks';
31
+
32
+ // The opening of the live zh blog (draft f6a2d629), not invented text.
33
+ const ZH_BLOG =
34
+ '来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
35
+ '中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
36
+ const ZH_HEADLINE = '中国—海湾科技扩张引发入局者对执行的疑问';
37
+ const EN_HEADLINE = 'China-Gulf Tech Expansion Raises Execution Questions for Entrants';
38
+ const AR_HEADLINE = 'يتطلب تأسيس الشركات بشكل أسرع في دبي تنفيذًا دقيقًا من قبل الأجانب';
39
+
40
+ describe('wordCount on space-delimited scripts — unchanged', () => {
41
+ it('counts an English headline', () => {
42
+ expect(wordCount(EN_HEADLINE)).toBe(9);
43
+ });
44
+
45
+ it('counts an Arabic headline, which was never broken', () => {
46
+ // Arabic uses spaces, so this was already right and must stay right:
47
+ // whitespace gave 12 on this live headline and segmentation gives 12.
48
+ expect(wordCount(AR_HEADLINE)).toBe(12);
49
+ });
50
+
51
+ it('splits a hyphenated compound, which whitespace counting did not', () => {
52
+ // The one real behaviour change for English, and it is a correction:
53
+ // "China-Gulf" is two words. MEASURED across all 75 live English drafts,
54
+ // this moves 9 of 225 band checks — and the single headline it flips is
55
+ // "Qatar's Post-World-Cup Economy...", which whitespace scored 7 (below the
56
+ // 8-word floor) and segmentation scores 9 (in band). Largest blog shift is
57
+ // 19 words on a ~450-word brief.
58
+ expect(wordCount('China-Gulf Tech')).toBe(3);
59
+ });
60
+
61
+ it('is 0 for empty, null and undefined', () => {
62
+ expect(wordCount('')).toBe(0);
63
+ expect(wordCount(null)).toBe(0);
64
+ expect(wordCount(undefined)).toBe(0);
65
+ expect(wordCount(' ')).toBe(0);
66
+ });
67
+
68
+ it('does not count punctuation as a word', () => {
69
+ expect(wordCount('Hello, world!')).toBe(2);
70
+ expect(wordCount('— — —')).toBe(0);
71
+ });
72
+ });
73
+
74
+ describe('wordCount on Chinese — the bug', () => {
75
+ it('no longer reports a 19-character headline as one word', () => {
76
+ expect(wordCount(ZH_HEADLINE)).toBeGreaterThan(5);
77
+ });
78
+
79
+ it('lands near the English source it was translated from', () => {
80
+ // The live pair: a 9-word English headline became this. Anything in the
81
+ // same neighbourhood is a number an editor can reason about; `1` was not.
82
+ const n = wordCount(ZH_HEADLINE);
83
+ expect(n).toBeGreaterThanOrEqual(8);
84
+ expect(n).toBeLessThanOrEqual(20);
85
+ });
86
+
87
+ it('counts a Chinese paragraph as prose, not as its Latin fragments', () => {
88
+ // The embedded 'Arabian Business' and 'GCC' were nearly the whole old count.
89
+ expect(wordCount(ZH_BLOG)).toBeGreaterThan(20);
90
+ });
91
+
92
+ it('counts mixed Chinese and Latin without double-counting the Latin', () => {
93
+ const mixed = 'GCC市场';
94
+ expect(wordCount(mixed)).toBeGreaterThanOrEqual(2);
95
+ expect(wordCount(mixed)).toBeLessThanOrEqual(3);
96
+ });
97
+ });
98
+
99
+ describe('wordCount degrades rather than throws', () => {
100
+ it('still returns a count where Intl.Segmenter is unavailable', () => {
101
+ const saved = (globalThis as { Intl: typeof Intl }).Intl;
102
+ try {
103
+ // Older runtimes, and any environment that trims Intl to save bytes.
104
+ (globalThis as unknown as { Intl: unknown }).Intl = {} as unknown;
105
+ // The OLD whitespace answer — 8, because it cannot split "China-Gulf".
106
+ // Degraded but usable, which is the point: a wrong count beats a thrown
107
+ // error on a rail whose job is to report problems.
108
+ expect(wordCount(EN_HEADLINE)).toBe(8);
109
+ expect(wordCount(ZH_HEADLINE)).toBeGreaterThan(0);
110
+ } finally {
111
+ (globalThis as { Intl: typeof Intl }).Intl = saved;
112
+ }
113
+ });
114
+ });
@@ -139,10 +139,78 @@ const DEFAULTS = {
139
139
  metaChars: [150, 160] as Band,
140
140
  };
141
141
 
142
- /** Count words in a string — whitespace-delimited, empty → 0. */
143
- export function wordCount(text: string | null | undefined): number {
142
+ /**
143
+ * The sliver of `Intl.Segmenter` this file uses, declared locally rather than by
144
+ * raising the package's `lib` to es2022.
145
+ *
146
+ * This package ships raw TypeScript and is compiled by each consuming app, so a
147
+ * `lib` bump here would not reach them anyway — every tenant app's own tsconfig
148
+ * would have to agree. A local structural type works whatever they target, and
149
+ * the runtime guard below is what actually decides whether it is used.
150
+ */
151
+ interface WordSegment {
152
+ isWordLike?: boolean;
153
+ }
154
+ interface SegmenterCtor {
155
+ new (locale?: string, options?: { granularity: 'word' }): {
156
+ segment(input: string): Iterable<WordSegment>;
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Words that carry meaning, in ANY script. Empty → 0.
162
+ *
163
+ * WHY NOT `split(/\s+/)` (bd startsim-wn2p.28). Because Chinese does not put
164
+ * spaces between words, and OGMC's Chinese review stage was greeted by this:
165
+ *
166
+ * Headline length 1 words (the headline is 19 Chinese characters)
167
+ * Blog word count 16 words (the blog is 855 Chinese characters)
168
+ * LinkedIn word count 7 words (the post is 364 Chinese characters)
169
+ *
170
+ * Whitespace counting was returning the number of Latin fragments that happened
171
+ * to be embedded in the prose. The rail whose job is to say what to fix was
172
+ * saying nothing at all, on exactly the language it had just been extended to.
173
+ *
174
+ * `Intl.Segmenter` applies ICU's per-locale word rules, and MEASURED against the
175
+ * three real translation pairs in the tenant it does the right thing in both
176
+ * directions — it rescues CJK and leaves space-delimited scripts alone:
177
+ *
178
+ * zh headline EN 9 | whitespace 1 | segmented 13
179
+ * blog EN 507 | whitespace 26 | segmented 609
180
+ * linkedin EN 190 | whitespace 11 | segmented 234
181
+ * ar blog EN 497 | whitespace 479 | segmented 476
182
+ *
183
+ * A NOTE ON THE BANDS, which this function deliberately does not touch. A
184
+ * faithful Chinese translation of a 507-word English brief segments to ~609
185
+ * words, so the English 400–500 band still reads it as over-long. That is an
186
+ * EDITORIAL question — what length OGMC wants a Chinese brief to be — not an
187
+ * arithmetic one, and `ContentChecksConfig` already takes per-call band
188
+ * overrides for when they answer it. Fixing the counting without inventing
189
+ * their policy is the honest half.
190
+ */
191
+ export function wordCount(text: string | null | undefined, locale?: string): number {
144
192
  if (!text) return 0;
145
- return text.trim().split(/\s+/).filter(Boolean).length;
193
+ const trimmed = text.trim();
194
+ if (!trimmed) return 0;
195
+ // Guarded rather than assumed: Intl.Segmenter is absent in older runtimes and
196
+ // in environments that trim Intl to save bytes. Falling back to the old
197
+ // behaviour is right for the Latin content that is most of this corpus, and a
198
+ // wrong count beats a thrown error on a validation rail.
199
+ const Segmenter = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
200
+ if (typeof Segmenter !== 'function') {
201
+ return trimmed.split(/\s+/).filter(Boolean).length;
202
+ }
203
+ try {
204
+ let n = 0;
205
+ for (const part of new Segmenter(locale, { granularity: 'word' }).segment(trimmed)) {
206
+ // `isWordLike` is what keeps punctuation and the spaces themselves out —
207
+ // the segmenter yields those as segments too.
208
+ if (part.isWordLike) n += 1;
209
+ }
210
+ return n;
211
+ } catch {
212
+ return trimmed.split(/\s+/).filter(Boolean).length;
213
+ }
146
214
  }
147
215
 
148
216
  /** Count comma-separated tags in a flat string, ignoring empties. */