paperlint 14.5.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.
@@ -222,7 +222,12 @@ const CARD_FIELDS = {
222
222
  }, true, false),
223
223
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
224
224
  file: string(false),
225
- situation: optional({ summary: string(false), question: string() }),
225
+ situation: optional({
226
+ summary: string(false),
227
+ question: string(),
228
+ // the source tag over the summary (#400); the question is the page's own and gets none
229
+ from: string(false),
230
+ }),
226
231
  excerpt: optional({
227
232
  file: string(false),
228
233
  // the file's language, after its path: "path · Python"
@@ -242,8 +247,15 @@ const CARD_FIELDS = {
242
247
  annotation: string(false),
243
248
  caption: string(false),
244
249
  mark_label: string(false),
250
+ // source tags (#400): over the code box, and over the caption under it
251
+ from: string(false),
252
+ caption_from: string(false),
253
+ }),
254
+ quote: optional({
255
+ text: string(),
256
+ source: string(false),
257
+ from: string(false),
245
258
  }),
246
- quote: optional({ text: string(), source: string(false) }),
247
259
  terms: array(obj({
248
260
  term: string(),
249
261
  note: array(obj({
@@ -369,6 +381,8 @@ const TRANSLATION = obj({
369
381
  labels: LABELS,
370
382
  // the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
371
383
  situation_question: string(false),
384
+ // the labels of the page's `sources`, by id; one left out reads as in the page's own language
385
+ sources: mapping(string(), NAME_PATTERN, 1, false),
372
386
  questions: mapping(obj({
373
387
  text: string(),
374
388
  short: string(false),
@@ -395,6 +409,8 @@ const configSchema = (kind) => obj({
395
409
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
396
410
  practice_round: integer(false),
397
411
  labels: LABELS,
412
+ // the source tags a card's blocks carry (#400): id → the tag's label, in colour order
413
+ sources: mapping(string(), NAME_PATTERN, 1, false),
398
414
  // the language of the page's own words, and the same words in more languages, with a switch
399
415
  lang: string(false, NAME_PATTERN),
400
416
  translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
@@ -674,6 +690,57 @@ function checkBlocks(cfg) {
674
690
  fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
675
691
  });
676
692
  }
693
+ // ── source tags (#400) ────────────────────────────────────────────────────────────────────────
694
+ /** The colours the page has for tags (`.tag.s0` to `.tag.s4` in page-template.html), in `sources` order. */
695
+ export const SOURCE_COLOURS = 5;
696
+ /** Each tag a card asks for: the path of the field that names it, the id it names, the text it sits over. */
697
+ const tagsOf = (at, card) => [
698
+ ...(card.quote?.from === undefined
699
+ ? []
700
+ : [{ at: `${at}.quote.from`, id: card.quote.from, over: "text" }]),
701
+ ...(card.situation?.from === undefined
702
+ ? []
703
+ : [
704
+ {
705
+ at: `${at}.situation.from`,
706
+ id: card.situation.from,
707
+ over: card.situation.summary,
708
+ },
709
+ ]),
710
+ ...(card.excerpt?.from === undefined
711
+ ? []
712
+ : [{ at: `${at}.excerpt.from`, id: card.excerpt.from, over: "box" }]),
713
+ ...(card.excerpt?.caption_from === undefined
714
+ ? []
715
+ : [
716
+ {
717
+ at: `${at}.excerpt.caption_from`,
718
+ id: card.excerpt.caption_from,
719
+ over: card.excerpt.caption,
720
+ },
721
+ ]),
722
+ ];
723
+ /**
724
+ * What the schema cannot say about source tags: a tag names an id of the page's `sources`, over a
725
+ * text the card has, the page has colours for its ids, and an id nothing uses is a dead entry.
726
+ */
727
+ function checkSources(cfg) {
728
+ const tags = cardsOf(cfg).flatMap(([at, card]) => tagsOf(at, card));
729
+ const ids = Object.keys(cfg.sources ?? {});
730
+ if (ids.length > SOURCE_COLOURS)
731
+ fail(`sources: ${String(ids.length)} ids, and the page has ${String(SOURCE_COLOURS)} colours for tags — merge ids, or drop one`);
732
+ tags.forEach((t) => {
733
+ if (cfg.sources === undefined)
734
+ fail(`${t.at}: names the source ${repr(t.id)}, and the page has no \`sources\` — add them, or take the tag out`);
735
+ else if (!has(cfg.sources, t.id))
736
+ fail(`${t.at}: ${repr(t.id)} is not in \`sources\` (${ids.join(", ")})`);
737
+ if (t.over === undefined)
738
+ fail(`${t.at}: tags a text the card does not have — add it, or take the tag out`);
739
+ });
740
+ const dead = ids.find((id) => !tags.some((t) => t.id === id));
741
+ if (dead !== undefined)
742
+ fail(`sources.${dead}: no item or practice item names it — a tag nothing carries is a dead entry`);
743
+ }
677
744
  /**
678
745
  * The texts a term may open a note from, the words asked about first: the quote, its source, then
679
746
  * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
@@ -841,6 +908,15 @@ function questionIn(q, t, at) {
841
908
  options,
842
909
  };
843
910
  }
911
+ /** A translated tag label names an id of the page's `sources`. */
912
+ function checkTranslatedSources(cfg, at, t) {
913
+ const known = Object.keys(cfg.sources ?? {});
914
+ const stray = Object.keys(t.sources ?? {}).find((id) => !known.includes(id));
915
+ if (stray !== undefined)
916
+ fail(`${at}.sources.${stray}: not one of the page's \`sources\` (${known.join(", ") || "none"})`);
917
+ }
918
+ /** The translated words a translation may leave out. */
919
+ const optionalWords = (t) => R.pickBy({ situation_question: t.situation_question, sources: t.sources }, R.isDefined);
844
920
  /**
845
921
  * Every language of the page with its words whole, the translations checked: each covers every
846
922
  * question and answer of the page and names none it does not have, and gives an intro, a reason
@@ -855,6 +931,7 @@ function translationsOf(cfg) {
855
931
  fail(`${at}: is the page's own language (\`lang\` is ${repr(base)}) — translate into another one`);
856
932
  checkPresence(`${at}.intro`, cfg.intro && "the page has an `intro`", t.intro);
857
933
  checkSituationWords(cfg, at, t);
934
+ checkTranslatedSources(cfg, at, t);
858
935
  const ids = cfg.questions.map((q) => q.id);
859
936
  const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
860
937
  if (stray !== undefined)
@@ -863,7 +940,7 @@ function translationsOf(cfg) {
863
940
  title: t.title ?? cfg.title,
864
941
  intro: t.intro ?? "",
865
942
  labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
866
- ...R.pickBy({ situation_question: t.situation_question }, R.isDefined),
943
+ ...optionalWords(t),
867
944
  questions: cfg.questions.map((q) => questionIn(q, t, at)),
868
945
  };
869
946
  });
@@ -913,7 +990,7 @@ const situationShown = (item) => item.situation === undefined
913
990
  ? {}
914
991
  : {
915
992
  situation: {
916
- ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
993
+ ...R.pickBy({ summary: item.situation.summary, from: item.situation.from }, R.isDefined),
917
994
  question: item.situation.question,
918
995
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
919
996
  },
@@ -993,6 +1070,15 @@ const DEFAULT_LABELS = {
993
1070
  how_to: "📖 How to answer",
994
1071
  for_example: "For example: ",
995
1072
  };
1073
+ /** The tags' ids and labels in colour order, only when items.json has `sources`: a page without them is as it was. */
1074
+ const sourcesShown = (cfg) => cfg.sources === undefined
1075
+ ? {}
1076
+ : {
1077
+ sources: Object.entries(cfg.sources).map(([id, label]) => ({
1078
+ id,
1079
+ label,
1080
+ })),
1081
+ };
996
1082
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
997
1083
  const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
998
1084
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
@@ -1002,6 +1088,7 @@ function payloadOf(cfg, page, described, translated) {
1002
1088
  title: cfg.title,
1003
1089
  intro: cfg.intro ?? "",
1004
1090
  labels: { ...DEFAULT_LABELS, ...cfg.labels },
1091
+ ...sourcesShown(cfg),
1005
1092
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
1006
1093
  familiarity_question: asksFamiliarity(cfg)
1007
1094
  ? (cfg.familiarity_question ?? "How well do you know {group}?")
@@ -1093,6 +1180,7 @@ export function prepareBuild(configPath, outDir) {
1093
1180
  checkUnits(cfg);
1094
1181
  checkBlocks(cfg);
1095
1182
  checkCardText(cfg);
1183
+ checkSources(cfg);
1096
1184
  checkNoSourceShown(cfg);
1097
1185
  const translated = translationsOf(cfg);
1098
1186
  const described = describeRules(cfg, dirname(configPath));
@@ -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(),
@@ -473,6 +485,8 @@ const TRANSLATION = obj({
473
485
  labels: LABELS,
474
486
  // the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
475
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),
476
490
  questions: mapping(
477
491
  obj({
478
492
  text: string(),
@@ -508,6 +522,8 @@ const configSchema = (kind: Kind): SchemaNode =>
508
522
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
509
523
  practice_round: integer(false),
510
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),
511
527
  // the language of the page's own words, and the same words in more languages, with a switch
512
528
  lang: string(false, NAME_PATTERN),
513
529
  translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
@@ -683,6 +699,7 @@ interface Blocks {
683
699
  readonly situation?: {
684
700
  readonly summary?: string;
685
701
  readonly question: string;
702
+ readonly from?: string;
686
703
  };
687
704
  readonly excerpt?: {
688
705
  readonly file?: string;
@@ -693,8 +710,14 @@ interface Blocks {
693
710
  readonly annotation?: string;
694
711
  readonly caption?: string;
695
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;
696
720
  };
697
- readonly quote?: { readonly text: string; readonly source?: string };
698
721
  readonly terms?: readonly Term[];
699
722
  readonly answer_notes?: Readonly<
700
723
  Record<string, Readonly<Record<string, string>>>
@@ -764,6 +787,7 @@ interface Config {
764
787
  readonly practice: readonly Practice[];
765
788
  readonly practice_round?: number;
766
789
  readonly labels?: Readonly<Record<string, string>>;
790
+ readonly sources?: Readonly<Record<string, string>>;
767
791
  readonly lang?: string;
768
792
  readonly translations?: Readonly<Record<string, Translation>>;
769
793
  readonly items: readonly Item[];
@@ -788,6 +812,7 @@ interface Translation {
788
812
  readonly intro?: string;
789
813
  readonly labels?: Readonly<Record<string, string>>;
790
814
  readonly situation_question?: string;
815
+ readonly sources?: Readonly<Record<string, string>>;
791
816
  readonly questions: Readonly<Record<string, QuestionWords>>;
792
817
  }
793
818
  /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
@@ -1056,6 +1081,65 @@ function checkBlocks(cfg: Config): void {
1056
1081
  );
1057
1082
  });
1058
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
+ }
1059
1143
  /**
1060
1144
  * The texts a term may open a note from, the words asked about first: the quote, its source, then
1061
1145
  * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
@@ -1289,6 +1373,21 @@ function questionIn(q: Question, t: Translation, at: string): Question {
1289
1373
  options,
1290
1374
  };
1291
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
+ );
1292
1391
  /**
1293
1392
  * Every language of the page with its words whole, the translations checked: each covers every
1294
1393
  * question and answer of the page and names none it does not have, and gives an intro, a reason
@@ -1309,6 +1408,7 @@ function translationsOf(cfg: Config) {
1309
1408
  t.intro,
1310
1409
  );
1311
1410
  checkSituationWords(cfg, at, t);
1411
+ checkTranslatedSources(cfg, at, t);
1312
1412
  const ids = cfg.questions.map((q) => q.id);
1313
1413
  const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
1314
1414
  if (stray !== undefined)
@@ -1319,7 +1419,7 @@ function translationsOf(cfg: Config) {
1319
1419
  title: t.title ?? cfg.title,
1320
1420
  intro: t.intro ?? "",
1321
1421
  labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
1322
- ...R.pickBy({ situation_question: t.situation_question }, R.isDefined),
1422
+ ...optionalWords(t),
1323
1423
  questions: cfg.questions.map((q) => questionIn(q, t, at)),
1324
1424
  };
1325
1425
  });
@@ -1397,7 +1497,10 @@ const situationShown = (item: Card) =>
1397
1497
  ? {}
1398
1498
  : {
1399
1499
  situation: {
1400
- ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
1500
+ ...R.pickBy(
1501
+ { summary: item.situation.summary, from: item.situation.from },
1502
+ R.isDefined,
1503
+ ),
1401
1504
  question: item.situation.question,
1402
1505
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
1403
1506
  },
@@ -1498,6 +1601,16 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
1498
1601
  how_to: "📖 How to answer",
1499
1602
  for_example: "For example: ",
1500
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
+ };
1501
1614
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
1502
1615
  const twoKeys = (cfg: Config) =>
1503
1616
  R.pickBy(
@@ -1518,6 +1631,7 @@ function payloadOf(
1518
1631
  title: cfg.title,
1519
1632
  intro: cfg.intro ?? "",
1520
1633
  labels: { ...DEFAULT_LABELS, ...cfg.labels },
1634
+ ...sourcesShown(cfg),
1521
1635
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
1522
1636
  familiarity_question: asksFamiliarity(cfg)
1523
1637
  ? (cfg.familiarity_question ?? "How well do you know {group}?")
@@ -1623,6 +1737,7 @@ export function prepareBuild(configPath: string, outDir: string) {
1623
1737
  checkUnits(cfg);
1624
1738
  checkBlocks(cfg);
1625
1739
  checkCardText(cfg);
1740
+ checkSources(cfg);
1626
1741
  checkNoSourceShown(cfg);
1627
1742
  const translated = translationsOf(cfg);
1628
1743
  const described = describeRules(cfg, dirname(configPath));
@@ -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
+ });
@@ -369,3 +369,53 @@ test("🔴 a translation that leaves out or invents a question, an answer, an in
369
369
  [0, 0],
370
370
  );
371
371
  });
372
+
373
+ /** A page with two tagged cards, and Russian labels for its tags. */
374
+ const taggedPage = (ru: Record<string, unknown>) =>
375
+ config({
376
+ sources: { repo: "From the repository", ours: "Our restatement" },
377
+ items: ITEMS.map((i, n) =>
378
+ n < 2 ? { ...i, quote: { text: "t", from: n ? "ours" : "repo" } } : i,
379
+ ),
380
+ translations: { ru: { ...RU, ...ru } },
381
+ });
382
+
383
+ test("🔴 translations.<lang>.sources hands the page the tag labels in that language, and one left out stays the page's own", () => {
384
+ const ru = { sources: { repo: "Из репозитория" } };
385
+ const data = z
386
+ .object({
387
+ translations: z.object({
388
+ ru: z.object({ sources: z.record(z.string(), z.string()) }).loose(),
389
+ }),
390
+ })
391
+ .loose()
392
+ .parse(dataOf(build(setup("lang-sources", taggedPage(ru))).page));
393
+ assert.deepEqual(data.translations.ru.sources, ru.sources);
394
+ });
395
+
396
+ test("🔴 a translated tag label for an id the page's sources do not have is refused, with or without sources on the page", () => {
397
+ const stray = { sources: { zzz: "Чужой" } };
398
+ const refused = (name: string, cfg: Record<string, unknown>) => {
399
+ const dir = setup(name, cfg);
400
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
401
+ };
402
+ assert.deepEqual(
403
+ [
404
+ refused("lang-sources-stray", taggedPage(stray)),
405
+ refused(
406
+ "lang-sources-none",
407
+ withRu({ translations: { ru: { ...RU, ...stray } } }),
408
+ ),
409
+ ],
410
+ [
411
+ {
412
+ status: 1,
413
+ out: "REFUSED: translations.ru.sources.zzz: not one of the page's `sources` (repo, ours)\n",
414
+ },
415
+ {
416
+ status: 1,
417
+ out: "REFUSED: translations.ru.sources.zzz: not one of the page's `sources` (none)\n",
418
+ },
419
+ ],
420
+ );
421
+ });