@washy-washy/core 1.3.0 → 1.4.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/README.md CHANGED
@@ -12,6 +12,10 @@ generator. This package holds no rendering or file I/O — just the pure logic
12
12
  a CSV/JSON chart is checked and reasoned about with, shared by the CLI, the
13
13
  web app, and [`@washy-washy/pdf`](https://github.com/alrayyes/washy-washy-pdf).
14
14
 
15
+ **Jump to:** [Browser](#browser) · [Node and Bun (CSV)](#node-and-bun-csv) ·
16
+ [What's in it](#whats-in-it) · [Development](#development) ·
17
+ [API reference](https://alrayyes.github.io/washy-washy-core/)
18
+
15
19
  ## Requirements
16
20
 
17
21
  - Node.js 18+, Bun, or any other runtime with modern ESM/`exports` map
@@ -29,8 +33,8 @@ npm install @washy-washy/core
29
33
 
30
34
  | Import | Use when | Includes |
31
35
  | --------------------------- | --------------------------------- | --------------------------------- |
32
- | `@washy-washy/core` | Node/Bun, reading a CSV from disk | Everything, including CSV parsing |
33
36
  | `@washy-washy/core/browser` | Bundled for a browser | Everything except CSV parsing |
37
+ | `@washy-washy/core` | Node/Bun, reading a CSV from disk | Everything, including CSV parsing |
34
38
 
35
39
  `@washy-washy/core`'s CSV parser depends on `csv-parse`, which reaches for
36
40
  Node's `Buffer` at import time even if nothing calls it — a bundler ships
@@ -41,15 +45,12 @@ consumer reads and writes charts as JSON instead (`chartFromJson`/
41
45
 
42
46
  ## Usage
43
47
 
44
- ```ts
45
- import { parseInstructions, parseMachine } from "@washy-washy/core";
48
+ ### Browser
46
49
 
47
- const machine = parseMachine(JSON.parse(machineFileContents));
48
- const instructions = parseInstructions(csvFileContents, machine);
49
- ```
50
+ Read and write a chart as JSON instead of CSV — `@washy-washy/core/browser`
51
+ has no CSV parser (see [Two entry points](#two-entry-points)).
50
52
 
51
53
  ```ts
52
- // In a browser bundle: read/write a chart as JSON instead of CSV.
53
54
  import {
54
55
  chartFromJson,
55
56
  chartToJson,
@@ -60,6 +61,15 @@ const machine = parseMachine(machineConfig);
60
61
  const instructions = chartFromJson(jsonFromStorage, machine);
61
62
  ```
62
63
 
64
+ ### Node and Bun (CSV)
65
+
66
+ ```ts
67
+ import { parseInstructions, parseMachine } from "@washy-washy/core";
68
+
69
+ const machine = parseMachine(JSON.parse(machineFileContents));
70
+ const instructions = parseInstructions(csvFileContents, machine);
71
+ ```
72
+
63
73
  ### What's in it
64
74
 
65
75
  - Every chart row carries an optional `referenceName`/`referenceLink` pair —
@@ -71,7 +81,9 @@ const instructions = chartFromJson(jsonFromStorage, machine);
71
81
  typed `Machine`. Every failure names the field that's wrong.
72
82
  - **`parseInstructions`** (CSV) and **`chartFromJson`**/**`chartToJson`**
73
83
  (JSON) parse a chart against a `Machine`, rejecting any row that asks for a
74
- setting the machine doesn't have.
84
+ setting the machine doesn't have. `duration` is checked too, against
85
+ `~H:MM` (the `~` is optional) — empty is still fine, for a pile with no
86
+ duration on record.
75
87
  - **`parseConfig`**/**`configFromJson`**/**`configToJson`** do the same for a
76
88
  machine and a chart together, as one `{ machine, chart }` object — the
77
89
  chart is always validated against the machine it's embedded with.
@@ -101,7 +113,8 @@ bun run check # lint, typecheck, test
101
113
  bun run build # emit dist/ (tsdown)
102
114
  ```
103
115
 
104
- See [CONTRIBUTING.md](CONTRIBUTING.md).
116
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and, for how the modules fit together,
117
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
105
118
 
106
119
  ## License
107
120
 
@@ -1,2 +1,2 @@
1
- import { A as mixTags, C as ColourGroup, D as Variant, E as ResolvedInstruction, F as Washer, I as formatTemperature, L as ironSetting, M as Iron, N as IronSetting, O as colourGroups, P as Machine, R as ironSettingKeys, S as parseConfig, T as MixTag, _ as washGroups, a as chartToJson, b as configFromJson, c as Blocker, d as canMix, f as cardGroups, g as resolve, h as mixBlocker, i as chartFromJson, j as variants, k as durationsOf, l as blockerCode, m as loadGroups, n as Row, o as instructionsFromRows, p as ironGroups, r as RowError, s as rowsFromInstructions, t as COLUMNS, u as blockerLegend, v as CONFIG_SCHEMA_URL, w as Instruction, x as configToJson, y as Config, z as parseMachine } from "./rows-Bn1LlIaD.mjs";
1
+ import { A as mixTags, C as ColourGroup, D as Variant, E as ResolvedInstruction, F as Washer, I as formatTemperature, L as ironSetting, M as Iron, N as IronSetting, O as colourGroups, P as Machine, R as ironSettingKeys, S as parseConfig, T as MixTag, _ as washGroups, a as chartToJson, b as configFromJson, c as Blocker, d as canMix, f as cardGroups, g as resolve, h as mixBlocker, i as chartFromJson, j as variants, k as durationsOf, l as blockerCode, m as loadGroups, n as Row, o as instructionsFromRows, p as ironGroups, r as RowError, s as rowsFromInstructions, t as COLUMNS, u as blockerLegend, v as CONFIG_SCHEMA_URL, w as Instruction, x as configToJson, y as Config, z as parseMachine } from "./rows-DJZMldFv.mjs";
2
2
  export { Blocker, COLUMNS, CONFIG_SCHEMA_URL, ColourGroup, Config, Instruction, Iron, IronSetting, Machine, MixTag, ResolvedInstruction, Row, RowError, Variant, Washer, blockerCode, blockerLegend, canMix, cardGroups, chartFromJson, chartToJson, colourGroups, configFromJson, configToJson, durationsOf, formatTemperature, instructionsFromRows, ironGroups, ironSetting, ironSettingKeys, loadGroups, mixBlocker, mixTags, parseConfig, parseMachine, resolve, rowsFromInstructions, variants, washGroups };
package/dist/browser.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as variants, D as parseMachine, E as ironSettingKeys, S as mixTags, T as ironSetting, _ as chartToJson, a as ironGroups, b as colourGroups, c as resolve, d as configFromJson, f as configToJson, g as chartFromJson, h as RowError, i as cardGroups, l as washGroups, m as COLUMNS, n as blockerLegend, o as loadGroups, p as parseConfig, r as canMix, s as mixBlocker, t as blockerCode, u as CONFIG_SCHEMA_URL, v as instructionsFromRows, w as formatTemperature, x as durationsOf, y as rowsFromInstructions } from "./mixing-Dt043zmh.mjs";
1
+ import { C as variants, D as parseMachine, E as ironSettingKeys, S as mixTags, T as ironSetting, _ as chartToJson, a as ironGroups, b as colourGroups, c as resolve, d as configFromJson, f as configToJson, g as chartFromJson, h as RowError, i as cardGroups, l as washGroups, m as COLUMNS, n as blockerLegend, o as loadGroups, p as parseConfig, r as canMix, s as mixBlocker, t as blockerCode, u as CONFIG_SCHEMA_URL, v as instructionsFromRows, w as formatTemperature, x as durationsOf, y as rowsFromInstructions } from "./mixing-BPsUUVhp.mjs";
2
2
  export { COLUMNS, CONFIG_SCHEMA_URL, RowError, blockerCode, blockerLegend, canMix, cardGroups, chartFromJson, chartToJson, colourGroups, configFromJson, configToJson, durationsOf, formatTemperature, instructionsFromRows, ironGroups, ironSetting, ironSettingKeys, loadGroups, mixBlocker, mixTags, parseConfig, parseMachine, resolve, rowsFromInstructions, variants, washGroups };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as mixTags, C as ColourGroup, D as Variant, E as ResolvedInstruction, F as Washer, I as formatTemperature, L as ironSetting, M as Iron, N as IronSetting, O as colourGroups, P as Machine, R as ironSettingKeys, S as parseConfig, T as MixTag, _ as washGroups, a as chartToJson, b as configFromJson, c as Blocker, d as canMix, f as cardGroups, g as resolve, h as mixBlocker, i as chartFromJson, j as variants, k as durationsOf, l as blockerCode, m as loadGroups, n as Row, o as instructionsFromRows, p as ironGroups, r as RowError, s as rowsFromInstructions, t as COLUMNS, u as blockerLegend, v as CONFIG_SCHEMA_URL, w as Instruction, x as configToJson, y as Config, z as parseMachine } from "./rows-Bn1LlIaD.mjs";
1
+ import { A as mixTags, C as ColourGroup, D as Variant, E as ResolvedInstruction, F as Washer, I as formatTemperature, L as ironSetting, M as Iron, N as IronSetting, O as colourGroups, P as Machine, R as ironSettingKeys, S as parseConfig, T as MixTag, _ as washGroups, a as chartToJson, b as configFromJson, c as Blocker, d as canMix, f as cardGroups, g as resolve, h as mixBlocker, i as chartFromJson, j as variants, k as durationsOf, l as blockerCode, m as loadGroups, n as Row, o as instructionsFromRows, p as ironGroups, r as RowError, s as rowsFromInstructions, t as COLUMNS, u as blockerLegend, v as CONFIG_SCHEMA_URL, w as Instruction, x as configToJson, y as Config, z as parseMachine } from "./rows-DJZMldFv.mjs";
2
2
  //#region src/csv.d.ts
3
3
  /**
4
4
  * Parses the instruction CSV. See `instructionsFromRows` for the row rules.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { C as variants, D as parseMachine, E as ironSettingKeys, S as mixTags, T as ironSetting, _ as chartToJson, a as ironGroups, b as colourGroups, c as resolve, d as configFromJson, f as configToJson, g as chartFromJson, h as RowError, i as cardGroups, l as washGroups, m as COLUMNS, n as blockerLegend, o as loadGroups, p as parseConfig, r as canMix, s as mixBlocker, t as blockerCode, u as CONFIG_SCHEMA_URL, v as instructionsFromRows, w as formatTemperature, x as durationsOf, y as rowsFromInstructions } from "./mixing-Dt043zmh.mjs";
1
+ import { C as variants, D as parseMachine, E as ironSettingKeys, S as mixTags, T as ironSetting, _ as chartToJson, a as ironGroups, b as colourGroups, c as resolve, d as configFromJson, f as configToJson, g as chartFromJson, h as RowError, i as cardGroups, l as washGroups, m as COLUMNS, n as blockerLegend, o as loadGroups, p as parseConfig, r as canMix, s as mixBlocker, t as blockerCode, u as CONFIG_SCHEMA_URL, v as instructionsFromRows, w as formatTemperature, x as durationsOf, y as rowsFromInstructions } from "./mixing-BPsUUVhp.mjs";
2
2
  import { parse } from "csv-parse/sync";
3
3
  //#region src/csv.ts
4
4
  /**
@@ -2,26 +2,33 @@
2
2
  function fail(what) {
3
3
  throw new Error(`machine: ${what}`);
4
4
  }
5
- function stringList(value, field, minimum) {
5
+ function stringList(value, field, minimum, maxLength) {
6
6
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry === "")) fail(`${field} must be a list of non-empty strings`);
7
7
  const list = value;
8
8
  if (list.length < minimum) fail(`${field} needs at least ${minimum} entries, found ${list.length}`);
9
9
  if (new Set(list).size !== list.length) fail(`${field} repeats a value`);
10
+ const overlong = list.find((entry) => entry.length > maxLength);
11
+ if (overlong !== void 0) fail(`${field} entries must be at most ${maxLength} characters, found "${overlong}"`);
10
12
  return list;
11
13
  }
12
- function text(value, field, fallback) {
13
- if (typeof value === "string" && value !== "") return value;
14
- if (fallback !== void 0) return fallback;
15
- return fail(`${field} must be a non-empty string`);
14
+ function text(value, field, maxLength, fallback) {
15
+ const resolved = typeof value === "string" && value !== "" ? value : fallback !== void 0 ? fallback : fail(`${field} must be a non-empty string`);
16
+ if (resolved.length > maxLength) fail(`${field} must be at most ${maxLength} characters, found ${resolved.length}`);
17
+ return resolved;
16
18
  }
19
+ /** How many dots a thermostat position's dial label draws — never more than a handful. */
20
+ const MAX_DOTS_LENGTH = 5;
17
21
  function parseIronSetting(value, index) {
18
22
  if (typeof value !== "object" || value === null) fail(`iron.settings[${index}] must be an object`);
19
23
  const raw = value;
24
+ const key = text(raw.key, `iron.settings[${index}].key`, 30);
25
+ const dots = typeof raw.dots === "string" ? raw.dots : "";
26
+ if (dots.length > MAX_DOTS_LENGTH) fail(`iron.settings[${index}].dots must be at most ${MAX_DOTS_LENGTH} characters`);
20
27
  return {
21
- key: text(raw.key, `iron.settings[${index}].key`),
22
- dots: typeof raw.dots === "string" ? raw.dots : "",
23
- label: text(raw.label, `iron.settings[${index}].label`),
24
- detail: text(raw.detail, `iron.settings[${index}].detail`, ""),
28
+ key,
29
+ dots,
30
+ label: text(raw.label, `iron.settings[${index}].label`, 20),
31
+ detail: text(raw.detail, `iron.settings[${index}].detail`, 60, ""),
25
32
  steam: raw.steam === true
26
33
  };
27
34
  }
@@ -49,15 +56,15 @@ function parseMachine(value) {
49
56
  if (new Set(keys.map((setting) => setting.key)).size !== keys.length) fail("iron.settings repeats a key");
50
57
  return {
51
58
  washer: {
52
- name: text(washerRaw.name, "washer.name"),
53
- capacity: text(washerRaw.capacity, "washer.capacity", ""),
54
- programs: stringList(washerRaw.programs, "washer.programs", 2),
55
- temperatures: stringList(washerRaw.temperatures, "washer.temperatures", 1),
56
- spins: stringList(washerRaw.spins, "washer.spins", 1),
57
- options: stringList(washerRaw.options ?? [], "washer.options", 0)
59
+ name: text(washerRaw.name, "washer.name", 60),
60
+ capacity: text(washerRaw.capacity, "washer.capacity", 20, ""),
61
+ programs: stringList(washerRaw.programs, "washer.programs", 2, 32),
62
+ temperatures: stringList(washerRaw.temperatures, "washer.temperatures", 1, 12),
63
+ spins: stringList(washerRaw.spins, "washer.spins", 1, 10),
64
+ options: stringList(washerRaw.options ?? [], "washer.options", 0, 20)
58
65
  },
59
66
  iron: {
60
- name: text(ironRaw.name, "iron.name"),
67
+ name: text(ironRaw.name, "iron.name", 60),
61
68
  settings: keys
62
69
  }
63
70
  };
@@ -184,6 +191,16 @@ function oneOf(row, column, value, allowed) {
184
191
  if (!allowed.includes(value)) throw new RowError(row, column, `"${value}" is not one of ${allowed.join(", ")}`);
185
192
  return value;
186
193
  }
194
+ /**
195
+ * Caps free-text fields the PDFs render, so a value long enough to visibly
196
+ * crowd a card or reference-sheet row is rejected here rather than in a
197
+ * layout that only degrades quietly. Limits from @washy-washy/pdf's actual
198
+ * fixed-width slots and wrap-safety analysis, not a guess.
199
+ */
200
+ function maxLength(row, column, value, max) {
201
+ if (value.length > max) throw new RowError(row, column, `must be at most ${max} characters, found ${value.length}`);
202
+ return value;
203
+ }
187
204
  function boolean(row, column, value) {
188
205
  const normalised = value.trim().toLowerCase();
189
206
  if ([
@@ -200,6 +217,13 @@ function boolean(row, column, value) {
200
217
  ].includes(normalised)) return false;
201
218
  throw new RowError(row, column, `"${value}" is not a yes/no value`);
202
219
  }
220
+ const DURATION_PATTERN = /^~?\d+:[0-5]\d$/;
221
+ /** Empty stays empty — some piles genuinely have no duration on record. */
222
+ function duration(row, column, value) {
223
+ if (value === "") return value;
224
+ if (!DURATION_PATTERN.test(value)) throw new RowError(row, column, `must match H:MM, found "${value}"`);
225
+ return maxLength(row, column, value, 12);
226
+ }
203
227
  /**
204
228
  * Checks rows shaped like the chart's schema — the same shape a CSV parses
205
229
  * into, or a JSON file round-tripped through `rowsFromInstructions` — against
@@ -221,7 +245,7 @@ function instructionsFromRows(rows, machine) {
221
245
  if (missing.length > 0) throw new Error(`the chart is missing column(s): ${missing.join(", ")}`);
222
246
  return rows.map((record, index) => {
223
247
  const row = index + 2;
224
- const clothingType = (record.clothing_type ?? "").trim();
248
+ const clothingType = maxLength(row, "clothing_type", (record.clothing_type ?? "").trim(), 60);
225
249
  if (clothingType === "") throw new RowError(row, "clothing_type", "must not be empty");
226
250
  const options = splitList(record.options ?? "").map((option) => oneOf(row, "options", option, washer.options));
227
251
  const tags = splitList(record.mix_tags ?? "").map((tag) => oneOf(row, "mix_tags", tag, mixTags));
@@ -231,22 +255,22 @@ function instructionsFromRows(rows, machine) {
231
255
  const ironSetting = ironing ? oneOf(row, "iron_setting", rawSetting, ironSettingKeys(machine)) : "";
232
256
  return {
233
257
  clothingType,
234
- detergent: record.detergent ?? "",
258
+ detergent: maxLength(row, "detergent", record.detergent ?? "", 200),
235
259
  fabricSoftener: boolean(row, "fabric_softener", record.fabric_softener ?? ""),
236
260
  temperature: oneOf(row, "temperature", record.temperature ?? "", washer.temperatures),
237
261
  spin: oneOf(row, "spin", record.spin ?? "", washer.spins),
238
- duration: record.duration ?? "",
262
+ duration: duration(row, "duration", record.duration ?? ""),
239
263
  program: oneOf(row, "program", record.program ?? "", washer.programs),
240
264
  options,
241
265
  ironing,
242
- ironingNotes: record.ironing_notes ?? "",
266
+ ironingNotes: maxLength(row, "ironing_notes", record.ironing_notes ?? "", 400),
243
267
  ironSetting,
244
- drying: record.drying ?? "",
268
+ drying: maxLength(row, "drying", record.drying ?? "", 150),
245
269
  colourGroup: oneOf(row, "colour_group", record.colour_group ?? "", colourGroups),
246
270
  mixTags: tags,
247
- notes: record.notes ?? "",
248
- referenceName: record.reference_name ?? "",
249
- referenceLink: record.reference_link ?? ""
271
+ notes: maxLength(row, "notes", record.notes ?? "", 500),
272
+ referenceName: maxLength(row, "reference_name", record.reference_name ?? "", 80),
273
+ referenceLink: maxLength(row, "reference_link", record.reference_link ?? "", 2048)
250
274
  };
251
275
  });
252
276
  }
@@ -547,4 +571,4 @@ function ironGroups(instructions, order) {
547
571
  //#endregion
548
572
  export { variants as C, parseMachine as D, ironSettingKeys as E, mixTags as S, ironSetting as T, chartToJson as _, ironGroups as a, colourGroups as b, resolve as c, configFromJson as d, configToJson as f, chartFromJson as g, RowError as h, cardGroups as i, washGroups as l, COLUMNS as m, blockerLegend as n, loadGroups as o, parseConfig as p, canMix as r, mixBlocker as s, blockerCode as t, CONFIG_SCHEMA_URL as u, instructionsFromRows as v, formatTemperature as w, durationsOf as x, rowsFromInstructions as y };
549
573
 
550
- //# sourceMappingURL=mixing-Dt043zmh.mjs.map
574
+ //# sourceMappingURL=mixing-BPsUUVhp.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mixing-BPsUUVhp.mjs","names":[],"sources":["../src/machine.ts","../src/types.ts","../src/rows.ts","../src/config.ts","../src/mixing.ts"],"sourcesContent":["/**\n * The appliances a chart is drawn for, as data rather than code.\n *\n * Everything the PDFs render — dial angles, which chips exist, what a valid CSV\n * value is — comes from a machine file, so pointing this at a different washing\n * machine is editing JSON rather than editing TypeScript. The labels in that\n * file are copied off the fascia in whatever language it is printed in, and\n * nothing here ever translates them: a chart that says \"Cottons\" when the dial\n * says \"Katoen\" is worse than no chart, because you have to translate it back\n * while standing in front of the machine.\n */\n\nexport interface Washer {\n /** @maxLength 60 */\n name: string;\n /** @maxLength 20 */\n capacity: string;\n /** Dial labels in physical order from twelve o'clock, clockwise. Index is the angle. */\n programs: string[];\n temperatures: string[];\n spins: string[];\n options: string[];\n}\n\nexport interface IronSetting {\n /** @maxLength 30 */\n key: string;\n /** @maxLength 5 */\n dots: string;\n /** @maxLength 20 */\n label: string;\n /** @maxLength 60 */\n detail: string;\n steam: boolean;\n}\n\nexport interface Iron {\n /** @maxLength 60 */\n name: string;\n /** Thermostat positions in order, coolest first. */\n settings: IronSetting[];\n}\n\nexport interface Machine {\n washer: Washer;\n iron: Iron;\n}\n\nfunction fail(what: string): never {\n throw new Error(`machine: ${what}`);\n}\n\nfunction stringList(value: unknown, field: string, minimum: number, maxLength: number): string[] {\n if (!Array.isArray(value) || value.some((entry) => typeof entry !== \"string\" || entry === \"\")) {\n fail(`${field} must be a list of non-empty strings`);\n }\n const list = value as string[];\n if (list.length < minimum)\n fail(`${field} needs at least ${minimum} entries, found ${list.length}`);\n if (new Set(list).size !== list.length) fail(`${field} repeats a value`);\n const overlong = list.find((entry) => entry.length > maxLength);\n if (overlong !== undefined) {\n fail(`${field} entries must be at most ${maxLength} characters, found \"${overlong}\"`);\n }\n return list;\n}\n\nfunction text(value: unknown, field: string, maxLength: number, fallback?: string): string {\n const resolved =\n typeof value === \"string\" && value !== \"\"\n ? value\n : fallback !== undefined\n ? fallback\n : fail(`${field} must be a non-empty string`);\n if (resolved.length > maxLength) {\n fail(`${field} must be at most ${maxLength} characters, found ${resolved.length}`);\n }\n return resolved;\n}\n\n/** How many dots a thermostat position's dial label draws — never more than a handful. */\nconst MAX_DOTS_LENGTH = 5;\n\nfunction parseIronSetting(value: unknown, index: number): IronSetting {\n if (typeof value !== \"object\" || value === null)\n fail(`iron.settings[${index}] must be an object`);\n const raw = value as Record<string, unknown>;\n const key = text(raw.key, `iron.settings[${index}].key`, 30);\n const dots = typeof raw.dots === \"string\" ? raw.dots : \"\";\n if (dots.length > MAX_DOTS_LENGTH) {\n fail(`iron.settings[${index}].dots must be at most ${MAX_DOTS_LENGTH} characters`);\n }\n return {\n key,\n dots,\n label: text(raw.label, `iron.settings[${index}].label`, 20),\n detail: text(raw.detail, `iron.settings[${index}].detail`, 60, \"\"),\n steam: raw.steam === true,\n };\n}\n\n/**\n * Checks a parsed machine file and hands back something the renderer can use.\n * Every failure names the field, because the fix is always an edit to that\n * line of JSON.\n *\n * @example\n * ```ts\n * const machine = parseMachine(JSON.parse(machineFileContents));\n * machine.washer.programs; // [\"Off\", \"Cottons\", \"Wool\", ...]\n * ```\n */\nexport function parseMachine(value: unknown): Machine {\n if (typeof value !== \"object\" || value === null) fail(\"the file must contain an object\");\n const raw = value as Record<string, unknown>;\n\n if (typeof raw.washer !== \"object\" || raw.washer === null) fail(\"washer is missing\");\n if (typeof raw.iron !== \"object\" || raw.iron === null) fail(\"iron is missing\");\n\n const washerRaw = raw.washer as Record<string, unknown>;\n const ironRaw = raw.iron as Record<string, unknown>;\n\n const settings = Array.isArray(ironRaw.settings)\n ? ironRaw.settings\n : fail(\"iron.settings is missing\");\n if (settings.length < 2) fail(\"iron.settings needs at least 2 positions to draw a ring\");\n\n const keys = settings.map((setting, index) => parseIronSetting(setting, index));\n if (new Set(keys.map((setting) => setting.key)).size !== keys.length) {\n fail(\"iron.settings repeats a key\");\n }\n\n return {\n washer: {\n name: text(washerRaw.name, \"washer.name\", 60),\n capacity: text(washerRaw.capacity, \"washer.capacity\", 20, \"\"),\n programs: stringList(washerRaw.programs, \"washer.programs\", 2, 32),\n temperatures: stringList(washerRaw.temperatures, \"washer.temperatures\", 1, 12),\n spins: stringList(washerRaw.spins, \"washer.spins\", 1, 10),\n options: stringList(washerRaw.options ?? [], \"washer.options\", 0, 20),\n },\n iron: {\n name: text(ironRaw.name, \"iron.name\", 60),\n settings: keys,\n },\n };\n}\n\n/**\n * @example\n * ```ts\n * ironSetting(machine, \"2\")?.label; // \"••\"\n * ironSetting(machine, \"nope\"); // undefined\n * ```\n */\nexport function ironSetting(machine: Machine, key: string): IronSetting | undefined {\n return machine.iron.settings.find((setting) => setting.key === key);\n}\n\n/**\n * The keys a row may write in `iron_setting`, coolest first.\n *\n * There is no key for \"do not iron\". That is the `ironing` column's job, and\n * two places to say the same thing is two places to disagree.\n *\n * @example\n * ```ts\n * ironSettingKeys(machine); // [\"min\", \"1\", \"2\", \"3\"]\n * ```\n */\nexport function ironSettingKeys(machine: Machine): string[] {\n return machine.iron.settings.map((setting) => setting.key);\n}\n\n/**\n * `40` becomes `40°`; `cold` stays `cold`. The machine file is free to use\n * whatever word its display shows for the cold wash, so the degree sign is\n * decided by whether the value is a number rather than by a list of exceptions.\n *\n * @example\n * ```ts\n * formatTemperature(\"40\"); // \"40°\"\n * formatTemperature(\"cold\"); // \"cold\"\n * ```\n */\nexport function formatTemperature(value: string): string {\n return /^\\d+$/.test(value) ? `${value}°` : value;\n}\n","/** Which pile a garment belongs to on colour grounds. `any` mixes with all. */\nexport const colourGroups = [\"white\", \"colour\", \"dark\", \"sport\", \"any\"] as const;\nexport type ColourGroup = (typeof colourGroups)[number];\n\n/**\n * Reasons two piles might not belong together even when the machine settings\n * agree. See `canMix` in `mixing.ts` for how each one is applied.\n */\nexport const mixTags = [\"lint-shedder\", \"lint-magnet\", \"dye-bleeder\", \"solo\"] as const;\nexport type MixTag = (typeof mixTags)[number];\n\n/**\n * One CSV row: everything needed to wash, iron and dry one pile of laundry.\n *\n * The machine-facing fields are plain strings rather than unions, because what\n * counts as valid depends on the machine file you point this at. `csv.ts`\n * checks each of them against that machine as it parses, so nothing downstream\n * has to wonder whether a programme name exists.\n */\nexport interface Instruction {\n /** @maxLength 60 */\n clothingType: string;\n /** @maxLength 200 */\n detergent: string;\n fabricSoftener: boolean;\n temperature: string;\n spin: string;\n /** @maxLength 12 */\n duration: string;\n program: string;\n options: string[];\n /** Whether this pile gets ironed at all. */\n ironing: boolean;\n /**\n * How to iron it, or why you don't. Free prose and often empty, because most\n * of what there is to say about a pile nobody irons is already said by\n * `ironing` being false.\n * @maxLength 400\n */\n ironingNotes: string;\n /** Thermostat position. Empty when `ironing` is false. */\n ironSetting: string;\n /** @maxLength 150 */\n drying: string;\n colourGroup: ColourGroup;\n mixTags: MixTag[];\n /** @maxLength 500 */\n notes: string;\n /**\n * Who to credit for a care instruction that isn't obvious from the garment\n * itself — \"the label says 40°\" doesn't need one, \"the manufacturer says\n * wash these alone\" might. Empty when there's nothing to cite.\n * @maxLength 80\n */\n referenceName: string;\n /**\n * A link backing up `referenceName`. Empty when there's nothing to cite.\n * @maxLength 2048\n */\n referenceLink: string;\n}\n\n/** An instruction plus the other piles it may share a drum with. */\nexport interface ResolvedInstruction extends Instruction {\n mixesWith: string[];\n}\n\n/**\n * How long a card or a load is going to tie the machine up.\n *\n * Usually one figure. Piles sharing a load agree on every setting, so they\n * ought to agree on this too — but the chart is data somebody types, and\n * printing both is more honest than picking the first one and hoping.\n *\n * @example\n * ```ts\n * durationsOf([pile, pile]); // \"~2:00\"\n * durationsOf([pile, { ...pile, duration: \"~1:10\" }]); // \"~2:00 / ~1:10\"\n * ```\n */\nexport function durationsOf(group: Instruction[]): string {\n return [...new Set(group.map((item) => item.duration).filter((value) => value !== \"\"))].join(\n \" / \",\n );\n}\n\n/**\n * Which sheet to draw.\n *\n * `full` is the whole chart. The other two are the same chart cut where the\n * work is: washing happens in front of the machine on a Sunday morning, ironing\n * happens at a board on a Wednesday evening, and neither job wants to read past\n * the other's advice to find its own.\n */\nexport const variants = [\"full\", \"wash\", \"iron\"] as const;\nexport type Variant = (typeof variants)[number];\n","import { ironSettingKeys, type Machine } from \"./machine\";\nimport type { ColourGroup, Instruction, MixTag } from \"./types\";\nimport { colourGroups, mixTags } from \"./types\";\n\nexport const COLUMNS = [\n \"clothing_type\",\n \"detergent\",\n \"fabric_softener\",\n \"temperature\",\n \"spin\",\n \"duration\",\n \"program\",\n \"options\",\n \"ironing\",\n \"ironing_notes\",\n \"iron_setting\",\n \"drying\",\n \"colour_group\",\n \"mix_tags\",\n \"notes\",\n \"reference_name\",\n \"reference_link\",\n] as const;\n\n/** One row, keyed the same way as a CSV column and the chart's JSON Schema. */\nexport type Row = Record<(typeof COLUMNS)[number], string>;\n\nexport class RowError extends Error {\n constructor(row: number, column: string, message: string) {\n super(`row ${row}, column \"${column}\": ${message}`);\n this.name = \"RowError\";\n }\n}\n\n/** `a|b|c` -> `[\"a\", \"b\", \"c\"]`, tolerating stray whitespace and empties. */\nfunction splitList(value: string): string[] {\n return value\n .split(\"|\")\n .map((part) => part.trim())\n .filter((part) => part.length > 0);\n}\n\nfunction oneOf<T extends string>(\n row: number,\n column: string,\n value: string,\n allowed: readonly T[],\n): T {\n if (!(allowed as readonly string[]).includes(value)) {\n throw new RowError(row, column, `\"${value}\" is not one of ${allowed.join(\", \")}`);\n }\n return value as T;\n}\n\n/**\n * Caps free-text fields the PDFs render, so a value long enough to visibly\n * crowd a card or reference-sheet row is rejected here rather than in a\n * layout that only degrades quietly. Limits from @washy-washy/pdf's actual\n * fixed-width slots and wrap-safety analysis, not a guess.\n */\nfunction maxLength(row: number, column: string, value: string, max: number): string {\n if (value.length > max) {\n throw new RowError(row, column, `must be at most ${max} characters, found ${value.length}`);\n }\n return value;\n}\n\nfunction boolean(row: number, column: string, value: string): boolean {\n const normalised = value.trim().toLowerCase();\n if ([\"yes\", \"y\", \"true\", \"1\"].includes(normalised)) return true;\n if ([\"no\", \"n\", \"false\", \"0\"].includes(normalised)) return false;\n throw new RowError(row, column, `\"${value}\" is not a yes/no value`);\n}\n\nconst DURATION_PATTERN = /^~?\\d+:[0-5]\\d$/;\n\n/** Empty stays empty — some piles genuinely have no duration on record. */\nfunction duration(row: number, column: string, value: string): string {\n if (value === \"\") return value;\n if (!DURATION_PATTERN.test(value)) {\n throw new RowError(row, column, `must match H:MM, found \"${value}\"`);\n }\n return maxLength(row, column, value, 12);\n}\n\n/**\n * Checks rows shaped like the chart's schema — the same shape a CSV parses\n * into, or a JSON file round-tripped through `rowsFromInstructions` — against\n * what the appliances in the machine file can actually be set to. A typo in a\n * programme name fails here rather than producing a PDF that tells you to turn\n * the dial to a position that does not exist — and so does a chart written for\n * a different machine than the one you passed.\n *\n * @example\n * ```ts\n * const instructions = instructionsFromRows(JSON.parse(jsonFromStorage), machine);\n * ```\n */\nexport function instructionsFromRows(\n rows: Record<string, string>[],\n machine: Machine,\n): Instruction[] {\n const { washer } = machine;\n\n if (rows.length === 0) throw new Error(\"the chart has no rows\");\n\n const header = Object.keys(rows[0] as Record<string, string>);\n const missing = COLUMNS.filter((column) => !header.includes(column));\n if (missing.length > 0) {\n throw new Error(`the chart is missing column(s): ${missing.join(\", \")}`);\n }\n\n return rows.map((record, index) => {\n // +2: one for the header row, one because humans count from 1. Kept even\n // for a JSON source, so an error message reads the same regardless of\n // which format the chart came from.\n const row = index + 2;\n\n const clothingType = maxLength(row, \"clothing_type\", (record.clothing_type ?? \"\").trim(), 60);\n if (clothingType === \"\") throw new RowError(row, \"clothing_type\", \"must not be empty\");\n\n const options = splitList(record.options ?? \"\").map((option) =>\n oneOf(row, \"options\", option, washer.options),\n );\n\n const tags = splitList(record.mix_tags ?? \"\").map((tag) =>\n oneOf<MixTag>(row, \"mix_tags\", tag, mixTags),\n );\n\n // `ironing` decides whether there is a thermostat position at all, so the\n // two are checked together: a pile you never iron has nowhere to point the\n // dial, and a pile you do iron has to say where.\n const ironing = boolean(row, \"ironing\", record.ironing ?? \"\");\n const rawSetting = (record.iron_setting ?? \"\").trim();\n if (!ironing && rawSetting !== \"\") {\n throw new RowError(\n row,\n \"iron_setting\",\n `must be empty when ironing is no, found \"${rawSetting}\"`,\n );\n }\n const ironSetting = ironing\n ? oneOf(row, \"iron_setting\", rawSetting, ironSettingKeys(machine))\n : \"\";\n\n return {\n clothingType,\n detergent: maxLength(row, \"detergent\", record.detergent ?? \"\", 200),\n fabricSoftener: boolean(row, \"fabric_softener\", record.fabric_softener ?? \"\"),\n temperature: oneOf(row, \"temperature\", record.temperature ?? \"\", washer.temperatures),\n spin: oneOf(row, \"spin\", record.spin ?? \"\", washer.spins),\n duration: duration(row, \"duration\", record.duration ?? \"\"),\n program: oneOf(row, \"program\", record.program ?? \"\", washer.programs),\n options,\n ironing,\n ironingNotes: maxLength(row, \"ironing_notes\", record.ironing_notes ?? \"\", 400),\n ironSetting,\n drying: maxLength(row, \"drying\", record.drying ?? \"\", 150),\n colourGroup: oneOf<ColourGroup>(row, \"colour_group\", record.colour_group ?? \"\", colourGroups),\n mixTags: tags,\n notes: maxLength(row, \"notes\", record.notes ?? \"\", 500),\n referenceName: maxLength(row, \"reference_name\", record.reference_name ?? \"\", 80),\n referenceLink: maxLength(row, \"reference_link\", record.reference_link ?? \"\", 2048),\n };\n });\n}\n\n/**\n * The reverse of `instructionsFromRows` — what a JSON export writes out.\n *\n * @example\n * ```ts\n * const rows = rowsFromInstructions(instructions);\n * ```\n */\nexport function rowsFromInstructions(instructions: Instruction[]): Row[] {\n return instructions.map((instruction) => ({\n clothing_type: instruction.clothingType,\n detergent: instruction.detergent,\n fabric_softener: instruction.fabricSoftener ? \"yes\" : \"no\",\n temperature: instruction.temperature,\n spin: instruction.spin,\n duration: instruction.duration,\n program: instruction.program,\n options: instruction.options.join(\"|\"),\n ironing: instruction.ironing ? \"yes\" : \"no\",\n ironing_notes: instruction.ironingNotes,\n iron_setting: instruction.ironSetting,\n drying: instruction.drying,\n colour_group: instruction.colourGroup,\n mix_tags: instruction.mixTags.join(\"|\"),\n notes: instruction.notes,\n reference_name: instruction.referenceName,\n reference_link: instruction.referenceLink,\n }));\n}\n\n/**\n * The JSON interchange format for a chart — the same row shape\n * a chart's JSON Schema describes, so a file written here validates against\n * it exactly as a CSV does.\n *\n * @example\n * ```ts\n * const json = chartToJson(instructions);\n * ```\n */\nexport function chartToJson(instructions: Instruction[]): string {\n return `${JSON.stringify(rowsFromInstructions(instructions), null, 2)}\\n`;\n}\n\n/**\n * The other half of `chartToJson`. Throws the same errors `parseInstructions` does.\n *\n * @example\n * ```ts\n * const instructions = chartFromJson(jsonFromStorage, machine);\n * ```\n */\nexport function chartFromJson(source: string, machine: Machine): Instruction[] {\n let rows: unknown;\n try {\n rows = JSON.parse(source);\n } catch (error) {\n throw new Error(`not valid JSON: ${error instanceof Error ? error.message : error}`);\n }\n if (!Array.isArray(rows)) throw new Error(\"the chart must be a JSON array of rows\");\n return instructionsFromRows(rows as Record<string, string>[], machine);\n}\n","import { type Machine, parseMachine } from \"./machine\";\nimport { instructionsFromRows, rowsFromInstructions } from \"./rows\";\nimport type { Instruction } from \"./types\";\n\n/**\n * Where `configToJson` points editors at the real JSON Schema (generated\n * from the `Config` type — see `schema/config.schema.json` and `bun run\n * schema`). Unpinned to the package's `latest` npm tag rather than the\n * running version, matching how most `$schema` URLs work: close enough,\n * without needing a build-time read of this package's own `package.json`.\n */\nexport const CONFIG_SCHEMA_URL =\n \"https://cdn.jsdelivr.net/npm/@washy-washy/core/schema/config.schema.json\";\n\n/**\n * The washing machine and the chart that describes it, in one place. Both\n * halves are exactly what `parseMachine`/`instructionsFromRows` already\n * produce — this is a wrapper around the pair, not a third shape.\n */\nexport interface Config {\n machine: Machine;\n chart: Instruction[];\n}\n\n/**\n * Validates a `{ machine, chart }` object, checking the chart against the\n * machine it's embedded with — never a different one. Every failure names\n * which half is wrong, same as `parseMachine` and `instructionsFromRows` do\n * on their own.\n *\n * @example\n * ```ts\n * const config = parseConfig(JSON.parse(configFileContents));\n * config.machine.washer.name; // \"Generic front loader\"\n * config.chart[0].clothingType; // \"Dark\"\n * ```\n */\nexport function parseConfig(value: unknown): Config {\n if (typeof value !== \"object\" || value === null) {\n throw new Error(\"config: the file must contain an object\");\n }\n const raw = value as Record<string, unknown>;\n\n if (!(\"machine\" in raw)) throw new Error(\"config: machine is missing\");\n if (!(\"chart\" in raw)) throw new Error(\"config: chart is missing\");\n\n const machine = parseMachine(raw.machine);\n\n if (!Array.isArray(raw.chart)) throw new Error(\"config: chart must be an array of rows\");\n const chart = instructionsFromRows(raw.chart as Record<string, string>[], machine);\n\n return { machine, chart };\n}\n\n/**\n * The reverse of `parseConfig` — what a JSON export writes out. Leads with a\n * `$schema` key so an editor that reads it (VS Code among them) validates\n * and autocompletes the file without the person editing it doing anything.\n * `parseConfig` ignores the key on the way back in — it never asks for\n * anything beyond `machine` and `chart`.\n *\n * @example\n * ```ts\n * const json = configToJson(config);\n * ```\n */\nexport function configToJson(config: Config): string {\n const rows = {\n $schema: CONFIG_SCHEMA_URL,\n machine: config.machine,\n chart: rowsFromInstructions(config.chart),\n };\n return `${JSON.stringify(rows, null, 2)}\\n`;\n}\n\n/**\n * The other half of `configToJson`. Throws the same errors `parseConfig` does.\n *\n * @example\n * ```ts\n * const config = configFromJson(configFileContents);\n * ```\n */\nexport function configFromJson(source: string): Config {\n let value: unknown;\n try {\n value = JSON.parse(source);\n } catch (error) {\n throw new Error(`config: not valid JSON: ${error instanceof Error ? error.message : error}`);\n }\n return parseConfig(value);\n}\n","import type { Instruction, ResolvedInstruction } from \"./types\";\n\n/** Why two piles cannot share a drum. `null` means they can. */\nexport type Blocker = \"solo\" | \"settings\" | \"colour\" | \"lint\";\n\nexport const blockerLegend: Record<Blocker, string> = {\n solo: \"Wash on its own\",\n settings: \"Different programme, temperature, spin or options\",\n colour: \"Colours would run into each other\",\n lint: \"One sheds lint onto the other\",\n};\n\n/** Short codes used in the compatibility matrix, so a cell stays one glyph. */\nexport const blockerCode: Record<Blocker, string> = {\n solo: \"S\",\n settings: \"P\",\n colour: \"C\",\n lint: \"L\",\n};\n\nfunction sameSettings(a: Instruction, b: Instruction): boolean {\n return (\n a.program === b.program &&\n a.temperature === b.temperature &&\n a.spin === b.spin &&\n a.options.length === b.options.length &&\n a.options.every((option) => b.options.includes(option))\n );\n}\n\nfunction compatibleColours(a: Instruction, b: Instruction): boolean {\n if (a.colourGroup === \"any\" || b.colourGroup === \"any\") return true;\n return a.colourGroup === b.colourGroup;\n}\n\n/**\n * Decides whether two piles can go in the drum together, and if not, why.\n *\n * The order matters: the reason reported is the one you would want to hear\n * first. \"Wash it alone\" beats \"the spin speed differs\", because changing the\n * spin speed would not help.\n *\n * @example\n * ```ts\n * mixBlocker(towels, socks); // \"lint\" — towels shed onto everything else\n * mixBlocker(socks, socks2); // null — nothing stops them sharing a drum\n * ```\n */\nexport function mixBlocker(a: Instruction, b: Instruction): Blocker | null {\n if (a.mixTags.includes(\"solo\") || b.mixTags.includes(\"solo\")) return \"solo\";\n\n // Terry sheds lint over everything, so towels only ever go with towels.\n const shedders = [a, b].filter((item) => item.mixTags.includes(\"lint-shedder\"));\n if (shedders.length === 1) return \"lint\";\n\n if (!compatibleColours(a, b)) return \"colour\";\n if (!sameSettings(a, b)) return \"settings\";\n return null;\n}\n\n/**\n * @example\n * ```ts\n * canMix(towels, socks); // false\n * ```\n */\nexport function canMix(a: Instruction, b: Instruction): boolean {\n return mixBlocker(a, b) === null;\n}\n\n/**\n * Annotates each instruction with the other piles it may share a load with.\n *\n * @example\n * ```ts\n * resolve(instructions)[0].mixesWith; // [\"Socks\", \"Jeans\"]\n * ```\n */\nexport function resolve(instructions: Instruction[]): ResolvedInstruction[] {\n return instructions.map((instruction) => ({\n ...instruction,\n mixesWith: instructions\n .filter((other) => other !== instruction && canMix(instruction, other))\n .map((other) => other.clothingType),\n }));\n}\n\n/**\n * Groups piles that can all be washed together — every member compatible with\n * every other, not merely with the first one it met.\n *\n * @example\n * ```ts\n * loadGroups(instructions); // [[towels], [socks, jeans], [wool]]\n * ```\n */\nexport function loadGroups<T extends Instruction>(instructions: T[]): T[][] {\n const groups: T[][] = [];\n for (const instruction of instructions) {\n const home = groups.find((group) => group.every((member) => canMix(member, instruction)));\n if (home) home.push(instruction);\n else groups.push([instruction]);\n }\n return groups;\n}\n\nfunction groupBy<T>(items: T[], key: (item: T) => string): T[][] {\n const groups = new Map<string, T[]>();\n for (const item of items) {\n const existing = groups.get(key(item));\n if (existing) existing.push(item);\n else groups.set(key(item), [item]);\n }\n return [...groups.values()];\n}\n\n/**\n * Everything you physically set on the washing machine: the programme, what\n * the display reads, which buttons are lit, and whether softener goes in.\n *\n * Deliberately *not* the prose. Two piles can want the same five settings and\n * still want different detergent or a different drying rack, and those the card\n * lists per pile rather than splitting into two near-identical cards.\n */\nfunction washFingerprint(item: Instruction): string {\n return JSON.stringify([\n item.program,\n item.temperature,\n item.spin,\n [...item.options].sort(),\n item.fabricSoftener,\n ]);\n}\n\n/**\n * Piles you set the machine and the iron up for identically. Every dial\n * drawing on their cards would be the same drawing, so they get one card\n * listing all the names.\n *\n * @example\n * ```ts\n * cardGroups(instructions).map((group) => group.map((item) => item.clothingType));\n * // [[\"Socks\", \"Jeans\"], [\"Towels\"]]\n * ```\n */\nexport function cardGroups<T extends Instruction>(instructions: T[]): T[][] {\n return groupBy(instructions, (item) =>\n JSON.stringify([washFingerprint(item), item.ironing, item.ironSetting]),\n );\n}\n\n/**\n * The same, for a sheet with no iron on it. Dark, Black Socks and Denim each\n * need their own card on the full chart only because they want three different\n * thermostat positions; standing at the machine they are one wash.\n *\n * @example\n * ```ts\n * washGroups(instructions); // merges piles cardGroups would have split on iron settings alone\n * ```\n */\nexport function washGroups<T extends Instruction>(instructions: T[]): T[][] {\n return groupBy(instructions, washFingerprint);\n}\n\n/**\n * Piles by where the iron's thermostat points, coolest first.\n *\n * An ironing sheet is read the other way round from a washing one. You do not\n * fetch a pile and look up its setting — you set the iron once and work through\n * everything that goes at that heat, so the thermostat position is the heading\n * and the piles are the list under it. `order` is `ironSettingKeys`; a pile you\n * never iron has no position in it and sorts last, which is right — it is the\n * pile you never pick up.\n *\n * @example\n * ```ts\n * ironGroups(instructions, ironSettingKeys(machine));\n * ```\n */\nexport function ironGroups<T extends Instruction>(\n instructions: T[],\n order: readonly string[],\n): T[][] {\n const rank = (setting: string) => {\n const at = order.indexOf(setting);\n return at < 0 ? order.length : at;\n };\n return groupBy(instructions, (item) => item.ironSetting).sort(\n (a, b) => rank((a[0] as T).ironSetting) - rank((b[0] as T).ironSetting),\n );\n}\n"],"mappings":"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"}
@@ -11,7 +11,9 @@
11
11
  * while standing in front of the machine.
12
12
  */
13
13
  interface Washer {
14
+ /** @maxLength 60 */
14
15
  name: string;
16
+ /** @maxLength 20 */
15
17
  capacity: string;
16
18
  /** Dial labels in physical order from twelve o'clock, clockwise. Index is the angle. */
17
19
  programs: string[];
@@ -20,13 +22,18 @@ interface Washer {
20
22
  options: string[];
21
23
  }
22
24
  interface IronSetting {
25
+ /** @maxLength 30 */
23
26
  key: string;
27
+ /** @maxLength 5 */
24
28
  dots: string;
29
+ /** @maxLength 20 */
25
30
  label: string;
31
+ /** @maxLength 60 */
26
32
  detail: string;
27
33
  steam: boolean;
28
34
  }
29
35
  interface Iron {
36
+ /** @maxLength 60 */
30
37
  name: string;
31
38
  /** Thermostat positions in order, coolest first. */
32
39
  settings: IronSetting[];
@@ -99,11 +106,14 @@ type MixTag = (typeof mixTags)[number];
99
106
  * has to wonder whether a programme name exists.
100
107
  */
101
108
  interface Instruction {
109
+ /** @maxLength 60 */
102
110
  clothingType: string;
111
+ /** @maxLength 200 */
103
112
  detergent: string;
104
113
  fabricSoftener: boolean;
105
114
  temperature: string;
106
115
  spin: string;
116
+ /** @maxLength 12 */
107
117
  duration: string;
108
118
  program: string;
109
119
  options: string[];
@@ -113,21 +123,28 @@ interface Instruction {
113
123
  * How to iron it, or why you don't. Free prose and often empty, because most
114
124
  * of what there is to say about a pile nobody irons is already said by
115
125
  * `ironing` being false.
126
+ * @maxLength 400
116
127
  */
117
128
  ironingNotes: string;
118
129
  /** Thermostat position. Empty when `ironing` is false. */
119
130
  ironSetting: string;
131
+ /** @maxLength 150 */
120
132
  drying: string;
121
133
  colourGroup: ColourGroup;
122
134
  mixTags: MixTag[];
135
+ /** @maxLength 500 */
123
136
  notes: string;
124
137
  /**
125
138
  * Who to credit for a care instruction that isn't obvious from the garment
126
139
  * itself — "the label says 40°" doesn't need one, "the manufacturer says
127
140
  * wash these alone" might. Empty when there's nothing to cite.
141
+ * @maxLength 80
128
142
  */
129
143
  referenceName: string;
130
- /** A link backing up `referenceName`. Empty when there's nothing to cite. */
144
+ /**
145
+ * A link backing up `referenceName`. Empty when there's nothing to cite.
146
+ * @maxLength 2048
147
+ */
131
148
  referenceLink: string;
132
149
  }
133
150
  /** An instruction plus the other piles it may share a drum with. */
@@ -352,4 +369,4 @@ declare function chartToJson(instructions: Instruction[]): string;
352
369
  declare function chartFromJson(source: string, machine: Machine): Instruction[];
353
370
  //#endregion
354
371
  export { mixTags as A, ColourGroup as C, Variant as D, ResolvedInstruction as E, Washer as F, formatTemperature as I, ironSetting as L, Iron as M, IronSetting as N, colourGroups as O, Machine as P, ironSettingKeys as R, parseConfig as S, MixTag as T, washGroups as _, chartToJson as a, configFromJson as b, Blocker as c, canMix as d, cardGroups as f, resolve as g, mixBlocker as h, chartFromJson as i, variants as j, durationsOf as k, blockerCode as l, loadGroups as m, Row as n, instructionsFromRows as o, ironGroups as p, RowError as r, rowsFromInstructions as s, COLUMNS as t, blockerLegend as u, CONFIG_SCHEMA_URL as v, Instruction as w, configToJson as x, Config as y, parseMachine as z };
355
- //# sourceMappingURL=rows-Bn1LlIaD.d.mts.map
372
+ //# sourceMappingURL=rows-DJZMldFv.d.mts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@washy-washy/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Chart parsing, mixing rules and machine validation for washy-washy — a laundry chart generator.",
5
5
  "keywords": [
6
6
  "laundry",
@@ -28,18 +28,22 @@
28
28
  "description": "One CSV row: everything needed to wash, iron and dry one pile of laundry.\n\nThe machine-facing fields are plain strings rather than unions, because what counts as valid depends on the machine file you point this at. `csv.ts` checks each of them against that machine as it parses, so nothing downstream has to wonder whether a programme name exists.",
29
29
  "properties": {
30
30
  "clothingType": {
31
+ "maxLength": 60,
31
32
  "type": "string"
32
33
  },
33
34
  "colourGroup": {
34
35
  "$ref": "#/definitions/ColourGroup"
35
36
  },
36
37
  "detergent": {
38
+ "maxLength": 200,
37
39
  "type": "string"
38
40
  },
39
41
  "drying": {
42
+ "maxLength": 150,
40
43
  "type": "string"
41
44
  },
42
45
  "duration": {
46
+ "maxLength": 12,
43
47
  "type": "string"
44
48
  },
45
49
  "fabricSoftener": {
@@ -55,6 +59,7 @@
55
59
  },
56
60
  "ironingNotes": {
57
61
  "description": "How to iron it, or why you don't. Free prose and often empty, because most of what there is to say about a pile nobody irons is already said by `ironing` being false.",
62
+ "maxLength": 400,
58
63
  "type": "string"
59
64
  },
60
65
  "mixTags": {
@@ -64,6 +69,7 @@
64
69
  "type": "array"
65
70
  },
66
71
  "notes": {
72
+ "maxLength": 500,
67
73
  "type": "string"
68
74
  },
69
75
  "options": {
@@ -77,10 +83,12 @@
77
83
  },
78
84
  "referenceLink": {
79
85
  "description": "A link backing up `referenceName`. Empty when there's nothing to cite.",
86
+ "maxLength": 2048,
80
87
  "type": "string"
81
88
  },
82
89
  "referenceName": {
83
90
  "description": "Who to credit for a care instruction that isn't obvious from the garment itself — \"the label says 40°\" doesn't need one, \"the manufacturer says wash these alone\" might. Empty when there's nothing to cite.",
91
+ "maxLength": 80,
84
92
  "type": "string"
85
93
  },
86
94
  "spin": {
@@ -115,6 +123,7 @@
115
123
  "additionalProperties": false,
116
124
  "properties": {
117
125
  "name": {
126
+ "maxLength": 60,
118
127
  "type": "string"
119
128
  },
120
129
  "settings": {
@@ -132,15 +141,19 @@
132
141
  "additionalProperties": false,
133
142
  "properties": {
134
143
  "detail": {
144
+ "maxLength": 60,
135
145
  "type": "string"
136
146
  },
137
147
  "dots": {
148
+ "maxLength": 5,
138
149
  "type": "string"
139
150
  },
140
151
  "key": {
152
+ "maxLength": 30,
141
153
  "type": "string"
142
154
  },
143
155
  "label": {
156
+ "maxLength": 20,
144
157
  "type": "string"
145
158
  },
146
159
  "steam": {
@@ -172,9 +185,11 @@
172
185
  "description": "The appliances a chart is drawn for, as data rather than code.\n\nEverything the PDFs render — dial angles, which chips exist, what a valid CSV value is — comes from a machine file, so pointing this at a different washing machine is editing JSON rather than editing TypeScript. The labels in that file are copied off the fascia in whatever language it is printed in, and nothing here ever translates them: a chart that says \"Cottons\" when the dial says \"Katoen\" is worse than no chart, because you have to translate it back while standing in front of the machine.",
173
186
  "properties": {
174
187
  "capacity": {
188
+ "maxLength": 20,
175
189
  "type": "string"
176
190
  },
177
191
  "name": {
192
+ "maxLength": 60,
178
193
  "type": "string"
179
194
  },
180
195
  "options": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"mixing-Dt043zmh.mjs","names":[],"sources":["../src/machine.ts","../src/types.ts","../src/rows.ts","../src/config.ts","../src/mixing.ts"],"sourcesContent":["/**\n * The appliances a chart is drawn for, as data rather than code.\n *\n * Everything the PDFs render — dial angles, which chips exist, what a valid CSV\n * value is — comes from a machine file, so pointing this at a different washing\n * machine is editing JSON rather than editing TypeScript. The labels in that\n * file are copied off the fascia in whatever language it is printed in, and\n * nothing here ever translates them: a chart that says \"Cottons\" when the dial\n * says \"Katoen\" is worse than no chart, because you have to translate it back\n * while standing in front of the machine.\n */\n\nexport interface Washer {\n name: string;\n capacity: string;\n /** Dial labels in physical order from twelve o'clock, clockwise. Index is the angle. */\n programs: string[];\n temperatures: string[];\n spins: string[];\n options: string[];\n}\n\nexport interface IronSetting {\n key: string;\n dots: string;\n label: string;\n detail: string;\n steam: boolean;\n}\n\nexport interface Iron {\n name: string;\n /** Thermostat positions in order, coolest first. */\n settings: IronSetting[];\n}\n\nexport interface Machine {\n washer: Washer;\n iron: Iron;\n}\n\nfunction fail(what: string): never {\n throw new Error(`machine: ${what}`);\n}\n\nfunction stringList(value: unknown, field: string, minimum: number): string[] {\n if (!Array.isArray(value) || value.some((entry) => typeof entry !== \"string\" || entry === \"\")) {\n fail(`${field} must be a list of non-empty strings`);\n }\n const list = value as string[];\n if (list.length < minimum)\n fail(`${field} needs at least ${minimum} entries, found ${list.length}`);\n if (new Set(list).size !== list.length) fail(`${field} repeats a value`);\n return list;\n}\n\nfunction text(value: unknown, field: string, fallback?: string): string {\n if (typeof value === \"string\" && value !== \"\") return value;\n if (fallback !== undefined) return fallback;\n return fail(`${field} must be a non-empty string`);\n}\n\nfunction parseIronSetting(value: unknown, index: number): IronSetting {\n if (typeof value !== \"object\" || value === null)\n fail(`iron.settings[${index}] must be an object`);\n const raw = value as Record<string, unknown>;\n const key = text(raw.key, `iron.settings[${index}].key`);\n return {\n key,\n dots: typeof raw.dots === \"string\" ? raw.dots : \"\",\n label: text(raw.label, `iron.settings[${index}].label`),\n detail: text(raw.detail, `iron.settings[${index}].detail`, \"\"),\n steam: raw.steam === true,\n };\n}\n\n/**\n * Checks a parsed machine file and hands back something the renderer can use.\n * Every failure names the field, because the fix is always an edit to that\n * line of JSON.\n *\n * @example\n * ```ts\n * const machine = parseMachine(JSON.parse(machineFileContents));\n * machine.washer.programs; // [\"Off\", \"Cottons\", \"Wool\", ...]\n * ```\n */\nexport function parseMachine(value: unknown): Machine {\n if (typeof value !== \"object\" || value === null) fail(\"the file must contain an object\");\n const raw = value as Record<string, unknown>;\n\n if (typeof raw.washer !== \"object\" || raw.washer === null) fail(\"washer is missing\");\n if (typeof raw.iron !== \"object\" || raw.iron === null) fail(\"iron is missing\");\n\n const washerRaw = raw.washer as Record<string, unknown>;\n const ironRaw = raw.iron as Record<string, unknown>;\n\n const settings = Array.isArray(ironRaw.settings)\n ? ironRaw.settings\n : fail(\"iron.settings is missing\");\n if (settings.length < 2) fail(\"iron.settings needs at least 2 positions to draw a ring\");\n\n const keys = settings.map((setting, index) => parseIronSetting(setting, index));\n if (new Set(keys.map((setting) => setting.key)).size !== keys.length) {\n fail(\"iron.settings repeats a key\");\n }\n\n return {\n washer: {\n name: text(washerRaw.name, \"washer.name\"),\n capacity: text(washerRaw.capacity, \"washer.capacity\", \"\"),\n programs: stringList(washerRaw.programs, \"washer.programs\", 2),\n temperatures: stringList(washerRaw.temperatures, \"washer.temperatures\", 1),\n spins: stringList(washerRaw.spins, \"washer.spins\", 1),\n options: stringList(washerRaw.options ?? [], \"washer.options\", 0),\n },\n iron: {\n name: text(ironRaw.name, \"iron.name\"),\n settings: keys,\n },\n };\n}\n\n/**\n * @example\n * ```ts\n * ironSetting(machine, \"2\")?.label; // \"••\"\n * ironSetting(machine, \"nope\"); // undefined\n * ```\n */\nexport function ironSetting(machine: Machine, key: string): IronSetting | undefined {\n return machine.iron.settings.find((setting) => setting.key === key);\n}\n\n/**\n * The keys a row may write in `iron_setting`, coolest first.\n *\n * There is no key for \"do not iron\". That is the `ironing` column's job, and\n * two places to say the same thing is two places to disagree.\n *\n * @example\n * ```ts\n * ironSettingKeys(machine); // [\"min\", \"1\", \"2\", \"3\"]\n * ```\n */\nexport function ironSettingKeys(machine: Machine): string[] {\n return machine.iron.settings.map((setting) => setting.key);\n}\n\n/**\n * `40` becomes `40°`; `cold` stays `cold`. The machine file is free to use\n * whatever word its display shows for the cold wash, so the degree sign is\n * decided by whether the value is a number rather than by a list of exceptions.\n *\n * @example\n * ```ts\n * formatTemperature(\"40\"); // \"40°\"\n * formatTemperature(\"cold\"); // \"cold\"\n * ```\n */\nexport function formatTemperature(value: string): string {\n return /^\\d+$/.test(value) ? `${value}°` : value;\n}\n","/** Which pile a garment belongs to on colour grounds. `any` mixes with all. */\nexport const colourGroups = [\"white\", \"colour\", \"dark\", \"sport\", \"any\"] as const;\nexport type ColourGroup = (typeof colourGroups)[number];\n\n/**\n * Reasons two piles might not belong together even when the machine settings\n * agree. See `canMix` in `mixing.ts` for how each one is applied.\n */\nexport const mixTags = [\"lint-shedder\", \"lint-magnet\", \"dye-bleeder\", \"solo\"] as const;\nexport type MixTag = (typeof mixTags)[number];\n\n/**\n * One CSV row: everything needed to wash, iron and dry one pile of laundry.\n *\n * The machine-facing fields are plain strings rather than unions, because what\n * counts as valid depends on the machine file you point this at. `csv.ts`\n * checks each of them against that machine as it parses, so nothing downstream\n * has to wonder whether a programme name exists.\n */\nexport interface Instruction {\n clothingType: string;\n detergent: string;\n fabricSoftener: boolean;\n temperature: string;\n spin: string;\n duration: string;\n program: string;\n options: string[];\n /** Whether this pile gets ironed at all. */\n ironing: boolean;\n /**\n * How to iron it, or why you don't. Free prose and often empty, because most\n * of what there is to say about a pile nobody irons is already said by\n * `ironing` being false.\n */\n ironingNotes: string;\n /** Thermostat position. Empty when `ironing` is false. */\n ironSetting: string;\n drying: string;\n colourGroup: ColourGroup;\n mixTags: MixTag[];\n notes: string;\n /**\n * Who to credit for a care instruction that isn't obvious from the garment\n * itself — \"the label says 40°\" doesn't need one, \"the manufacturer says\n * wash these alone\" might. Empty when there's nothing to cite.\n */\n referenceName: string;\n /** A link backing up `referenceName`. Empty when there's nothing to cite. */\n referenceLink: string;\n}\n\n/** An instruction plus the other piles it may share a drum with. */\nexport interface ResolvedInstruction extends Instruction {\n mixesWith: string[];\n}\n\n/**\n * How long a card or a load is going to tie the machine up.\n *\n * Usually one figure. Piles sharing a load agree on every setting, so they\n * ought to agree on this too — but the chart is data somebody types, and\n * printing both is more honest than picking the first one and hoping.\n *\n * @example\n * ```ts\n * durationsOf([pile, pile]); // \"~2:00\"\n * durationsOf([pile, { ...pile, duration: \"~1:10\" }]); // \"~2:00 / ~1:10\"\n * ```\n */\nexport function durationsOf(group: Instruction[]): string {\n return [...new Set(group.map((item) => item.duration).filter((value) => value !== \"\"))].join(\n \" / \",\n );\n}\n\n/**\n * Which sheet to draw.\n *\n * `full` is the whole chart. The other two are the same chart cut where the\n * work is: washing happens in front of the machine on a Sunday morning, ironing\n * happens at a board on a Wednesday evening, and neither job wants to read past\n * the other's advice to find its own.\n */\nexport const variants = [\"full\", \"wash\", \"iron\"] as const;\nexport type Variant = (typeof variants)[number];\n","import { ironSettingKeys, type Machine } from \"./machine\";\nimport type { ColourGroup, Instruction, MixTag } from \"./types\";\nimport { colourGroups, mixTags } from \"./types\";\n\nexport const COLUMNS = [\n \"clothing_type\",\n \"detergent\",\n \"fabric_softener\",\n \"temperature\",\n \"spin\",\n \"duration\",\n \"program\",\n \"options\",\n \"ironing\",\n \"ironing_notes\",\n \"iron_setting\",\n \"drying\",\n \"colour_group\",\n \"mix_tags\",\n \"notes\",\n \"reference_name\",\n \"reference_link\",\n] as const;\n\n/** One row, keyed the same way as a CSV column and the chart's JSON Schema. */\nexport type Row = Record<(typeof COLUMNS)[number], string>;\n\nexport class RowError extends Error {\n constructor(row: number, column: string, message: string) {\n super(`row ${row}, column \"${column}\": ${message}`);\n this.name = \"RowError\";\n }\n}\n\n/** `a|b|c` -> `[\"a\", \"b\", \"c\"]`, tolerating stray whitespace and empties. */\nfunction splitList(value: string): string[] {\n return value\n .split(\"|\")\n .map((part) => part.trim())\n .filter((part) => part.length > 0);\n}\n\nfunction oneOf<T extends string>(\n row: number,\n column: string,\n value: string,\n allowed: readonly T[],\n): T {\n if (!(allowed as readonly string[]).includes(value)) {\n throw new RowError(row, column, `\"${value}\" is not one of ${allowed.join(\", \")}`);\n }\n return value as T;\n}\n\nfunction boolean(row: number, column: string, value: string): boolean {\n const normalised = value.trim().toLowerCase();\n if ([\"yes\", \"y\", \"true\", \"1\"].includes(normalised)) return true;\n if ([\"no\", \"n\", \"false\", \"0\"].includes(normalised)) return false;\n throw new RowError(row, column, `\"${value}\" is not a yes/no value`);\n}\n\n/**\n * Checks rows shaped like the chart's schema — the same shape a CSV parses\n * into, or a JSON file round-tripped through `rowsFromInstructions` — against\n * what the appliances in the machine file can actually be set to. A typo in a\n * programme name fails here rather than producing a PDF that tells you to turn\n * the dial to a position that does not exist — and so does a chart written for\n * a different machine than the one you passed.\n *\n * @example\n * ```ts\n * const instructions = instructionsFromRows(JSON.parse(jsonFromStorage), machine);\n * ```\n */\nexport function instructionsFromRows(\n rows: Record<string, string>[],\n machine: Machine,\n): Instruction[] {\n const { washer } = machine;\n\n if (rows.length === 0) throw new Error(\"the chart has no rows\");\n\n const header = Object.keys(rows[0] as Record<string, string>);\n const missing = COLUMNS.filter((column) => !header.includes(column));\n if (missing.length > 0) {\n throw new Error(`the chart is missing column(s): ${missing.join(\", \")}`);\n }\n\n return rows.map((record, index) => {\n // +2: one for the header row, one because humans count from 1. Kept even\n // for a JSON source, so an error message reads the same regardless of\n // which format the chart came from.\n const row = index + 2;\n\n const clothingType = (record.clothing_type ?? \"\").trim();\n if (clothingType === \"\") throw new RowError(row, \"clothing_type\", \"must not be empty\");\n\n const options = splitList(record.options ?? \"\").map((option) =>\n oneOf(row, \"options\", option, washer.options),\n );\n\n const tags = splitList(record.mix_tags ?? \"\").map((tag) =>\n oneOf<MixTag>(row, \"mix_tags\", tag, mixTags),\n );\n\n // `ironing` decides whether there is a thermostat position at all, so the\n // two are checked together: a pile you never iron has nowhere to point the\n // dial, and a pile you do iron has to say where.\n const ironing = boolean(row, \"ironing\", record.ironing ?? \"\");\n const rawSetting = (record.iron_setting ?? \"\").trim();\n if (!ironing && rawSetting !== \"\") {\n throw new RowError(\n row,\n \"iron_setting\",\n `must be empty when ironing is no, found \"${rawSetting}\"`,\n );\n }\n const ironSetting = ironing\n ? oneOf(row, \"iron_setting\", rawSetting, ironSettingKeys(machine))\n : \"\";\n\n return {\n clothingType,\n detergent: record.detergent ?? \"\",\n fabricSoftener: boolean(row, \"fabric_softener\", record.fabric_softener ?? \"\"),\n temperature: oneOf(row, \"temperature\", record.temperature ?? \"\", washer.temperatures),\n spin: oneOf(row, \"spin\", record.spin ?? \"\", washer.spins),\n duration: record.duration ?? \"\",\n program: oneOf(row, \"program\", record.program ?? \"\", washer.programs),\n options,\n ironing,\n ironingNotes: record.ironing_notes ?? \"\",\n ironSetting,\n drying: record.drying ?? \"\",\n colourGroup: oneOf<ColourGroup>(row, \"colour_group\", record.colour_group ?? \"\", colourGroups),\n mixTags: tags,\n notes: record.notes ?? \"\",\n referenceName: record.reference_name ?? \"\",\n referenceLink: record.reference_link ?? \"\",\n };\n });\n}\n\n/**\n * The reverse of `instructionsFromRows` — what a JSON export writes out.\n *\n * @example\n * ```ts\n * const rows = rowsFromInstructions(instructions);\n * ```\n */\nexport function rowsFromInstructions(instructions: Instruction[]): Row[] {\n return instructions.map((instruction) => ({\n clothing_type: instruction.clothingType,\n detergent: instruction.detergent,\n fabric_softener: instruction.fabricSoftener ? \"yes\" : \"no\",\n temperature: instruction.temperature,\n spin: instruction.spin,\n duration: instruction.duration,\n program: instruction.program,\n options: instruction.options.join(\"|\"),\n ironing: instruction.ironing ? \"yes\" : \"no\",\n ironing_notes: instruction.ironingNotes,\n iron_setting: instruction.ironSetting,\n drying: instruction.drying,\n colour_group: instruction.colourGroup,\n mix_tags: instruction.mixTags.join(\"|\"),\n notes: instruction.notes,\n reference_name: instruction.referenceName,\n reference_link: instruction.referenceLink,\n }));\n}\n\n/**\n * The JSON interchange format for a chart — the same row shape\n * a chart's JSON Schema describes, so a file written here validates against\n * it exactly as a CSV does.\n *\n * @example\n * ```ts\n * const json = chartToJson(instructions);\n * ```\n */\nexport function chartToJson(instructions: Instruction[]): string {\n return `${JSON.stringify(rowsFromInstructions(instructions), null, 2)}\\n`;\n}\n\n/**\n * The other half of `chartToJson`. Throws the same errors `parseInstructions` does.\n *\n * @example\n * ```ts\n * const instructions = chartFromJson(jsonFromStorage, machine);\n * ```\n */\nexport function chartFromJson(source: string, machine: Machine): Instruction[] {\n let rows: unknown;\n try {\n rows = JSON.parse(source);\n } catch (error) {\n throw new Error(`not valid JSON: ${error instanceof Error ? error.message : error}`);\n }\n if (!Array.isArray(rows)) throw new Error(\"the chart must be a JSON array of rows\");\n return instructionsFromRows(rows as Record<string, string>[], machine);\n}\n","import { type Machine, parseMachine } from \"./machine\";\nimport { instructionsFromRows, rowsFromInstructions } from \"./rows\";\nimport type { Instruction } from \"./types\";\n\n/**\n * Where `configToJson` points editors at the real JSON Schema (generated\n * from the `Config` type — see `schema/config.schema.json` and `bun run\n * schema`). Unpinned to the package's `latest` npm tag rather than the\n * running version, matching how most `$schema` URLs work: close enough,\n * without needing a build-time read of this package's own `package.json`.\n */\nexport const CONFIG_SCHEMA_URL =\n \"https://cdn.jsdelivr.net/npm/@washy-washy/core/schema/config.schema.json\";\n\n/**\n * The washing machine and the chart that describes it, in one place. Both\n * halves are exactly what `parseMachine`/`instructionsFromRows` already\n * produce — this is a wrapper around the pair, not a third shape.\n */\nexport interface Config {\n machine: Machine;\n chart: Instruction[];\n}\n\n/**\n * Validates a `{ machine, chart }` object, checking the chart against the\n * machine it's embedded with — never a different one. Every failure names\n * which half is wrong, same as `parseMachine` and `instructionsFromRows` do\n * on their own.\n *\n * @example\n * ```ts\n * const config = parseConfig(JSON.parse(configFileContents));\n * config.machine.washer.name; // \"Generic front loader\"\n * config.chart[0].clothingType; // \"Dark\"\n * ```\n */\nexport function parseConfig(value: unknown): Config {\n if (typeof value !== \"object\" || value === null) {\n throw new Error(\"config: the file must contain an object\");\n }\n const raw = value as Record<string, unknown>;\n\n if (!(\"machine\" in raw)) throw new Error(\"config: machine is missing\");\n if (!(\"chart\" in raw)) throw new Error(\"config: chart is missing\");\n\n const machine = parseMachine(raw.machine);\n\n if (!Array.isArray(raw.chart)) throw new Error(\"config: chart must be an array of rows\");\n const chart = instructionsFromRows(raw.chart as Record<string, string>[], machine);\n\n return { machine, chart };\n}\n\n/**\n * The reverse of `parseConfig` — what a JSON export writes out. Leads with a\n * `$schema` key so an editor that reads it (VS Code among them) validates\n * and autocompletes the file without the person editing it doing anything.\n * `parseConfig` ignores the key on the way back in — it never asks for\n * anything beyond `machine` and `chart`.\n *\n * @example\n * ```ts\n * const json = configToJson(config);\n * ```\n */\nexport function configToJson(config: Config): string {\n const rows = {\n $schema: CONFIG_SCHEMA_URL,\n machine: config.machine,\n chart: rowsFromInstructions(config.chart),\n };\n return `${JSON.stringify(rows, null, 2)}\\n`;\n}\n\n/**\n * The other half of `configToJson`. Throws the same errors `parseConfig` does.\n *\n * @example\n * ```ts\n * const config = configFromJson(configFileContents);\n * ```\n */\nexport function configFromJson(source: string): Config {\n let value: unknown;\n try {\n value = JSON.parse(source);\n } catch (error) {\n throw new Error(`config: not valid JSON: ${error instanceof Error ? error.message : error}`);\n }\n return parseConfig(value);\n}\n","import type { Instruction, ResolvedInstruction } from \"./types\";\n\n/** Why two piles cannot share a drum. `null` means they can. */\nexport type Blocker = \"solo\" | \"settings\" | \"colour\" | \"lint\";\n\nexport const blockerLegend: Record<Blocker, string> = {\n solo: \"Wash on its own\",\n settings: \"Different programme, temperature, spin or options\",\n colour: \"Colours would run into each other\",\n lint: \"One sheds lint onto the other\",\n};\n\n/** Short codes used in the compatibility matrix, so a cell stays one glyph. */\nexport const blockerCode: Record<Blocker, string> = {\n solo: \"S\",\n settings: \"P\",\n colour: \"C\",\n lint: \"L\",\n};\n\nfunction sameSettings(a: Instruction, b: Instruction): boolean {\n return (\n a.program === b.program &&\n a.temperature === b.temperature &&\n a.spin === b.spin &&\n a.options.length === b.options.length &&\n a.options.every((option) => b.options.includes(option))\n );\n}\n\nfunction compatibleColours(a: Instruction, b: Instruction): boolean {\n if (a.colourGroup === \"any\" || b.colourGroup === \"any\") return true;\n return a.colourGroup === b.colourGroup;\n}\n\n/**\n * Decides whether two piles can go in the drum together, and if not, why.\n *\n * The order matters: the reason reported is the one you would want to hear\n * first. \"Wash it alone\" beats \"the spin speed differs\", because changing the\n * spin speed would not help.\n *\n * @example\n * ```ts\n * mixBlocker(towels, socks); // \"lint\" — towels shed onto everything else\n * mixBlocker(socks, socks2); // null — nothing stops them sharing a drum\n * ```\n */\nexport function mixBlocker(a: Instruction, b: Instruction): Blocker | null {\n if (a.mixTags.includes(\"solo\") || b.mixTags.includes(\"solo\")) return \"solo\";\n\n // Terry sheds lint over everything, so towels only ever go with towels.\n const shedders = [a, b].filter((item) => item.mixTags.includes(\"lint-shedder\"));\n if (shedders.length === 1) return \"lint\";\n\n if (!compatibleColours(a, b)) return \"colour\";\n if (!sameSettings(a, b)) return \"settings\";\n return null;\n}\n\n/**\n * @example\n * ```ts\n * canMix(towels, socks); // false\n * ```\n */\nexport function canMix(a: Instruction, b: Instruction): boolean {\n return mixBlocker(a, b) === null;\n}\n\n/**\n * Annotates each instruction with the other piles it may share a load with.\n *\n * @example\n * ```ts\n * resolve(instructions)[0].mixesWith; // [\"Socks\", \"Jeans\"]\n * ```\n */\nexport function resolve(instructions: Instruction[]): ResolvedInstruction[] {\n return instructions.map((instruction) => ({\n ...instruction,\n mixesWith: instructions\n .filter((other) => other !== instruction && canMix(instruction, other))\n .map((other) => other.clothingType),\n }));\n}\n\n/**\n * Groups piles that can all be washed together — every member compatible with\n * every other, not merely with the first one it met.\n *\n * @example\n * ```ts\n * loadGroups(instructions); // [[towels], [socks, jeans], [wool]]\n * ```\n */\nexport function loadGroups<T extends Instruction>(instructions: T[]): T[][] {\n const groups: T[][] = [];\n for (const instruction of instructions) {\n const home = groups.find((group) => group.every((member) => canMix(member, instruction)));\n if (home) home.push(instruction);\n else groups.push([instruction]);\n }\n return groups;\n}\n\nfunction groupBy<T>(items: T[], key: (item: T) => string): T[][] {\n const groups = new Map<string, T[]>();\n for (const item of items) {\n const existing = groups.get(key(item));\n if (existing) existing.push(item);\n else groups.set(key(item), [item]);\n }\n return [...groups.values()];\n}\n\n/**\n * Everything you physically set on the washing machine: the programme, what\n * the display reads, which buttons are lit, and whether softener goes in.\n *\n * Deliberately *not* the prose. Two piles can want the same five settings and\n * still want different detergent or a different drying rack, and those the card\n * lists per pile rather than splitting into two near-identical cards.\n */\nfunction washFingerprint(item: Instruction): string {\n return JSON.stringify([\n item.program,\n item.temperature,\n item.spin,\n [...item.options].sort(),\n item.fabricSoftener,\n ]);\n}\n\n/**\n * Piles you set the machine and the iron up for identically. Every dial\n * drawing on their cards would be the same drawing, so they get one card\n * listing all the names.\n *\n * @example\n * ```ts\n * cardGroups(instructions).map((group) => group.map((item) => item.clothingType));\n * // [[\"Socks\", \"Jeans\"], [\"Towels\"]]\n * ```\n */\nexport function cardGroups<T extends Instruction>(instructions: T[]): T[][] {\n return groupBy(instructions, (item) =>\n JSON.stringify([washFingerprint(item), item.ironing, item.ironSetting]),\n );\n}\n\n/**\n * The same, for a sheet with no iron on it. Dark, Black Socks and Denim each\n * need their own card on the full chart only because they want three different\n * thermostat positions; standing at the machine they are one wash.\n *\n * @example\n * ```ts\n * washGroups(instructions); // merges piles cardGroups would have split on iron settings alone\n * ```\n */\nexport function washGroups<T extends Instruction>(instructions: T[]): T[][] {\n return groupBy(instructions, washFingerprint);\n}\n\n/**\n * Piles by where the iron's thermostat points, coolest first.\n *\n * An ironing sheet is read the other way round from a washing one. You do not\n * fetch a pile and look up its setting — you set the iron once and work through\n * everything that goes at that heat, so the thermostat position is the heading\n * and the piles are the list under it. `order` is `ironSettingKeys`; a pile you\n * never iron has no position in it and sorts last, which is right — it is the\n * pile you never pick up.\n *\n * @example\n * ```ts\n * ironGroups(instructions, ironSettingKeys(machine));\n * ```\n */\nexport function ironGroups<T extends Instruction>(\n instructions: T[],\n order: readonly string[],\n): T[][] {\n const rank = (setting: string) => {\n const at = order.indexOf(setting);\n return at < 0 ? order.length : at;\n };\n return groupBy(instructions, (item) => item.ironSetting).sort(\n (a, b) => rank((a[0] as T).ironSetting) - rank((b[0] as T).ironSetting),\n );\n}\n"],"mappings":"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"}