paperlint 13.4.0 → 13.5.0

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.
@@ -12,6 +12,7 @@ type Json = null | boolean | number | string | readonly Json[] | {
12
12
  type JsonObject = {
13
13
  readonly [key: string]: Json;
14
14
  };
15
+ declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
15
16
  type SchemaNode = {
16
17
  kind: "string";
17
18
  required: boolean;
@@ -53,9 +54,19 @@ interface Question {
53
54
  readonly short?: string;
54
55
  readonly options: readonly Option[];
55
56
  }
56
- type Item = Readonly<Record<string, string>> & {
57
+ interface Unit {
58
+ readonly id: string;
59
+ readonly text: string;
60
+ readonly part: number;
61
+ readonly of: number;
62
+ }
63
+ type Item = {
64
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
65
+ } & {
57
66
  readonly source_id: string;
58
67
  readonly group: string;
68
+ readonly body: string;
69
+ readonly unit?: Unit;
59
70
  };
60
71
  interface Practice {
61
72
  readonly title?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "13.4.0",
3
+ "version": "13.5.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,8 +37,16 @@ length.
37
37
  - **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
38
38
  the earlier round are kept but not counted.
39
39
  - **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
40
- `code`, `context`. Nothing else: the allow-list is fixed in the generator, and a field outside it
40
+ `code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
41
41
  is taken out of the item, not added to the list to make a build pass.
42
+ - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
43
+ ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
44
+ own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
45
+ whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
46
+ The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
47
+ split across groups), naming the item. `id` names one occurrence of the line (a file and line
48
+ number, say) and is never shown: two files holding the same words are two lines, each judged in
49
+ full. Splitting the line is the study's job, before the page.
42
50
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
43
51
  next one; a different seed is refused once a key exists.
44
52
  - **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
@@ -89,7 +89,14 @@ const readJson = (path) => {
89
89
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
90
90
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
91
91
  const PRIVATE_FIELDS = ["source_id"];
92
- const ALLOWED_FIELDS = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
92
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
93
+ // the card beneath the item; the line is text the labeller may read, like `context`.
94
+ const UNIT_FIELD = "unit";
95
+ const ALLOWED_FIELDS = [
96
+ ...PAGE_FIELDS,
97
+ UNIT_FIELD,
98
+ ...PRIVATE_FIELDS,
99
+ ];
93
100
  const FAMILIARITY = [
94
101
  { value: "well", label: "Well" },
95
102
  { value: "somewhat", label: "Somewhat" },
@@ -140,10 +147,13 @@ const mapping = (values, keyPattern, min = 0) => ({
140
147
  key_pattern: keyPattern,
141
148
  values,
142
149
  });
143
- const obj = (fields, closed = true) => ({ kind: "object", required: true, closed, fields });
150
+ const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
144
151
  const ITEM_SCHEMA = obj({
145
152
  source_id: string(),
146
153
  ...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
154
+ [UNIT_FIELD]: obj(
155
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
156
+ { id: string(), text: string(), part: integer(), of: integer() }, true, false),
147
157
  }, false);
148
158
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
149
159
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -356,7 +366,41 @@ function checkConfig(cfg) {
356
366
  }
357
367
  /** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
358
368
  const practiceOf = (cfg) => cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
359
- // ── the items: the leak guard, and the references to the config ───────────────────────────────
369
+ /** The items of each line, in items.json order; a line's list is never empty. */
370
+ const linesOf = (cfg) => cfg.items.reduce((byLine, item, n) => {
371
+ if (item.unit === undefined)
372
+ return byLine;
373
+ const part = { item, unit: item.unit, n };
374
+ const seen = byLine.get(item.unit.id);
375
+ return byLine.set(item.unit.id, seen ? [seen[0], ...seen.slice(1), part] : [part]);
376
+ }, new Map());
377
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
378
+ function checkPart({ item, unit, n }, parts) {
379
+ const [first] = parts;
380
+ const at = `items[${String(n)}]`;
381
+ if (unit.text !== first.unit.text)
382
+ fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
383
+ if (unit.of !== first.unit.of)
384
+ fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
385
+ if (unit.part < 1 || unit.part > unit.of)
386
+ fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
387
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
388
+ if (earlier !== undefined)
389
+ fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
390
+ if (item.group !== first.item.group)
391
+ fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
392
+ }
393
+ function checkUnits(cfg) {
394
+ for (const [line, parts] of linesOf(cfg)) {
395
+ parts.forEach((p) => {
396
+ checkPart(p, parts);
397
+ });
398
+ const [first] = parts;
399
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
400
+ if (missing !== undefined)
401
+ fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
402
+ }
403
+ }
360
404
  function checkItemFields(cfg) {
361
405
  const sids = [];
362
406
  cfg.items.forEach((item, n) => {
@@ -373,6 +417,11 @@ function checkItemFields(cfg) {
373
417
  fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
374
418
  });
375
419
  }
420
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
421
+ const visibleTexts = (item) => [
422
+ ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
423
+ [UNIT_FIELD, item.unit?.text ?? ""],
424
+ ];
376
425
  const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
377
426
  /**
378
427
  * A source id typed into a visible field gives the source away as well as a field would. Short ids
@@ -385,7 +434,7 @@ function checkNoSourceShown(cfg) {
385
434
  for (const item of cfg.items) {
386
435
  for (const sid of long) {
387
436
  const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`, "u");
388
- const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
437
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
389
438
  if (field !== undefined) {
390
439
  fail(`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`);
391
440
  }
@@ -426,11 +475,21 @@ function assignIds(items, seed, keyPath) {
426
475
  ]);
427
476
  return { ids, entries };
428
477
  }
429
- // ── a build, short of the page ──────────────────────────────────────────────────────────────────
430
- const shownFields = (item) => Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
431
- const v = item[f];
432
- return v === undefined ? [] : [[f, v]];
433
- }));
478
+ const shownFields = (item) => ({
479
+ ...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
480
+ const v = item[f];
481
+ return v === undefined ? [] : [[f, v]];
482
+ })),
483
+ ...(item.unit === undefined
484
+ ? {}
485
+ : {
486
+ [UNIT_FIELD]: {
487
+ text: item.unit.text,
488
+ part: item.unit.part,
489
+ of: item.unit.of,
490
+ },
491
+ }),
492
+ });
434
493
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
435
494
  function itemsOnPage(cfg, keyPath) {
436
495
  const knows = cfg.labeller_knows;
@@ -524,6 +583,7 @@ export function prepareBuild(configPath, outDir) {
524
583
  assertConfig(cfg);
525
584
  checkConfig(cfg);
526
585
  checkItemFields(cfg);
586
+ checkUnits(cfg);
527
587
  checkNoSourceShown(cfg);
528
588
  mkdirSync(outDir, { recursive: true });
529
589
  const keyPath = join(outDir, "key.json");
@@ -81,7 +81,15 @@ const FULL: DocObject = (() => {
81
81
  familiarity: true,
82
82
  familiarity_question: "How well do you know {group}?",
83
83
  items: ITEMS.map((i, n) =>
84
- n === 0 ? { ...i, title: "T", code: "c = 1", context: "ctx" } : i,
84
+ n === 0
85
+ ? {
86
+ ...i,
87
+ title: "T",
88
+ code: "c = 1",
89
+ context: "ctx",
90
+ unit: { id: "L0", text: "One line.", part: 1, of: 1 },
91
+ }
92
+ : i,
85
93
  ),
86
94
  }),
87
95
  );
@@ -1167,6 +1175,138 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
1167
1175
  assert.match(r.out, /practice round 1: 3 of 3 matched/);
1168
1176
  });
1169
1177
 
1178
+ /** Items 0 and 3 (both Rust) as the two requirements of one line. */
1179
+ const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
1180
+ const withUnit = (
1181
+ parts: readonly (readonly [n: number, unit: Record<string, unknown>])[],
1182
+ ): Record<string, unknown> =>
1183
+ config({
1184
+ items: ITEMS.map((item, n) => {
1185
+ const u = parts.find(([at]) => at === n);
1186
+ return u === undefined ? item : { ...item, unit: u[1] };
1187
+ }),
1188
+ });
1189
+
1190
+ /** A line whose parts do not add up, and the refusal that names it. */
1191
+ const UNIT_REFUSALS: readonly (readonly [
1192
+ string,
1193
+ Record<string, unknown>,
1194
+ string,
1195
+ ])[] = [
1196
+ [
1197
+ "unit-missing-part",
1198
+ withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
1199
+ `items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
1200
+ ],
1201
+ [
1202
+ "unit-part-twice",
1203
+ withUnit([
1204
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1205
+ [3, { id: "L1", text: LINE, part: 1, of: 2 }],
1206
+ ]),
1207
+ "items[3].unit.part: 1 repeats items[0].unit.part",
1208
+ ],
1209
+ [
1210
+ "unit-of-differs",
1211
+ withUnit([
1212
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1213
+ [3, { id: "L1", text: LINE, part: 2, of: 3 }],
1214
+ ]),
1215
+ "items[3].unit.of: 3, but items[0].unit.of says 2 for the same line",
1216
+ ],
1217
+ [
1218
+ "unit-part-range",
1219
+ withUnit([[0, { id: "L1", text: LINE, part: 3, of: 2 }]]),
1220
+ "items[0].unit.part: 3 is not between 1 and 2",
1221
+ ],
1222
+ [
1223
+ "unit-groups-differ",
1224
+ withUnit([
1225
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1226
+ [1, { id: "L1", text: LINE, part: 2, of: 2 }],
1227
+ ]),
1228
+ "items[1].group: 'python', but items[0] of the same line is in 'rust'",
1229
+ ],
1230
+ [
1231
+ "unit-text-differs",
1232
+ withUnit([
1233
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1234
+ [3, { id: "L1", text: "Another line.", part: 2, of: 2 }],
1235
+ ]),
1236
+ "items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
1237
+ ],
1238
+ [
1239
+ "unit-halves-of-two-occurrences",
1240
+ withUnit([
1241
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1242
+ [3, { id: "L2", text: LINE, part: 2, of: 2 }],
1243
+ ]),
1244
+ "items[0].unit: part 2 of 2 of line 'L1' is not among the items",
1245
+ ],
1246
+ [
1247
+ "unit-leaks-source",
1248
+ withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
1249
+ "the source id 'case-1003' appears in the visible `unit` of item case-1000",
1250
+ ],
1251
+ ];
1252
+
1253
+ test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
1254
+ for (const [name, cfg, message] of UNIT_REFUSALS) {
1255
+ const dir = setup(name, cfg);
1256
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
1257
+ assert.equal(r.status, 1, `${name}\n${r.out}`);
1258
+ assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
1259
+ }
1260
+ });
1261
+
1262
+ test("a line whose every requirement is an item builds, and each item carries its part and the whole line", () => {
1263
+ const dir = setup(
1264
+ "unit-ok",
1265
+ withUnit([
1266
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1267
+ [3, { id: "L1", text: LINE, part: 2, of: 2 }],
1268
+ ]),
1269
+ );
1270
+ const { page } = build(dir);
1271
+ const json =
1272
+ /<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
1273
+ page,
1274
+ )?.[1];
1275
+ assert.ok(json !== undefined);
1276
+ const units = z
1277
+ .object({
1278
+ items: z.array(
1279
+ z.object({
1280
+ unit: z
1281
+ .object({ text: z.string(), part: z.number(), of: z.number() })
1282
+ .optional(),
1283
+ }),
1284
+ ),
1285
+ })
1286
+ .parse(JSON.parse(json.replace(/<\\\//g, "</")))
1287
+ .items.flatMap((i) => (i.unit ? [i.unit] : []))
1288
+ .sort((a, b) => a.part - b.part);
1289
+ // what the page carries: the part and the line, never the line's private id
1290
+ assert.deepEqual(units, [
1291
+ { text: LINE, part: 1, of: 2 },
1292
+ { text: LINE, part: 2, of: 2 },
1293
+ ]);
1294
+ });
1295
+
1296
+ test("🔴 the same line in two places is two lines: four items, two ids, it builds, and the id stays off the page", () => {
1297
+ const dir = setup(
1298
+ "unit-same-text",
1299
+ withUnit([
1300
+ [0, { id: "a.md:3", text: LINE, part: 1, of: 2 }],
1301
+ [3, { id: "a.md:3", text: LINE, part: 2, of: 2 }],
1302
+ [6, { id: "b.md:9", text: LINE, part: 1, of: 2 }],
1303
+ [9, { id: "b.md:9", text: LINE, part: 2, of: 2 }],
1304
+ ]),
1305
+ );
1306
+ const { page } = build(dir);
1307
+ assert.ok(!page.includes("a.md:3"), "the line's id is private");
1308
+ });
1309
+
1170
1310
  test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
1171
1311
  const dir = setup("familiarity-off", approval({ familiarity: false }));
1172
1312
  const { key } = build(dir);
@@ -94,7 +94,14 @@ const readJson = (path: string): Json => {
94
94
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
95
95
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
96
96
  const PRIVATE_FIELDS = ["source_id"] as const;
97
- const ALLOWED_FIELDS: readonly string[] = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
97
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
98
+ // the card beneath the item; the line is text the labeller may read, like `context`.
99
+ const UNIT_FIELD = "unit";
100
+ const ALLOWED_FIELDS: readonly string[] = [
101
+ ...PAGE_FIELDS,
102
+ UNIT_FIELD,
103
+ ...PRIVATE_FIELDS,
104
+ ];
98
105
 
99
106
  const FAMILIARITY = [
100
107
  { value: "well", label: "Well" },
@@ -180,7 +187,8 @@ const mapping = (
180
187
  const obj = (
181
188
  fields: Readonly<Record<string, SchemaNode>>,
182
189
  closed = true,
183
- ): SchemaNode => ({ kind: "object", required: true, closed, fields });
190
+ required = true,
191
+ ): SchemaNode => ({ kind: "object", required, closed, fields });
184
192
 
185
193
  const ITEM_SCHEMA = obj(
186
194
  {
@@ -188,6 +196,12 @@ const ITEM_SCHEMA = obj(
188
196
  ...Object.fromEntries(
189
197
  PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
190
198
  ),
199
+ [UNIT_FIELD]: obj(
200
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
201
+ { id: string(), text: string(), part: integer(), of: integer() },
202
+ true,
203
+ false,
204
+ ),
191
205
  },
192
206
  false, // an unlisted item field is the leak guard's refusal, with its own message
193
207
  );
@@ -348,9 +362,19 @@ interface Question {
348
362
  readonly short?: string;
349
363
  readonly options: readonly Option[];
350
364
  }
351
- type Item = Readonly<Record<string, string>> & {
365
+ interface Unit {
366
+ readonly id: string;
367
+ readonly text: string;
368
+ readonly part: number;
369
+ readonly of: number;
370
+ }
371
+ type Item = {
372
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
373
+ } & {
352
374
  readonly source_id: string;
353
375
  readonly group: string;
376
+ readonly body: string;
377
+ readonly unit?: Unit;
354
378
  };
355
379
  interface Practice {
356
380
  readonly title?: string;
@@ -507,6 +531,69 @@ const practiceOf = (
507
531
  cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
508
532
 
509
533
  // ── the items: the leak guard, and the references to the config ───────────────────────────────
534
+ /**
535
+ * A line with several requirements is split upstream into one item per requirement, each carrying
536
+ * `unit: {text, part, of}`. Every part of a line must be an item, once, in one group (so
537
+ * `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
538
+ */
539
+ interface Part {
540
+ readonly item: Item;
541
+ readonly unit: Unit;
542
+ readonly n: number;
543
+ }
544
+ type Parts = readonly [Part, ...Part[]];
545
+ /** The items of each line, in items.json order; a line's list is never empty. */
546
+ const linesOf = (cfg: Config): ReadonlyMap<string, Parts> =>
547
+ cfg.items.reduce((byLine, item, n) => {
548
+ if (item.unit === undefined) return byLine;
549
+ const part: Part = { item, unit: item.unit, n };
550
+ const seen = byLine.get(item.unit.id);
551
+ return byLine.set(
552
+ item.unit.id,
553
+ seen ? [seen[0], ...seen.slice(1), part] : [part],
554
+ );
555
+ }, new Map<string, Parts>());
556
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
557
+ function checkPart({ item, unit, n }: Part, parts: Parts): void {
558
+ const [first] = parts;
559
+ const at = `items[${String(n)}]`;
560
+ if (unit.text !== first.unit.text)
561
+ fail(
562
+ `${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`,
563
+ );
564
+ if (unit.of !== first.unit.of)
565
+ fail(
566
+ `${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
567
+ );
568
+ if (unit.part < 1 || unit.part > unit.of)
569
+ fail(
570
+ `${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
571
+ );
572
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
573
+ if (earlier !== undefined)
574
+ fail(
575
+ `${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`,
576
+ );
577
+ if (item.group !== first.item.group)
578
+ fail(
579
+ `${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
580
+ );
581
+ }
582
+ function checkUnits(cfg: Config): void {
583
+ for (const [line, parts] of linesOf(cfg)) {
584
+ parts.forEach((p) => {
585
+ checkPart(p, parts);
586
+ });
587
+ const [first] = parts;
588
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
589
+ (k) => !parts.some((p) => p.unit.part === k),
590
+ );
591
+ if (missing !== undefined)
592
+ fail(
593
+ `items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
594
+ );
595
+ }
596
+ }
510
597
  function checkItemFields(cfg: Config): void {
511
598
  const sids: string[] = [];
512
599
  cfg.items.forEach((item, n) => {
@@ -529,6 +616,11 @@ function checkItemFields(cfg: Config): void {
529
616
  );
530
617
  });
531
618
  }
619
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
620
+ const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
621
+ ...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
622
+ [UNIT_FIELD, item.unit?.text ?? ""],
623
+ ];
532
624
  const escapeRegExp = (s: string): string =>
533
625
  s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
534
626
  /**
@@ -545,7 +637,7 @@ function checkNoSourceShown(cfg: Config): void {
545
637
  `(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
546
638
  "u",
547
639
  );
548
- const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
640
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
549
641
  if (field !== undefined) {
550
642
  fail(
551
643
  `the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
@@ -612,13 +704,27 @@ function assignIds(
612
704
  }
613
705
 
614
706
  // ── a build, short of the page ──────────────────────────────────────────────────────────────────
615
- const shownFields = (item: Item): Readonly<Record<string, string>> =>
616
- Object.fromEntries(
707
+ /** A unit as the page shows it: the part and the line, without the line's private id. */
708
+ type ShownUnit = Omit<Unit, "id">;
709
+ const shownFields = (
710
+ item: Item,
711
+ ): Readonly<Record<string, string | ShownUnit>> => ({
712
+ ...Object.fromEntries(
617
713
  PAGE_FIELDS.flatMap((f) => {
618
714
  const v = item[f];
619
715
  return v === undefined ? [] : [[f, v]];
620
716
  }),
621
- );
717
+ ),
718
+ ...(item.unit === undefined
719
+ ? {}
720
+ : {
721
+ [UNIT_FIELD]: {
722
+ text: item.unit.text,
723
+ part: item.unit.part,
724
+ of: item.unit.of,
725
+ },
726
+ }),
727
+ });
622
728
 
623
729
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
624
730
  function itemsOnPage(cfg: Config, keyPath: string) {
@@ -729,6 +835,7 @@ export function prepareBuild(configPath: string, outDir: string) {
729
835
  assertConfig(cfg);
730
836
  checkConfig(cfg);
731
837
  checkItemFields(cfg);
838
+ checkUnits(cfg);
732
839
  checkNoSourceShown(cfg);
733
840
  mkdirSync(outDir, { recursive: true });
734
841
  const keyPath = join(outDir, "key.json");
@@ -412,6 +412,44 @@ test("🔴 without the store the page is read-only, and the real items stay read
412
412
  assert.match(shown, /G001/, "a real item is shown read-only");
413
413
  });
414
414
 
415
+ test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
416
+ const line = "TypeScript only (no JavaScript); top-level await is allowed.";
417
+ const { page } = build(
418
+ setup(
419
+ "unit-card",
420
+ config({
421
+ items: ITEMS.map((item, n) =>
422
+ n === 0
423
+ ? {
424
+ ...item,
425
+ body: "TypeScript only (no JavaScript)",
426
+ unit: { id: "L1", text: line, part: 1, of: 2 },
427
+ }
428
+ : n === 3
429
+ ? {
430
+ ...item,
431
+ body: "top-level await is allowed",
432
+ unit: { id: "L1", text: line, part: 2, of: 2 },
433
+ }
434
+ : item,
435
+ ),
436
+ }),
437
+ ),
438
+ );
439
+ const shown = await showPage(page, {}, {});
440
+ assert.match(
441
+ shown,
442
+ /From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
443
+ shown,
444
+ );
445
+ assert.match(shown, /From the line \(requirement 2 of 2\):/);
446
+ assert.equal(
447
+ occurrences(shown, "From the line"),
448
+ 2,
449
+ "only the two parts carry the line",
450
+ );
451
+ });
452
+
415
453
  test("🔴 familiarity off: no group asks how well it is known, and a group is done when its items are", async () => {
416
454
  const { page, key } = build(
417
455
  setup("familiarity-off-page", approval({ familiarity: false })),
@@ -53,7 +53,11 @@
53
53
  * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
54
54
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
55
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
- * "context": "…"}]
56
+ * "context": "…",
57
+ * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
58
+ * unit: optional; one requirement of a line that holds
59
+ * several. Every part of the line is an item, in one group;
60
+ * id (never shown) tells two places with the same words apart.
57
61
  * }
58
62
  *
59
63
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -53,7 +53,11 @@
53
53
  * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
54
54
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
55
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
- * "context": "…"}]
56
+ * "context": "…",
57
+ * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
58
+ * unit: optional; one requirement of a line that holds
59
+ * several. Every part of the line is an item, in one group;
60
+ * id (never shown) tells two places with the same words apart.
57
61
  * }
58
62
  *
59
63
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -312,6 +312,11 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
312
312
  card.appendChild(rich("p", "body", item.body));
313
313
  if (item.code) card.appendChild(codeBlock(item.code));
314
314
  if (item.context) card.appendChild(rich("p", "muted", item.context));
315
+ // One requirement of a line that holds several: which one, and the whole line it came from.
316
+ if (item.unit) {
317
+ card.appendChild(el("p", "muted", "From the line (requirement " + item.unit.part + " of " + item.unit.of + "):"));
318
+ card.appendChild(rich("p", "quote", item.unit.text));
319
+ }
315
320
  var a = answersOf(item.id);
316
321
  askOne(card, a, state.editing[item.id], function (q, v) {
317
322
  delete state.editing[item.id];