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.
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { z } from "zod";
11
11
  import { describeRule, } from "./rule-descriptions.js";
12
+ import { applyPreset } from "./label-page-presets.js";
12
13
  // ── refusals and output ───────────────────────────────────────────────────────────────────────
13
14
  export class Refusal extends Error {
14
15
  exitCode;
@@ -74,6 +75,11 @@ const writeJson = (path, value) => {
74
75
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
75
76
  };
76
77
  const JSON_VALUE = z.json();
78
+ /** The config with its named preset applied (label-page-presets.ts), refused by path. */
79
+ const withPreset = (raw) => {
80
+ const r = applyPreset(raw);
81
+ return r.ok ? JSON_VALUE.parse(r.value) : fail(r.message);
82
+ };
77
83
  const readJson = (path) => {
78
84
  try {
79
85
  return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
@@ -99,11 +105,22 @@ const RULES_FIELD = "rules";
99
105
  const UNIT_FIELD = "unit";
100
106
  // Two readings of one requirement, shown as rows A and B (#374).
101
107
  const READINGS_FIELD = "readings";
108
+ // Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
109
+ // items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
110
+ const BLOCK_FIELDS = [
111
+ "situation",
112
+ "excerpt",
113
+ "references",
114
+ "more",
115
+ "table",
116
+ "repeat",
117
+ ];
102
118
  const ALLOWED_FIELDS = [
103
119
  ...PAGE_FIELDS,
104
120
  RULES_FIELD,
105
121
  UNIT_FIELD,
106
122
  READINGS_FIELD,
123
+ ...BLOCK_FIELDS,
107
124
  ...PRIVATE_FIELDS,
108
125
  ];
109
126
  const FAMILIARITY = [
@@ -140,6 +157,8 @@ const string = (required = true, pattern, why) => pattern === undefined
140
157
  pattern,
141
158
  ...(why === undefined ? {} : { why }),
142
159
  };
160
+ /** A string that may be empty: a blank line of code. */
161
+ const text = () => ({ kind: "text", required: true });
143
162
  const integer = (required = true) => ({
144
163
  kind: "integer",
145
164
  required,
@@ -164,6 +183,13 @@ const mapping = (values, keyPattern, min = 0) => ({
164
183
  const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
165
184
  // What an item and a practice item both may carry beyond text: a practice card looks like the real
166
185
  // cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
186
+ const LINK = string(false, "https?://\\S+", "an http(s) link");
187
+ const optional = (fields) => obj(fields, true, false);
188
+ const TEXT_FIELD = obj({
189
+ label: string(false),
190
+ text: string(),
191
+ style: string(false, "code|note", "code or note"),
192
+ });
167
193
  const CARD_FIELDS = {
168
194
  [RULES_FIELD]: array(obj({
169
195
  linter: string(true, "eslint|ruff", "eslint or ruff"),
@@ -180,6 +206,43 @@ const CARD_FIELDS = {
180
206
  }, true, false),
181
207
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
182
208
  file: string(false),
209
+ situation: optional({ summary: string(), question: string() }),
210
+ excerpt: optional({
211
+ file: string(false),
212
+ url: LINK,
213
+ lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
214
+ caption: string(false),
215
+ mark_label: string(false),
216
+ }),
217
+ references: optional({
218
+ summary: string(),
219
+ entries: array(obj({
220
+ label: string(false),
221
+ title: string(),
222
+ fields: array(TEXT_FIELD, true, 1),
223
+ url: LINK,
224
+ url_label: string(false),
225
+ }), true, 1),
226
+ }),
227
+ more: optional({
228
+ summary: string(),
229
+ items: array(string(), true, 1),
230
+ rest: string(false),
231
+ }),
232
+ table: optional({
233
+ summary: string(),
234
+ note: string(false),
235
+ rows: array(obj({
236
+ label: string(),
237
+ value: string(),
238
+ plain: string(false),
239
+ mark: optional({
240
+ tone: string(true, "yes|no", "yes or no"),
241
+ text: string(),
242
+ }),
243
+ }), true, 1),
244
+ }),
245
+ repeat: optional({ group: string(), index: integer(), of: integer() }),
183
246
  };
184
247
  const KINDS = {
185
248
  // keep or drop a drawn requirement: the line it came from, marked, and the rule read for it
@@ -254,6 +317,15 @@ const configSchema = (kind) => obj({
254
317
  questions: array(QUESTION_SCHEMA, true, 1),
255
318
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
256
319
  practice_round: integer(false),
320
+ // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
321
+ labels: optional({
322
+ place: string(false),
323
+ body: string(false),
324
+ line: string(false),
325
+ line_part: string(false),
326
+ repeat: string(false),
327
+ practice_example: string(false),
328
+ }),
257
329
  items: array(itemSchema(kind), true, 1),
258
330
  });
259
331
  /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
@@ -310,6 +382,14 @@ function checkBoolean(value, where) {
310
382
  if (typeof value !== "boolean")
311
383
  fail(`${where}: must be true or false, got ${repr(value)}`);
312
384
  }
385
+ /** A string that may be empty (a blank line of code), or an integer. */
386
+ function checkScalar(value, kind, where) {
387
+ if (kind === "text" && typeof value !== "string")
388
+ fail(`${where}: must be a string, got ${repr(value)}`);
389
+ if (kind === "integer" &&
390
+ (typeof value !== "number" || !Number.isInteger(value)))
391
+ fail(`${where}: must be an integer, got ${repr(value)}`);
392
+ }
313
393
  /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
314
394
  function checkValue(value, node, path) {
315
395
  const where = path || "items.json";
@@ -317,10 +397,9 @@ function checkValue(value, node, path) {
317
397
  case "string":
318
398
  checkString(value, node, where);
319
399
  break;
400
+ case "text":
320
401
  case "integer":
321
- if (typeof value !== "number" || !Number.isInteger(value)) {
322
- fail(`${where}: must be an integer, got ${repr(value)}`);
323
- }
402
+ checkScalar(value, node.kind, where);
324
403
  break;
325
404
  case "boolean":
326
405
  checkBoolean(value, where);
@@ -469,6 +548,33 @@ function checkUnits(cfg) {
469
548
  });
470
549
  }
471
550
  }
551
+ /**
552
+ * A situation's sentence or question may name the excerpt's marked line as `{line}`: the page writes
553
+ * «line N: » and the line's code there, so a question sits next to the code it is about.
554
+ */
555
+ const LINE_TOKEN = "{line}";
556
+ const SITUATION_TEXTS = ["summary", "question"];
557
+ /** The line `{line}` stands for: the first marked line, its number and its code without the indent. */
558
+ const lineOf = (card) => card.excerpt?.lines
559
+ .filter((l) => l.mark)
560
+ .map((l) => ({ n: l.n, text: l.text.trim() }))[0];
561
+ const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
562
+ /**
563
+ * What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
564
+ * and a repeat sits inside its count.
565
+ */
566
+ function checkBlocks(cfg) {
567
+ cardsOf(cfg).forEach(([at, card]) => {
568
+ if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
569
+ fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
570
+ const token = SITUATION_TEXTS.find((f) => card.situation?.[f].includes(LINE_TOKEN));
571
+ if (token !== undefined && card.excerpt === undefined)
572
+ 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}\``);
573
+ const r = card.repeat;
574
+ if (r !== undefined && (r.index < 1 || r.index > r.of))
575
+ fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
576
+ });
577
+ }
472
578
  /** Every card of the config, items then practice, by path. */
473
579
  const cardsOf = (cfg) => [
474
580
  ...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
@@ -490,12 +596,22 @@ function checkItemFields(cfg) {
490
596
  fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
491
597
  });
492
598
  }
493
- /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
494
- const visibleTexts = (item) => [
495
- ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
496
- [UNIT_FIELD, item.unit?.text ?? ""],
497
- [READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
498
- ];
599
+ /** Every string inside a value: each is tested on its own, as the page renders it, never a serialisation. */
600
+ const stringsIn = (v) => typeof v === "string"
601
+ ? [v]
602
+ : Array.isArray(v)
603
+ ? v.flatMap(stringsIn)
604
+ : typeof v === "object" && v !== null
605
+ ? Object.values(v).flatMap(stringsIn)
606
+ : [];
607
+ /**
608
+ * Every text of an item the labeller sees, by field: each string the page shows (the fields as
609
+ * `shownFields` passes them, so a repeat without its group), and every rule it names.
610
+ */
611
+ const visibleTexts = (item) => Object.entries({
612
+ ...shownFields(item),
613
+ [RULES_FIELD]: item.rules ?? [],
614
+ }).flatMap(([f, v]) => stringsIn(v).map((t) => [f, t]));
499
615
  const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
500
616
  /**
501
617
  * A source id typed into a visible field gives the source away as well as a field would. Short ids
@@ -549,6 +665,17 @@ function assignIds(items, seed, keyPath) {
549
665
  ]);
550
666
  return { ids, entries };
551
667
  }
668
+ // ── a build, short of the page ──────────────────────────────────────────────────────────────────
669
+ /** A card's situation as the page shows it, with the line `{line}` stands for. */
670
+ const situationShown = (item) => item.situation === undefined
671
+ ? {}
672
+ : {
673
+ situation: {
674
+ summary: item.situation.summary,
675
+ question: item.situation.question,
676
+ ...(usesLine(item) ? { line: lineOf(item) } : {}),
677
+ },
678
+ };
552
679
  const shownFields = (item) => ({
553
680
  ...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
554
681
  const v = item[f];
@@ -565,15 +692,20 @@ const shownFields = (item) => ({
565
692
  },
566
693
  }),
567
694
  ...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
695
+ ...Object.fromEntries(BLOCK_FIELDS.filter((f) => f !== "repeat" && f !== "situation").flatMap((f) => (item[f] === undefined ? [] : [[f, item[f]]]))),
696
+ ...situationShown(item),
697
+ ...(item.repeat === undefined
698
+ ? {}
699
+ : { repeat: { index: item.repeat.index, of: item.repeat.of } }),
568
700
  });
569
701
  const ruleKey = (r) => `${r.linter} ${r.id}`;
570
702
  /** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
571
703
  function describeRules(cfg, fromDir) {
572
- return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).map((r, k) => {
704
+ return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).flatMap((r, k) => {
573
705
  const text = describeRule(r, fromDir);
574
706
  return text === undefined
575
707
  ? fail(`${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`)
576
- : [ruleKey(r), text];
708
+ : [[ruleKey(r), text]];
577
709
  })));
578
710
  }
579
711
  const rulesShown = (card, described) => card.rules === undefined
@@ -606,12 +738,21 @@ const practiceShown = (cfg, described) => practiceOf(cfg).map((p) => ({
606
738
  ...shownFields(p),
607
739
  ...rulesShown(p, described),
608
740
  }));
741
+ /** The page's own words where `labels` leaves one out (references/closed-choice-generator.md). */
742
+ const DEFAULT_LABELS = {
743
+ place: "{id} · {n} of {total}",
744
+ line: "Line",
745
+ line_part: "{line} · part {part} of {of}",
746
+ repeat: "Card {index} of {of}",
747
+ practice_example: "",
748
+ };
609
749
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
610
750
  function payloadOf(cfg, page, described) {
611
751
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
612
752
  return {
613
753
  title: cfg.title,
614
754
  intro: cfg.intro ?? "",
755
+ labels: { ...DEFAULT_LABELS, ...cfg.labels },
615
756
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
616
757
  familiarity_question: asksFamiliarity(cfg)
617
758
  ? (cfg.familiarity_question ?? "How well do you know {group}?")
@@ -689,13 +830,14 @@ function keyOf(cfg, page, earlier) {
689
830
  * returns what the page shows. A page written by hand can take its blindness and its key from here.
690
831
  */
691
832
  export function prepareBuild(configPath, outDir) {
692
- const cfg = readJson(configPath);
833
+ const cfg = withPreset(readJson(configPath));
693
834
  assertConfig(cfg);
694
835
  // Once its `kind` is known to be one, the fields that kind needs on every card.
695
836
  checkValue(cfg, configSchema(cfg.kind), "");
696
837
  checkConfig(cfg);
697
838
  checkItemFields(cfg);
698
839
  checkUnits(cfg);
840
+ checkBlocks(cfg);
699
841
  checkNoSourceShown(cfg);
700
842
  const described = describeRules(cfg, dirname(configPath));
701
843
  mkdirSync(outDir, { recursive: true });
@@ -29,7 +29,15 @@ import {
29
29
  /** A node of the declared config schema, as `example-generator.py schema` prints it. */
30
30
  const Node: z.ZodType<SchemaNode> = z.lazy(() =>
31
31
  z.object({
32
- kind: z.enum(["string", "integer", "boolean", "map", "array", "object"]),
32
+ kind: z.enum([
33
+ "string",
34
+ "text",
35
+ "integer",
36
+ "boolean",
37
+ "map",
38
+ "array",
39
+ "object",
40
+ ]),
33
41
  required: z.boolean(),
34
42
  pattern: z.string().optional(),
35
43
  key_pattern: z.string().optional(),
@@ -42,7 +50,8 @@ const Node: z.ZodType<SchemaNode> = z.lazy(() =>
42
50
  );
43
51
 
44
52
  interface SchemaNode {
45
- readonly kind: "string" | "integer" | "boolean" | "map" | "array" | "object";
53
+ readonly kind:
54
+ "string" | "text" | "integer" | "boolean" | "map" | "array" | "object";
46
55
  readonly required: boolean;
47
56
  readonly pattern?: string | undefined;
48
57
  readonly key_pattern?: string | undefined;
@@ -73,6 +82,40 @@ const CARD = {
73
82
  rules: [{ linter: "eslint", id: "no-console" }],
74
83
  unit: { id: "L0", text: "One line.", mark: "One", part: 1, of: 1 },
75
84
  readings: { a: "on", b: "off" },
85
+ situation: { summary: "S", question: "Q?" },
86
+ excerpt: {
87
+ file: "src/a.ts",
88
+ url: "https://example.com/a.ts",
89
+ lines: [{ n: 1, text: "", mark: true }],
90
+ caption: "C",
91
+ mark_label: "M",
92
+ },
93
+ references: {
94
+ summary: "S",
95
+ entries: [
96
+ {
97
+ label: "L",
98
+ title: "T",
99
+ fields: [{ label: "F", text: "X", style: "code" }],
100
+ url: "https://example.com/r",
101
+ url_label: "U",
102
+ },
103
+ ],
104
+ },
105
+ more: { summary: "S", items: ["`src/b.ts`"], rest: "R" },
106
+ table: {
107
+ summary: "S",
108
+ note: "N",
109
+ rows: [
110
+ {
111
+ label: "L",
112
+ value: "V",
113
+ plain: "P",
114
+ mark: { tone: "yes", text: "Y" },
115
+ },
116
+ ],
117
+ },
118
+ repeat: { group: "g", index: 1, of: 1 },
76
119
  };
77
120
  /** A config with EVERY optional field present, so every schema path has a value to spoil. */
78
121
  const FULL: DocObject = (() => {
@@ -92,6 +135,14 @@ const FULL: DocObject = (() => {
92
135
  labeller_knows: ["python", "go", "rust"],
93
136
  familiarity: true,
94
137
  familiarity_question: "How well do you know {group}?",
138
+ labels: {
139
+ place: "C",
140
+ body: "B",
141
+ line: "L",
142
+ line_part: "{line} {part}/{of}",
143
+ repeat: "R",
144
+ practice_example: "P",
145
+ },
95
146
  practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
96
147
  items: ITEMS.map((i, n) => (n === 0 ? { ...i, ...CARD } : i)),
97
148
  }),
@@ -187,6 +238,7 @@ function spoil(path: string, bad: Doc | undefined): DocObject {
187
238
 
188
239
  const WRONG_TYPES: Readonly<Record<SchemaNode["kind"], readonly Doc[]>> = {
189
240
  string: [null, 1, true, ["x"], {}, "", " "],
241
+ text: [null, 1, true, ["x"], {}],
190
242
  integer: [null, "7", true, 7.5, []],
191
243
  boolean: [null, "false", 0, [], {}],
192
244
  map: [null, "x", ["x"], 1],