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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second language for the page's own words (#394): what `translations` must cover, what the page
|
|
3
|
+
* is handed, and that the ids and the key do not depend on it.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { test } from "vitest";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import {
|
|
11
|
+
ITEMS,
|
|
12
|
+
QUESTIONS,
|
|
13
|
+
build,
|
|
14
|
+
config,
|
|
15
|
+
run,
|
|
16
|
+
setup,
|
|
17
|
+
} from "../../../test/label-page-kit.ts";
|
|
18
|
+
|
|
19
|
+
/** The questions of the kit's page in Russian: every question and every answer, by id and value. */
|
|
20
|
+
const RU = {
|
|
21
|
+
title: "Разметьте",
|
|
22
|
+
labels: { how_to: "📖 Как отвечать", for_example: "Например: " },
|
|
23
|
+
questions: {
|
|
24
|
+
label: {
|
|
25
|
+
text: "Это верно?",
|
|
26
|
+
short: "верно?",
|
|
27
|
+
options: {
|
|
28
|
+
yes: { label: "Да", example: "Сортировка вернула 1, 2, 3." },
|
|
29
|
+
no: { label: "Нет", example: "Сортировка вернула 3, 1, 2." },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
boundary: {
|
|
33
|
+
text: "Ясный случай или пограничный?",
|
|
34
|
+
options: {
|
|
35
|
+
clear: { label: "Ясный", example: "Пустой список." },
|
|
36
|
+
boundary: { label: "Пограничный", example: "Два равных ключа." },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const withRu = (over: Record<string, unknown> = {}) =>
|
|
42
|
+
config({ translations: { ru: RU }, ...over });
|
|
43
|
+
|
|
44
|
+
/** The page's data: the JSON the template is filled with. */
|
|
45
|
+
const dataOf = (page: string): unknown =>
|
|
46
|
+
JSON.parse(
|
|
47
|
+
String(
|
|
48
|
+
page
|
|
49
|
+
.split('<script type="application/json" id="data">')[1]
|
|
50
|
+
?.split("</script>")[0],
|
|
51
|
+
).replaceAll("<\\/", "</"),
|
|
52
|
+
);
|
|
53
|
+
const Data = z
|
|
54
|
+
.object({ lang: z.string(), translations: z.record(z.string(), z.unknown()) })
|
|
55
|
+
.loose();
|
|
56
|
+
|
|
57
|
+
/** What the page is handed for Russian: the kit's labels with RU's over them, and every question. */
|
|
58
|
+
function expectedRu() {
|
|
59
|
+
const [label, boundary] = QUESTIONS;
|
|
60
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
61
|
+
const inRu = (qid: "label" | "boundary", value: string) =>
|
|
62
|
+
qid === "label"
|
|
63
|
+
? RU.questions.label.options[value === "yes" ? "yes" : "no"]
|
|
64
|
+
: RU.questions.boundary.options[value === "clear" ? "clear" : "boundary"];
|
|
65
|
+
return {
|
|
66
|
+
title: RU.title,
|
|
67
|
+
intro: "",
|
|
68
|
+
labels: {
|
|
69
|
+
place: "{id} · {n} of {total}",
|
|
70
|
+
line: "Line",
|
|
71
|
+
line_part: "{line} · part {part} of {of}",
|
|
72
|
+
repeat: "Card {index} of {of}",
|
|
73
|
+
practice_example: "",
|
|
74
|
+
hidden: "lines {from}–{to}",
|
|
75
|
+
hidden_one: "line {from}",
|
|
76
|
+
wait: "⏳ You can answer in {s} s",
|
|
77
|
+
...RU.labels,
|
|
78
|
+
},
|
|
79
|
+
questions: [
|
|
80
|
+
{
|
|
81
|
+
id: "label",
|
|
82
|
+
text: "Это верно?",
|
|
83
|
+
short: "верно?",
|
|
84
|
+
options: label.options.map((o) => ({
|
|
85
|
+
value: o.value,
|
|
86
|
+
...inRu("label", o.value),
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "boundary",
|
|
91
|
+
text: "Ясный случай или пограничный?",
|
|
92
|
+
options: boundary.options.map((o) => ({
|
|
93
|
+
value: o.value,
|
|
94
|
+
...inRu("boundary", o.value),
|
|
95
|
+
})),
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** The keys a built page's data has, of `names`. */
|
|
101
|
+
const dataKeys = (page: string, names: readonly string[]) =>
|
|
102
|
+
names.filter((k) =>
|
|
103
|
+
Object.hasOwn(z.object({}).loose().parse(dataOf(page)), k),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
test("🔴 a translation hands the page each language's words whole, and leaves the key byte for byte as without it", () => {
|
|
107
|
+
const plain = setup("lang-plain", config());
|
|
108
|
+
const translated = setup("lang-ru", withRu());
|
|
109
|
+
const { page } = build(translated);
|
|
110
|
+
const keyOf = (dir: string) =>
|
|
111
|
+
readFileSync(join(dir, "out", "key.json"), "utf8");
|
|
112
|
+
const data = Data.parse(dataOf(page));
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
[
|
|
115
|
+
data.lang,
|
|
116
|
+
data.translations,
|
|
117
|
+
dataKeys(build(plain).page, ["lang", "translations"]),
|
|
118
|
+
],
|
|
119
|
+
// Guards: a page without translations carries neither key, so it is as it was.
|
|
120
|
+
["en", { ru: expectedRu() }, []],
|
|
121
|
+
);
|
|
122
|
+
assert.equal(keyOf(translated), keyOf(plain));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("🔴 a page that names its language without translations keeps it, and still carries no translations", () => {
|
|
126
|
+
const page = build(setup("lang-only", config({ lang: "de" }))).page;
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
[
|
|
129
|
+
z.object({ lang: z.string() }).loose().parse(dataOf(page)).lang,
|
|
130
|
+
dataKeys(page, ["lang", "translations"]),
|
|
131
|
+
],
|
|
132
|
+
["de", ["lang"]],
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** RU with an intro, a reason prompt on «no» and a card question naming the marked line. */
|
|
137
|
+
const RU_FULL = {
|
|
138
|
+
...RU,
|
|
139
|
+
intro: "Отвечайте как знаете.",
|
|
140
|
+
situation_question: "Строка {n} верна?",
|
|
141
|
+
questions: {
|
|
142
|
+
...RU.questions,
|
|
143
|
+
label: {
|
|
144
|
+
...RU.questions.label,
|
|
145
|
+
options: {
|
|
146
|
+
...RU.questions.label.options,
|
|
147
|
+
no: { ...RU.questions.label.options.no, ask_why: "Почему?" },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
/** A German page with an intro, a reason prompt and a card that asks in its own words, and RU_FULL. */
|
|
153
|
+
function fullConfig() {
|
|
154
|
+
const [label, boundary] = QUESTIONS;
|
|
155
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
156
|
+
const [first, ...rest] = ITEMS;
|
|
157
|
+
return config({
|
|
158
|
+
lang: "de",
|
|
159
|
+
intro: "Answer as you know.",
|
|
160
|
+
questions: [
|
|
161
|
+
{
|
|
162
|
+
...label,
|
|
163
|
+
options: [
|
|
164
|
+
label.options[0],
|
|
165
|
+
{ ...label.options[1], ask_why: "Why not?" },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
boundary,
|
|
169
|
+
],
|
|
170
|
+
translations: { ru: RU_FULL },
|
|
171
|
+
items: [
|
|
172
|
+
{
|
|
173
|
+
...first,
|
|
174
|
+
situation: { question: "Is line 3 correct?" },
|
|
175
|
+
excerpt: { lines: [{ n: 3, text: "x = 1", mark: true }] },
|
|
176
|
+
},
|
|
177
|
+
...rest,
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const FullData = z
|
|
182
|
+
.object({
|
|
183
|
+
lang: z.string(),
|
|
184
|
+
translations: z.object({
|
|
185
|
+
ru: z
|
|
186
|
+
.object({
|
|
187
|
+
intro: z.string(),
|
|
188
|
+
situation_question: z.string(),
|
|
189
|
+
questions: z.array(z.unknown()),
|
|
190
|
+
})
|
|
191
|
+
.loose(),
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
.loose();
|
|
195
|
+
|
|
196
|
+
test("🔴 the page's own language may be named, and an intro, a reason prompt and a card's own question are translated with it", () => {
|
|
197
|
+
const data = FullData.parse(
|
|
198
|
+
dataOf(build(setup("lang-full", fullConfig())).page),
|
|
199
|
+
);
|
|
200
|
+
const ru = data.translations.ru;
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
[data.lang, ru.intro, ru.situation_question, ru.questions[0]],
|
|
203
|
+
[
|
|
204
|
+
"de",
|
|
205
|
+
RU_FULL.intro,
|
|
206
|
+
RU_FULL.situation_question,
|
|
207
|
+
{
|
|
208
|
+
id: "label",
|
|
209
|
+
text: "Это верно?",
|
|
210
|
+
short: "верно?",
|
|
211
|
+
options: [
|
|
212
|
+
{ value: "yes", ...RU.questions.label.options.yes },
|
|
213
|
+
{ value: "no", ...RU_FULL.questions.label.options.no },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** The kit's page with its Russian words changed by `ru`, and more of the config by `over`. */
|
|
221
|
+
const refusedWith = (
|
|
222
|
+
ru: Record<string, unknown>,
|
|
223
|
+
over: Record<string, unknown> = {},
|
|
224
|
+
) => withRu({ translations: { ru: { ...RU, ...ru } }, ...over });
|
|
225
|
+
const ruQuestion = (
|
|
226
|
+
qid: "label" | "boundary",
|
|
227
|
+
change: Record<string, unknown>,
|
|
228
|
+
) => ({
|
|
229
|
+
questions: { ...RU.questions, [qid]: { ...RU.questions[qid], ...change } },
|
|
230
|
+
});
|
|
231
|
+
const ruOptions = (options: Record<string, unknown>) =>
|
|
232
|
+
ruQuestion("label", { options });
|
|
233
|
+
const situated = (excerpt: boolean) => ({
|
|
234
|
+
items: ITEMS.map((i, n) =>
|
|
235
|
+
n === 0
|
|
236
|
+
? {
|
|
237
|
+
...i,
|
|
238
|
+
situation: { question: "Is it correct?" },
|
|
239
|
+
...(excerpt
|
|
240
|
+
? { excerpt: { lines: [{ n: 3, text: "x", mark: true }] } }
|
|
241
|
+
: {}),
|
|
242
|
+
}
|
|
243
|
+
: i,
|
|
244
|
+
),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/** A page whose first question asks why on «no». */
|
|
248
|
+
function asksWhy() {
|
|
249
|
+
const [label] = QUESTIONS;
|
|
250
|
+
assert.ok(label !== undefined);
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
...label,
|
|
254
|
+
options: [label.options[0], { ...label.options[1], ask_why: "Why?" }],
|
|
255
|
+
},
|
|
256
|
+
QUESTIONS[1],
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
/** Configs only the translation checks refuse, each with its refusal. */
|
|
260
|
+
const REFUSALS: readonly (readonly [
|
|
261
|
+
string,
|
|
262
|
+
Record<string, unknown>,
|
|
263
|
+
string,
|
|
264
|
+
])[] = [
|
|
265
|
+
[
|
|
266
|
+
"own",
|
|
267
|
+
withRu({ translations: { en: RU } }),
|
|
268
|
+
"translations.en: is the page's own language (`lang` is 'en') — translate into another one",
|
|
269
|
+
],
|
|
270
|
+
[
|
|
271
|
+
"no-question",
|
|
272
|
+
refusedWith({ questions: { label: RU.questions.label } }),
|
|
273
|
+
"translations.ru.questions.boundary: is required and missing — every question of the page is translated",
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
"stray-question",
|
|
277
|
+
refusedWith({
|
|
278
|
+
questions: { ...RU.questions, ghost: RU.questions.label },
|
|
279
|
+
}),
|
|
280
|
+
"translations.ru.questions.ghost: not a question of this page (label, boundary)",
|
|
281
|
+
],
|
|
282
|
+
[
|
|
283
|
+
"no-answer",
|
|
284
|
+
refusedWith(ruOptions({ yes: RU.questions.label.options.yes })),
|
|
285
|
+
"translations.ru.questions.label.options.no: is required and missing — every answer is translated",
|
|
286
|
+
],
|
|
287
|
+
[
|
|
288
|
+
"stray-answer",
|
|
289
|
+
refusedWith(
|
|
290
|
+
ruOptions({
|
|
291
|
+
...RU.questions.label.options,
|
|
292
|
+
maybe: { label: "Может", example: "e" },
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
"translations.ru.questions.label.options.maybe: not one of yes, no",
|
|
296
|
+
],
|
|
297
|
+
[
|
|
298
|
+
"same-label",
|
|
299
|
+
refusedWith(
|
|
300
|
+
ruOptions({
|
|
301
|
+
...RU.questions.label.options,
|
|
302
|
+
no: { label: " да ", example: "e" },
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
305
|
+
"translations.ru.questions.label.options.no.label: ' да ' reads the same as options.yes.label — each button needs its own label",
|
|
306
|
+
],
|
|
307
|
+
[
|
|
308
|
+
"no-why",
|
|
309
|
+
withRu({ questions: asksWhy() }),
|
|
310
|
+
"translations.ru.questions.label.options.no.ask_why: is required and missing — this answer asks why",
|
|
311
|
+
],
|
|
312
|
+
[
|
|
313
|
+
"stray-why",
|
|
314
|
+
refusedWith(
|
|
315
|
+
ruOptions({
|
|
316
|
+
...RU.questions.label.options,
|
|
317
|
+
no: { ...RU.questions.label.options.no, ask_why: "Почему?" },
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
320
|
+
"translations.ru.questions.label.options.no.ask_why: is never shown — the page has no text it translates",
|
|
321
|
+
],
|
|
322
|
+
[
|
|
323
|
+
"no-intro",
|
|
324
|
+
withRu({ intro: "Answer." }),
|
|
325
|
+
"translations.ru.intro: is required and missing — the page has an `intro`",
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
"stray-intro",
|
|
329
|
+
refusedWith({ intro: "Отвечайте." }),
|
|
330
|
+
"translations.ru.intro: is never shown — the page has no text it translates",
|
|
331
|
+
],
|
|
332
|
+
[
|
|
333
|
+
"no-situation",
|
|
334
|
+
withRu(situated(true)),
|
|
335
|
+
"translations.ru.situation_question: is required and missing — items[0].situation asks the first question in the card's own words",
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
"stray-situation",
|
|
339
|
+
refusedWith({ situation_question: "Верно?" }),
|
|
340
|
+
"translations.ru.situation_question: is never shown — the page has no text it translates",
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
"line-without-code",
|
|
344
|
+
refusedWith({ situation_question: "Строка {n} верна?" }, situated(false)),
|
|
345
|
+
"translations.ru.situation_question: names the marked line, and items[0] has no `excerpt` — add it, or write the question without `{n}` and `{line}`",
|
|
346
|
+
],
|
|
347
|
+
];
|
|
348
|
+
/** Build `cfg` in a folder of its own; the exit status and what it printed. */
|
|
349
|
+
const tryBuild = (name: string, cfg: Record<string, unknown>) => {
|
|
350
|
+
const dir = setup(name, cfg);
|
|
351
|
+
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
test("🔴 a translation that leaves out or invents a question, an answer, an intro, a reason prompt or a card's question stops the build, by path", () => {
|
|
355
|
+
const cases = REFUSALS;
|
|
356
|
+
assert.deepEqual(
|
|
357
|
+
cases.map(([name, cfg]) =>
|
|
358
|
+
tryBuild(`lang-refused-${name}`, cfg).out.trim(),
|
|
359
|
+
),
|
|
360
|
+
cases.map(([, , why]) => `REFUSED: ${why}`),
|
|
361
|
+
);
|
|
362
|
+
// Guards: the same card question with its marked line in the code, and one without `{n}` on a
|
|
363
|
+
// card without code, both build.
|
|
364
|
+
assert.deepEqual(
|
|
365
|
+
[
|
|
366
|
+
refusedWith({ situation_question: "Строка {n} верна?" }, situated(true)),
|
|
367
|
+
refusedWith({ situation_question: "Верно?" }, situated(false)),
|
|
368
|
+
].map((cfg, n) => tryBuild(`lang-situated-${String(n)}`, cfg).status),
|
|
369
|
+
[0, 0],
|
|
370
|
+
);
|
|
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
|
+
});
|