paperlint 14.2.0 → 14.3.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.
@@ -111,6 +111,9 @@ const UNIT_FIELD = "unit";
111
111
  const READINGS_FIELD = "readings";
112
112
  // Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
113
113
  // items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
114
+ // `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
115
+ // its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
116
+ // under an answer that says what the answer means on this card.
114
117
  const BLOCK_FIELDS = [
115
118
  "situation",
116
119
  "excerpt",
@@ -118,6 +121,9 @@ const BLOCK_FIELDS = [
118
121
  "more",
119
122
  "table",
120
123
  "repeat",
124
+ "quote",
125
+ "terms",
126
+ "answer_notes",
121
127
  ];
122
128
  const ALLOWED_FIELDS = [
123
129
  ...PAGE_FIELDS,
@@ -183,9 +189,9 @@ const array = (items, required = true, min = 0) => ({
183
189
  min,
184
190
  items,
185
191
  });
186
- const mapping = (values, keyPattern, min = 0) => ({
192
+ const mapping = (values, keyPattern, min = 0, required = true) => ({
187
193
  kind: "map",
188
- required: true,
194
+ required,
189
195
  min,
190
196
  key_pattern: keyPattern,
191
197
  values,
@@ -216,14 +222,42 @@ const CARD_FIELDS = {
216
222
  }, true, false),
217
223
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
218
224
  file: string(false),
219
- situation: optional({ summary: string(), question: string() }),
225
+ situation: optional({ summary: string(false), question: string() }),
220
226
  excerpt: optional({
221
227
  file: string(false),
228
+ // the file's language, after its path: "path · Python"
229
+ lang: string(false),
222
230
  url: LINK,
223
- lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
231
+ // the header of an extra gutter column; each line's `gutter` is its value there ("depth")
232
+ gutter_header: string(false),
233
+ lines: array(obj({
234
+ n: integer(),
235
+ text: text(),
236
+ mark: boolean(),
237
+ gutter: string(false),
238
+ // a line shown only to place the code (an enclosing block's opener): drawn faint
239
+ context: boolean(false),
240
+ }), true, 1),
241
+ // one short fact, drawn in a box right under the marked line, pointing at it
242
+ annotation: string(false),
224
243
  caption: string(false),
225
244
  mark_label: string(false),
226
245
  }),
246
+ quote: optional({ text: string(), source: string(false) }),
247
+ terms: array(obj({
248
+ term: string(),
249
+ note: array(obj({
250
+ title: string(false),
251
+ says: string(false),
252
+ quote: string(false),
253
+ code: string(false),
254
+ example: string(false),
255
+ url: string(true, "https?://\\S+", "an http(s) link"),
256
+ url_label: string(),
257
+ }), true, 1),
258
+ }), false, 1),
259
+ // question id → option value → the sub-line under that answer's button on this card
260
+ answer_notes: mapping(mapping(string(), NAME_PATTERN, 1), NAME_PATTERN, 1, false),
227
261
  references: optional({
228
262
  summary: string(),
229
263
  entries: array(obj({
@@ -282,7 +316,7 @@ const itemSchema = (kind) => obj({
282
316
  source_id: string(),
283
317
  ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
284
318
  f,
285
- string(f === "group" || f === "body"),
319
+ string(f === "group"),
286
320
  ])),
287
321
  ...cardFields(kind),
288
322
  [ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
@@ -300,7 +334,7 @@ const OPTION_SCHEMA = obj({
300
334
  const practiceSchema = (kind) => obj({
301
335
  ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
302
336
  f,
303
- string(f === "body"),
337
+ string(false),
304
338
  ])),
305
339
  ...cardFields(kind),
306
340
  expected: mapping(string(), NAME_PATTERN, 1),
@@ -340,6 +374,9 @@ const configSchema = (kind) => obj({
340
374
  line_part: string(false),
341
375
  repeat: string(false),
342
376
  practice_example: string(false),
377
+ hidden: string(false),
378
+ hidden_one: string(false),
379
+ wait: string(false),
343
380
  }),
344
381
  items: array(itemSchema(kind), true, 1),
345
382
  });
@@ -600,7 +637,7 @@ const SITUATION_TEXTS = ["summary", "question"];
600
637
  const lineOf = (card) => card.excerpt?.lines
601
638
  .filter((l) => l.mark)
602
639
  .map((l) => ({ n: l.n, text: l.text.trim() }))[0];
603
- const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
640
+ const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
604
641
  /**
605
642
  * What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
606
643
  * and a repeat sits inside its count.
@@ -609,7 +646,7 @@ function checkBlocks(cfg) {
609
646
  cardsOf(cfg).forEach(([at, card]) => {
610
647
  if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
611
648
  fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
612
- const token = SITUATION_TEXTS.find((f) => card.situation?.[f].includes(LINE_TOKEN));
649
+ const token = SITUATION_TEXTS.find((f) => card.situation?.[f]?.includes(LINE_TOKEN));
613
650
  if (token !== undefined && card.excerpt === undefined)
614
651
  fail(`${at}.situation.${token}: \`${LINE_TOKEN}\` names the excerpt's marked line, and the card has no \`excerpt\` — add it, or write the ${token} without \`${LINE_TOKEN}\``);
615
652
  const r = card.repeat;
@@ -617,6 +654,61 @@ function checkBlocks(cfg) {
617
654
  fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
618
655
  });
619
656
  }
657
+ /**
658
+ * The texts a term may open a note from, the words asked about first: the quote, its source, then
659
+ * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
660
+ * backticks, so a term the build finds is a term the page draws.
661
+ */
662
+ const termTexts = (card) => [card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(R.isDefined);
663
+ const findsTerm = (card, term) => termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
664
+ /**
665
+ * What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
666
+ * term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
667
+ * note names a question of the page and one of its options.
668
+ */
669
+ function checkCardText(cfg) {
670
+ const options = new Map(cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]));
671
+ cardsOf(cfg).forEach(([at, card]) => {
672
+ if (card.body === undefined && card.quote === undefined)
673
+ fail(`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``);
674
+ checkTerms(at, card);
675
+ checkAnswerNotes(at, card, options);
676
+ });
677
+ }
678
+ /**
679
+ * One term's text inside another's on the same card (or the same text twice): the page draws one
680
+ * button for the words, so the other term's note would never open.
681
+ */
682
+ function checkTermsDoNotOverlap(at, card) {
683
+ const texts = (card.terms ?? []).map((t) => t.term);
684
+ const named = (k) => `${at}.terms[${String(k)}]`;
685
+ texts.forEach((inner, k) => {
686
+ const j = texts.findIndex((outer, m) => m !== k && outer.includes(inner) && (outer !== inner || m > k));
687
+ if (j !== -1)
688
+ fail(`${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`);
689
+ });
690
+ }
691
+ function checkTerms(at, card) {
692
+ checkTermsDoNotOverlap(at, card);
693
+ (card.terms ?? []).forEach((t, k) => {
694
+ const where = `${at}.terms[${String(k)}]`;
695
+ if (!findsTerm(card, t.term))
696
+ fail(`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`);
697
+ const bare = t.note.findIndex((e) => e.quote === undefined && e.code === undefined);
698
+ if (bare !== -1)
699
+ fail(`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`);
700
+ });
701
+ }
702
+ function checkAnswerNotes(at, card, options) {
703
+ Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
704
+ const values = options.get(qid);
705
+ if (values === undefined)
706
+ return fail(`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`);
707
+ const stray = Object.keys(notes).find((v) => !values.includes(v));
708
+ if (stray !== undefined)
709
+ fail(`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`);
710
+ });
711
+ }
620
712
  /** Every card of the config, items then practice, by path. */
621
713
  const cardsOf = (cfg) => [
622
714
  ...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
@@ -713,7 +805,7 @@ const situationShown = (item) => item.situation === undefined
713
805
  ? {}
714
806
  : {
715
807
  situation: {
716
- summary: item.situation.summary,
808
+ ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
717
809
  question: item.situation.question,
718
810
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
719
811
  },
@@ -787,6 +879,9 @@ const DEFAULT_LABELS = {
787
879
  line_part: "{line} · part {part} of {of}",
788
880
  repeat: "Card {index} of {of}",
789
881
  practice_example: "",
882
+ hidden: "lines {from}\u2013{to}",
883
+ hidden_one: "line {from}",
884
+ wait: "\u23f3 You can answer in {s} s",
790
885
  };
791
886
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
792
887
  const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
@@ -886,6 +981,7 @@ export function prepareBuild(configPath, outDir) {
886
981
  checkItemFields(cfg);
887
982
  checkUnits(cfg);
888
983
  checkBlocks(cfg);
984
+ checkCardText(cfg);
889
985
  checkNoSourceShown(cfg);
890
986
  const described = describeRules(cfg, dirname(configPath));
891
987
  mkdirSync(outDir, { recursive: true });
@@ -86,11 +86,32 @@ const CARD = {
86
86
  situation: { summary: "S", question: "Q?" },
87
87
  excerpt: {
88
88
  file: "src/a.ts",
89
+ lang: "TypeScript",
89
90
  url: "https://example.com/a.ts",
90
- lines: [{ n: 1, text: "", mark: true }],
91
+ gutter_header: "depth",
92
+ lines: [{ n: 1, text: "", mark: true, gutter: "1", context: false }],
93
+ annotation: "a `Term` here",
91
94
  caption: "C",
92
95
  mark_label: "M",
93
96
  },
97
+ quote: { text: "Q with Term", source: "`R.md`, line 1" },
98
+ terms: [
99
+ {
100
+ term: "Term",
101
+ note: [
102
+ {
103
+ title: "T",
104
+ says: "S",
105
+ quote: "Q",
106
+ code: "x = 1",
107
+ example: "E",
108
+ url: "https://example.com/t",
109
+ url_label: "U",
110
+ },
111
+ ],
112
+ },
113
+ ],
114
+ answer_notes: { label: { yes: "like A" } },
94
115
  references: {
95
116
  summary: "S",
96
117
  entries: [
@@ -145,6 +166,9 @@ const FULL: DocObject = (() => {
145
166
  line_part: "{line} {part}/{of}",
146
167
  repeat: "R",
147
168
  practice_example: "P",
169
+ hidden: "H {from}-{to}",
170
+ hidden_one: "H {from}",
171
+ wait: "W {s}",
148
172
  },
149
173
  practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
150
174
  items: ITEMS.map((i, n) =>
@@ -120,6 +120,9 @@ const UNIT_FIELD = "unit";
120
120
  const READINGS_FIELD = "readings";
121
121
  // Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
122
122
  // items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
123
+ // `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
124
+ // its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
125
+ // under an answer that says what the answer means on this card.
123
126
  const BLOCK_FIELDS = [
124
127
  "situation",
125
128
  "excerpt",
@@ -127,6 +130,9 @@ const BLOCK_FIELDS = [
127
130
  "more",
128
131
  "table",
129
132
  "repeat",
133
+ "quote",
134
+ "terms",
135
+ "answer_notes",
130
136
  ] as const;
131
137
  const ALLOWED_FIELDS: readonly string[] = [
132
138
  ...PAGE_FIELDS,
@@ -227,9 +233,10 @@ const mapping = (
227
233
  values: SchemaNode,
228
234
  keyPattern: string,
229
235
  min = 0,
236
+ required = true,
230
237
  ): SchemaNode => ({
231
238
  kind: "map",
232
- required: true,
239
+ required,
233
240
  min,
234
241
  key_pattern: keyPattern,
235
242
  values,
@@ -273,14 +280,59 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
273
280
  ),
274
281
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
275
282
  file: string(false),
276
- situation: optional({ summary: string(), question: string() }),
283
+ situation: optional({ summary: string(false), question: string() }),
277
284
  excerpt: optional({
278
285
  file: string(false),
286
+ // the file's language, after its path: "path · Python"
287
+ lang: string(false),
279
288
  url: LINK,
280
- lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
289
+ // the header of an extra gutter column; each line's `gutter` is its value there ("depth")
290
+ gutter_header: string(false),
291
+ lines: array(
292
+ obj({
293
+ n: integer(),
294
+ text: text(),
295
+ mark: boolean(),
296
+ gutter: string(false),
297
+ // a line shown only to place the code (an enclosing block's opener): drawn faint
298
+ context: boolean(false),
299
+ }),
300
+ true,
301
+ 1,
302
+ ),
303
+ // one short fact, drawn in a box right under the marked line, pointing at it
304
+ annotation: string(false),
281
305
  caption: string(false),
282
306
  mark_label: string(false),
283
307
  }),
308
+ quote: optional({ text: string(), source: string(false) }),
309
+ terms: array(
310
+ obj({
311
+ term: string(),
312
+ note: array(
313
+ obj({
314
+ title: string(false),
315
+ says: string(false),
316
+ quote: string(false),
317
+ code: string(false),
318
+ example: string(false),
319
+ url: string(true, "https?://\\S+", "an http(s) link"),
320
+ url_label: string(),
321
+ }),
322
+ true,
323
+ 1,
324
+ ),
325
+ }),
326
+ false,
327
+ 1,
328
+ ),
329
+ // question id → option value → the sub-line under that answer's button on this card
330
+ answer_notes: mapping(
331
+ mapping(string(), NAME_PATTERN, 1),
332
+ NAME_PATTERN,
333
+ 1,
334
+ false,
335
+ ),
284
336
  references: optional({
285
337
  summary: string(),
286
338
  entries: array(
@@ -362,7 +414,7 @@ const itemSchema = (kind: Kind): SchemaNode =>
362
414
  ...Object.fromEntries(
363
415
  PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
364
416
  f,
365
- string(f === "group" || f === "body"),
417
+ string(f === "group"),
366
418
  ]),
367
419
  ),
368
420
  ...cardFields(kind),
@@ -385,7 +437,7 @@ const practiceSchema = (kind: Kind): SchemaNode =>
385
437
  ...Object.fromEntries(
386
438
  PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
387
439
  f,
388
- string(f === "body"),
440
+ string(false),
389
441
  ]),
390
442
  ),
391
443
  ...cardFields(kind),
@@ -427,6 +479,9 @@ const configSchema = (kind: Kind): SchemaNode =>
427
479
  line_part: string(false),
428
480
  repeat: string(false),
429
481
  practice_example: string(false),
482
+ hidden: string(false),
483
+ hidden_one: string(false),
484
+ wait: string(false),
430
485
  }),
431
486
  items: array(itemSchema(kind), true, 1),
432
487
  });
@@ -578,17 +633,44 @@ interface CodeLine {
578
633
  readonly n: number;
579
634
  readonly text: string;
580
635
  readonly mark: boolean;
636
+ readonly gutter?: string;
637
+ readonly context?: boolean;
638
+ }
639
+ /** One entry of a term's note: a verbatim quote or snippet, and the link to where it is from. */
640
+ interface NoteEntry {
641
+ readonly title?: string;
642
+ readonly says?: string;
643
+ readonly quote?: string;
644
+ readonly code?: string;
645
+ readonly example?: string;
646
+ readonly url: string;
647
+ readonly url_label: string;
648
+ }
649
+ interface Term {
650
+ readonly term: string;
651
+ readonly note: readonly NoteEntry[];
581
652
  }
582
653
  /** The content blocks (#380), each optional on any kind. */
583
654
  interface Blocks {
584
- readonly situation?: { readonly summary: string; readonly question: string };
655
+ readonly situation?: {
656
+ readonly summary?: string;
657
+ readonly question: string;
658
+ };
585
659
  readonly excerpt?: {
586
660
  readonly file?: string;
661
+ readonly lang?: string;
587
662
  readonly url?: string;
663
+ readonly gutter_header?: string;
588
664
  readonly lines: readonly CodeLine[];
665
+ readonly annotation?: string;
589
666
  readonly caption?: string;
590
667
  readonly mark_label?: string;
591
668
  };
669
+ readonly quote?: { readonly text: string; readonly source?: string };
670
+ readonly terms?: readonly Term[];
671
+ readonly answer_notes?: Readonly<
672
+ Record<string, Readonly<Record<string, string>>>
673
+ >;
592
674
  readonly references?: {
593
675
  readonly summary: string;
594
676
  readonly entries: readonly {
@@ -624,7 +706,7 @@ interface Blocks {
624
706
  type Card = {
625
707
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
626
708
  } & Blocks & {
627
- readonly body: string;
709
+ readonly body?: string;
628
710
  readonly rules?: readonly RuleRef[];
629
711
  readonly unit?: Unit;
630
712
  readonly readings?: { readonly a: string; readonly b: string };
@@ -897,7 +979,7 @@ const lineOf = (card: Card) =>
897
979
  .filter((l) => l.mark)
898
980
  .map((l) => ({ n: l.n, text: l.text.trim() }))[0];
899
981
  const usesLine = (card: Card): boolean =>
900
- SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
982
+ SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
901
983
  /**
902
984
  * What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
903
985
  * and a repeat sits inside its count.
@@ -909,7 +991,7 @@ function checkBlocks(cfg: Config): void {
909
991
  `${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
910
992
  );
911
993
  const token = SITUATION_TEXTS.find((f) =>
912
- card.situation?.[f].includes(LINE_TOKEN),
994
+ card.situation?.[f]?.includes(LINE_TOKEN),
913
995
  );
914
996
  if (token !== undefined && card.excerpt === undefined)
915
997
  fail(
@@ -922,6 +1004,88 @@ function checkBlocks(cfg: Config): void {
922
1004
  );
923
1005
  });
924
1006
  }
1007
+ /**
1008
+ * The texts a term may open a note from, the words asked about first: the quote, its source, then
1009
+ * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
1010
+ * backticks, so a term the build finds is a term the page draws.
1011
+ */
1012
+ const termTexts = (card: Card): readonly string[] =>
1013
+ [card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(
1014
+ R.isDefined,
1015
+ );
1016
+ const findsTerm = (card: Card, term: string): boolean =>
1017
+ termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
1018
+ /**
1019
+ * What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
1020
+ * term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
1021
+ * note names a question of the page and one of its options.
1022
+ */
1023
+ function checkCardText(cfg: Config): void {
1024
+ const options = new Map(
1025
+ cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]),
1026
+ );
1027
+ cardsOf(cfg).forEach(([at, card]) => {
1028
+ if (card.body === undefined && card.quote === undefined)
1029
+ fail(
1030
+ `${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``,
1031
+ );
1032
+ checkTerms(at, card);
1033
+ checkAnswerNotes(at, card, options);
1034
+ });
1035
+ }
1036
+ /**
1037
+ * One term's text inside another's on the same card (or the same text twice): the page draws one
1038
+ * button for the words, so the other term's note would never open.
1039
+ */
1040
+ function checkTermsDoNotOverlap(at: string, card: Card): void {
1041
+ const texts = (card.terms ?? []).map((t) => t.term);
1042
+ const named = (k: number) => `${at}.terms[${String(k)}]`;
1043
+ texts.forEach((inner, k) => {
1044
+ const j = texts.findIndex(
1045
+ (outer, m) =>
1046
+ m !== k && outer.includes(inner) && (outer !== inner || m > k),
1047
+ );
1048
+ if (j !== -1)
1049
+ fail(
1050
+ `${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`,
1051
+ );
1052
+ });
1053
+ }
1054
+ function checkTerms(at: string, card: Card): void {
1055
+ checkTermsDoNotOverlap(at, card);
1056
+ (card.terms ?? []).forEach((t, k) => {
1057
+ const where = `${at}.terms[${String(k)}]`;
1058
+ if (!findsTerm(card, t.term))
1059
+ fail(
1060
+ `${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`,
1061
+ );
1062
+ const bare = t.note.findIndex(
1063
+ (e) => e.quote === undefined && e.code === undefined,
1064
+ );
1065
+ if (bare !== -1)
1066
+ fail(
1067
+ `${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`,
1068
+ );
1069
+ });
1070
+ }
1071
+ function checkAnswerNotes(
1072
+ at: string,
1073
+ card: Card,
1074
+ options: ReadonlyMap<string, readonly string[]>,
1075
+ ): void {
1076
+ Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
1077
+ const values = options.get(qid);
1078
+ if (values === undefined)
1079
+ return fail(
1080
+ `${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`,
1081
+ );
1082
+ const stray = Object.keys(notes).find((v) => !values.includes(v));
1083
+ if (stray !== undefined)
1084
+ fail(
1085
+ `${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`,
1086
+ );
1087
+ });
1088
+ }
925
1089
  /** Every card of the config, items then practice, by path. */
926
1090
  const cardsOf = (cfg: Config) => [
927
1091
  ...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
@@ -1058,7 +1222,7 @@ const situationShown = (item: Card) =>
1058
1222
  ? {}
1059
1223
  : {
1060
1224
  situation: {
1061
- summary: item.situation.summary,
1225
+ ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
1062
1226
  question: item.situation.question,
1063
1227
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
1064
1228
  },
@@ -1153,6 +1317,9 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
1153
1317
  line_part: "{line} · part {part} of {of}",
1154
1318
  repeat: "Card {index} of {of}",
1155
1319
  practice_example: "",
1320
+ hidden: "lines {from}\u2013{to}",
1321
+ hidden_one: "line {from}",
1322
+ wait: "\u23f3 You can answer in {s} s",
1156
1323
  };
1157
1324
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
1158
1325
  const twoKeys = (cfg: Config) =>
@@ -1276,6 +1443,7 @@ export function prepareBuild(configPath: string, outDir: string) {
1276
1443
  checkItemFields(cfg);
1277
1444
  checkUnits(cfg);
1278
1445
  checkBlocks(cfg);
1446
+ checkCardText(cfg);
1279
1447
  checkNoSourceShown(cfg);
1280
1448
  const described = describeRules(cfg, dirname(configPath));
1281
1449
  mkdirSync(outDir, { recursive: true });
@@ -87,7 +87,7 @@ const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
87
87
  );
88
88
  };
89
89
 
90
- test("🔴 a page naming the preset shows its words on the generic blocks: the linter's message, the rule's docs folded with the docs-examples note, the files like it, and the settings with their sides", async () => {
90
+ test("🔴 a page naming the preset shows its words on the generic blocks: the linter's message, the rule's docs with the docs-examples note, the files like it, and the settings with their sides", async () => {
91
91
  const { page, key } = build(setup("preset-face", preset()));
92
92
  const shown = await showPage(page, {}, {}, PRACTICE_DONE);
93
93
  const id = Object.entries(key.ids).find(