paperlint 14.3.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.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +168 -1
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +12 -2
- package/skills/build-label-page/SKILL.md.spec.ts +11 -1
- package/skills/build-label-page/references/checklist.md +3 -3
- package/skills/build-label-page/references/closed-choice-generator.md +73 -13
- package/skills/build-label-page/scripts/fixtures/v7-cards-ru.json +23 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +8 -9
- package/skills/build-label-page/scripts/label-page-browser.test.ts +278 -0
- package/skills/build-label-page/scripts/label-page-core.js +126 -14
- package/skills/build-label-page/scripts/label-page-core.test.ts +68 -0
- package/skills/build-label-page/scripts/label-page-core.ts +193 -13
- package/skills/build-label-page/scripts/label-page-html.test.ts +29 -0
- package/skills/build-label-page/scripts/label-page-translations.test.ts +371 -0
- package/skills/build-label-page/scripts/page-template.html +83 -40
|
@@ -1357,3 +1357,281 @@ test(
|
|
|
1357
1357
|
},
|
|
1358
1358
|
BROWSER_TEST_MS,
|
|
1359
1359
|
);
|
|
1360
|
+
|
|
1361
|
+
/** Where a part of a card sits, by its `data-test`: the first one of that kind. */
|
|
1362
|
+
const boxOf = async (card: Locator, kind: string) =>
|
|
1363
|
+
card.getByTestId(kind).first().boundingBox();
|
|
1364
|
+
const Panel = z.object({
|
|
1365
|
+
border: z.number(),
|
|
1366
|
+
radius: z.number(),
|
|
1367
|
+
padding: z.number(),
|
|
1368
|
+
});
|
|
1369
|
+
test(
|
|
1370
|
+
"🔴 a quote card reads once from top to bottom, quote, code, question, answers, wait bar, inside a panel of its own; its «still needs» line is not shown",
|
|
1371
|
+
async () => {
|
|
1372
|
+
await onV7Page(
|
|
1373
|
+
"browser-v7-order",
|
|
1374
|
+
async (page, cardOf) => {
|
|
1375
|
+
const card = await cardOf("card-docstring");
|
|
1376
|
+
const parts = ["quote", "excerpt-path", "question", "answer", "wait"];
|
|
1377
|
+
const boxes = await Promise.all(parts.map((k) => boxOf(card, k)));
|
|
1378
|
+
// Guards: each part starts below the one before it ends, so the eye never goes back up.
|
|
1379
|
+
assert.deepEqual(
|
|
1380
|
+
R.zip(boxes, boxes.slice(1)).map(
|
|
1381
|
+
([a, b]) => a !== null && b !== null && b.y >= a.y + a.height,
|
|
1382
|
+
),
|
|
1383
|
+
[true, true, true, true],
|
|
1384
|
+
JSON.stringify(boxes),
|
|
1385
|
+
);
|
|
1386
|
+
const status = card.getByTestId("status");
|
|
1387
|
+
assert.deepEqual(
|
|
1388
|
+
[await metaOf(status), await status.isVisible()],
|
|
1389
|
+
[{ state: "missing", missing: ["forbid"] }, false],
|
|
1390
|
+
);
|
|
1391
|
+
// A panel: a border all round, rounded, padded, and wide space before the next card.
|
|
1392
|
+
const panel = Panel.parse(
|
|
1393
|
+
await card.evaluate((e) => {
|
|
1394
|
+
const s = getComputedStyle(e);
|
|
1395
|
+
return {
|
|
1396
|
+
border: parseFloat(s.borderBottomWidth),
|
|
1397
|
+
radius: parseFloat(s.borderTopLeftRadius),
|
|
1398
|
+
padding: parseFloat(s.paddingLeft),
|
|
1399
|
+
};
|
|
1400
|
+
}),
|
|
1401
|
+
);
|
|
1402
|
+
assert.ok(
|
|
1403
|
+
panel.border >= 1 && panel.radius >= 8 && panel.padding >= 12,
|
|
1404
|
+
JSON.stringify(panel),
|
|
1405
|
+
);
|
|
1406
|
+
const cards = await page.getByTestId("item").all();
|
|
1407
|
+
const [first, second] = await Promise.all(
|
|
1408
|
+
cards.slice(0, 2).map(async (c) => c.boundingBox()),
|
|
1409
|
+
);
|
|
1410
|
+
assert.ok(first && second, "two cards drawn");
|
|
1411
|
+
assert.ok(
|
|
1412
|
+
second.y - (first.y + first.height) >= 32,
|
|
1413
|
+
JSON.stringify([first, second]),
|
|
1414
|
+
);
|
|
1415
|
+
},
|
|
1416
|
+
true,
|
|
1417
|
+
);
|
|
1418
|
+
},
|
|
1419
|
+
BROWSER_TEST_MS,
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
// ── a second language for the page's own words (#394) ──────────────────────────────────────────
|
|
1423
|
+
const RU_FIXTURE = join(import.meta.dirname, "fixtures", "v7-cards-ru.json");
|
|
1424
|
+
const Words = z.object({
|
|
1425
|
+
labels: z.object({ for_example: z.string() }).loose(),
|
|
1426
|
+
questions: z.array(
|
|
1427
|
+
z.object({
|
|
1428
|
+
id: z.string(),
|
|
1429
|
+
text: z.string(),
|
|
1430
|
+
options: z.array(
|
|
1431
|
+
z.object({
|
|
1432
|
+
value: z.string(),
|
|
1433
|
+
label: z.string(),
|
|
1434
|
+
example: z.string(),
|
|
1435
|
+
icon: z.string(),
|
|
1436
|
+
}),
|
|
1437
|
+
),
|
|
1438
|
+
}),
|
|
1439
|
+
),
|
|
1440
|
+
});
|
|
1441
|
+
const RU_RAW = z.json().parse(JSON.parse(readFileSync(RU_FIXTURE, "utf8")));
|
|
1442
|
+
const RU_WORDS = z
|
|
1443
|
+
.object({
|
|
1444
|
+
situation_question: z.string(),
|
|
1445
|
+
labels: z.object({ for_example: z.string() }).loose(),
|
|
1446
|
+
questions: z.record(
|
|
1447
|
+
z.string(),
|
|
1448
|
+
z.object({
|
|
1449
|
+
text: z.string(),
|
|
1450
|
+
options: z.record(
|
|
1451
|
+
z.string(),
|
|
1452
|
+
z.object({ label: z.string(), example: z.string() }),
|
|
1453
|
+
),
|
|
1454
|
+
}),
|
|
1455
|
+
),
|
|
1456
|
+
})
|
|
1457
|
+
.parse(RU_RAW);
|
|
1458
|
+
/** The fixture's page words in one language: the base's, or the Russian translation laid over them. */
|
|
1459
|
+
function wordsIn(lang: "en" | "ru") {
|
|
1460
|
+
const base = Words.parse({
|
|
1461
|
+
labels: { for_example: "For example: " },
|
|
1462
|
+
questions: z.object({ questions: z.unknown() }).loose().parse(V7_CONFIG)
|
|
1463
|
+
.questions,
|
|
1464
|
+
});
|
|
1465
|
+
if (lang === "en") return base;
|
|
1466
|
+
return {
|
|
1467
|
+
labels: RU_WORDS.labels,
|
|
1468
|
+
questions: base.questions.map((q) => ({
|
|
1469
|
+
...q,
|
|
1470
|
+
text: RU_WORDS.questions[q.id]?.text ?? "",
|
|
1471
|
+
options: q.options.map((o) => ({
|
|
1472
|
+
...o,
|
|
1473
|
+
...RU_WORDS.questions[q.id]?.options[o.value],
|
|
1474
|
+
})),
|
|
1475
|
+
})),
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/** What the docstring card and the how-to rows say in `lang`, as the fixture states it. */
|
|
1479
|
+
function expectedIn(lang: "en" | "ru") {
|
|
1480
|
+
const card = fixtureCard("card-docstring");
|
|
1481
|
+
const { situation, quote } = z
|
|
1482
|
+
.object({
|
|
1483
|
+
situation: z.object({ question: z.string() }),
|
|
1484
|
+
quote: z.object({ text: z.string() }),
|
|
1485
|
+
})
|
|
1486
|
+
.loose()
|
|
1487
|
+
.parse(card);
|
|
1488
|
+
const w = wordsIn(lang);
|
|
1489
|
+
const [q] = w.questions;
|
|
1490
|
+
assert.ok(q !== undefined);
|
|
1491
|
+
const notes = card.answer_notes.forbid;
|
|
1492
|
+
return {
|
|
1493
|
+
switch: { lang, langs: ["en", "ru"] },
|
|
1494
|
+
quote: unticked(quote.text),
|
|
1495
|
+
question:
|
|
1496
|
+
lang === "en"
|
|
1497
|
+
? situation.question
|
|
1498
|
+
: RU_WORDS.situation_question.replace(
|
|
1499
|
+
"{n}",
|
|
1500
|
+
String(card.excerpt.lines.find((l) => l.mark)?.n),
|
|
1501
|
+
),
|
|
1502
|
+
answers: q.options.map((o) => [
|
|
1503
|
+
{ question: q.id, value: o.value },
|
|
1504
|
+
`${o.icon}${o.label}${unticked(notes[o.value] ?? "")}`,
|
|
1505
|
+
]),
|
|
1506
|
+
howTo: [
|
|
1507
|
+
q.text,
|
|
1508
|
+
...q.options.map(
|
|
1509
|
+
(o) => `${o.icon} ${o.label}${w.labels.for_example}${o.example}`,
|
|
1510
|
+
),
|
|
1511
|
+
],
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
/** The same, read from the page. */
|
|
1515
|
+
async function shownIn(page: Page, card: Locator) {
|
|
1516
|
+
const texts = async (scope: Page | Locator, kind: string) =>
|
|
1517
|
+
scope.getByTestId(kind).allTextContents();
|
|
1518
|
+
return {
|
|
1519
|
+
switch: await metaOf(page.getByTestId("lang-switch")),
|
|
1520
|
+
quote: (await texts(card, "quote")).join(""),
|
|
1521
|
+
question: (await texts(card, "question")).join(""),
|
|
1522
|
+
answers: await Promise.all(
|
|
1523
|
+
(await allByKind(card, "answer")).map(async (a) => [
|
|
1524
|
+
a.meta,
|
|
1525
|
+
await a.loc.textContent(),
|
|
1526
|
+
]),
|
|
1527
|
+
),
|
|
1528
|
+
howTo: [
|
|
1529
|
+
...(await texts(page, "how-to-question")),
|
|
1530
|
+
...(await texts(page, "how-to-answer")),
|
|
1531
|
+
],
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
test(
|
|
1535
|
+
"🔴 the language switch swaps the page's own words both ways, keeps the quote and the code as built, and is remembered over a reload",
|
|
1536
|
+
async () => {
|
|
1537
|
+
const cfg = {
|
|
1538
|
+
...R.omit(V7_CONFIG, ["min_seconds"]),
|
|
1539
|
+
translations: { ru: RU_RAW },
|
|
1540
|
+
};
|
|
1541
|
+
const { page: html, key } = build(setup("browser-v7-lang", cfg));
|
|
1542
|
+
const at = "http://label.test/";
|
|
1543
|
+
const page = await (
|
|
1544
|
+
await sharedBrowser()
|
|
1545
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1546
|
+
try {
|
|
1547
|
+
// A real origin, so the page has a localStorage; every other request is refused, none leaves.
|
|
1548
|
+
await page.route("**/*", (r) =>
|
|
1549
|
+
r.request().url() === at
|
|
1550
|
+
? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
|
|
1551
|
+
: r.abort(),
|
|
1552
|
+
);
|
|
1553
|
+
await page.addInitScript(
|
|
1554
|
+
STORE(
|
|
1555
|
+
Object.fromEntries(
|
|
1556
|
+
["forbid", "allow", "elsewhere"].map((v, n) => [
|
|
1557
|
+
`r1-P${String(n + 1)}`,
|
|
1558
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1559
|
+
]),
|
|
1560
|
+
),
|
|
1561
|
+
20,
|
|
1562
|
+
),
|
|
1563
|
+
);
|
|
1564
|
+
await page.goto(at, { waitUntil: "domcontentloaded" });
|
|
1565
|
+
const id = Object.entries(key.ids).find(
|
|
1566
|
+
([, v]) => v.source_id === "card-docstring",
|
|
1567
|
+
)?.[0];
|
|
1568
|
+
const card = async () => {
|
|
1569
|
+
await page.getByTestId("item").first().waitFor();
|
|
1570
|
+
const found = (await allByKind(page, "item")).find(
|
|
1571
|
+
(c) => Id.parse(c.meta).id === id,
|
|
1572
|
+
);
|
|
1573
|
+
assert.ok(found !== undefined, "the card is drawn");
|
|
1574
|
+
return found.loc;
|
|
1575
|
+
};
|
|
1576
|
+
const option = async (lang: string) =>
|
|
1577
|
+
(await allByKind(page, "lang-option")).find(
|
|
1578
|
+
(o) => z.object({ lang: z.string() }).parse(o.meta).lang === lang,
|
|
1579
|
+
)?.loc;
|
|
1580
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
|
|
1581
|
+
await (await option("ru"))?.click();
|
|
1582
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
|
|
1583
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1584
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
|
|
1585
|
+
await (await option("en"))?.click();
|
|
1586
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
|
|
1587
|
+
} finally {
|
|
1588
|
+
await page.context().close();
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
BROWSER_TEST_MS,
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
test(
|
|
1595
|
+
"🔴 a page without translations names its own language on the document: the one it declares, else English",
|
|
1596
|
+
async () => {
|
|
1597
|
+
const htmlLang = async (cfg: Record<string, unknown>, dir: string) => {
|
|
1598
|
+
const { page: html } = build(setup(dir, cfg));
|
|
1599
|
+
const at = "http://label.test/";
|
|
1600
|
+
const page = await (
|
|
1601
|
+
await sharedBrowser()
|
|
1602
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1603
|
+
try {
|
|
1604
|
+
await page.route("**/*", (r) =>
|
|
1605
|
+
r.request().url() === at
|
|
1606
|
+
? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
|
|
1607
|
+
: r.abort(),
|
|
1608
|
+
);
|
|
1609
|
+
await page.addInitScript(
|
|
1610
|
+
STORE(
|
|
1611
|
+
Object.fromEntries(
|
|
1612
|
+
["forbid", "allow", "elsewhere"].map((v, n) => [
|
|
1613
|
+
`r1-P${String(n + 1)}`,
|
|
1614
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1615
|
+
]),
|
|
1616
|
+
),
|
|
1617
|
+
20,
|
|
1618
|
+
),
|
|
1619
|
+
);
|
|
1620
|
+
await page.goto(at, { waitUntil: "domcontentloaded" });
|
|
1621
|
+
await page.getByTestId("item").first().waitFor();
|
|
1622
|
+
return await page.evaluate(() => document.documentElement.lang);
|
|
1623
|
+
} finally {
|
|
1624
|
+
await page.context().close();
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
const plain = R.omit(V7_CONFIG, ["min_seconds"]);
|
|
1628
|
+
assert.deepEqual(
|
|
1629
|
+
[
|
|
1630
|
+
await htmlLang(plain, "browser-v7-lang-none"),
|
|
1631
|
+
await htmlLang({ ...plain, lang: "de" }, "browser-v7-lang-de"),
|
|
1632
|
+
],
|
|
1633
|
+
["en", "de"],
|
|
1634
|
+
);
|
|
1635
|
+
},
|
|
1636
|
+
BROWSER_TEST_MS,
|
|
1637
|
+
);
|
|
@@ -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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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. */
|