paperlint 14.4.0 → 14.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.
@@ -347,6 +347,34 @@ const QUESTION_SCHEMA = obj({
347
347
  short: string(false),
348
348
  options: array(OPTION_SCHEMA, true, 2),
349
349
  });
350
+ // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
351
+ const LABELS = optional({
352
+ place: string(false),
353
+ body: string(false),
354
+ line: string(false),
355
+ line_part: string(false),
356
+ repeat: string(false),
357
+ practice_example: string(false),
358
+ hidden: string(false),
359
+ hidden_one: string(false),
360
+ wait: string(false),
361
+ how_to: string(false),
362
+ for_example: string(false),
363
+ });
364
+ // The page's own words in another language (#394): never the quote, code, notes or any card text
365
+ // the labeller judges. Keyed like the page, so a translation names its questions and answers by id.
366
+ const TRANSLATION = obj({
367
+ title: string(false),
368
+ intro: string(false),
369
+ labels: LABELS,
370
+ // the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
371
+ situation_question: string(false),
372
+ questions: mapping(obj({
373
+ text: string(),
374
+ short: string(false),
375
+ options: mapping(obj({ label: string(), example: string(), ask_why: string(false) }), NAME_PATTERN, 1),
376
+ }), NAME_PATTERN, 1),
377
+ });
350
378
  const configSchema = (kind) => obj({
351
379
  study: string(true, NAME_PATTERN), // becomes a file name
352
380
  kind: string(true, Object.keys(KINDS).join("|"), `one of ${KIND_NAMES}`),
@@ -366,18 +394,10 @@ const configSchema = (kind) => obj({
366
394
  questions: array(QUESTION_SCHEMA, true, 1),
367
395
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
368
396
  practice_round: integer(false),
369
- // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
370
- labels: optional({
371
- place: string(false),
372
- body: string(false),
373
- line: string(false),
374
- line_part: string(false),
375
- repeat: string(false),
376
- practice_example: string(false),
377
- hidden: string(false),
378
- hidden_one: string(false),
379
- wait: string(false),
380
- }),
397
+ labels: LABELS,
398
+ // the language of the page's own words, and the same words in more languages, with a switch
399
+ lang: string(false, NAME_PATTERN),
400
+ translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
381
401
  items: array(itemSchema(kind), true, 1),
382
402
  });
383
403
  /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
@@ -765,6 +785,94 @@ function checkNoSourceShown(cfg) {
765
785
  }
766
786
  }
767
787
  }
788
+ // ── a second language for the page's own words (#394) ─────────────────────────────────────────
789
+ /** The language of the page's own words when items.json does not name it. */
790
+ const BASE_LANG = "en";
791
+ /** In a translated `situation_question`, the number of the card's marked line. */
792
+ const NUMBER_TOKEN = "{n}";
793
+ /** A translated text is given exactly when the page shows the text it translates. */
794
+ function checkPresence(where, shown, given) {
795
+ if (shown !== undefined && given === undefined)
796
+ fail(`${where}: is required and missing — ${shown}`);
797
+ if (shown === undefined && given !== undefined)
798
+ fail(`${where}: is never shown — the page has no text it translates`);
799
+ }
800
+ /** The cards' first question, translated: given exactly when a card has a `situation`. */
801
+ function checkSituationWords(cfg, at, t) {
802
+ const cards = cardsOf(cfg).filter(([, c]) => c.situation !== undefined);
803
+ const first = cards[0];
804
+ checkPresence(`${at}.situation_question`, first &&
805
+ `${first[0]}.situation asks the first question in the card's own words`, t.situation_question);
806
+ const q = t.situation_question ?? "";
807
+ const bare = cards.find(([, c]) => lineOf(c) === undefined);
808
+ if (bare !== undefined &&
809
+ [NUMBER_TOKEN, LINE_TOKEN].some((token) => q.includes(token)))
810
+ fail(`${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}\``);
811
+ }
812
+ /** One answer in the translation's words; refused when the translation leaves it out. */
813
+ const optionIn = (o, words, where) => {
814
+ const w = words.options[o.value] ??
815
+ fail(`${where}.options.${o.value}: is required and missing — every answer is translated`);
816
+ checkPresence(`${where}.options.${o.value}.ask_why`, o.ask_why && "this answer asks why", w.ask_why);
817
+ return {
818
+ ...R.omit(o, ["ask_why"]),
819
+ label: w.label,
820
+ example: w.example,
821
+ ...R.pickBy({ ask_why: w.ask_why }, R.isDefined),
822
+ };
823
+ };
824
+ /** One question in the translation's words: every answer of it, no answer it does not have. */
825
+ function questionIn(q, t, at) {
826
+ const where = `${at}.questions.${q.id}`;
827
+ const words = t.questions[q.id] ??
828
+ fail(`${where}: is required and missing — every question of the page is translated`);
829
+ const values = q.options.map((o) => o.value);
830
+ const stray = Object.keys(words.options).find((v) => !values.includes(v));
831
+ if (stray !== undefined)
832
+ fail(`${where}.options.${stray}: not one of ${values.join(", ")}`);
833
+ const options = q.options.map((o) => optionIn(o, words, where));
834
+ const twice = firstRepeat(options.map((o) => o.label));
835
+ if (twice !== undefined)
836
+ fail(`${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`);
837
+ return {
838
+ id: q.id,
839
+ text: words.text,
840
+ ...R.pickBy({ short: words.short }, R.isDefined),
841
+ options,
842
+ };
843
+ }
844
+ /**
845
+ * Every language of the page with its words whole, the translations checked: each covers every
846
+ * question and answer of the page and names none it does not have, and gives an intro, a reason
847
+ * prompt and a situation question exactly where the page shows one. A label it leaves out reads as
848
+ * in the page's own language.
849
+ */
850
+ function translationsOf(cfg) {
851
+ const base = cfg.lang ?? BASE_LANG;
852
+ return R.mapValues(cfg.translations ?? {}, (t, lang) => {
853
+ const at = `translations.${lang}`;
854
+ if (lang === base)
855
+ fail(`${at}: is the page's own language (\`lang\` is ${repr(base)}) — translate into another one`);
856
+ checkPresence(`${at}.intro`, cfg.intro && "the page has an `intro`", t.intro);
857
+ checkSituationWords(cfg, at, t);
858
+ const ids = cfg.questions.map((q) => q.id);
859
+ const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
860
+ if (stray !== undefined)
861
+ fail(`${at}.questions.${stray}: not a question of this page (${ids.join(", ")})`);
862
+ return {
863
+ title: t.title ?? cfg.title,
864
+ intro: t.intro ?? "",
865
+ labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
866
+ ...R.pickBy({ situation_question: t.situation_question }, R.isDefined),
867
+ questions: cfg.questions.map((q) => questionIn(q, t, at)),
868
+ };
869
+ });
870
+ }
871
+ /** The languages: the page's own when items.json names it, and with translations always (English when unnamed).
872
+ * A page that names neither is as it was. */
873
+ const languagesOf = (cfg, translated) => cfg.translations === undefined
874
+ ? R.pickBy({ lang: cfg.lang }, R.isDefined)
875
+ : { lang: cfg.lang ?? BASE_LANG, translations: translated };
768
876
  /** key.json is this script's own output, written by `build`; its shape is trusted, not re-checked. */
769
877
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- an assertion signature, no body to run
770
878
  function assertOwnKey(_value) { }
@@ -882,11 +990,13 @@ const DEFAULT_LABELS = {
882
990
  hidden: "lines {from}\u2013{to}",
883
991
  hidden_one: "line {from}",
884
992
  wait: "\u23f3 You can answer in {s} s",
993
+ how_to: "📖 How to answer",
994
+ for_example: "For example: ",
885
995
  };
886
996
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
887
997
  const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
888
998
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
889
- function payloadOf(cfg, page, described) {
999
+ function payloadOf(cfg, page, described, translated) {
890
1000
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
891
1001
  return {
892
1002
  title: cfg.title,
@@ -899,6 +1009,7 @@ function payloadOf(cfg, page, described) {
899
1009
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
900
1010
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
901
1011
  ...twoKeys(cfg),
1012
+ ...languagesOf(cfg, translated),
902
1013
  questions: cfg.questions,
903
1014
  practice: {
904
1015
  round: practiceRound(cfg),
@@ -983,6 +1094,7 @@ export function prepareBuild(configPath, outDir) {
983
1094
  checkBlocks(cfg);
984
1095
  checkCardText(cfg);
985
1096
  checkNoSourceShown(cfg);
1097
+ const translated = translationsOf(cfg);
986
1098
  const described = describeRules(cfg, dirname(configPath));
987
1099
  mkdirSync(outDir, { recursive: true });
988
1100
  const keyPath = join(outDir, "key.json");
@@ -990,7 +1102,7 @@ export function prepareBuild(configPath, outDir) {
990
1102
  const page = itemsOnPage(cfg, keyPath);
991
1103
  writeJson(keyPath, keyOf(cfg, page, earlier));
992
1104
  return {
993
- payload: payloadOf(cfg, page, described),
1105
+ payload: payloadOf(cfg, page, described, translated),
994
1106
  dropped: cfg.items.length - page.items.length,
995
1107
  };
996
1108
  }
@@ -139,6 +139,42 @@ const CARD = {
139
139
  },
140
140
  repeat: { group: "g", index: 1, of: 1 },
141
141
  };
142
+ /** The translation of FULL's page, with every optional field present. */
143
+ const RU_FULL = {
144
+ title: "Т",
145
+ intro: "Разметьте.",
146
+ labels: {
147
+ place: "К",
148
+ body: "Т",
149
+ line: "С",
150
+ line_part: "{line} {part}/{of}",
151
+ repeat: "П",
152
+ practice_example: "П",
153
+ hidden: "С {from}-{to}",
154
+ hidden_one: "С {from}",
155
+ wait: "Ж {s}",
156
+ how_to: "Как",
157
+ for_example: "Н: ",
158
+ },
159
+ situation_question: "В?",
160
+ questions: {
161
+ label: {
162
+ text: "Верно?",
163
+ short: "верно?",
164
+ options: {
165
+ yes: { label: "Да", example: "1, 2, 3" },
166
+ no: { label: "Нет", example: "3, 1, 2", ask_why: "Что не так?" },
167
+ },
168
+ },
169
+ boundary: {
170
+ text: "Граница?",
171
+ options: {
172
+ clear: { label: "Ясно", example: "[]" },
173
+ boundary: { label: "Граница", example: "равные ключи" },
174
+ },
175
+ },
176
+ },
177
+ };
142
178
  /** A config with EVERY optional field present, so every schema path has a value to spoil. */
143
179
  const FULL: DocObject = (() => {
144
180
  const doc = Doc.parse(
@@ -169,7 +205,11 @@ const FULL: DocObject = (() => {
169
205
  hidden: "H {from}-{to}",
170
206
  hidden_one: "H {from}",
171
207
  wait: "W {s}",
208
+ how_to: "How",
209
+ for_example: "E: ",
172
210
  },
211
+ lang: "en",
212
+ translations: { ru: RU_FULL },
173
213
  practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
174
214
  items: ITEMS.map((i, n) =>
175
215
  n === 0 ? { ...i, ...CARD, answerers: ["author"] } : i,
@@ -304,6 +344,15 @@ function badCases({ node, member }: Leaf): readonly BadCase[] {
304
344
  return [...wrongType, ...extra];
305
345
  }
306
346
 
347
+ /** `doc` without the value at `steps`. */
348
+ function without(
349
+ doc: DocObject,
350
+ steps: readonly (string | number)[],
351
+ ): DocObject {
352
+ const out = put(doc, steps, undefined);
353
+ assert.ok(isObject(out));
354
+ return out;
355
+ }
307
356
  /** `doc` with its first item's own answerers taken out. */
308
357
  function withoutItemAnswerers(doc: DocObject): DocObject {
309
358
  const out = put(doc, ["items", 0, "answerers"], undefined);
@@ -317,6 +366,25 @@ function withoutItemAnswerers(doc: DocObject): DocObject {
317
366
  const RELAX: Partial<Record<string, (doc: DocObject) => DocObject>> = {
318
367
  familiarity: (doc) => R.omit(doc, ["familiarity_question"]),
319
368
  answerers: withoutItemAnswerers,
369
+ // A translation gives an intro, a reason prompt and a card question exactly where the page has one.
370
+ intro: (doc) => without(doc, ["translations", "ru", "intro"]),
371
+ "translations.ru.intro": (doc) => R.omit(doc, ["intro"]),
372
+ "questions[0].options[1].ask_why": (doc) =>
373
+ without(doc, [
374
+ "translations",
375
+ "ru",
376
+ "questions",
377
+ "label",
378
+ "options",
379
+ "no",
380
+ "ask_why",
381
+ ]),
382
+ "translations.ru.situation_question": (doc) =>
383
+ without(without(doc, ["items", 0, "situation"]), [
384
+ "practice",
385
+ 0,
386
+ "situation",
387
+ ]),
320
388
  };
321
389
 
322
390
  /** Spoil `leaf` each way in `badCases`; every one must stop the build and name the path. */
@@ -451,6 +451,42 @@ const QUESTION_SCHEMA = obj({
451
451
  short: string(false),
452
452
  options: array(OPTION_SCHEMA, true, 2),
453
453
  });
454
+ // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
455
+ const LABELS = optional({
456
+ place: string(false),
457
+ body: string(false),
458
+ line: string(false),
459
+ line_part: string(false),
460
+ repeat: string(false),
461
+ practice_example: string(false),
462
+ hidden: string(false),
463
+ hidden_one: string(false),
464
+ wait: string(false),
465
+ how_to: string(false),
466
+ for_example: string(false),
467
+ });
468
+ // The page's own words in another language (#394): never the quote, code, notes or any card text
469
+ // the labeller judges. Keyed like the page, so a translation names its questions and answers by id.
470
+ const TRANSLATION = obj({
471
+ title: string(false),
472
+ intro: string(false),
473
+ labels: LABELS,
474
+ // the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
475
+ situation_question: string(false),
476
+ questions: mapping(
477
+ obj({
478
+ text: string(),
479
+ short: string(false),
480
+ options: mapping(
481
+ obj({ label: string(), example: string(), ask_why: string(false) }),
482
+ NAME_PATTERN,
483
+ 1,
484
+ ),
485
+ }),
486
+ NAME_PATTERN,
487
+ 1,
488
+ ),
489
+ });
454
490
  const configSchema = (kind: Kind): SchemaNode =>
455
491
  obj({
456
492
  study: string(true, NAME_PATTERN), // becomes a file name
@@ -471,18 +507,10 @@ const configSchema = (kind: Kind): SchemaNode =>
471
507
  questions: array(QUESTION_SCHEMA, true, 1),
472
508
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
473
509
  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
- }),
510
+ labels: LABELS,
511
+ // the language of the page's own words, and the same words in more languages, with a switch
512
+ lang: string(false, NAME_PATTERN),
513
+ translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
486
514
  items: array(itemSchema(kind), true, 1),
487
515
  });
488
516
  /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
@@ -736,8 +764,32 @@ interface Config {
736
764
  readonly practice: readonly Practice[];
737
765
  readonly practice_round?: number;
738
766
  readonly labels?: Readonly<Record<string, string>>;
767
+ readonly lang?: string;
768
+ readonly translations?: Readonly<Record<string, Translation>>;
739
769
  readonly items: readonly Item[];
740
770
  }
771
+ interface QuestionWords {
772
+ readonly text: string;
773
+ readonly short?: string;
774
+ readonly options: Readonly<
775
+ Record<
776
+ string,
777
+ {
778
+ readonly label: string;
779
+ readonly example: string;
780
+ readonly ask_why?: string;
781
+ }
782
+ >
783
+ >;
784
+ }
785
+ /** The page's own words in one more language (#394). */
786
+ interface Translation {
787
+ readonly title?: string;
788
+ readonly intro?: string;
789
+ readonly labels?: Readonly<Record<string, string>>;
790
+ readonly situation_question?: string;
791
+ readonly questions: Readonly<Record<string, QuestionWords>>;
792
+ }
741
793
  /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
742
794
  function assertConfig(value: Json): asserts value is Json & Config {
743
795
  checkValue(value, CONFIG_SCHEMA, "");
@@ -1157,6 +1209,129 @@ function checkNoSourceShown(cfg: Config): void {
1157
1209
  }
1158
1210
  }
1159
1211
 
1212
+ // ── a second language for the page's own words (#394) ─────────────────────────────────────────
1213
+ /** The language of the page's own words when items.json does not name it. */
1214
+ const BASE_LANG = "en";
1215
+ /** In a translated `situation_question`, the number of the card's marked line. */
1216
+ const NUMBER_TOKEN = "{n}";
1217
+ /** A translated text is given exactly when the page shows the text it translates. */
1218
+ function checkPresence(
1219
+ where: string,
1220
+ shown: string | undefined,
1221
+ given: string | undefined,
1222
+ ): void {
1223
+ if (shown !== undefined && given === undefined)
1224
+ fail(`${where}: is required and missing — ${shown}`);
1225
+ if (shown === undefined && given !== undefined)
1226
+ fail(`${where}: is never shown — the page has no text it translates`);
1227
+ }
1228
+ /** The cards' first question, translated: given exactly when a card has a `situation`. */
1229
+ function checkSituationWords(cfg: Config, at: string, t: Translation): void {
1230
+ const cards = cardsOf(cfg).filter(([, c]) => c.situation !== undefined);
1231
+ const first = cards[0];
1232
+ checkPresence(
1233
+ `${at}.situation_question`,
1234
+ first &&
1235
+ `${first[0]}.situation asks the first question in the card's own words`,
1236
+ t.situation_question,
1237
+ );
1238
+ const q = t.situation_question ?? "";
1239
+ const bare = cards.find(([, c]) => lineOf(c) === undefined);
1240
+ if (
1241
+ bare !== undefined &&
1242
+ [NUMBER_TOKEN, LINE_TOKEN].some((token) => q.includes(token))
1243
+ )
1244
+ fail(
1245
+ `${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}\``,
1246
+ );
1247
+ }
1248
+ /** One answer in the translation's words; refused when the translation leaves it out. */
1249
+ const optionIn = (o: Option, words: QuestionWords, where: string): Option => {
1250
+ const w =
1251
+ words.options[o.value] ??
1252
+ fail(
1253
+ `${where}.options.${o.value}: is required and missing — every answer is translated`,
1254
+ );
1255
+ checkPresence(
1256
+ `${where}.options.${o.value}.ask_why`,
1257
+ o.ask_why && "this answer asks why",
1258
+ w.ask_why,
1259
+ );
1260
+ return {
1261
+ ...R.omit(o, ["ask_why"]),
1262
+ label: w.label,
1263
+ example: w.example,
1264
+ ...R.pickBy({ ask_why: w.ask_why }, R.isDefined),
1265
+ };
1266
+ };
1267
+ /** One question in the translation's words: every answer of it, no answer it does not have. */
1268
+ function questionIn(q: Question, t: Translation, at: string): Question {
1269
+ const where = `${at}.questions.${q.id}`;
1270
+ const words =
1271
+ t.questions[q.id] ??
1272
+ fail(
1273
+ `${where}: is required and missing — every question of the page is translated`,
1274
+ );
1275
+ const values = q.options.map((o) => o.value);
1276
+ const stray = Object.keys(words.options).find((v) => !values.includes(v));
1277
+ if (stray !== undefined)
1278
+ fail(`${where}.options.${stray}: not one of ${values.join(", ")}`);
1279
+ const options = q.options.map((o) => optionIn(o, words, where));
1280
+ const twice = firstRepeat(options.map((o) => o.label));
1281
+ if (twice !== undefined)
1282
+ fail(
1283
+ `${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`,
1284
+ );
1285
+ return {
1286
+ id: q.id,
1287
+ text: words.text,
1288
+ ...R.pickBy({ short: words.short }, R.isDefined),
1289
+ options,
1290
+ };
1291
+ }
1292
+ /**
1293
+ * Every language of the page with its words whole, the translations checked: each covers every
1294
+ * question and answer of the page and names none it does not have, and gives an intro, a reason
1295
+ * prompt and a situation question exactly where the page shows one. A label it leaves out reads as
1296
+ * in the page's own language.
1297
+ */
1298
+ function translationsOf(cfg: Config) {
1299
+ const base = cfg.lang ?? BASE_LANG;
1300
+ return R.mapValues(cfg.translations ?? {}, (t, lang) => {
1301
+ const at = `translations.${lang}`;
1302
+ if (lang === base)
1303
+ fail(
1304
+ `${at}: is the page's own language (\`lang\` is ${repr(base)}) — translate into another one`,
1305
+ );
1306
+ checkPresence(
1307
+ `${at}.intro`,
1308
+ cfg.intro && "the page has an `intro`",
1309
+ t.intro,
1310
+ );
1311
+ checkSituationWords(cfg, at, t);
1312
+ const ids = cfg.questions.map((q) => q.id);
1313
+ const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
1314
+ if (stray !== undefined)
1315
+ fail(
1316
+ `${at}.questions.${stray}: not a question of this page (${ids.join(", ")})`,
1317
+ );
1318
+ return {
1319
+ title: t.title ?? cfg.title,
1320
+ intro: t.intro ?? "",
1321
+ labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
1322
+ ...R.pickBy({ situation_question: t.situation_question }, R.isDefined),
1323
+ questions: cfg.questions.map((q) => questionIn(q, t, at)),
1324
+ };
1325
+ });
1326
+ }
1327
+ type Translated = ReturnType<typeof translationsOf>;
1328
+ /** The languages: the page's own when items.json names it, and with translations always (English when unnamed).
1329
+ * A page that names neither is as it was. */
1330
+ const languagesOf = (cfg: Config, translated: Translated) =>
1331
+ cfg.translations === undefined
1332
+ ? R.pickBy({ lang: cfg.lang }, R.isDefined)
1333
+ : { lang: cfg.lang ?? BASE_LANG, translations: translated };
1334
+
1160
1335
  // ── ids: neutral, in an order fixed by the seed, kept across rebuilds ─────────────────────────
1161
1336
  interface KeyEntry {
1162
1337
  readonly source_id: string;
@@ -1320,6 +1495,8 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
1320
1495
  hidden: "lines {from}\u2013{to}",
1321
1496
  hidden_one: "line {from}",
1322
1497
  wait: "\u23f3 You can answer in {s} s",
1498
+ how_to: "📖 How to answer",
1499
+ for_example: "For example: ",
1323
1500
  };
1324
1501
  /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
1325
1502
  const twoKeys = (cfg: Config) =>
@@ -1332,6 +1509,7 @@ function payloadOf(
1332
1509
  cfg: Config,
1333
1510
  page: ReturnType<typeof itemsOnPage>,
1334
1511
  described: Described,
1512
+ translated: Translated,
1335
1513
  ) {
1336
1514
  const used = Object.keys(cfg.groups).filter((g) =>
1337
1515
  page.items.some((i) => i.group === g),
@@ -1347,6 +1525,7 @@ function payloadOf(
1347
1525
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
1348
1526
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
1349
1527
  ...twoKeys(cfg),
1528
+ ...languagesOf(cfg, translated),
1350
1529
  questions: cfg.questions,
1351
1530
  practice: {
1352
1531
  round: practiceRound(cfg),
@@ -1445,6 +1624,7 @@ export function prepareBuild(configPath: string, outDir: string) {
1445
1624
  checkBlocks(cfg);
1446
1625
  checkCardText(cfg);
1447
1626
  checkNoSourceShown(cfg);
1627
+ const translated = translationsOf(cfg);
1448
1628
  const described = describeRules(cfg, dirname(configPath));
1449
1629
  mkdirSync(outDir, { recursive: true });
1450
1630
  const keyPath = join(outDir, "key.json");
@@ -1452,7 +1632,7 @@ export function prepareBuild(configPath: string, outDir: string) {
1452
1632
  const page = itemsOnPage(cfg, keyPath);
1453
1633
  writeJson(keyPath, keyOf(cfg, page, earlier));
1454
1634
  return {
1455
- payload: payloadOf(cfg, page, described),
1635
+ payload: payloadOf(cfg, page, described, translated),
1456
1636
  dropped: cfg.items.length - page.items.length,
1457
1637
  };
1458
1638
  }