paperlint 14.0.0 → 14.1.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 +68 -1
- package/dist/skills/build-label-page/scripts/label-page-presets.d.ts +10 -0
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +6 -2
- package/skills/build-label-page/SKILL.md.spec.ts +5 -1
- package/skills/build-label-page/references/closed-choice-generator.md +129 -5
- package/skills/build-label-page/scripts/fixtures/blocks-items.json +214 -0
- package/skills/build-label-page/scripts/fixtures/linter-config-witness-items.json +448 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +335 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +328 -2
- package/skills/build-label-page/scripts/label-page-core.js +154 -12
- package/skills/build-label-page/scripts/label-page-core.test.ts +54 -2
- package/skills/build-label-page/scripts/label-page-core.ts +253 -31
- package/skills/build-label-page/scripts/label-page-html.test.ts +22 -0
- package/skills/build-label-page/scripts/label-page-presets.js +222 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +175 -0
- package/skills/build-label-page/scripts/label-page-presets.ts +257 -0
- package/skills/build-label-page/scripts/label-page.js +8 -2
- package/skills/build-label-page/scripts/label-page.ts +8 -2
- package/skills/build-label-page/scripts/page-template.html +237 -30
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type Described as RuleText,
|
|
14
14
|
type RuleRef,
|
|
15
15
|
} from "./rule-descriptions.ts";
|
|
16
|
+
import { applyPreset } from "./label-page-presets.ts";
|
|
16
17
|
|
|
17
18
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
18
19
|
export class Refusal extends Error {
|
|
@@ -83,6 +84,11 @@ const writeJson = (path: string, value: unknown): void => {
|
|
|
83
84
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
84
85
|
};
|
|
85
86
|
const JSON_VALUE = z.json();
|
|
87
|
+
/** The config with its named preset applied (label-page-presets.ts), refused by path. */
|
|
88
|
+
const withPreset = (raw: Json): Json => {
|
|
89
|
+
const r = applyPreset(raw);
|
|
90
|
+
return r.ok ? JSON_VALUE.parse(r.value) : fail(r.message);
|
|
91
|
+
};
|
|
86
92
|
const readJson = (path: string): Json => {
|
|
87
93
|
try {
|
|
88
94
|
return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -108,11 +114,22 @@ const RULES_FIELD = "rules";
|
|
|
108
114
|
const UNIT_FIELD = "unit";
|
|
109
115
|
// Two readings of one requirement, shown as rows A and B (#374).
|
|
110
116
|
const READINGS_FIELD = "readings";
|
|
117
|
+
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
118
|
+
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
119
|
+
const BLOCK_FIELDS = [
|
|
120
|
+
"situation",
|
|
121
|
+
"excerpt",
|
|
122
|
+
"references",
|
|
123
|
+
"more",
|
|
124
|
+
"table",
|
|
125
|
+
"repeat",
|
|
126
|
+
] as const;
|
|
111
127
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
112
128
|
...PAGE_FIELDS,
|
|
113
129
|
RULES_FIELD,
|
|
114
130
|
UNIT_FIELD,
|
|
115
131
|
READINGS_FIELD,
|
|
132
|
+
...BLOCK_FIELDS,
|
|
116
133
|
...PRIVATE_FIELDS,
|
|
117
134
|
];
|
|
118
135
|
|
|
@@ -152,6 +169,7 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
|
152
169
|
// refusal, what a value must be (a pattern) or why a field is required (a page kind needs it).
|
|
153
170
|
type SchemaNode = (
|
|
154
171
|
| { kind: "string"; required: boolean; pattern?: string }
|
|
172
|
+
| { kind: "text"; required: boolean }
|
|
155
173
|
| { kind: "integer"; required: boolean }
|
|
156
174
|
| { kind: "boolean"; required: boolean }
|
|
157
175
|
| { kind: "array"; required: boolean; min: number; items: SchemaNode }
|
|
@@ -179,6 +197,8 @@ const string = (required = true, pattern?: string, why?: string): SchemaNode =>
|
|
|
179
197
|
pattern,
|
|
180
198
|
...(why === undefined ? {} : { why }),
|
|
181
199
|
};
|
|
200
|
+
/** A string that may be empty: a blank line of code. */
|
|
201
|
+
const text = (): SchemaNode => ({ kind: "text", required: true });
|
|
182
202
|
const integer = (required = true): SchemaNode => ({
|
|
183
203
|
kind: "integer",
|
|
184
204
|
required,
|
|
@@ -212,6 +232,14 @@ const obj = (
|
|
|
212
232
|
|
|
213
233
|
// What an item and a practice item both may carry beyond text: a practice card looks like the real
|
|
214
234
|
// cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
|
|
235
|
+
const LINK = string(false, "https?://\\S+", "an http(s) link");
|
|
236
|
+
const optional = (fields: Readonly<Record<string, SchemaNode>>): SchemaNode =>
|
|
237
|
+
obj(fields, true, false);
|
|
238
|
+
const TEXT_FIELD = obj({
|
|
239
|
+
label: string(false),
|
|
240
|
+
text: string(),
|
|
241
|
+
style: string(false, "code|note", "code or note"),
|
|
242
|
+
});
|
|
215
243
|
const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
216
244
|
[RULES_FIELD]: array(
|
|
217
245
|
obj({
|
|
@@ -235,6 +263,51 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
|
235
263
|
),
|
|
236
264
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
237
265
|
file: string(false),
|
|
266
|
+
situation: optional({ summary: string(), question: string() }),
|
|
267
|
+
excerpt: optional({
|
|
268
|
+
file: string(false),
|
|
269
|
+
url: LINK,
|
|
270
|
+
lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
|
|
271
|
+
caption: string(false),
|
|
272
|
+
mark_label: string(false),
|
|
273
|
+
}),
|
|
274
|
+
references: optional({
|
|
275
|
+
summary: string(),
|
|
276
|
+
entries: array(
|
|
277
|
+
obj({
|
|
278
|
+
label: string(false),
|
|
279
|
+
title: string(),
|
|
280
|
+
fields: array(TEXT_FIELD, true, 1),
|
|
281
|
+
url: LINK,
|
|
282
|
+
url_label: string(false),
|
|
283
|
+
}),
|
|
284
|
+
true,
|
|
285
|
+
1,
|
|
286
|
+
),
|
|
287
|
+
}),
|
|
288
|
+
more: optional({
|
|
289
|
+
summary: string(),
|
|
290
|
+
items: array(string(), true, 1),
|
|
291
|
+
rest: string(false),
|
|
292
|
+
}),
|
|
293
|
+
table: optional({
|
|
294
|
+
summary: string(),
|
|
295
|
+
note: string(false),
|
|
296
|
+
rows: array(
|
|
297
|
+
obj({
|
|
298
|
+
label: string(),
|
|
299
|
+
value: string(),
|
|
300
|
+
plain: string(false),
|
|
301
|
+
mark: optional({
|
|
302
|
+
tone: string(true, "yes|no", "yes or no"),
|
|
303
|
+
text: string(),
|
|
304
|
+
}),
|
|
305
|
+
}),
|
|
306
|
+
true,
|
|
307
|
+
1,
|
|
308
|
+
),
|
|
309
|
+
}),
|
|
310
|
+
repeat: optional({ group: string(), index: integer(), of: integer() }),
|
|
238
311
|
};
|
|
239
312
|
/**
|
|
240
313
|
* THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
|
|
@@ -331,6 +404,15 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
331
404
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
332
405
|
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
333
406
|
practice_round: integer(false),
|
|
407
|
+
// Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
|
|
408
|
+
labels: optional({
|
|
409
|
+
place: string(false),
|
|
410
|
+
body: string(false),
|
|
411
|
+
line: string(false),
|
|
412
|
+
line_part: string(false),
|
|
413
|
+
repeat: string(false),
|
|
414
|
+
practice_example: string(false),
|
|
415
|
+
}),
|
|
334
416
|
items: array(itemSchema(kind), true, 1),
|
|
335
417
|
});
|
|
336
418
|
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
@@ -412,6 +494,20 @@ function checkBoolean(value: Json, where: string): void {
|
|
|
412
494
|
if (typeof value !== "boolean")
|
|
413
495
|
fail(`${where}: must be true or false, got ${repr(value)}`);
|
|
414
496
|
}
|
|
497
|
+
/** A string that may be empty (a blank line of code), or an integer. */
|
|
498
|
+
function checkScalar(
|
|
499
|
+
value: Json,
|
|
500
|
+
kind: "text" | "integer",
|
|
501
|
+
where: string,
|
|
502
|
+
): void {
|
|
503
|
+
if (kind === "text" && typeof value !== "string")
|
|
504
|
+
fail(`${where}: must be a string, got ${repr(value)}`);
|
|
505
|
+
if (
|
|
506
|
+
kind === "integer" &&
|
|
507
|
+
(typeof value !== "number" || !Number.isInteger(value))
|
|
508
|
+
)
|
|
509
|
+
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
510
|
+
}
|
|
415
511
|
/** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
|
|
416
512
|
function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
417
513
|
const where = path || "items.json";
|
|
@@ -419,10 +515,9 @@ function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
|
419
515
|
case "string":
|
|
420
516
|
checkString(value, node, where);
|
|
421
517
|
break;
|
|
518
|
+
case "text":
|
|
422
519
|
case "integer":
|
|
423
|
-
|
|
424
|
-
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
425
|
-
}
|
|
520
|
+
checkScalar(value, node.kind, where);
|
|
426
521
|
break;
|
|
427
522
|
case "boolean":
|
|
428
523
|
checkBoolean(value, where);
|
|
@@ -459,15 +554,66 @@ interface Unit {
|
|
|
459
554
|
readonly part: number;
|
|
460
555
|
readonly of: number;
|
|
461
556
|
}
|
|
557
|
+
interface TextField {
|
|
558
|
+
readonly label?: string;
|
|
559
|
+
readonly text: string;
|
|
560
|
+
readonly style?: string;
|
|
561
|
+
}
|
|
562
|
+
interface CodeLine {
|
|
563
|
+
readonly n: number;
|
|
564
|
+
readonly text: string;
|
|
565
|
+
readonly mark: boolean;
|
|
566
|
+
}
|
|
567
|
+
/** The content blocks (#380), each optional on any kind. */
|
|
568
|
+
interface Blocks {
|
|
569
|
+
readonly situation?: { readonly summary: string; readonly question: string };
|
|
570
|
+
readonly excerpt?: {
|
|
571
|
+
readonly file?: string;
|
|
572
|
+
readonly url?: string;
|
|
573
|
+
readonly lines: readonly CodeLine[];
|
|
574
|
+
readonly caption?: string;
|
|
575
|
+
readonly mark_label?: string;
|
|
576
|
+
};
|
|
577
|
+
readonly references?: {
|
|
578
|
+
readonly summary: string;
|
|
579
|
+
readonly entries: readonly {
|
|
580
|
+
readonly label?: string;
|
|
581
|
+
readonly title: string;
|
|
582
|
+
readonly fields: readonly TextField[];
|
|
583
|
+
readonly url?: string;
|
|
584
|
+
readonly url_label?: string;
|
|
585
|
+
}[];
|
|
586
|
+
};
|
|
587
|
+
readonly more?: {
|
|
588
|
+
readonly summary: string;
|
|
589
|
+
readonly items: readonly string[];
|
|
590
|
+
readonly rest?: string;
|
|
591
|
+
};
|
|
592
|
+
readonly table?: {
|
|
593
|
+
readonly summary: string;
|
|
594
|
+
readonly note?: string;
|
|
595
|
+
readonly rows: readonly {
|
|
596
|
+
readonly label: string;
|
|
597
|
+
readonly value: string;
|
|
598
|
+
readonly plain?: string;
|
|
599
|
+
readonly mark?: { readonly tone: string; readonly text: string };
|
|
600
|
+
}[];
|
|
601
|
+
};
|
|
602
|
+
readonly repeat?: {
|
|
603
|
+
readonly group: string;
|
|
604
|
+
readonly index: number;
|
|
605
|
+
readonly of: number;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
462
608
|
/** What a card shows, on an item and on a practice item alike. */
|
|
463
609
|
type Card = {
|
|
464
610
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
465
|
-
} & {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
};
|
|
611
|
+
} & Blocks & {
|
|
612
|
+
readonly body: string;
|
|
613
|
+
readonly rules?: readonly RuleRef[];
|
|
614
|
+
readonly unit?: Unit;
|
|
615
|
+
readonly readings?: { readonly a: string; readonly b: string };
|
|
616
|
+
};
|
|
471
617
|
type Item = Card & { readonly source_id: string; readonly group: string };
|
|
472
618
|
type Practice = Card & {
|
|
473
619
|
readonly expected: Readonly<Record<string, string>>;
|
|
@@ -486,6 +632,7 @@ interface Config {
|
|
|
486
632
|
readonly questions: readonly Question[];
|
|
487
633
|
readonly practice: readonly Practice[];
|
|
488
634
|
readonly practice_round?: number;
|
|
635
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
489
636
|
readonly items: readonly Item[];
|
|
490
637
|
}
|
|
491
638
|
/** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
|
|
@@ -688,6 +835,43 @@ function checkUnits(cfg: Config): void {
|
|
|
688
835
|
});
|
|
689
836
|
}
|
|
690
837
|
}
|
|
838
|
+
/**
|
|
839
|
+
* A situation's sentence or question may name the excerpt's marked line as `{line}`: the page writes
|
|
840
|
+
* «line N: » and the line's code there, so a question sits next to the code it is about.
|
|
841
|
+
*/
|
|
842
|
+
const LINE_TOKEN = "{line}";
|
|
843
|
+
const SITUATION_TEXTS = ["summary", "question"] as const;
|
|
844
|
+
/** The line `{line}` stands for: the first marked line, its number and its code without the indent. */
|
|
845
|
+
const lineOf = (card: Card) =>
|
|
846
|
+
card.excerpt?.lines
|
|
847
|
+
.filter((l) => l.mark)
|
|
848
|
+
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
849
|
+
const usesLine = (card: Card): boolean =>
|
|
850
|
+
SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
|
|
851
|
+
/**
|
|
852
|
+
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
853
|
+
* and a repeat sits inside its count.
|
|
854
|
+
*/
|
|
855
|
+
function checkBlocks(cfg: Config): void {
|
|
856
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
857
|
+
if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
|
|
858
|
+
fail(
|
|
859
|
+
`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
|
|
860
|
+
);
|
|
861
|
+
const token = SITUATION_TEXTS.find((f) =>
|
|
862
|
+
card.situation?.[f].includes(LINE_TOKEN),
|
|
863
|
+
);
|
|
864
|
+
if (token !== undefined && card.excerpt === undefined)
|
|
865
|
+
fail(
|
|
866
|
+
`${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}\``,
|
|
867
|
+
);
|
|
868
|
+
const r = card.repeat;
|
|
869
|
+
if (r !== undefined && (r.index < 1 || r.index > r.of))
|
|
870
|
+
fail(
|
|
871
|
+
`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`,
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
691
875
|
/** Every card of the config, items then practice, by path. */
|
|
692
876
|
const cardsOf = (cfg: Config) => [
|
|
693
877
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
|
|
@@ -715,12 +899,24 @@ function checkItemFields(cfg: Config): void {
|
|
|
715
899
|
);
|
|
716
900
|
});
|
|
717
901
|
}
|
|
718
|
-
/** Every
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
902
|
+
/** Every string inside a value: each is tested on its own, as the page renders it, never a serialisation. */
|
|
903
|
+
const stringsIn = (v: unknown): readonly string[] =>
|
|
904
|
+
typeof v === "string"
|
|
905
|
+
? [v]
|
|
906
|
+
: Array.isArray(v)
|
|
907
|
+
? v.flatMap(stringsIn)
|
|
908
|
+
: typeof v === "object" && v !== null
|
|
909
|
+
? Object.values(v).flatMap(stringsIn)
|
|
910
|
+
: [];
|
|
911
|
+
/**
|
|
912
|
+
* Every text of an item the labeller sees, by field: each string the page shows (the fields as
|
|
913
|
+
* `shownFields` passes them, so a repeat without its group), and every rule it names.
|
|
914
|
+
*/
|
|
915
|
+
const visibleTexts = (item: Item): readonly (readonly [string, string])[] =>
|
|
916
|
+
Object.entries({
|
|
917
|
+
...shownFields(item),
|
|
918
|
+
[RULES_FIELD]: item.rules ?? [],
|
|
919
|
+
}).flatMap(([f, v]) => stringsIn(v).map((t) => [f, t] as const));
|
|
724
920
|
const escapeRegExp = (s: string): string =>
|
|
725
921
|
s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
726
922
|
/**
|
|
@@ -804,13 +1000,18 @@ function assignIds(
|
|
|
804
1000
|
}
|
|
805
1001
|
|
|
806
1002
|
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
807
|
-
/** A
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1003
|
+
/** A card's situation as the page shows it, with the line `{line}` stands for. */
|
|
1004
|
+
const situationShown = (item: Card) =>
|
|
1005
|
+
item.situation === undefined
|
|
1006
|
+
? {}
|
|
1007
|
+
: {
|
|
1008
|
+
situation: {
|
|
1009
|
+
summary: item.situation.summary,
|
|
1010
|
+
question: item.situation.question,
|
|
1011
|
+
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
1012
|
+
},
|
|
1013
|
+
};
|
|
1014
|
+
const shownFields = (item: Card): Readonly<Record<string, unknown>> => ({
|
|
814
1015
|
...Object.fromEntries(
|
|
815
1016
|
PAGE_FIELDS.flatMap((f) => {
|
|
816
1017
|
const v = item[f];
|
|
@@ -828,6 +1029,15 @@ const shownFields = (
|
|
|
828
1029
|
},
|
|
829
1030
|
}),
|
|
830
1031
|
...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
|
|
1032
|
+
...Object.fromEntries(
|
|
1033
|
+
BLOCK_FIELDS.filter((f) => f !== "repeat" && f !== "situation").flatMap(
|
|
1034
|
+
(f) => (item[f] === undefined ? [] : [[f, item[f]]]),
|
|
1035
|
+
),
|
|
1036
|
+
),
|
|
1037
|
+
...situationShown(item),
|
|
1038
|
+
...(item.repeat === undefined
|
|
1039
|
+
? {}
|
|
1040
|
+
: { repeat: { index: item.repeat.index, of: item.repeat.of } }),
|
|
831
1041
|
});
|
|
832
1042
|
/** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
|
|
833
1043
|
type Described = ReadonlyMap<string, RuleText>;
|
|
@@ -836,14 +1046,16 @@ const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
|
|
|
836
1046
|
function describeRules(cfg: Config, fromDir: string): Described {
|
|
837
1047
|
return new Map(
|
|
838
1048
|
cardsOf(cfg).flatMap(([at, card]) =>
|
|
839
|
-
(card.rules ?? []).
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1049
|
+
(card.rules ?? []).flatMap(
|
|
1050
|
+
(r, k): readonly (readonly [string, RuleText])[] => {
|
|
1051
|
+
const text = describeRule(r, fromDir);
|
|
1052
|
+
return text === undefined
|
|
1053
|
+
? fail(
|
|
1054
|
+
`${at}.rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
|
|
1055
|
+
)
|
|
1056
|
+
: [[ruleKey(r), text]];
|
|
1057
|
+
},
|
|
1058
|
+
),
|
|
847
1059
|
),
|
|
848
1060
|
);
|
|
849
1061
|
}
|
|
@@ -882,6 +1094,14 @@ const practiceShown = (cfg: Config, described: Described) =>
|
|
|
882
1094
|
...shownFields(p),
|
|
883
1095
|
...rulesShown(p, described),
|
|
884
1096
|
}));
|
|
1097
|
+
/** The page's own words where `labels` leaves one out (references/closed-choice-generator.md). */
|
|
1098
|
+
const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
1099
|
+
place: "{id} · {n} of {total}",
|
|
1100
|
+
line: "Line",
|
|
1101
|
+
line_part: "{line} · part {part} of {of}",
|
|
1102
|
+
repeat: "Card {index} of {of}",
|
|
1103
|
+
practice_example: "",
|
|
1104
|
+
};
|
|
885
1105
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
886
1106
|
function payloadOf(
|
|
887
1107
|
cfg: Config,
|
|
@@ -894,6 +1114,7 @@ function payloadOf(
|
|
|
894
1114
|
return {
|
|
895
1115
|
title: cfg.title,
|
|
896
1116
|
intro: cfg.intro ?? "",
|
|
1117
|
+
labels: { ...DEFAULT_LABELS, ...cfg.labels },
|
|
897
1118
|
groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
|
|
898
1119
|
familiarity_question: asksFamiliarity(cfg)
|
|
899
1120
|
? (cfg.familiarity_question ?? "How well do you know {group}?")
|
|
@@ -983,13 +1204,14 @@ function keyOf(
|
|
|
983
1204
|
* returns what the page shows. A page written by hand can take its blindness and its key from here.
|
|
984
1205
|
*/
|
|
985
1206
|
export function prepareBuild(configPath: string, outDir: string) {
|
|
986
|
-
const cfg = readJson(configPath);
|
|
1207
|
+
const cfg = withPreset(readJson(configPath));
|
|
987
1208
|
assertConfig(cfg);
|
|
988
1209
|
// Once its `kind` is known to be one, the fields that kind needs on every card.
|
|
989
1210
|
checkValue(cfg, configSchema(cfg.kind), "");
|
|
990
1211
|
checkConfig(cfg);
|
|
991
1212
|
checkItemFields(cfg);
|
|
992
1213
|
checkUnits(cfg);
|
|
1214
|
+
checkBlocks(cfg);
|
|
993
1215
|
checkNoSourceShown(cfg);
|
|
994
1216
|
const described = describeRules(cfg, dirname(configPath));
|
|
995
1217
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -482,3 +482,25 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
|
|
|
482
482
|
assert.doesNotMatch(shown, /how well you know it/);
|
|
483
483
|
assert.match(shown, /Nothing left/, shown);
|
|
484
484
|
});
|
|
485
|
+
|
|
486
|
+
test("🔴 «All items are complete» waits for every group's familiarity too, as «Next unanswered» does", async () => {
|
|
487
|
+
const { page, key } = build(setup("all-done-groups", config()));
|
|
488
|
+
const answers = Object.fromEntries(
|
|
489
|
+
Object.keys(key.ids).map((id) => [
|
|
490
|
+
id,
|
|
491
|
+
{ answers: { label: "yes", boundary: "clear" }, source: "page" },
|
|
492
|
+
]),
|
|
493
|
+
);
|
|
494
|
+
const said = async (groups: Record<string, unknown>) =>
|
|
495
|
+
/All 12 items are complete/.test(
|
|
496
|
+
(await openLive(page, { answers, groups, practice: DONE_PRACTICE })).app
|
|
497
|
+
.textContent,
|
|
498
|
+
);
|
|
499
|
+
const rated = Object.fromEntries(
|
|
500
|
+
["python", "go", "rust"].map((g) => [
|
|
501
|
+
g,
|
|
502
|
+
{ familiarity: "well", source: "page" },
|
|
503
|
+
]),
|
|
504
|
+
);
|
|
505
|
+
assert.deepEqual([await said({}), await said(rated)], [false, true]);
|
|
506
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRESETS: named, opt-in words for one narrow use of the generic content blocks. An items.json picks
|
|
3
|
+
* one by name (`"preset": "linter-config-witness"`); the preset turns its own compact card fields into
|
|
4
|
+
* the generic blocks and supplies the page's `labels`, and the items file's own `labels` override
|
|
5
|
+
* them. The core and the page know only the blocks: every word below stays in this file.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
// ── linter-config-witness: two linter configs of one requirement disagree on a line of code ──────
|
|
9
|
+
const LINE = z.strictObject({
|
|
10
|
+
n: z.int(),
|
|
11
|
+
text: z.string(),
|
|
12
|
+
mark: z.boolean(),
|
|
13
|
+
});
|
|
14
|
+
const EXAMPLE = z.strictObject({
|
|
15
|
+
file: z.string().min(1),
|
|
16
|
+
url: z.url().optional(),
|
|
17
|
+
lines: z.array(LINE).min(1),
|
|
18
|
+
message: z.string().min(1),
|
|
19
|
+
caught_by: z.enum(["a", "b"]),
|
|
20
|
+
});
|
|
21
|
+
const EXPLANATION = z
|
|
22
|
+
.strictObject({
|
|
23
|
+
source: z.enum([
|
|
24
|
+
"ruff rule",
|
|
25
|
+
"eslint docs",
|
|
26
|
+
"plugin docs",
|
|
27
|
+
"config message",
|
|
28
|
+
"none",
|
|
29
|
+
]),
|
|
30
|
+
what: z.string().min(1).optional(),
|
|
31
|
+
why: z.string().min(1).optional(),
|
|
32
|
+
example_bad: z.string().min(1).optional(),
|
|
33
|
+
example_good: z.string().min(1).optional(),
|
|
34
|
+
url: z.url().optional(),
|
|
35
|
+
})
|
|
36
|
+
.refine((x) => x.source === "none" || x.what !== undefined, {
|
|
37
|
+
message: "`what` is required unless `source` is 'none'",
|
|
38
|
+
path: ["what"],
|
|
39
|
+
});
|
|
40
|
+
const RULE = z.strictObject({
|
|
41
|
+
linter: z.string().min(1),
|
|
42
|
+
id: z.string().min(1),
|
|
43
|
+
explanation: EXPLANATION,
|
|
44
|
+
});
|
|
45
|
+
const SETTING = z.strictObject({
|
|
46
|
+
key: z.string().regex(/^(files|ignores|rule|option:\S+)$/u),
|
|
47
|
+
raw: z.string().min(1),
|
|
48
|
+
plain: z.string().min(1),
|
|
49
|
+
matches_example: z.boolean().nullable().optional(),
|
|
50
|
+
});
|
|
51
|
+
const LINTER_CARD = z.looseObject({
|
|
52
|
+
example: EXAMPLE,
|
|
53
|
+
rules: z.array(RULE).min(1),
|
|
54
|
+
more_like_this: z
|
|
55
|
+
.strictObject({ count: z.int(), files: z.array(z.string()) })
|
|
56
|
+
.optional(),
|
|
57
|
+
details: z.strictObject({
|
|
58
|
+
a: z.array(SETTING).min(1),
|
|
59
|
+
b: z.array(SETTING).min(1),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const LINTER_LABELS = {
|
|
63
|
+
place: "Card {n} of {total}",
|
|
64
|
+
body: "Requirement",
|
|
65
|
+
line: "Where this requirement comes from (the project's rules file)",
|
|
66
|
+
line_part: "Where this requirement comes from (the project's rules file)",
|
|
67
|
+
repeat: "Card {index} of {of} for this requirement",
|
|
68
|
+
practice_example: "Example of this answer, about other code: ",
|
|
69
|
+
};
|
|
70
|
+
const KEY_LABEL = {
|
|
71
|
+
files: "Checks files",
|
|
72
|
+
ignores: "Leaves out",
|
|
73
|
+
rule: "Rule",
|
|
74
|
+
};
|
|
75
|
+
const DOCS_NOTE = "The flagged/allowed examples are the docs' own, with the docs' own settings, not this project's.";
|
|
76
|
+
/** A rule as its docs explain it, as a reference entry. */
|
|
77
|
+
const ruleEntry = (r) => {
|
|
78
|
+
const x = r.explanation;
|
|
79
|
+
return {
|
|
80
|
+
label: "Rule",
|
|
81
|
+
title: `${r.linter} \`${r.id}\``,
|
|
82
|
+
fields: [
|
|
83
|
+
x.source === "none"
|
|
84
|
+
? { style: "note", text: "No official explanation for this rule." }
|
|
85
|
+
: {
|
|
86
|
+
...(x.source === "config message"
|
|
87
|
+
? { label: "The config's own message" }
|
|
88
|
+
: {}),
|
|
89
|
+
text: String(x.what),
|
|
90
|
+
},
|
|
91
|
+
...(x.why === undefined ? [] : [{ label: "Why", text: x.why }]),
|
|
92
|
+
...(x.example_bad === undefined && x.example_good === undefined
|
|
93
|
+
? []
|
|
94
|
+
: [{ style: "note", text: DOCS_NOTE }]),
|
|
95
|
+
...(x.example_bad === undefined
|
|
96
|
+
? []
|
|
97
|
+
: [{ label: "Flagged", style: "code", text: x.example_bad }]),
|
|
98
|
+
...(x.example_good === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [{ label: "Allowed", style: "code", text: x.example_good }]),
|
|
101
|
+
],
|
|
102
|
+
...(x.url === undefined ? {} : { url: x.url, url_label: "Rule docs" }),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/** Each glob or option on its own row: differences first (A only, B only), what both share last. */
|
|
106
|
+
const settingRows = (d) => {
|
|
107
|
+
const same = (r, s) => r.key === s.key && r.raw === s.raw;
|
|
108
|
+
const onlyIn = (xs, ys) => xs.filter((r) => !ys.some((s) => same(r, s)));
|
|
109
|
+
const rows = [
|
|
110
|
+
...onlyIn(d.a, d.b).map((r) => [r, "A only"]),
|
|
111
|
+
...onlyIn(d.b, d.a).map((r) => [r, "B only"]),
|
|
112
|
+
...d.a
|
|
113
|
+
.filter((r) => d.b.some((s) => same(r, s)))
|
|
114
|
+
.map((r) => [r, "A and B"]),
|
|
115
|
+
];
|
|
116
|
+
return rows.map(([r, side]) => ({
|
|
117
|
+
label: `${side} · ${KEY_LABEL[r.key] ?? `Option ${r.key.replace(/^option:/u, "")}`}`,
|
|
118
|
+
value: r.raw,
|
|
119
|
+
plain: r.plain,
|
|
120
|
+
...(r.matches_example === true
|
|
121
|
+
? { mark: { tone: "yes", text: "✓ matches this file" } }
|
|
122
|
+
: r.matches_example === false
|
|
123
|
+
? { mark: { tone: "no", text: "✗ does not match this file" } }
|
|
124
|
+
: {}),
|
|
125
|
+
}));
|
|
126
|
+
};
|
|
127
|
+
/** The files like this one, as a folded list; none when there are none. */
|
|
128
|
+
const moreList = (more) => more === undefined || more.count === 0
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
more: {
|
|
132
|
+
summary: `${String(more.count)} more file(s) with the same situation`,
|
|
133
|
+
items: more.files.map((f) => `\`${f}\``),
|
|
134
|
+
...(more.count > more.files.length
|
|
135
|
+
? { rest: `and ${String(more.count - more.files.length)} more` }
|
|
136
|
+
: {}),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
/** One card in the generic blocks. */
|
|
140
|
+
function linterCard(card) {
|
|
141
|
+
const { example: ex, rules, more_like_this: more, details, ...rest } = card;
|
|
142
|
+
const by = ex.caught_by === "a" ? ["A", "B"] : ["B", "A"];
|
|
143
|
+
return {
|
|
144
|
+
...rest,
|
|
145
|
+
excerpt: {
|
|
146
|
+
file: ex.file,
|
|
147
|
+
...(ex.url === undefined ? {} : { url: ex.url }),
|
|
148
|
+
lines: ex.lines,
|
|
149
|
+
caption: `The linter says: ${ex.message}`,
|
|
150
|
+
},
|
|
151
|
+
references: {
|
|
152
|
+
summary: rules.length > 1
|
|
153
|
+
? "What the linter rules do (official docs)"
|
|
154
|
+
: "What the linter rule does (official docs)",
|
|
155
|
+
entries: rules.map(ruleEntry),
|
|
156
|
+
},
|
|
157
|
+
...moreList(more),
|
|
158
|
+
table: {
|
|
159
|
+
summary: "Settings behind this card",
|
|
160
|
+
note: `Setting ${String(by[0])} flags the example line; setting ${String(by[1])} passes it.`,
|
|
161
|
+
rows: settingRows(details),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const CONFIG = z.looseObject({
|
|
166
|
+
preset: z.string(),
|
|
167
|
+
labels: z.record(z.string(), z.string()).optional(),
|
|
168
|
+
practice: z.array(z.unknown()),
|
|
169
|
+
items: z.array(z.unknown()),
|
|
170
|
+
});
|
|
171
|
+
const PRESETS = {
|
|
172
|
+
"linter-config-witness": {
|
|
173
|
+
labels: LINTER_LABELS,
|
|
174
|
+
card: linterCard,
|
|
175
|
+
schema: LINTER_CARD,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
/** Each problem zod found, as the build's refusals read: the path, then what is wrong. */
|
|
179
|
+
const issuesOf = (where, e) => e.issues
|
|
180
|
+
.map((issue) => `${where}${issue.path
|
|
181
|
+
.map((p) => typeof p === "number" ? `[${String(p)}]` : `.${String(p)}`)
|
|
182
|
+
.join("")}: ${issue.message}`)
|
|
183
|
+
.join("; ");
|
|
184
|
+
/** Every card through the preset, practice first, each refused by its path. */
|
|
185
|
+
const mapCards = (preset, practice, items) => [
|
|
186
|
+
...practice.map((c, n) => [`practice[${String(n)}]`, c]),
|
|
187
|
+
...items.map((c, n) => [`items[${String(n)}]`, c]),
|
|
188
|
+
].map(([at, c]) => {
|
|
189
|
+
const r = preset.schema.safeParse(c);
|
|
190
|
+
return r.success
|
|
191
|
+
? { ok: true, value: preset.card(r.data) }
|
|
192
|
+
: { ok: false, message: issuesOf(at, r.error) };
|
|
193
|
+
});
|
|
194
|
+
/** The config with its preset applied; a config without `preset` comes back as it was. */
|
|
195
|
+
export function applyPreset(raw) {
|
|
196
|
+
const cfg = CONFIG.safeParse(raw);
|
|
197
|
+
if (!cfg.success)
|
|
198
|
+
return { ok: true, value: raw };
|
|
199
|
+
const { preset: name, labels, practice, items, ...rest } = cfg.data;
|
|
200
|
+
const preset = PRESETS[name];
|
|
201
|
+
if (preset === undefined)
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `preset: '${name}' is not a preset (${Object.keys(PRESETS).join(", ")})`,
|
|
205
|
+
};
|
|
206
|
+
const cards = mapCards(preset, practice, items);
|
|
207
|
+
const bad = cards.find((c) => !c.ok);
|
|
208
|
+
if (bad !== undefined)
|
|
209
|
+
return bad;
|
|
210
|
+
const made = cards
|
|
211
|
+
.filter((c) => c.ok)
|
|
212
|
+
.map((c) => c.value);
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
value: {
|
|
216
|
+
...rest,
|
|
217
|
+
labels: { ...preset.labels, ...labels },
|
|
218
|
+
practice: made.slice(0, practice.length),
|
|
219
|
+
items: made.slice(practice.length),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|