paperlint 14.2.0 → 14.3.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/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +27 -2
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +5 -3
- package/skills/build-label-page/SKILL.md.spec.ts +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +44 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +452 -10
- package/skills/build-label-page/scripts/label-page-core.js +105 -9
- package/skills/build-label-page/scripts/label-page-core.test.ts +25 -1
- package/skills/build-label-page/scripts/label-page-core.ts +178 -10
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/page-template.html +292 -53
|
@@ -111,6 +111,9 @@ const UNIT_FIELD = "unit";
|
|
|
111
111
|
const READINGS_FIELD = "readings";
|
|
112
112
|
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
113
113
|
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
114
|
+
// `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
|
|
115
|
+
// its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
|
|
116
|
+
// under an answer that says what the answer means on this card.
|
|
114
117
|
const BLOCK_FIELDS = [
|
|
115
118
|
"situation",
|
|
116
119
|
"excerpt",
|
|
@@ -118,6 +121,9 @@ const BLOCK_FIELDS = [
|
|
|
118
121
|
"more",
|
|
119
122
|
"table",
|
|
120
123
|
"repeat",
|
|
124
|
+
"quote",
|
|
125
|
+
"terms",
|
|
126
|
+
"answer_notes",
|
|
121
127
|
];
|
|
122
128
|
const ALLOWED_FIELDS = [
|
|
123
129
|
...PAGE_FIELDS,
|
|
@@ -183,9 +189,9 @@ const array = (items, required = true, min = 0) => ({
|
|
|
183
189
|
min,
|
|
184
190
|
items,
|
|
185
191
|
});
|
|
186
|
-
const mapping = (values, keyPattern, min = 0) => ({
|
|
192
|
+
const mapping = (values, keyPattern, min = 0, required = true) => ({
|
|
187
193
|
kind: "map",
|
|
188
|
-
required
|
|
194
|
+
required,
|
|
189
195
|
min,
|
|
190
196
|
key_pattern: keyPattern,
|
|
191
197
|
values,
|
|
@@ -216,14 +222,42 @@ const CARD_FIELDS = {
|
|
|
216
222
|
}, true, false),
|
|
217
223
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
218
224
|
file: string(false),
|
|
219
|
-
situation: optional({ summary: string(), question: string() }),
|
|
225
|
+
situation: optional({ summary: string(false), question: string() }),
|
|
220
226
|
excerpt: optional({
|
|
221
227
|
file: string(false),
|
|
228
|
+
// the file's language, after its path: "path · Python"
|
|
229
|
+
lang: string(false),
|
|
222
230
|
url: LINK,
|
|
223
|
-
|
|
231
|
+
// the header of an extra gutter column; each line's `gutter` is its value there ("depth")
|
|
232
|
+
gutter_header: string(false),
|
|
233
|
+
lines: array(obj({
|
|
234
|
+
n: integer(),
|
|
235
|
+
text: text(),
|
|
236
|
+
mark: boolean(),
|
|
237
|
+
gutter: string(false),
|
|
238
|
+
// a line shown only to place the code (an enclosing block's opener): drawn faint
|
|
239
|
+
context: boolean(false),
|
|
240
|
+
}), true, 1),
|
|
241
|
+
// one short fact, drawn in a box right under the marked line, pointing at it
|
|
242
|
+
annotation: string(false),
|
|
224
243
|
caption: string(false),
|
|
225
244
|
mark_label: string(false),
|
|
226
245
|
}),
|
|
246
|
+
quote: optional({ text: string(), source: string(false) }),
|
|
247
|
+
terms: array(obj({
|
|
248
|
+
term: string(),
|
|
249
|
+
note: array(obj({
|
|
250
|
+
title: string(false),
|
|
251
|
+
says: string(false),
|
|
252
|
+
quote: string(false),
|
|
253
|
+
code: string(false),
|
|
254
|
+
example: string(false),
|
|
255
|
+
url: string(true, "https?://\\S+", "an http(s) link"),
|
|
256
|
+
url_label: string(),
|
|
257
|
+
}), true, 1),
|
|
258
|
+
}), false, 1),
|
|
259
|
+
// question id → option value → the sub-line under that answer's button on this card
|
|
260
|
+
answer_notes: mapping(mapping(string(), NAME_PATTERN, 1), NAME_PATTERN, 1, false),
|
|
227
261
|
references: optional({
|
|
228
262
|
summary: string(),
|
|
229
263
|
entries: array(obj({
|
|
@@ -282,7 +316,7 @@ const itemSchema = (kind) => obj({
|
|
|
282
316
|
source_id: string(),
|
|
283
317
|
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
284
318
|
f,
|
|
285
|
-
string(f === "group"
|
|
319
|
+
string(f === "group"),
|
|
286
320
|
])),
|
|
287
321
|
...cardFields(kind),
|
|
288
322
|
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
@@ -300,7 +334,7 @@ const OPTION_SCHEMA = obj({
|
|
|
300
334
|
const practiceSchema = (kind) => obj({
|
|
301
335
|
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
302
336
|
f,
|
|
303
|
-
string(
|
|
337
|
+
string(false),
|
|
304
338
|
])),
|
|
305
339
|
...cardFields(kind),
|
|
306
340
|
expected: mapping(string(), NAME_PATTERN, 1),
|
|
@@ -340,6 +374,9 @@ const configSchema = (kind) => obj({
|
|
|
340
374
|
line_part: string(false),
|
|
341
375
|
repeat: string(false),
|
|
342
376
|
practice_example: string(false),
|
|
377
|
+
hidden: string(false),
|
|
378
|
+
hidden_one: string(false),
|
|
379
|
+
wait: string(false),
|
|
343
380
|
}),
|
|
344
381
|
items: array(itemSchema(kind), true, 1),
|
|
345
382
|
});
|
|
@@ -600,7 +637,7 @@ const SITUATION_TEXTS = ["summary", "question"];
|
|
|
600
637
|
const lineOf = (card) => card.excerpt?.lines
|
|
601
638
|
.filter((l) => l.mark)
|
|
602
639
|
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
603
|
-
const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]
|
|
640
|
+
const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
604
641
|
/**
|
|
605
642
|
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
606
643
|
* and a repeat sits inside its count.
|
|
@@ -609,7 +646,7 @@ function checkBlocks(cfg) {
|
|
|
609
646
|
cardsOf(cfg).forEach(([at, card]) => {
|
|
610
647
|
if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
|
|
611
648
|
fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
|
|
612
|
-
const token = SITUATION_TEXTS.find((f) => card.situation?.[f]
|
|
649
|
+
const token = SITUATION_TEXTS.find((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
613
650
|
if (token !== undefined && card.excerpt === undefined)
|
|
614
651
|
fail(`${at}.situation.${token}: \`${LINE_TOKEN}\` names the excerpt's marked line, and the card has no \`excerpt\` — add it, or write the ${token} without \`${LINE_TOKEN}\``);
|
|
615
652
|
const r = card.repeat;
|
|
@@ -617,6 +654,61 @@ function checkBlocks(cfg) {
|
|
|
617
654
|
fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
|
|
618
655
|
});
|
|
619
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* The texts a term may open a note from, the words asked about first: the quote, its source, then
|
|
659
|
+
* the excerpt's annotation. The page looks a term up in the same texts, split the same way at
|
|
660
|
+
* backticks, so a term the build finds is a term the page draws.
|
|
661
|
+
*/
|
|
662
|
+
const termTexts = (card) => [card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(R.isDefined);
|
|
663
|
+
const findsTerm = (card, term) => termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
|
|
664
|
+
/**
|
|
665
|
+
* What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
|
|
666
|
+
* term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
|
|
667
|
+
* note names a question of the page and one of its options.
|
|
668
|
+
*/
|
|
669
|
+
function checkCardText(cfg) {
|
|
670
|
+
const options = new Map(cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]));
|
|
671
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
672
|
+
if (card.body === undefined && card.quote === undefined)
|
|
673
|
+
fail(`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``);
|
|
674
|
+
checkTerms(at, card);
|
|
675
|
+
checkAnswerNotes(at, card, options);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* One term's text inside another's on the same card (or the same text twice): the page draws one
|
|
680
|
+
* button for the words, so the other term's note would never open.
|
|
681
|
+
*/
|
|
682
|
+
function checkTermsDoNotOverlap(at, card) {
|
|
683
|
+
const texts = (card.terms ?? []).map((t) => t.term);
|
|
684
|
+
const named = (k) => `${at}.terms[${String(k)}]`;
|
|
685
|
+
texts.forEach((inner, k) => {
|
|
686
|
+
const j = texts.findIndex((outer, m) => m !== k && outer.includes(inner) && (outer !== inner || m > k));
|
|
687
|
+
if (j !== -1)
|
|
688
|
+
fail(`${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
function checkTerms(at, card) {
|
|
692
|
+
checkTermsDoNotOverlap(at, card);
|
|
693
|
+
(card.terms ?? []).forEach((t, k) => {
|
|
694
|
+
const where = `${at}.terms[${String(k)}]`;
|
|
695
|
+
if (!findsTerm(card, t.term))
|
|
696
|
+
fail(`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`);
|
|
697
|
+
const bare = t.note.findIndex((e) => e.quote === undefined && e.code === undefined);
|
|
698
|
+
if (bare !== -1)
|
|
699
|
+
fail(`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function checkAnswerNotes(at, card, options) {
|
|
703
|
+
Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
|
|
704
|
+
const values = options.get(qid);
|
|
705
|
+
if (values === undefined)
|
|
706
|
+
return fail(`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`);
|
|
707
|
+
const stray = Object.keys(notes).find((v) => !values.includes(v));
|
|
708
|
+
if (stray !== undefined)
|
|
709
|
+
fail(`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
620
712
|
/** Every card of the config, items then practice, by path. */
|
|
621
713
|
const cardsOf = (cfg) => [
|
|
622
714
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
|
|
@@ -713,7 +805,7 @@ const situationShown = (item) => item.situation === undefined
|
|
|
713
805
|
? {}
|
|
714
806
|
: {
|
|
715
807
|
situation: {
|
|
716
|
-
summary: item.situation.summary,
|
|
808
|
+
...R.pickBy({ summary: item.situation.summary }, R.isDefined),
|
|
717
809
|
question: item.situation.question,
|
|
718
810
|
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
719
811
|
},
|
|
@@ -787,6 +879,9 @@ const DEFAULT_LABELS = {
|
|
|
787
879
|
line_part: "{line} · part {part} of {of}",
|
|
788
880
|
repeat: "Card {index} of {of}",
|
|
789
881
|
practice_example: "",
|
|
882
|
+
hidden: "lines {from}\u2013{to}",
|
|
883
|
+
hidden_one: "line {from}",
|
|
884
|
+
wait: "\u23f3 You can answer in {s} s",
|
|
790
885
|
};
|
|
791
886
|
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
792
887
|
const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
|
|
@@ -886,6 +981,7 @@ export function prepareBuild(configPath, outDir) {
|
|
|
886
981
|
checkItemFields(cfg);
|
|
887
982
|
checkUnits(cfg);
|
|
888
983
|
checkBlocks(cfg);
|
|
984
|
+
checkCardText(cfg);
|
|
889
985
|
checkNoSourceShown(cfg);
|
|
890
986
|
const described = describeRules(cfg, dirname(configPath));
|
|
891
987
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -86,11 +86,32 @@ const CARD = {
|
|
|
86
86
|
situation: { summary: "S", question: "Q?" },
|
|
87
87
|
excerpt: {
|
|
88
88
|
file: "src/a.ts",
|
|
89
|
+
lang: "TypeScript",
|
|
89
90
|
url: "https://example.com/a.ts",
|
|
90
|
-
|
|
91
|
+
gutter_header: "depth",
|
|
92
|
+
lines: [{ n: 1, text: "", mark: true, gutter: "1", context: false }],
|
|
93
|
+
annotation: "a `Term` here",
|
|
91
94
|
caption: "C",
|
|
92
95
|
mark_label: "M",
|
|
93
96
|
},
|
|
97
|
+
quote: { text: "Q with Term", source: "`R.md`, line 1" },
|
|
98
|
+
terms: [
|
|
99
|
+
{
|
|
100
|
+
term: "Term",
|
|
101
|
+
note: [
|
|
102
|
+
{
|
|
103
|
+
title: "T",
|
|
104
|
+
says: "S",
|
|
105
|
+
quote: "Q",
|
|
106
|
+
code: "x = 1",
|
|
107
|
+
example: "E",
|
|
108
|
+
url: "https://example.com/t",
|
|
109
|
+
url_label: "U",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
answer_notes: { label: { yes: "like A" } },
|
|
94
115
|
references: {
|
|
95
116
|
summary: "S",
|
|
96
117
|
entries: [
|
|
@@ -145,6 +166,9 @@ const FULL: DocObject = (() => {
|
|
|
145
166
|
line_part: "{line} {part}/{of}",
|
|
146
167
|
repeat: "R",
|
|
147
168
|
practice_example: "P",
|
|
169
|
+
hidden: "H {from}-{to}",
|
|
170
|
+
hidden_one: "H {from}",
|
|
171
|
+
wait: "W {s}",
|
|
148
172
|
},
|
|
149
173
|
practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
|
|
150
174
|
items: ITEMS.map((i, n) =>
|
|
@@ -120,6 +120,9 @@ const UNIT_FIELD = "unit";
|
|
|
120
120
|
const READINGS_FIELD = "readings";
|
|
121
121
|
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
122
122
|
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
123
|
+
// `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
|
|
124
|
+
// its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
|
|
125
|
+
// under an answer that says what the answer means on this card.
|
|
123
126
|
const BLOCK_FIELDS = [
|
|
124
127
|
"situation",
|
|
125
128
|
"excerpt",
|
|
@@ -127,6 +130,9 @@ const BLOCK_FIELDS = [
|
|
|
127
130
|
"more",
|
|
128
131
|
"table",
|
|
129
132
|
"repeat",
|
|
133
|
+
"quote",
|
|
134
|
+
"terms",
|
|
135
|
+
"answer_notes",
|
|
130
136
|
] as const;
|
|
131
137
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
132
138
|
...PAGE_FIELDS,
|
|
@@ -227,9 +233,10 @@ const mapping = (
|
|
|
227
233
|
values: SchemaNode,
|
|
228
234
|
keyPattern: string,
|
|
229
235
|
min = 0,
|
|
236
|
+
required = true,
|
|
230
237
|
): SchemaNode => ({
|
|
231
238
|
kind: "map",
|
|
232
|
-
required
|
|
239
|
+
required,
|
|
233
240
|
min,
|
|
234
241
|
key_pattern: keyPattern,
|
|
235
242
|
values,
|
|
@@ -273,14 +280,59 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
|
273
280
|
),
|
|
274
281
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
275
282
|
file: string(false),
|
|
276
|
-
situation: optional({ summary: string(), question: string() }),
|
|
283
|
+
situation: optional({ summary: string(false), question: string() }),
|
|
277
284
|
excerpt: optional({
|
|
278
285
|
file: string(false),
|
|
286
|
+
// the file's language, after its path: "path · Python"
|
|
287
|
+
lang: string(false),
|
|
279
288
|
url: LINK,
|
|
280
|
-
|
|
289
|
+
// the header of an extra gutter column; each line's `gutter` is its value there ("depth")
|
|
290
|
+
gutter_header: string(false),
|
|
291
|
+
lines: array(
|
|
292
|
+
obj({
|
|
293
|
+
n: integer(),
|
|
294
|
+
text: text(),
|
|
295
|
+
mark: boolean(),
|
|
296
|
+
gutter: string(false),
|
|
297
|
+
// a line shown only to place the code (an enclosing block's opener): drawn faint
|
|
298
|
+
context: boolean(false),
|
|
299
|
+
}),
|
|
300
|
+
true,
|
|
301
|
+
1,
|
|
302
|
+
),
|
|
303
|
+
// one short fact, drawn in a box right under the marked line, pointing at it
|
|
304
|
+
annotation: string(false),
|
|
281
305
|
caption: string(false),
|
|
282
306
|
mark_label: string(false),
|
|
283
307
|
}),
|
|
308
|
+
quote: optional({ text: string(), source: string(false) }),
|
|
309
|
+
terms: array(
|
|
310
|
+
obj({
|
|
311
|
+
term: string(),
|
|
312
|
+
note: array(
|
|
313
|
+
obj({
|
|
314
|
+
title: string(false),
|
|
315
|
+
says: string(false),
|
|
316
|
+
quote: string(false),
|
|
317
|
+
code: string(false),
|
|
318
|
+
example: string(false),
|
|
319
|
+
url: string(true, "https?://\\S+", "an http(s) link"),
|
|
320
|
+
url_label: string(),
|
|
321
|
+
}),
|
|
322
|
+
true,
|
|
323
|
+
1,
|
|
324
|
+
),
|
|
325
|
+
}),
|
|
326
|
+
false,
|
|
327
|
+
1,
|
|
328
|
+
),
|
|
329
|
+
// question id → option value → the sub-line under that answer's button on this card
|
|
330
|
+
answer_notes: mapping(
|
|
331
|
+
mapping(string(), NAME_PATTERN, 1),
|
|
332
|
+
NAME_PATTERN,
|
|
333
|
+
1,
|
|
334
|
+
false,
|
|
335
|
+
),
|
|
284
336
|
references: optional({
|
|
285
337
|
summary: string(),
|
|
286
338
|
entries: array(
|
|
@@ -362,7 +414,7 @@ const itemSchema = (kind: Kind): SchemaNode =>
|
|
|
362
414
|
...Object.fromEntries(
|
|
363
415
|
PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
364
416
|
f,
|
|
365
|
-
string(f === "group"
|
|
417
|
+
string(f === "group"),
|
|
366
418
|
]),
|
|
367
419
|
),
|
|
368
420
|
...cardFields(kind),
|
|
@@ -385,7 +437,7 @@ const practiceSchema = (kind: Kind): SchemaNode =>
|
|
|
385
437
|
...Object.fromEntries(
|
|
386
438
|
PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
387
439
|
f,
|
|
388
|
-
string(
|
|
440
|
+
string(false),
|
|
389
441
|
]),
|
|
390
442
|
),
|
|
391
443
|
...cardFields(kind),
|
|
@@ -427,6 +479,9 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
427
479
|
line_part: string(false),
|
|
428
480
|
repeat: string(false),
|
|
429
481
|
practice_example: string(false),
|
|
482
|
+
hidden: string(false),
|
|
483
|
+
hidden_one: string(false),
|
|
484
|
+
wait: string(false),
|
|
430
485
|
}),
|
|
431
486
|
items: array(itemSchema(kind), true, 1),
|
|
432
487
|
});
|
|
@@ -578,17 +633,44 @@ interface CodeLine {
|
|
|
578
633
|
readonly n: number;
|
|
579
634
|
readonly text: string;
|
|
580
635
|
readonly mark: boolean;
|
|
636
|
+
readonly gutter?: string;
|
|
637
|
+
readonly context?: boolean;
|
|
638
|
+
}
|
|
639
|
+
/** One entry of a term's note: a verbatim quote or snippet, and the link to where it is from. */
|
|
640
|
+
interface NoteEntry {
|
|
641
|
+
readonly title?: string;
|
|
642
|
+
readonly says?: string;
|
|
643
|
+
readonly quote?: string;
|
|
644
|
+
readonly code?: string;
|
|
645
|
+
readonly example?: string;
|
|
646
|
+
readonly url: string;
|
|
647
|
+
readonly url_label: string;
|
|
648
|
+
}
|
|
649
|
+
interface Term {
|
|
650
|
+
readonly term: string;
|
|
651
|
+
readonly note: readonly NoteEntry[];
|
|
581
652
|
}
|
|
582
653
|
/** The content blocks (#380), each optional on any kind. */
|
|
583
654
|
interface Blocks {
|
|
584
|
-
readonly situation?: {
|
|
655
|
+
readonly situation?: {
|
|
656
|
+
readonly summary?: string;
|
|
657
|
+
readonly question: string;
|
|
658
|
+
};
|
|
585
659
|
readonly excerpt?: {
|
|
586
660
|
readonly file?: string;
|
|
661
|
+
readonly lang?: string;
|
|
587
662
|
readonly url?: string;
|
|
663
|
+
readonly gutter_header?: string;
|
|
588
664
|
readonly lines: readonly CodeLine[];
|
|
665
|
+
readonly annotation?: string;
|
|
589
666
|
readonly caption?: string;
|
|
590
667
|
readonly mark_label?: string;
|
|
591
668
|
};
|
|
669
|
+
readonly quote?: { readonly text: string; readonly source?: string };
|
|
670
|
+
readonly terms?: readonly Term[];
|
|
671
|
+
readonly answer_notes?: Readonly<
|
|
672
|
+
Record<string, Readonly<Record<string, string>>>
|
|
673
|
+
>;
|
|
592
674
|
readonly references?: {
|
|
593
675
|
readonly summary: string;
|
|
594
676
|
readonly entries: readonly {
|
|
@@ -624,7 +706,7 @@ interface Blocks {
|
|
|
624
706
|
type Card = {
|
|
625
707
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
626
708
|
} & Blocks & {
|
|
627
|
-
readonly body
|
|
709
|
+
readonly body?: string;
|
|
628
710
|
readonly rules?: readonly RuleRef[];
|
|
629
711
|
readonly unit?: Unit;
|
|
630
712
|
readonly readings?: { readonly a: string; readonly b: string };
|
|
@@ -897,7 +979,7 @@ const lineOf = (card: Card) =>
|
|
|
897
979
|
.filter((l) => l.mark)
|
|
898
980
|
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
899
981
|
const usesLine = (card: Card): boolean =>
|
|
900
|
-
SITUATION_TEXTS.some((f) => card.situation?.[f]
|
|
982
|
+
SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
901
983
|
/**
|
|
902
984
|
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
903
985
|
* and a repeat sits inside its count.
|
|
@@ -909,7 +991,7 @@ function checkBlocks(cfg: Config): void {
|
|
|
909
991
|
`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
|
|
910
992
|
);
|
|
911
993
|
const token = SITUATION_TEXTS.find((f) =>
|
|
912
|
-
card.situation?.[f]
|
|
994
|
+
card.situation?.[f]?.includes(LINE_TOKEN),
|
|
913
995
|
);
|
|
914
996
|
if (token !== undefined && card.excerpt === undefined)
|
|
915
997
|
fail(
|
|
@@ -922,6 +1004,88 @@ function checkBlocks(cfg: Config): void {
|
|
|
922
1004
|
);
|
|
923
1005
|
});
|
|
924
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The texts a term may open a note from, the words asked about first: the quote, its source, then
|
|
1009
|
+
* the excerpt's annotation. The page looks a term up in the same texts, split the same way at
|
|
1010
|
+
* backticks, so a term the build finds is a term the page draws.
|
|
1011
|
+
*/
|
|
1012
|
+
const termTexts = (card: Card): readonly string[] =>
|
|
1013
|
+
[card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(
|
|
1014
|
+
R.isDefined,
|
|
1015
|
+
);
|
|
1016
|
+
const findsTerm = (card: Card, term: string): boolean =>
|
|
1017
|
+
termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
|
|
1018
|
+
/**
|
|
1019
|
+
* What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
|
|
1020
|
+
* term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
|
|
1021
|
+
* note names a question of the page and one of its options.
|
|
1022
|
+
*/
|
|
1023
|
+
function checkCardText(cfg: Config): void {
|
|
1024
|
+
const options = new Map(
|
|
1025
|
+
cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]),
|
|
1026
|
+
);
|
|
1027
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
1028
|
+
if (card.body === undefined && card.quote === undefined)
|
|
1029
|
+
fail(
|
|
1030
|
+
`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``,
|
|
1031
|
+
);
|
|
1032
|
+
checkTerms(at, card);
|
|
1033
|
+
checkAnswerNotes(at, card, options);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* One term's text inside another's on the same card (or the same text twice): the page draws one
|
|
1038
|
+
* button for the words, so the other term's note would never open.
|
|
1039
|
+
*/
|
|
1040
|
+
function checkTermsDoNotOverlap(at: string, card: Card): void {
|
|
1041
|
+
const texts = (card.terms ?? []).map((t) => t.term);
|
|
1042
|
+
const named = (k: number) => `${at}.terms[${String(k)}]`;
|
|
1043
|
+
texts.forEach((inner, k) => {
|
|
1044
|
+
const j = texts.findIndex(
|
|
1045
|
+
(outer, m) =>
|
|
1046
|
+
m !== k && outer.includes(inner) && (outer !== inner || m > k),
|
|
1047
|
+
);
|
|
1048
|
+
if (j !== -1)
|
|
1049
|
+
fail(
|
|
1050
|
+
`${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`,
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
function checkTerms(at: string, card: Card): void {
|
|
1055
|
+
checkTermsDoNotOverlap(at, card);
|
|
1056
|
+
(card.terms ?? []).forEach((t, k) => {
|
|
1057
|
+
const where = `${at}.terms[${String(k)}]`;
|
|
1058
|
+
if (!findsTerm(card, t.term))
|
|
1059
|
+
fail(
|
|
1060
|
+
`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`,
|
|
1061
|
+
);
|
|
1062
|
+
const bare = t.note.findIndex(
|
|
1063
|
+
(e) => e.quote === undefined && e.code === undefined,
|
|
1064
|
+
);
|
|
1065
|
+
if (bare !== -1)
|
|
1066
|
+
fail(
|
|
1067
|
+
`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`,
|
|
1068
|
+
);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function checkAnswerNotes(
|
|
1072
|
+
at: string,
|
|
1073
|
+
card: Card,
|
|
1074
|
+
options: ReadonlyMap<string, readonly string[]>,
|
|
1075
|
+
): void {
|
|
1076
|
+
Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
|
|
1077
|
+
const values = options.get(qid);
|
|
1078
|
+
if (values === undefined)
|
|
1079
|
+
return fail(
|
|
1080
|
+
`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`,
|
|
1081
|
+
);
|
|
1082
|
+
const stray = Object.keys(notes).find((v) => !values.includes(v));
|
|
1083
|
+
if (stray !== undefined)
|
|
1084
|
+
fail(
|
|
1085
|
+
`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`,
|
|
1086
|
+
);
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
925
1089
|
/** Every card of the config, items then practice, by path. */
|
|
926
1090
|
const cardsOf = (cfg: Config) => [
|
|
927
1091
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
|
|
@@ -1058,7 +1222,7 @@ const situationShown = (item: Card) =>
|
|
|
1058
1222
|
? {}
|
|
1059
1223
|
: {
|
|
1060
1224
|
situation: {
|
|
1061
|
-
summary: item.situation.summary,
|
|
1225
|
+
...R.pickBy({ summary: item.situation.summary }, R.isDefined),
|
|
1062
1226
|
question: item.situation.question,
|
|
1063
1227
|
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
1064
1228
|
},
|
|
@@ -1153,6 +1317,9 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
1153
1317
|
line_part: "{line} · part {part} of {of}",
|
|
1154
1318
|
repeat: "Card {index} of {of}",
|
|
1155
1319
|
practice_example: "",
|
|
1320
|
+
hidden: "lines {from}\u2013{to}",
|
|
1321
|
+
hidden_one: "line {from}",
|
|
1322
|
+
wait: "\u23f3 You can answer in {s} s",
|
|
1156
1323
|
};
|
|
1157
1324
|
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
1158
1325
|
const twoKeys = (cfg: Config) =>
|
|
@@ -1276,6 +1443,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1276
1443
|
checkItemFields(cfg);
|
|
1277
1444
|
checkUnits(cfg);
|
|
1278
1445
|
checkBlocks(cfg);
|
|
1446
|
+
checkCardText(cfg);
|
|
1279
1447
|
checkNoSourceShown(cfg);
|
|
1280
1448
|
const described = describeRules(cfg, dirname(configPath));
|
|
1281
1449
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -87,7 +87,7 @@ const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
|
|
|
87
87
|
);
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
-
test("🔴 a page naming the preset shows its words on the generic blocks: the linter's message, the rule's docs
|
|
90
|
+
test("🔴 a page naming the preset shows its words on the generic blocks: the linter's message, the rule's docs with the docs-examples note, the files like it, and the settings with their sides", async () => {
|
|
91
91
|
const { page, key } = build(setup("preset-face", preset()));
|
|
92
92
|
const shown = await showPage(page, {}, {}, PRACTICE_DONE);
|
|
93
93
|
const id = Object.entries(key.ids).find(
|