@escape-game-over/atlas 0.1.12 → 0.1.14

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.
@@ -112,9 +112,27 @@ than an item that silently never matches.
112
112
  | Kind | State | Item value | Matches when | In the URL | History |
113
113
  | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
114
114
  | `text` | the query | searchable text | folded query is a substring; empty keeps all | `?q=…`, dropped if empty | replace |
115
- | `choice` | one value, `""` | the item's one | exact; `""` keeps all | `?category=…`, dropped if empty | push |
115
+ | `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
116
116
  | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
117
117
 
118
+ **A `choice` item may hold several values.** The state stays one — the tab strip
119
+ picks one category, the URL carries one — but an item can sit in more than one
120
+ and then answers to any of them:
121
+
122
+ ```ts
123
+ items: [
124
+ { key: "burgos", values: { category: "city" } },
125
+ { key: "avila", values: { category: ["walk", "city"] } },
126
+ ]
127
+ ```
128
+
129
+ An escape room is adventure *and* sci-fi; a film is a comedy *and* a drama. This
130
+ is a widening of `choice` rather than a fourth kind, because a kind for it would
131
+ carry the same state, the same parameter, the same `set` semantics and the same
132
+ control, and differ by one operator — two kinds doing one job, which is what the
133
+ `flag` note below refuses. A list whose items each sit in one category writes a
134
+ bare string and is untouched.
135
+
118
136
  **`text` folds both sides** — `NFD`, drop the combining marks, lowercase, trim.
119
137
  This is the piece that is missing nearly everywhere, and it is not cosmetic: a
120
138
  reader who types `malaga` is otherwise told there is no *Málaga*, and every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@biomejs/biome": "2.5.12",
57
- "@types/node": "26.4.1",
57
+ "@types/node": "26.5.0",
58
58
  "@vitest/coverage-istanbul": "5.0.0",
59
59
  "astro": "7.3.1",
60
60
  "typescript": "6.0.3",
@@ -61,7 +61,9 @@
61
61
  * The kinds are closed, and deliberately few:
62
62
  *
63
63
  * - `text` — free entry, folded and substring-matched. The search box.
64
- * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
64
+ * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group. The
65
+ * control picks one; an item may hold several and answer to any — see
66
+ * `ItemValue`.
65
67
  * - `flag` — a narrowing toggle: off matches everything, on keeps only the
66
68
  * items that carry it. "In stock", "Step-free access".
67
69
  *
@@ -78,26 +80,58 @@ export type Field =
78
80
  export type FieldMap = Readonly<Record<string, Field>>;
79
81
 
80
82
  /**
81
- * What one field contributes, on an item and in the state.
83
+ * What one field contributes, in the state.
82
84
  *
83
- * One type for both roles because per kind they genuinely coincide, though they
84
- * mean different things: on an item a `text` value is everything that field
85
- * searches a name and a blurb joined while in the state it is what someone
86
- * typed. A `flag` is what the item is on one side and what was asked for on the
87
- * other.
85
+ * Always one value per field, whatever the kind: a `choice` is the one category
86
+ * asked for, a `text` is what someone typed, a `flag` is whether the toggle is
87
+ * on. This is what `set` takes and what reaches the address bar.
88
88
  */
89
- export type FieldValue<F extends Field> = F["kind"] extends "flag"
89
+ export type StateValue<F extends Field> = F["kind"] extends "flag"
90
90
  ? boolean
91
91
  : string;
92
92
 
93
+ /**
94
+ * What one field contributes, on an item.
95
+ *
96
+ * The two roles read alike but are not the same, and `choice` is where they part
97
+ * company. On an item a `text` value is everything that field searches — a name
98
+ * and a blurb joined — while in the state it is what someone typed; a `flag` is
99
+ * what the item *is* on one side and what was *asked for* on the other.
100
+ *
101
+ * **A `choice` item may hold several values, and then it answers to any of
102
+ * them.** The asymmetry is the point: an escape room is adventure *and* sci-fi,
103
+ * a film is a comedy *and* a drama, but the tab strip above them still picks
104
+ * one, and the URL still carries one. Widened here rather than given a kind of
105
+ * its own, because a second kind would carry the same state, the same
106
+ * parameter, the same `set` semantics and the same control, and differ by one
107
+ * operator — two kinds doing one job, which is exactly what `Field` refuses
108
+ * when it declines to let `flag` become a tri-state.
109
+ *
110
+ * A list whose items each sit in one category writes a bare string and is
111
+ * untouched by this.
112
+ */
113
+ export type ItemValue<F extends Field> = F["kind"] extends "flag"
114
+ ? boolean
115
+ : F["kind"] extends "choice"
116
+ ? string | readonly string[]
117
+ : string;
118
+
119
+ /**
120
+ * What one field contributes.
121
+ *
122
+ * @deprecated Say which side you are on: `StateValue` or `ItemValue`. This
123
+ * named both while they were the same type, and is `StateValue` now.
124
+ */
125
+ export type FieldValue<F extends Field> = StateValue<F>;
126
+
93
127
  /** Every field's value for one item, derived from the field declaration. */
94
128
  export type ItemValues<F extends FieldMap> = {
95
- readonly [K in keyof F]: FieldValue<F[K]>;
129
+ readonly [K in keyof F]: ItemValue<F[K]>;
96
130
  };
97
131
 
98
132
  /** What the list is filtered to right now. */
99
133
  export type FilterState<F extends FieldMap> = {
100
- readonly [K in keyof F]: FieldValue<F[K]>;
134
+ readonly [K in keyof F]: StateValue<F[K]>;
101
135
  };
102
136
 
103
137
  export interface FilterItem<F extends FieldMap> {
@@ -168,7 +202,7 @@ export interface Filters<F extends FieldMap> {
168
202
  readonly matched: ReadonlySet<string>;
169
203
  set<K extends keyof F>(
170
204
  field: K,
171
- value: FieldValue<F[K]>,
205
+ value: StateValue<F[K]>,
172
206
  options?: SetOptions
173
207
  ): void;
174
208
  /** Clears one field, or all of them. Always a push: clearing is deliberate. */
@@ -268,8 +302,8 @@ export function filters<const F extends FieldMap>(
268
302
  haystacks.set(item.key, perField);
269
303
  }
270
304
 
271
- const empty = (name: keyof F & string): FieldValue<F[typeof name]> =>
272
- (fields[name]?.kind === "flag" ? false : "") as FieldValue<
305
+ const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
306
+ (fields[name]?.kind === "flag" ? false : "") as StateValue<
273
307
  F[typeof name]
274
308
  >;
275
309
 
@@ -323,7 +357,14 @@ export function filters<const F extends FieldMap>(
323
357
  } else if (kind === "choice") {
324
358
  const wanted = state[name] as string;
325
359
  if (wanted === "") continue;
326
- if (item.values[name] !== wanted) {
360
+ // An item may sit in several categories while the control
361
+ // still picks one — see `ItemValue`. One value is the same
362
+ // question asked of a set of one.
363
+ const held = item.values[name];
364
+ const holds = Array.isArray(held)
365
+ ? held.includes(wanted)
366
+ : held === wanted;
367
+ if (!holds) {
327
368
  hit = false;
328
369
  break;
329
370
  }