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.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +277 -10
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +11 -1
- package/skills/build-label-page/SKILL.md.spec.ts +10 -0
- package/skills/build-label-page/references/closed-choice-generator.md +48 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +127 -1
- package/skills/build-label-page/scripts/label-page-core.js +92 -4
- package/skills/build-label-page/scripts/label-page-core.ts +120 -5
- package/skills/build-label-page/scripts/label-page-sources.test.ts +231 -0
- package/skills/build-label-page/scripts/label-page-translations.test.ts +50 -0
- package/skills/build-label-page/scripts/page-template.html +24 -4
|
@@ -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({
|
|
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
|
-
...
|
|
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({
|
|
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
|
-
...
|
|
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(
|
|
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
|
+
});
|