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.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +435 -1
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +21 -1
- package/skills/build-label-page/SKILL.md.spec.ts +20 -0
- package/skills/build-label-page/references/closed-choice-generator.md +104 -0
- package/skills/build-label-page/scripts/fixtures/v7-cards-ru.json +23 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +344 -1
- package/skills/build-label-page/scripts/label-page-core.js +217 -17
- package/skills/build-label-page/scripts/label-page-core.test.ts +68 -0
- package/skills/build-label-page/scripts/label-page-core.ts +312 -17
- package/skills/build-label-page/scripts/label-page-sources.test.ts +231 -0
- package/skills/build-label-page/scripts/label-page-translations.test.ts +421 -0
- package/skills/build-label-page/scripts/page-template.html +91 -29
|
@@ -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({
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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(
|
|
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
|
+
});
|