@urbicon-ui/design-engine 6.3.1 → 6.3.3
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 +1 -1
- package/src/linter/linter.test.ts +63 -0
- package/src/linter/rules.ts +110 -1
- package/src/linter/tokens.test.ts +34 -1
- package/src/linter/tokens.ts +58 -0
- package/src/manifest/manifest.test.ts +19 -0
- package/src/manifest/manifest.ts +36 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/design-engine",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.3",
|
|
4
4
|
"description": "Deterministic design engine for Urbicon UI — the zero-dependency design linter, manifest parser and quality rubric that power the design loop (validate · context · judge).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -507,3 +507,66 @@ describe('rule metadata', () => {
|
|
|
507
507
|
expect(ids(lintDesign(code).findings)).toEqual(ids(lintDesign(code).findings));
|
|
508
508
|
});
|
|
509
509
|
});
|
|
510
|
+
|
|
511
|
+
describe('deep-internal-import', () => {
|
|
512
|
+
it('flags a deep import into a package component file', () => {
|
|
513
|
+
const code =
|
|
514
|
+
"<script>import Button from '@urbicon-ui/blocks/primitives/Button/Button.svelte';</script>";
|
|
515
|
+
expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(true);
|
|
516
|
+
});
|
|
517
|
+
it('flags an internal-directory path even without a file extension', () => {
|
|
518
|
+
const code = "<script>import { x } from '@urbicon-ui/blocks/icons/registry';</script>";
|
|
519
|
+
expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
it('does NOT flag the package root or documented public subpaths', () => {
|
|
522
|
+
const code = [
|
|
523
|
+
"import { Button } from '@urbicon-ui/blocks';",
|
|
524
|
+
"import { addDays } from '@urbicon-ui/blocks/date';",
|
|
525
|
+
"import en from '@urbicon-ui/blocks/i18n/en';",
|
|
526
|
+
"import '@urbicon-ui/blocks/style/index.css';"
|
|
527
|
+
].join('\n');
|
|
528
|
+
expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('hardcoded-motion', () => {
|
|
533
|
+
it('flags a hardcoded duration and a cubic-bezier easing', () => {
|
|
534
|
+
const code =
|
|
535
|
+
'<div class="transition-transform duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]">';
|
|
536
|
+
expect(lintDesign(code).findings.filter((f) => f.ruleId === 'hardcoded-motion')).toHaveLength(
|
|
537
|
+
2
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
it('flags a fractional-second duration', () => {
|
|
541
|
+
expect(has(lintDesign('<div class="duration-[0.2s]">').findings, 'hardcoded-motion')).toBe(
|
|
542
|
+
true
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
it('does NOT flag motion tokens or named eases', () => {
|
|
546
|
+
const code =
|
|
547
|
+
'<div class="duration-[var(--blocks-duration-fast)] ease-[var(--blocks-ease-smooth)] ease-out">';
|
|
548
|
+
expect(has(lintDesign(code).findings, 'hardcoded-motion')).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('token-hallucination — intent typos', () => {
|
|
553
|
+
it('flags a misspelled intent (`bg-primay` → `bg-primary`)', () => {
|
|
554
|
+
expect(has(lintDesign('<button class="bg-primay">').findings, 'token-hallucination')).toBe(
|
|
555
|
+
true
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
it('flags a misspelled intent on a text utility (`text-sucess`)', () => {
|
|
559
|
+
expect(has(lintDesign('<span class="text-sucess">').findings, 'token-hallucination')).toBe(
|
|
560
|
+
true
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
it('does NOT flag arbitrary, far-from-intent cores (`bg-brand`, `bg-cover`)', () => {
|
|
564
|
+
const { findings } = lintDesign('<div class="bg-brand text-on-brand bg-cover">');
|
|
565
|
+
expect(has(findings, 'token-hallucination')).toBe(false);
|
|
566
|
+
});
|
|
567
|
+
it('does NOT flag a valid intent', () => {
|
|
568
|
+
expect(
|
|
569
|
+
has(lintDesign('<div class="bg-primary text-success">').findings, 'token-hallucination')
|
|
570
|
+
).toBe(false);
|
|
571
|
+
});
|
|
572
|
+
});
|
package/src/linter/rules.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
KNOWN_BAD_NAMESPACES,
|
|
14
14
|
KNOWN_FOREIGN_CORES,
|
|
15
15
|
SEMANTIC_NAMESPACES,
|
|
16
|
+
suggestIntentTypo,
|
|
16
17
|
VALID_TOKEN_CORES
|
|
17
18
|
} from './tokens.js';
|
|
18
19
|
import type { Finding, Rule } from './types.js';
|
|
@@ -261,6 +262,94 @@ const dynamicClassInterpolation: Rule = {
|
|
|
261
262
|
}
|
|
262
263
|
};
|
|
263
264
|
|
|
265
|
+
/** Subpath segments that are internal to an `@urbicon-ui` package — never a public export. */
|
|
266
|
+
const INTERNAL_SUBPATH_SEGMENTS = new Set([
|
|
267
|
+
'primitives',
|
|
268
|
+
'components',
|
|
269
|
+
'lib',
|
|
270
|
+
'dist',
|
|
271
|
+
'src',
|
|
272
|
+
'icons'
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A deep import into an `@urbicon-ui` package: either a concrete component/module
|
|
277
|
+
* file (`…/Button.svelte`, `…/foo.js`) or a path through an internal directory
|
|
278
|
+
* (`primitives/`, `components/`, …). The documented public subpaths — `./date`,
|
|
279
|
+
* `./style/*.css`, `./i18n/en` — are flat, extensionless-or-CSS, and stay allowed.
|
|
280
|
+
*/
|
|
281
|
+
function isDeepInternalSubpath(subpath: string): boolean {
|
|
282
|
+
if (/\.svelte(\.[jt]s)?$|\.[jt]s$/.test(subpath)) return true;
|
|
283
|
+
return subpath.split('/').some((seg) => INTERNAL_SUBPATH_SEGMENTS.has(seg));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const deepInternalImport: Rule = {
|
|
287
|
+
id: 'deep-internal-import',
|
|
288
|
+
severity: 'error',
|
|
289
|
+
description: 'Deep/internal import into an `@urbicon-ui` package instead of its public root.',
|
|
290
|
+
check(lines) {
|
|
291
|
+
// The module specifier of any import / export-from / @import / dynamic import.
|
|
292
|
+
const re = /['"](@urbicon-ui\/[a-z-]+)\/([^'"]+)['"]/g;
|
|
293
|
+
const findings: Finding[] = [];
|
|
294
|
+
lines.forEach((line, i) => {
|
|
295
|
+
for (const m of line.matchAll(re)) {
|
|
296
|
+
const pkg = m[1]!;
|
|
297
|
+
const subpath = m[2]!;
|
|
298
|
+
if (!isDeepInternalSubpath(subpath)) continue;
|
|
299
|
+
findings.push({
|
|
300
|
+
ruleId: this.id,
|
|
301
|
+
severity: this.severity,
|
|
302
|
+
kind: 'deterministic',
|
|
303
|
+
message: `Deep import \`${pkg}/${subpath}\` reaches into ${pkg}'s internals — they can move between releases.`,
|
|
304
|
+
fix: `Import from the package root: \`import { … } from '${pkg}'\`.`,
|
|
305
|
+
line: i + 1,
|
|
306
|
+
match: `${pkg}/${subpath}`
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return dedupeByLine(findings);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const hardcodedMotion: Rule = {
|
|
315
|
+
id: 'hardcoded-motion',
|
|
316
|
+
severity: 'error',
|
|
317
|
+
description:
|
|
318
|
+
'Hardcoded transition duration or `cubic-bezier()` easing instead of a motion token.',
|
|
319
|
+
check(lines) {
|
|
320
|
+
// `duration-[250ms]` / `duration-[0.2s]` — but not `duration-[var(--blocks-duration-fast)]`.
|
|
321
|
+
const duration = /\bduration-\[\d+(?:\.\d+)?m?s\]/g;
|
|
322
|
+
// `ease-[cubic-bezier(…)]` — but not `ease-[var(--blocks-ease-smooth)]` or named `ease-out`.
|
|
323
|
+
const easing = /\bease-\[cubic-bezier\([^\]]*\)\]/g;
|
|
324
|
+
const findings: Finding[] = [];
|
|
325
|
+
lines.forEach((line, i) => {
|
|
326
|
+
for (const m of line.matchAll(duration)) {
|
|
327
|
+
findings.push({
|
|
328
|
+
ruleId: this.id,
|
|
329
|
+
severity: this.severity,
|
|
330
|
+
kind: 'deterministic',
|
|
331
|
+
message: `Hardcoded transition duration \`${m[0]}\` bypasses the motion scale (no global speed / reduced-motion control).`,
|
|
332
|
+
fix: 'Use a duration token: `duration-[var(--blocks-duration-fast)]` / `-normal` / `-slow`.',
|
|
333
|
+
line: i + 1,
|
|
334
|
+
match: m[0]
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
for (const m of line.matchAll(easing)) {
|
|
338
|
+
findings.push({
|
|
339
|
+
ruleId: this.id,
|
|
340
|
+
severity: this.severity,
|
|
341
|
+
kind: 'deterministic',
|
|
342
|
+
message: `Hardcoded \`cubic-bezier()\` easing \`${m[0]}\` bypasses the motion system's easing tokens.`,
|
|
343
|
+
fix: 'Use an easing token: `ease-[var(--blocks-ease-smooth)]` / `-snappy` / `-gentle`, or a named Tailwind ease (`ease-out`).',
|
|
344
|
+
line: i + 1,
|
|
345
|
+
match: m[0]
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return dedupeByLine(findings);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
264
353
|
/**
|
|
265
354
|
* Token hallucination: a colour utility whose core *looks* like an Urbicon UI
|
|
266
355
|
* semantic token (right namespace / intent prefix) but is not in the validated
|
|
@@ -284,7 +373,25 @@ const tokenHallucination: Rule = {
|
|
|
284
373
|
lines.forEach((line, i) => {
|
|
285
374
|
for (const m of line.matchAll(re)) {
|
|
286
375
|
const core = m[2]!;
|
|
287
|
-
if (!looksSemantic(core))
|
|
376
|
+
if (!looksSemantic(core)) {
|
|
377
|
+
// Not in our vocabulary — but a bare core one edit from an intent is almost
|
|
378
|
+
// certainly a misspelling (`bg-primay` → `bg-primary`), where the namespace
|
|
379
|
+
// heuristic alone would let it pass silently. Arbitrary cores (`bg-cover`,
|
|
380
|
+
// `bg-brand`) are far from every intent and stay unflagged.
|
|
381
|
+
const intended = suggestIntentTypo(core);
|
|
382
|
+
if (intended) {
|
|
383
|
+
findings.push({
|
|
384
|
+
ruleId: this.id,
|
|
385
|
+
severity: this.severity,
|
|
386
|
+
kind: 'deterministic',
|
|
387
|
+
message: `\`${m[1]}-${core}\` looks like a typo of \`${m[1]}-${intended}\`.`,
|
|
388
|
+
fix: `Did you mean \`${m[1]}-${intended}\`? Valid intents: ${INTENT_NAMES.join(', ')}.`,
|
|
389
|
+
line: i + 1,
|
|
390
|
+
match: m[0]
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
288
395
|
if (validCores.has(core)) continue;
|
|
289
396
|
|
|
290
397
|
findings.push({
|
|
@@ -348,6 +455,8 @@ export const RULES: Rule[] = [
|
|
|
348
455
|
darkModeOverride,
|
|
349
456
|
focusNotVisible,
|
|
350
457
|
hardcodedZIndex,
|
|
458
|
+
hardcodedMotion,
|
|
459
|
+
deepInternalImport,
|
|
351
460
|
dynamicClassInterpolation,
|
|
352
461
|
tokenHallucination,
|
|
353
462
|
...MARKUP_RULES
|
|
@@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isSingleEditApart,
|
|
7
|
+
resolveValidTokenCores,
|
|
8
|
+
suggestIntentTypo,
|
|
9
|
+
VALID_TOKEN_CORES
|
|
10
|
+
} from './tokens.js';
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Drift guard: the hardcoded {@link VALID_TOKEN_CORES} is the design-engine's
|
|
@@ -109,3 +114,31 @@ describe('resolveValidTokenCores', () => {
|
|
|
109
114
|
expect(VALID_TOKEN_CORES.has('surface-brand')).toBe(false);
|
|
110
115
|
});
|
|
111
116
|
});
|
|
117
|
+
|
|
118
|
+
describe('isSingleEditApart', () => {
|
|
119
|
+
it('detects single substitutions, insertions, deletions, and adjacent transpositions', () => {
|
|
120
|
+
expect(isSingleEditApart('primay', 'primary')).toBe(true); // insertion
|
|
121
|
+
expect(isSingleEditApart('primaryy', 'primary')).toBe(true); // deletion
|
|
122
|
+
expect(isSingleEditApart('prinary', 'primary')).toBe(true); // substitution
|
|
123
|
+
expect(isSingleEditApart('priamry', 'primary')).toBe(true); // transposition
|
|
124
|
+
});
|
|
125
|
+
it('rejects identical strings and edits of distance ≥ 2', () => {
|
|
126
|
+
expect(isSingleEditApart('primary', 'primary')).toBe(false);
|
|
127
|
+
expect(isSingleEditApart('brand', 'primary')).toBe(false);
|
|
128
|
+
expect(isSingleEditApart('prmiarx', 'primary')).toBe(false); // two edits
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('suggestIntentTypo', () => {
|
|
133
|
+
it('maps a one-edit misspelling to the intended intent', () => {
|
|
134
|
+
expect(suggestIntentTypo('primay')).toBe('primary');
|
|
135
|
+
expect(suggestIntentTypo('sucess')).toBe('success');
|
|
136
|
+
expect(suggestIntentTypo('wraning')).toBe('warning'); // transposition
|
|
137
|
+
});
|
|
138
|
+
it('returns null for exact intents, hyphenated cores, and unrelated words', () => {
|
|
139
|
+
expect(suggestIntentTypo('primary')).toBeNull(); // exact intent, not a typo
|
|
140
|
+
expect(suggestIntentTypo('primary-subtle')).toBeNull(); // hyphenated — left to whitelist
|
|
141
|
+
expect(suggestIntentTypo('brand')).toBeNull(); // far from every intent
|
|
142
|
+
expect(suggestIntentTypo('cover')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/linter/tokens.ts
CHANGED
|
@@ -231,4 +231,62 @@ export const KNOWN_BAD_NAMESPACES: Record<string, string> = {
|
|
|
231
231
|
'-fg': 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text.'
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Whether `a` and `b` differ by exactly one typo: a single substitution,
|
|
236
|
+
* insertion, deletion, or adjacent transposition (Optimal String Alignment
|
|
237
|
+
* distance 1). Deliberately stricter than Levenshtein ≤ 2 — it catches the common
|
|
238
|
+
* typo classes (`primay`→`primary`, `sucess`→`success`, `priamry`→`primary`)
|
|
239
|
+
* while keeping unrelated words (`brand`, `cover`, `accent`) clearly apart, so
|
|
240
|
+
* the typo check never fires on a legitimately different utility.
|
|
241
|
+
*/
|
|
242
|
+
export function isSingleEditApart(a: string, b: string): boolean {
|
|
243
|
+
if (a === b) return false;
|
|
244
|
+
if (a.length === b.length) {
|
|
245
|
+
let diffs = 0;
|
|
246
|
+
let at = -1;
|
|
247
|
+
for (let i = 0; i < a.length; i++) {
|
|
248
|
+
if (a[i] !== b[i]) {
|
|
249
|
+
diffs++;
|
|
250
|
+
if (at === -1) at = i;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (diffs === 1) return true; // one substitution
|
|
254
|
+
// adjacent transposition: two diffs that are a swapped neighbouring pair
|
|
255
|
+
return diffs === 2 && at >= 0 && a[at] === b[at + 1] && a[at + 1] === b[at];
|
|
256
|
+
}
|
|
257
|
+
if (Math.abs(a.length - b.length) !== 1) return false;
|
|
258
|
+
// one insertion/deletion: the shorter is the longer with a single char removed
|
|
259
|
+
const [short, long] = a.length < b.length ? [a, b] : [b, a];
|
|
260
|
+
let i = 0;
|
|
261
|
+
let j = 0;
|
|
262
|
+
let skipped = false;
|
|
263
|
+
while (i < short.length && j < long.length) {
|
|
264
|
+
if (short[i] === long[j]) {
|
|
265
|
+
i++;
|
|
266
|
+
j++;
|
|
267
|
+
} else if (!skipped) {
|
|
268
|
+
skipped = true;
|
|
269
|
+
j++;
|
|
270
|
+
} else {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* If `core` is a likely typo of an intent name — a single bare word one edit away
|
|
279
|
+
* from `primary`/`secondary`/… but not an exact intent — return the intended
|
|
280
|
+
* intent, else `null`. Hyphenated cores (`primary-subtle`, `success-foo`) are left
|
|
281
|
+
* to the namespace/whitelist checks; only the bare intent word is typo-matched.
|
|
282
|
+
*/
|
|
283
|
+
export function suggestIntentTypo(core: string): string | null {
|
|
284
|
+
if (core.includes('-')) return null;
|
|
285
|
+
if ((INTENT_NAMES as readonly string[]).includes(core)) return null;
|
|
286
|
+
for (const intent of INTENT_NAMES) {
|
|
287
|
+
if (isSingleEditApart(core, intent)) return intent;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
234
292
|
export { INTENT_NAMES, INTENT_VARIANTS, SCALE_STEPS };
|
|
@@ -172,6 +172,25 @@ describe('parseManifest — product intent', () => {
|
|
|
172
172
|
expect(parseManifest(md).intent.references).toEqual(['Linear', 'Stripe']);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
it('joins a soft-wrapped Audience value instead of truncating at the first line', () => {
|
|
176
|
+
const md =
|
|
177
|
+
'## Product Intent\n\n**Audience:** Homeowners with rooftop solar and a battery — non-experts who glance\nat production, consumption and savings.\n';
|
|
178
|
+
expect(parseManifest(md).intent.audience).toBe(
|
|
179
|
+
'Homeowners with rooftop solar and a battery — non-experts who glance at production, consumption and savings.'
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps the continuation items of a soft-wrapped inline reference list', () => {
|
|
184
|
+
const md = '## Product Intent\n\n**References:** Linear, Stripe, Notion,\nFigma, Things\n';
|
|
185
|
+
expect(parseManifest(md).intent.references).toEqual([
|
|
186
|
+
'Linear',
|
|
187
|
+
'Stripe',
|
|
188
|
+
'Notion',
|
|
189
|
+
'Figma',
|
|
190
|
+
'Things'
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
175
194
|
it('returns an empty intent when the section is absent', () => {
|
|
176
195
|
const intent = parseManifest('# Bare\n\nsome prose\n').intent;
|
|
177
196
|
expect(intent).toEqual({ voice: [], references: [], antiReferences: [] });
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -106,35 +106,62 @@ function parseIntent(body: string): ProductIntent {
|
|
|
106
106
|
if (section === null) return emptyIntent();
|
|
107
107
|
const lines = section.split('\n');
|
|
108
108
|
|
|
109
|
+
// A line that opens a different labelled field, or a heading — either terminates
|
|
110
|
+
// the value currently being gathered.
|
|
111
|
+
const isFieldOrHeading = (l: string): boolean => /^\*\*[^*]+:\*\*/.test(l) || /^#{1,6}\s/.test(l);
|
|
112
|
+
|
|
113
|
+
// `**Label:** value`, joining soft-wrapped continuation lines into one value —
|
|
114
|
+
// a Markdown line-wrap inside the value (common for a sentence-long Audience) is
|
|
115
|
+
// a continuation, not a truncation point. Stops at the first blank line, the next
|
|
116
|
+
// labelled field, or a heading.
|
|
109
117
|
const inlineField = (label: string): string | undefined => {
|
|
110
|
-
const
|
|
111
|
-
for (
|
|
112
|
-
const m =
|
|
113
|
-
if (m)
|
|
118
|
+
const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const m = lines[i]!.match(labelRe);
|
|
121
|
+
if (!m) continue;
|
|
122
|
+
const parts = m[1]!.trim() ? [m[1]!.trim()] : [];
|
|
123
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
124
|
+
const l = lines[j]!;
|
|
125
|
+
if (l.trim() === '' || isFieldOrHeading(l)) break;
|
|
126
|
+
parts.push(l.trim());
|
|
127
|
+
}
|
|
128
|
+
const value = parts.join(' ').trim();
|
|
129
|
+
return value === '' ? undefined : value;
|
|
114
130
|
}
|
|
115
131
|
return undefined;
|
|
116
132
|
};
|
|
117
133
|
|
|
118
|
-
// Items under a `**Label:**`: an inline comma list on the label line and
|
|
119
|
-
// bullet lines that follow it, up to
|
|
120
|
-
//
|
|
134
|
+
// Items under a `**Label:**`: an inline comma list on the label line (and its
|
|
135
|
+
// soft-wrapped continuation lines) and/or the bullet lines that follow it, up to
|
|
136
|
+
// the next labelled field. Blank/prose lines are skipped, not terminators.
|
|
121
137
|
const listField = (label: string): string[] => {
|
|
122
138
|
const items: string[] = [];
|
|
123
139
|
const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
|
|
124
140
|
let capturing = false;
|
|
141
|
+
let inInlineRun = false; // still gathering the soft-wrapped inline comma value
|
|
125
142
|
for (const l of lines) {
|
|
126
143
|
if (!capturing) {
|
|
127
144
|
const m = l.match(labelRe);
|
|
128
145
|
if (m) {
|
|
129
146
|
capturing = true;
|
|
147
|
+
inInlineRun = true;
|
|
130
148
|
const inline = m[1]!.trim();
|
|
131
149
|
if (inline) items.push(...splitList(inline));
|
|
132
150
|
}
|
|
133
151
|
continue;
|
|
134
152
|
}
|
|
135
|
-
if (
|
|
153
|
+
if (isFieldOrHeading(l)) break; // next labelled field / heading ends this one
|
|
136
154
|
const bullet = l.match(/^\s*[-*]\s+(.+)$/);
|
|
137
|
-
if (bullet)
|
|
155
|
+
if (bullet) {
|
|
156
|
+
items.push(bullet[1]!.trim());
|
|
157
|
+
inInlineRun = false; // bullets started — later loose lines aren't inline-list continuation
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (l.trim() === '') {
|
|
161
|
+
inInlineRun = false; // blank closes the inline run (bullets may still follow)
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (inInlineRun) items.push(...splitList(l.trim())); // soft-wrapped continuation of the inline list
|
|
138
165
|
}
|
|
139
166
|
return items;
|
|
140
167
|
};
|