paperlint 14.4.0 → 14.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +11 -1
- package/skills/build-label-page/SKILL.md.spec.ts +10 -0
- package/skills/build-label-page/references/closed-choice-generator.md +56 -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 +217 -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-translations.test.ts +371 -0
- package/skills/build-label-page/scripts/page-template.html +67 -25
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
});
|
|
@@ -143,6 +143,9 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
143
143
|
.match.no { color: var(--bad); }
|
|
144
144
|
.who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
|
|
145
145
|
.who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
|
|
146
|
+
/* The language switch: small buttons side by side under the title, the picked one pressed. */
|
|
147
|
+
.langs { display: flex; gap: 8px; }
|
|
148
|
+
.langs button { width: auto; min-width: 56px; min-height: 44px; text-align: center; }
|
|
146
149
|
</style>
|
|
147
150
|
<main id="app"><p class="muted">Loading…</p></main>
|
|
148
151
|
<script type="application/json" id="data">__DATA__</script>
|
|
@@ -182,6 +185,34 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
182
185
|
var WHY_PAUSE_MS = 800;
|
|
183
186
|
|
|
184
187
|
try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
|
|
188
|
+
var BASE = { title: DATA.title, intro: DATA.intro, labels: DATA.labels, questions: DATA.questions };
|
|
189
|
+
// ── the page's own words, in the language picked (DATA.translations, #394) ─────────────────
|
|
190
|
+
// Only the page's words change: the quote, the code and every note on a card stay as built.
|
|
191
|
+
var LANGS = DATA.translations ? [DATA.lang].concat(Object.keys(DATA.translations)) : [];
|
|
192
|
+
state.lang = DATA.lang || "en";
|
|
193
|
+
try {
|
|
194
|
+
var said = localStorage.getItem("label-lang");
|
|
195
|
+
if (LANGS.indexOf(said) !== -1) state.lang = said;
|
|
196
|
+
} catch (e) {}
|
|
197
|
+
function words() { return state.lang === DATA.lang || !DATA.translations ? BASE : DATA.translations[state.lang]; }
|
|
198
|
+
function Qs() { return words().questions; }
|
|
199
|
+
function pickLang(lang) {
|
|
200
|
+
state.lang = lang;
|
|
201
|
+
try { localStorage.setItem("label-lang", lang); } catch (e) {}
|
|
202
|
+
render();
|
|
203
|
+
}
|
|
204
|
+
// One button per language, its tag on it ("EN", "RU"); the one picked is pressed.
|
|
205
|
+
function langSwitch() {
|
|
206
|
+
var row = mark(el("div", "langs"), "lang-switch", { lang: state.lang, langs: LANGS });
|
|
207
|
+
LANGS.forEach(function (lang) {
|
|
208
|
+
var b = mark(el("button", "", lang.toUpperCase()), "lang-option", { lang: lang });
|
|
209
|
+
b.type = "button";
|
|
210
|
+
b.setAttribute("aria-pressed", String(state.lang === lang));
|
|
211
|
+
b.addEventListener("click", function () { pickLang(lang); });
|
|
212
|
+
row.appendChild(b);
|
|
213
|
+
});
|
|
214
|
+
return row;
|
|
215
|
+
}
|
|
185
216
|
// ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
|
|
186
217
|
// "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
|
|
187
218
|
var SEP = "~";
|
|
@@ -240,7 +271,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
240
271
|
function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
|
|
241
272
|
function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
|
|
242
273
|
function waitSeconds(id) { return Math.ceil((MIN_SECONDS * 1000 - dwellMs(id)) / 1000); }
|
|
243
|
-
function waitText(id) { return fill(
|
|
274
|
+
function waitText(id) { return fill(words().labels.wait, { s: waitSeconds(id) }); }
|
|
244
275
|
// The seconds stored with an item: those of its first save, kept on every later write.
|
|
245
276
|
function secondsOf(id) {
|
|
246
277
|
var d = state.answers[id];
|
|
@@ -341,11 +372,11 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
341
372
|
return d[qid];
|
|
342
373
|
}
|
|
343
374
|
function needsWhy(q, v) { return (DATA.allowed.needs_why[q.id] || []).indexOf(v) !== -1; }
|
|
344
|
-
function questionOf(qid) { return
|
|
375
|
+
function questionOf(qid) { return Qs().filter(function (q) { return q.id === qid; })[0]; }
|
|
345
376
|
// What an item still needs: an answer, or the reason its answer asks for (named "<short> (why)").
|
|
346
377
|
function missingFor(id) {
|
|
347
378
|
var a = answersOf(id), r = reasonsOf(id);
|
|
348
|
-
return
|
|
379
|
+
return Qs().map(function (q) {
|
|
349
380
|
if (!valid(DATA.allowed.questions[q.id], a[q.id])) return q;
|
|
350
381
|
if (needsWhy(q, a[q.id]) && !(r[q.id] || "").trim()) {
|
|
351
382
|
return { id: q.id, text: q.text + " (why)", short: (q.short || q.text) + " (why)" };
|
|
@@ -373,12 +404,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
373
404
|
}
|
|
374
405
|
function practiceMissing(pid) {
|
|
375
406
|
var a = practiceAnswers(pid);
|
|
376
|
-
return
|
|
407
|
+
return Qs().filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
|
|
377
408
|
}
|
|
378
409
|
function practiceAnswered(p) { return practiceMissing(p.id).length === 0; }
|
|
379
410
|
function practiceMatched(p) {
|
|
380
411
|
var a = practiceAnswers(p.id);
|
|
381
|
-
return
|
|
412
|
+
return Qs().every(function (q) { return a[q.id] === p.expected[q.id]; });
|
|
382
413
|
}
|
|
383
414
|
function practiceOpen() { return !DATA.practice.items.every(practiceAnswered); }
|
|
384
415
|
function groupDone(g) { return familiarity(g) !== undefined && groupItems(g).every(function (i) { return itemDone(i.id); }); }
|
|
@@ -540,7 +571,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
540
571
|
if (o.icon) b.appendChild(el("span", "icon", o.icon));
|
|
541
572
|
b.appendChild(el("span", "label", o.label));
|
|
542
573
|
if (notes && notes[o.value]) b.appendChild(mark(rich("span", "sub", notes[o.value]), "answer-sub", { question: qid, value: o.value }));
|
|
543
|
-
if (withExamples && o.example) b.appendChild(rich("span", "ex",
|
|
574
|
+
if (withExamples && o.example) b.appendChild(rich("span", "ex", words().labels.practice_example + o.example));
|
|
544
575
|
b.type = "button";
|
|
545
576
|
b.setAttribute("aria-pressed", String(current === o.value));
|
|
546
577
|
b.disabled = disabled;
|
|
@@ -589,7 +620,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
589
620
|
var shown = itemsShown();
|
|
590
621
|
if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
|
|
591
622
|
mark(card, "item", { id: item.id, index: shown.indexOf(item) + 1, total: shown.length });
|
|
592
|
-
card.appendChild(el("div", "id", fill(
|
|
623
|
+
card.appendChild(el("div", "id", fill(words().labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
|
|
593
624
|
var ctx = termsOf(item);
|
|
594
625
|
evidence(card, item, ctx);
|
|
595
626
|
var a = answersOf(item.id);
|
|
@@ -602,7 +633,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
602
633
|
var note = ask.appendChild(mark(el("p", "gate", waitText(item.id)), "wait", { id: item.id, seconds: waitSeconds(item.id) }));
|
|
603
634
|
state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll(".opts button")) };
|
|
604
635
|
}
|
|
605
|
-
|
|
636
|
+
Qs().forEach(function (q) {
|
|
606
637
|
var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
|
|
607
638
|
if (!o) return;
|
|
608
639
|
card.appendChild(el("p", "q", o.ask_why));
|
|
@@ -716,7 +747,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
716
747
|
if (prev && l.n > prev.n + 1) {
|
|
717
748
|
var from = prev.n + 1, to = l.n - 1;
|
|
718
749
|
pre.appendChild(mark(codeRow(" gap", "\u22ee", "", withGutter,
|
|
719
|
-
fill(from === to ?
|
|
750
|
+
fill(from === to ? words().labels.hidden_one : words().labels.hidden, { from: from, to: to })).row, "gap", { from: from, to: to }));
|
|
720
751
|
}
|
|
721
752
|
var r = codeRow((l.mark ? " hit" : "") + (l.context ? " ctx" : ""), (l.mark ? "\u25b6" : "") + l.n,
|
|
722
753
|
l.gutter, withGutter, l.text);
|
|
@@ -936,11 +967,11 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
936
967
|
if (item.quote) card.appendChild(quoteBlock(item, ctx));
|
|
937
968
|
if (item.body !== undefined) {
|
|
938
969
|
var body = rich("p", "body", item.body);
|
|
939
|
-
card.appendChild(
|
|
970
|
+
card.appendChild(words().labels.body ? row(words().labels.body, body) : body);
|
|
940
971
|
}
|
|
941
|
-
if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(
|
|
972
|
+
if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(words().labels.repeat, item.repeat)));
|
|
942
973
|
if (item.code) card.appendChild(codeBlock(item.code));
|
|
943
|
-
if (item.unit) card.appendChild(row(item.unit.of > 1 ? fill(
|
|
974
|
+
if (item.unit) card.appendChild(row(item.unit.of > 1 ? fill(words().labels.line_part, { line: words().labels.line, part: item.unit.part, of: item.unit.of }) : words().labels.line,
|
|
944
975
|
markedLine(item.unit)));
|
|
945
976
|
// A rule the item names, with what it does in the linter's own words (looked up at build time).
|
|
946
977
|
(item.rules || []).forEach(function (r) {
|
|
@@ -959,13 +990,21 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
959
990
|
var o = q.options.filter(function (x) { return x.value === v; })[0];
|
|
960
991
|
return o ? o.label : v;
|
|
961
992
|
}
|
|
993
|
+
// A card's own wording of the first question, or the translation's, with `{n}` its marked line's number.
|
|
994
|
+
function situationQuestion(item) {
|
|
995
|
+
var own = words().situation_question;
|
|
996
|
+
if (own === undefined) return situated("p", "q", item.situation.question, item.situation.line);
|
|
997
|
+
var hit = item.excerpt && item.excerpt.lines.filter(function (l) { return l.mark; })[0];
|
|
998
|
+
var line = hit ? { n: hit.n, text: hit.text.trim() } : null;
|
|
999
|
+
return situated("p", "q", line ? fill(own, { n: line.n }) : own, line);
|
|
1000
|
+
}
|
|
962
1001
|
// One question per screen: the answered ones stay as a line (with "Change"), and only one question
|
|
963
1002
|
// is asked: the one being changed, else the first unanswered.
|
|
964
1003
|
function askOne(card, a, editing, onPick, onChange, off, withExamples, item) {
|
|
965
|
-
var open =
|
|
966
|
-
var asked =
|
|
1004
|
+
var open = Qs().filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
|
|
1005
|
+
var asked = Qs().filter(function (q) { return q.id === editing; })[0] || open[0];
|
|
967
1006
|
var situation = item.situation, notes = item.answer_notes || {};
|
|
968
|
-
|
|
1007
|
+
Qs().forEach(function (q) {
|
|
969
1008
|
if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
|
|
970
1009
|
var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
|
|
971
1010
|
if (onChange) {
|
|
@@ -980,7 +1019,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
980
1019
|
if (!asked) return null;
|
|
981
1020
|
var ask = el("div", "ask");
|
|
982
1021
|
// A card's situation asks the page's first question in its own words; the others keep theirs.
|
|
983
|
-
ask.appendChild(mark(situation && asked ===
|
|
1022
|
+
ask.appendChild(mark(situation && asked === Qs()[0] ? situationQuestion(item) : rich("p", "q", asked.text),
|
|
984
1023
|
"question", { question: asked.id }));
|
|
985
1024
|
ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples, notes[asked.id], asked.id));
|
|
986
1025
|
card.appendChild(ask);
|
|
@@ -990,18 +1029,19 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
990
1029
|
function howTo() {
|
|
991
1030
|
var sec = el("section");
|
|
992
1031
|
var body = sec;
|
|
993
|
-
|
|
1032
|
+
var heading = words().labels.how_to;
|
|
1033
|
+
if (practiceOpen()) sec.appendChild(el("h2", "", heading));
|
|
994
1034
|
else {
|
|
995
1035
|
body = sec.appendChild(el("details"));
|
|
996
|
-
body.appendChild(el("summary", "",
|
|
1036
|
+
body.appendChild(el("summary", "", heading));
|
|
997
1037
|
}
|
|
998
|
-
|
|
999
|
-
body.appendChild(el("p", "q", q.text));
|
|
1038
|
+
Qs().forEach(function (q) {
|
|
1039
|
+
body.appendChild(mark(el("p", "q", q.text), "how-to-question", { question: q.id }));
|
|
1000
1040
|
var list = el("div", "examples");
|
|
1001
1041
|
q.options.forEach(function (o) {
|
|
1002
|
-
var row = el("div", "example");
|
|
1042
|
+
var row = mark(el("div", "example"), "how-to-answer", { question: q.id, value: o.value });
|
|
1003
1043
|
row.appendChild(el("b", "", (o.icon ? o.icon + " " : "") + o.label));
|
|
1004
|
-
row.appendChild(rich("span", "muted",
|
|
1044
|
+
row.appendChild(rich("span", "muted", words().labels.for_example + o.example));
|
|
1005
1045
|
list.appendChild(row);
|
|
1006
1046
|
});
|
|
1007
1047
|
body.appendChild(list);
|
|
@@ -1016,7 +1056,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
1016
1056
|
var a = practiceAnswers(p.id);
|
|
1017
1057
|
askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p);
|
|
1018
1058
|
if (practiceAnswered(p)) {
|
|
1019
|
-
var expected =
|
|
1059
|
+
var expected = Qs().map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
|
|
1020
1060
|
card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
|
|
1021
1061
|
(practiceMatched(p) ? "Matched. " : "Not matched. Expected: " + expected + ". ") + p.why));
|
|
1022
1062
|
}
|
|
@@ -1134,8 +1174,10 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
1134
1174
|
app.textContent = "";
|
|
1135
1175
|
var top = el("header", "top");
|
|
1136
1176
|
app.appendChild(top);
|
|
1137
|
-
|
|
1138
|
-
|
|
1177
|
+
try { document.documentElement.lang = state.lang; } catch (e) {}
|
|
1178
|
+
top.appendChild(el("h1", "", words().title));
|
|
1179
|
+
if (LANGS.length) top.appendChild(langSwitch());
|
|
1180
|
+
if (words().intro) top.appendChild(el("p", "muted", words().intro));
|
|
1139
1181
|
if (state.store === "none") {
|
|
1140
1182
|
top.appendChild(el("div", "banner",
|
|
1141
1183
|
"Answers cannot be saved in this view, so it is read-only. Open the page from the claude.ai " +
|