paperlint 14.4.0 → 14.6.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.
@@ -280,7 +280,12 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
280
280
  ),
281
281
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
282
282
  file: string(false),
283
- situation: optional({ summary: string(false), question: string() }),
283
+ situation: optional({
284
+ summary: string(false),
285
+ question: string(),
286
+ // the source tag over the summary (#400); the question is the page's own and gets none
287
+ from: string(false),
288
+ }),
284
289
  excerpt: optional({
285
290
  file: string(false),
286
291
  // the file's language, after its path: "path · Python"
@@ -304,8 +309,15 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
304
309
  annotation: string(false),
305
310
  caption: string(false),
306
311
  mark_label: string(false),
312
+ // source tags (#400): over the code box, and over the caption under it
313
+ from: string(false),
314
+ caption_from: string(false),
315
+ }),
316
+ quote: optional({
317
+ text: string(),
318
+ source: string(false),
319
+ from: string(false),
307
320
  }),
308
- quote: optional({ text: string(), source: string(false) }),
309
321
  terms: array(
310
322
  obj({
311
323
  term: string(),
@@ -451,6 +463,44 @@ const QUESTION_SCHEMA = obj({
451
463
  short: string(false),
452
464
  options: array(OPTION_SCHEMA, true, 2),
453
465
  });
466
+ // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
467
+ const LABELS = optional({
468
+ place: string(false),
469
+ body: string(false),
470
+ line: string(false),
471
+ line_part: string(false),
472
+ repeat: string(false),
473
+ practice_example: string(false),
474
+ hidden: string(false),
475
+ hidden_one: string(false),
476
+ wait: string(false),
477
+ how_to: string(false),
478
+ for_example: string(false),
479
+ });
480
+ // The page's own words in another language (#394): never the quote, code, notes or any card text
481
+ // the labeller judges. Keyed like the page, so a translation names its questions and answers by id.
482
+ const TRANSLATION = obj({
483
+ title: string(false),
484
+ intro: string(false),
485
+ labels: LABELS,
486
+ // the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
487
+ situation_question: string(false),
488
+ // the labels of the page's `sources`, by id; one left out reads as in the page's own language
489
+ sources: mapping(string(), NAME_PATTERN, 1, false),
490
+ questions: mapping(
491
+ obj({
492
+ text: string(),
493
+ short: string(false),
494
+ options: mapping(
495
+ obj({ label: string(), example: string(), ask_why: string(false) }),
496
+ NAME_PATTERN,
497
+ 1,
498
+ ),
499
+ }),
500
+ NAME_PATTERN,
501
+ 1,
502
+ ),
503
+ });
454
504
  const configSchema = (kind: Kind): SchemaNode =>
455
505
  obj({
456
506
  study: string(true, NAME_PATTERN), // becomes a file name
@@ -471,18 +521,12 @@ const configSchema = (kind: Kind): SchemaNode =>
471
521
  questions: array(QUESTION_SCHEMA, true, 1),
472
522
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
473
523
  practice_round: integer(false),
474
- // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
475
- labels: optional({
476
- place: string(false),
477
- body: string(false),
478
- line: string(false),
479
- line_part: string(false),
480
- repeat: string(false),
481
- practice_example: string(false),
482
- hidden: string(false),
483
- hidden_one: string(false),
484
- wait: string(false),
485
- }),
524
+ labels: LABELS,
525
+ // the source tags a card's blocks carry (#400): id → the tag's label, in colour order
526
+ sources: mapping(string(), NAME_PATTERN, 1, false),
527
+ // the language of the page's own words, and the same words in more languages, with a switch
528
+ lang: string(false, NAME_PATTERN),
529
+ translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
486
530
  items: array(itemSchema(kind), true, 1),
487
531
  });
488
532
  /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
@@ -655,6 +699,7 @@ interface Blocks {
655
699
  readonly situation?: {
656
700
  readonly summary?: string;
657
701
  readonly question: string;
702
+ readonly from?: string;
658
703
  };
659
704
  readonly excerpt?: {
660
705
  readonly file?: string;
@@ -665,8 +710,14 @@ interface Blocks {
665
710
  readonly annotation?: string;
666
711
  readonly caption?: string;
667
712
  readonly mark_label?: string;
713
+ readonly from?: string;
714
+ readonly caption_from?: string;
715
+ };
716
+ readonly quote?: {
717
+ readonly text: string;
718
+ readonly source?: string;
719
+ readonly from?: string;
668
720
  };
669
- readonly quote?: { readonly text: string; readonly source?: string };
670
721
  readonly terms?: readonly Term[];
671
722
  readonly answer_notes?: Readonly<
672
723
  Record<string, Readonly<Record<string, string>>>
@@ -736,8 +787,34 @@ interface Config {
736
787
  readonly practice: readonly Practice[];
737
788
  readonly practice_round?: number;
738
789
  readonly labels?: Readonly<Record<string, string>>;
790
+ readonly sources?: Readonly<Record<string, string>>;
791
+ readonly lang?: string;
792
+ readonly translations?: Readonly<Record<string, Translation>>;
739
793
  readonly items: readonly Item[];
740
794
  }
795
+ interface QuestionWords {
796
+ readonly text: string;
797
+ readonly short?: string;
798
+ readonly options: Readonly<
799
+ Record<
800
+ string,
801
+ {
802
+ readonly label: string;
803
+ readonly example: string;
804
+ readonly ask_why?: string;
805
+ }
806
+ >
807
+ >;
808
+ }
809
+ /** The page's own words in one more language (#394). */
810
+ interface Translation {
811
+ readonly title?: string;
812
+ readonly intro?: string;
813
+ readonly labels?: Readonly<Record<string, string>>;
814
+ readonly situation_question?: string;
815
+ readonly sources?: Readonly<Record<string, string>>;
816
+ readonly questions: Readonly<Record<string, QuestionWords>>;
817
+ }
741
818
  /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
742
819
  function assertConfig(value: Json): asserts value is Json & Config {
743
820
  checkValue(value, CONFIG_SCHEMA, "");
@@ -1004,6 +1081,65 @@ function checkBlocks(cfg: Config): void {
1004
1081
  );
1005
1082
  });
1006
1083
  }
1084
+ // ── source tags (#400) ────────────────────────────────────────────────────────────────────────
1085
+ /** The colours the page has for tags (`.tag.s0` to `.tag.s4` in page-template.html), in `sources` order. */
1086
+ export const SOURCE_COLOURS = 5;
1087
+ /** Each tag a card asks for: the path of the field that names it, the id it names, the text it sits over. */
1088
+ const tagsOf = (at: string, card: Card) => [
1089
+ ...(card.quote?.from === undefined
1090
+ ? []
1091
+ : [{ at: `${at}.quote.from`, id: card.quote.from, over: "text" }]),
1092
+ ...(card.situation?.from === undefined
1093
+ ? []
1094
+ : [
1095
+ {
1096
+ at: `${at}.situation.from`,
1097
+ id: card.situation.from,
1098
+ over: card.situation.summary,
1099
+ },
1100
+ ]),
1101
+ ...(card.excerpt?.from === undefined
1102
+ ? []
1103
+ : [{ at: `${at}.excerpt.from`, id: card.excerpt.from, over: "box" }]),
1104
+ ...(card.excerpt?.caption_from === undefined
1105
+ ? []
1106
+ : [
1107
+ {
1108
+ at: `${at}.excerpt.caption_from`,
1109
+ id: card.excerpt.caption_from,
1110
+ over: card.excerpt.caption,
1111
+ },
1112
+ ]),
1113
+ ];
1114
+ /**
1115
+ * What the schema cannot say about source tags: a tag names an id of the page's `sources`, over a
1116
+ * text the card has, the page has colours for its ids, and an id nothing uses is a dead entry.
1117
+ */
1118
+ function checkSources(cfg: Config): void {
1119
+ const tags = cardsOf(cfg).flatMap(([at, card]) => tagsOf(at, card));
1120
+ const ids = Object.keys(cfg.sources ?? {});
1121
+ if (ids.length > SOURCE_COLOURS)
1122
+ fail(
1123
+ `sources: ${String(ids.length)} ids, and the page has ${String(SOURCE_COLOURS)} colours for tags — merge ids, or drop one`,
1124
+ );
1125
+ tags.forEach((t) => {
1126
+ if (cfg.sources === undefined)
1127
+ fail(
1128
+ `${t.at}: names the source ${repr(t.id)}, and the page has no \`sources\` — add them, or take the tag out`,
1129
+ );
1130
+ else if (!has(cfg.sources, t.id))
1131
+ fail(`${t.at}: ${repr(t.id)} is not in \`sources\` (${ids.join(", ")})`);
1132
+ if (t.over === undefined)
1133
+ fail(
1134
+ `${t.at}: tags a text the card does not have — add it, or take the tag out`,
1135
+ );
1136
+ });
1137
+ const dead = ids.find((id) => !tags.some((t) => t.id === id));
1138
+ if (dead !== undefined)
1139
+ fail(
1140
+ `sources.${dead}: no item or practice item names it — a tag nothing carries is a dead entry`,
1141
+ );
1142
+ }
1007
1143
  /**
1008
1144
  * The texts a term may open a note from, the words asked about first: the quote, its source, then
1009
1145
  * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
@@ -1157,6 +1293,145 @@ function checkNoSourceShown(cfg: Config): void {
1157
1293
  }
1158
1294
  }
1159
1295
 
1296
+ // ── a second language for the page's own words (#394) ─────────────────────────────────────────
1297
+ /** The language of the page's own words when items.json does not name it. */
1298
+ const BASE_LANG = "en";
1299
+ /** In a translated `situation_question`, the number of the card's marked line. */
1300
+ const NUMBER_TOKEN = "{n}";
1301
+ /** A translated text is given exactly when the page shows the text it translates. */
1302
+ function checkPresence(
1303
+ where: string,
1304
+ shown: string | undefined,
1305
+ given: string | undefined,
1306
+ ): void {
1307
+ if (shown !== undefined && given === undefined)
1308
+ fail(`${where}: is required and missing — ${shown}`);
1309
+ if (shown === undefined && given !== undefined)
1310
+ fail(`${where}: is never shown — the page has no text it translates`);
1311
+ }
1312
+ /** The cards' first question, translated: given exactly when a card has a `situation`. */
1313
+ function checkSituationWords(cfg: Config, at: string, t: Translation): void {
1314
+ const cards = cardsOf(cfg).filter(([, c]) => c.situation !== undefined);
1315
+ const first = cards[0];
1316
+ checkPresence(
1317
+ `${at}.situation_question`,
1318
+ first &&
1319
+ `${first[0]}.situation asks the first question in the card's own words`,
1320
+ t.situation_question,
1321
+ );
1322
+ const q = t.situation_question ?? "";
1323
+ const bare = cards.find(([, c]) => lineOf(c) === undefined);
1324
+ if (
1325
+ bare !== undefined &&
1326
+ [NUMBER_TOKEN, LINE_TOKEN].some((token) => q.includes(token))
1327
+ )
1328
+ fail(
1329
+ `${at}.situation_question: names the marked line, and ${bare[0]} has no \`excerpt\` — add it, or write the question without \`${NUMBER_TOKEN}\` and \`${LINE_TOKEN}\``,
1330
+ );
1331
+ }
1332
+ /** One answer in the translation's words; refused when the translation leaves it out. */
1333
+ const optionIn = (o: Option, words: QuestionWords, where: string): Option => {
1334
+ const w =
1335
+ words.options[o.value] ??
1336
+ fail(
1337
+ `${where}.options.${o.value}: is required and missing — every answer is translated`,
1338
+ );
1339
+ checkPresence(
1340
+ `${where}.options.${o.value}.ask_why`,
1341
+ o.ask_why && "this answer asks why",
1342
+ w.ask_why,
1343
+ );
1344
+ return {
1345
+ ...R.omit(o, ["ask_why"]),
1346
+ label: w.label,
1347
+ example: w.example,
1348
+ ...R.pickBy({ ask_why: w.ask_why }, R.isDefined),
1349
+ };
1350
+ };
1351
+ /** One question in the translation's words: every answer of it, no answer it does not have. */
1352
+ function questionIn(q: Question, t: Translation, at: string): Question {
1353
+ const where = `${at}.questions.${q.id}`;
1354
+ const words =
1355
+ t.questions[q.id] ??
1356
+ fail(
1357
+ `${where}: is required and missing — every question of the page is translated`,
1358
+ );
1359
+ const values = q.options.map((o) => o.value);
1360
+ const stray = Object.keys(words.options).find((v) => !values.includes(v));
1361
+ if (stray !== undefined)
1362
+ fail(`${where}.options.${stray}: not one of ${values.join(", ")}`);
1363
+ const options = q.options.map((o) => optionIn(o, words, where));
1364
+ const twice = firstRepeat(options.map((o) => o.label));
1365
+ if (twice !== undefined)
1366
+ fail(
1367
+ `${where}.options.${String(values[twice.at])}.label: ${repr(options[twice.at]?.label)} reads the same as options.${String(values[twice.earlier])}.label — each button needs its own label`,
1368
+ );
1369
+ return {
1370
+ id: q.id,
1371
+ text: words.text,
1372
+ ...R.pickBy({ short: words.short }, R.isDefined),
1373
+ options,
1374
+ };
1375
+ }
1376
+ /** A translated tag label names an id of the page's `sources`. */
1377
+ function checkTranslatedSources(cfg: Config, at: string, t: Translation): void {
1378
+ const known = Object.keys(cfg.sources ?? {});
1379
+ const stray = Object.keys(t.sources ?? {}).find((id) => !known.includes(id));
1380
+ if (stray !== undefined)
1381
+ fail(
1382
+ `${at}.sources.${stray}: not one of the page's \`sources\` (${known.join(", ") || "none"})`,
1383
+ );
1384
+ }
1385
+ /** The translated words a translation may leave out. */
1386
+ const optionalWords = (t: Translation) =>
1387
+ R.pickBy(
1388
+ { situation_question: t.situation_question, sources: t.sources },
1389
+ R.isDefined,
1390
+ );
1391
+ /**
1392
+ * Every language of the page with its words whole, the translations checked: each covers every
1393
+ * question and answer of the page and names none it does not have, and gives an intro, a reason
1394
+ * prompt and a situation question exactly where the page shows one. A label it leaves out reads as
1395
+ * in the page's own language.
1396
+ */
1397
+ function translationsOf(cfg: Config) {
1398
+ const base = cfg.lang ?? BASE_LANG;
1399
+ return R.mapValues(cfg.translations ?? {}, (t, lang) => {
1400
+ const at = `translations.${lang}`;
1401
+ if (lang === base)
1402
+ fail(
1403
+ `${at}: is the page's own language (\`lang\` is ${repr(base)}) — translate into another one`,
1404
+ );
1405
+ checkPresence(
1406
+ `${at}.intro`,
1407
+ cfg.intro && "the page has an `intro`",
1408
+ t.intro,
1409
+ );
1410
+ checkSituationWords(cfg, at, t);
1411
+ checkTranslatedSources(cfg, at, t);
1412
+ const ids = cfg.questions.map((q) => q.id);
1413
+ const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
1414
+ if (stray !== undefined)
1415
+ fail(
1416
+ `${at}.questions.${stray}: not a question of this page (${ids.join(", ")})`,
1417
+ );
1418
+ return {
1419
+ title: t.title ?? cfg.title,
1420
+ intro: t.intro ?? "",
1421
+ labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
1422
+ ...optionalWords(t),
1423
+ questions: cfg.questions.map((q) => questionIn(q, t, at)),
1424
+ };
1425
+ });
1426
+ }
1427
+ type Translated = ReturnType<typeof translationsOf>;
1428
+ /** The languages: the page's own when items.json names it, and with translations always (English when unnamed).
1429
+ * A page that names neither is as it was. */
1430
+ const languagesOf = (cfg: Config, translated: Translated) =>
1431
+ cfg.translations === undefined
1432
+ ? R.pickBy({ lang: cfg.lang }, R.isDefined)
1433
+ : { lang: cfg.lang ?? BASE_LANG, translations: translated };
1434
+
1160
1435
  // ── ids: neutral, in an order fixed by the seed, kept across rebuilds ─────────────────────────
1161
1436
  interface KeyEntry {
1162
1437
  readonly source_id: string;
@@ -1222,7 +1497,10 @@ const situationShown = (item: Card) =>
1222
1497
  ? {}
1223
1498
  : {
1224
1499
  situation: {
1225
- ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
1500
+ ...R.pickBy(
1501
+ { summary: item.situation.summary, from: item.situation.from },
1502
+ R.isDefined,
1503
+ ),
1226
1504
  question: item.situation.question,
1227
1505
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
1228
1506
  },
@@ -1320,7 +1598,19 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
1320
1598
  hidden: "lines {from}\u2013{to}",
1321
1599
  hidden_one: "line {from}",
1322
1600
  wait: "\u23f3 You can answer in {s} s",
1601
+ how_to: "📖 How to answer",
1602
+ for_example: "For example: ",
1323
1603
  };
1604
+ /** The tags' ids and labels in colour order, only when items.json has `sources`: a page without them is as it was. */
1605
+ const sourcesShown = (cfg: Config) =>
1606
+ cfg.sources === undefined
1607
+ ? {}
1608
+ : {
1609
+ sources: Object.entries(cfg.sources).map(([id, label]) => ({
1610
+ id,
1611
+ label,
1612
+ })),
1613
+ };
1324
1614
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
1325
1615
  const twoKeys = (cfg: Config) =>
1326
1616
  R.pickBy(
@@ -1332,6 +1622,7 @@ function payloadOf(
1332
1622
  cfg: Config,
1333
1623
  page: ReturnType<typeof itemsOnPage>,
1334
1624
  described: Described,
1625
+ translated: Translated,
1335
1626
  ) {
1336
1627
  const used = Object.keys(cfg.groups).filter((g) =>
1337
1628
  page.items.some((i) => i.group === g),
@@ -1340,6 +1631,7 @@ function payloadOf(
1340
1631
  title: cfg.title,
1341
1632
  intro: cfg.intro ?? "",
1342
1633
  labels: { ...DEFAULT_LABELS, ...cfg.labels },
1634
+ ...sourcesShown(cfg),
1343
1635
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
1344
1636
  familiarity_question: asksFamiliarity(cfg)
1345
1637
  ? (cfg.familiarity_question ?? "How well do you know {group}?")
@@ -1347,6 +1639,7 @@ function payloadOf(
1347
1639
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
1348
1640
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
1349
1641
  ...twoKeys(cfg),
1642
+ ...languagesOf(cfg, translated),
1350
1643
  questions: cfg.questions,
1351
1644
  practice: {
1352
1645
  round: practiceRound(cfg),
@@ -1444,7 +1737,9 @@ export function prepareBuild(configPath: string, outDir: string) {
1444
1737
  checkUnits(cfg);
1445
1738
  checkBlocks(cfg);
1446
1739
  checkCardText(cfg);
1740
+ checkSources(cfg);
1447
1741
  checkNoSourceShown(cfg);
1742
+ const translated = translationsOf(cfg);
1448
1743
  const described = describeRules(cfg, dirname(configPath));
1449
1744
  mkdirSync(outDir, { recursive: true });
1450
1745
  const keyPath = join(outDir, "key.json");
@@ -1452,7 +1747,7 @@ export function prepareBuild(configPath: string, outDir: string) {
1452
1747
  const page = itemsOnPage(cfg, keyPath);
1453
1748
  writeJson(keyPath, keyOf(cfg, page, earlier));
1454
1749
  return {
1455
- payload: payloadOf(cfg, page, described),
1750
+ payload: payloadOf(cfg, page, described, translated),
1456
1751
  dropped: cfg.items.length - page.items.length,
1457
1752
  };
1458
1753
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Source tags (#400): the page-level `sources`, the `from` and `caption_from` fields that name one,
3
+ * what the build refuses by path, and what the page is handed.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { join } from "node:path";
7
+ import { test } from "vitest";
8
+ import { z } from "zod";
9
+ import {
10
+ ITEMS,
11
+ PRACTICE,
12
+ build,
13
+ config,
14
+ run,
15
+ setup,
16
+ } from "../../../test/label-page-kit.ts";
17
+
18
+ const SOURCES = { repo: "From the repository", ours: "Our restatement" };
19
+ const EXCERPT = { lines: [{ n: 3, text: "x = 1", mark: true }] };
20
+ const [FIRST, SECOND, ...REST] = ITEMS;
21
+ assert.ok(FIRST !== undefined && SECOND !== undefined);
22
+
23
+ /** A page whose first two cards carry a quote, a situation and an excerpt, each tagged by `tags`. */
24
+ function tagged(
25
+ tags: {
26
+ readonly quote?: string;
27
+ readonly situation?: string;
28
+ readonly excerpt?: string;
29
+ readonly caption?: string;
30
+ },
31
+ over: Record<string, unknown> = {},
32
+ ) {
33
+ return config({
34
+ sources: SOURCES,
35
+ items: [
36
+ {
37
+ ...FIRST,
38
+ quote: { text: "Use tabs", ...pick("from", tags.quote) },
39
+ situation: {
40
+ summary: "A reviewer objected.",
41
+ question: "Is line 3 right?",
42
+ ...pick("from", tags.situation),
43
+ },
44
+ excerpt: {
45
+ ...EXCERPT,
46
+ caption: "The reviewer wrote: no.",
47
+ ...pick("from", tags.excerpt),
48
+ ...pick("caption_from", tags.caption),
49
+ },
50
+ },
51
+ SECOND,
52
+ ...REST,
53
+ ],
54
+ ...over,
55
+ });
56
+ }
57
+ const pick = (key: string, value: string | undefined) =>
58
+ value === undefined ? {} : { [key]: value };
59
+ const ALL = {
60
+ quote: "repo",
61
+ situation: "ours",
62
+ excerpt: "repo",
63
+ caption: "ours",
64
+ };
65
+
66
+ const Data = z
67
+ .object({
68
+ sources: z
69
+ .array(z.object({ id: z.string(), label: z.string() }))
70
+ .optional(),
71
+ items: z.array(z.record(z.string(), z.unknown())),
72
+ })
73
+ .loose();
74
+ const dataOf = (page: string) =>
75
+ Data.parse(
76
+ JSON.parse(
77
+ String(
78
+ page
79
+ .split('<script type="application/json" id="data">')[1]
80
+ ?.split("</script>")[0],
81
+ ).replaceAll("<\\/", "</"),
82
+ ),
83
+ );
84
+
85
+ test("🔴 sources and the four tags reach the page whole, in colour order; a page without sources carries none of it", () => {
86
+ const data = dataOf(build(setup("src-happy", tagged(ALL))).page);
87
+ assert.deepEqual(
88
+ [
89
+ data.sources,
90
+ ...["quote", "situation", "excerpt"].map(
91
+ (k) =>
92
+ z
93
+ .object({ from: z.string() })
94
+ .loose()
95
+ .parse(data.items.find((i) => k in i)?.[k]).from,
96
+ ),
97
+ z
98
+ .object({ caption_from: z.string() })
99
+ .loose()
100
+ .parse(data.items.find((i) => "excerpt" in i)?.["excerpt"])
101
+ .caption_from,
102
+ ],
103
+ [
104
+ [
105
+ { id: "repo", label: SOURCES.repo },
106
+ { id: "ours", label: SOURCES.ours },
107
+ ],
108
+ "repo",
109
+ "ours",
110
+ "repo",
111
+ "ours",
112
+ ],
113
+ );
114
+ const plain = build(setup("src-none", config())).page;
115
+ // Guards: no sources, no key on the page, so it is as it was.
116
+ assert.equal(Object.hasOwn(dataOf(plain), "sources"), false);
117
+ assert.equal(plain.includes('"from"'), false);
118
+ });
119
+
120
+ const PRACTICE_TAGGED = config({
121
+ sources: { repo: "From the repository" },
122
+ practice: [
123
+ {
124
+ body: "p",
125
+ quote: { text: "q", from: "repo" },
126
+ expected: { label: "yes", boundary: "clear" },
127
+ why: "w",
128
+ },
129
+ ...PRACTICE.slice(1),
130
+ ],
131
+ });
132
+
133
+ /** Pages the build refuses, each with its whole refusal. */
134
+ const REFUSED: readonly (readonly [string, Record<string, unknown>, string])[] =
135
+ [
136
+ [
137
+ "src-no-sources",
138
+ config({
139
+ items: [
140
+ { ...FIRST, quote: { text: "t", from: "repo" } },
141
+ SECOND,
142
+ ...REST,
143
+ ],
144
+ }),
145
+ "items[0].quote.from: names the source 'repo', and the page has no `sources` — add them, or take the tag out",
146
+ ],
147
+ [
148
+ "src-unknown-id",
149
+ tagged({ quote: "repo", situation: "nope" }),
150
+ "items[0].situation.from: 'nope' is not in `sources` (repo, ours)",
151
+ ],
152
+ [
153
+ "src-unknown-caption",
154
+ tagged({ quote: "repo", caption: "nope" }),
155
+ "items[0].excerpt.caption_from: 'nope' is not in `sources` (repo, ours)",
156
+ ],
157
+ [
158
+ "src-dead-entry",
159
+ tagged({ quote: "repo" }, { sources: { ...SOURCES, spare: "Spare" } }),
160
+ "sources.ours: no item or practice item names it — a tag nothing carries is a dead entry",
161
+ ],
162
+ [
163
+ "src-empty-label",
164
+ tagged(
165
+ { quote: "repo", situation: "ours" },
166
+ { sources: { repo: "", ours: "O" } },
167
+ ),
168
+ "sources.repo: must be a non-empty string, got ''",
169
+ ],
170
+ [
171
+ "src-too-many",
172
+ tagged(
173
+ { quote: "a" },
174
+ { sources: Object.fromEntries("abcdef".split("").map((c) => [c, c])) },
175
+ ),
176
+ "sources: 6 ids, and the page has 5 colours for tags — merge ids, or drop one",
177
+ ],
178
+ [
179
+ "src-no-caption",
180
+ config({
181
+ sources: SOURCES,
182
+ items: [
183
+ { ...FIRST, excerpt: { ...EXCERPT, caption_from: "repo" } },
184
+ { ...SECOND, quote: { text: "t", from: "ours" } },
185
+ ...REST,
186
+ ],
187
+ }),
188
+ "items[0].excerpt.caption_from: tags a text the card does not have — add it, or take the tag out",
189
+ ],
190
+ [
191
+ "src-no-summary",
192
+ config({
193
+ sources: SOURCES,
194
+ items: [
195
+ {
196
+ ...FIRST,
197
+ situation: { question: "Is line 3 right?", from: "repo" },
198
+ excerpt: EXCERPT,
199
+ },
200
+ { ...SECOND, quote: { text: "t", from: "ours" } },
201
+ ...REST,
202
+ ],
203
+ }),
204
+ "items[0].situation.from: tags a text the card does not have — add it, or take the tag out",
205
+ ],
206
+ ];
207
+
208
+ test("🔴 the build refuses a tag with no sources, an id not in sources, a dead entry, an empty label, more ids than colours, and a tag over a text the card lacks", () => {
209
+ assert.deepEqual(
210
+ REFUSED.map(([name, cfg]) => {
211
+ const dir = setup(name, cfg);
212
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
213
+ }),
214
+ REFUSED.map(([, , message]) => ({
215
+ status: 1,
216
+ out: `REFUSED: ${message}\n`,
217
+ })),
218
+ );
219
+ });
220
+
221
+ test("a practice item may carry a tag, and it counts as using the source", () => {
222
+ const { page } = build(
223
+ setup("src-practice", {
224
+ ...PRACTICE_TAGGED,
225
+ items: [{ ...FIRST }, ...ITEMS.slice(1)],
226
+ }),
227
+ );
228
+ assert.deepEqual(dataOf(page).sources, [
229
+ { id: "repo", label: "From the repository" },
230
+ ]);
231
+ });