@escape-game-over/atlas 0.1.10 → 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.10",
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
  }
package/src/index.ts CHANGED
@@ -125,6 +125,9 @@ export {
125
125
  breadcrumbList,
126
126
  businessId,
127
127
  type CatalogEntry,
128
+ type CreativeWorkInput,
129
+ type CreativeWorkProperty,
130
+ creativeWork,
128
131
  type FaqEntry,
129
132
  faqPage,
130
133
  geoCoordinates,
@@ -188,6 +191,8 @@ export {
188
191
  formatQuantities,
189
192
  type Price,
190
193
  type PriceTier,
194
+ type Quantity,
195
+ type QuantityRange,
191
196
  type TieredPrice,
192
197
  tierRange,
193
198
  } from "./money.ts";
@@ -0,0 +1,125 @@
1
+ import type { Quantity } from "../money.ts";
2
+ import type { HttpsUrl } from "../url.ts";
3
+ import type { BusinessId, OrganizationId } from "./ids.ts";
4
+ import { type JsonLdNode, schemaType } from "./node.ts";
5
+ import { quantitativeValue } from "./quantity.ts";
6
+
7
+ /**
8
+ * One measured fact about the work, as `additionalProperty` states one.
9
+ *
10
+ * schema.org has a named property for a few of these — `timeRequired` on a
11
+ * `CreativeWork`, `numberOfPlayers` on a `Game` — and nothing at all for most:
12
+ * how many puzzles a room holds, how much floor it needs, how hard it is. A
13
+ * `PropertyValue` is the vocabulary's own answer for exactly that, and using it
14
+ * for the whole set keeps one list rather than splitting a spec table across
15
+ * named properties and a leftovers bag by whichever half the vocabulary
16
+ * happened to name.
17
+ *
18
+ * The two unit properties are not alternatives to taste. `unitCode` is the
19
+ * standard one — a UN/CEFACT Common Code, `MIN` for minutes, `MTK` for square
20
+ * metres — and is what a consumer reads without parsing anything. `unitText` is
21
+ * free text, and schema.org states its purpose exactly: for when you *cannot*
22
+ * provide a standard code. Putting a code in `unitText` publishes `"MIN"` as a
23
+ * word rather than as a unit, which is the mistake this note exists to stop.
24
+ *
25
+ * Both may be given — a code for machines and a symbol for a reader — and
26
+ * neither need be: a count of puzzles is a number of nothing.
27
+ *
28
+ * Neither is checked against the code list, matching `LocalBusinessInput.type`:
29
+ * lib does not carry a copy of someone else's vocabulary to fall behind it.
30
+ */
31
+ export interface CreativeWorkProperty {
32
+ /** In the reader's language: lib does not translate, see `genre`. */
33
+ readonly name: string;
34
+ readonly value: Quantity;
35
+ /**
36
+ * UN/CEFACT Common Code — `"MIN"`, `"MTK"` — a URL, or `prefix:code` for
37
+ * another scheme. Two or three alphanumerics, despite schema.org saying
38
+ * three: the list holds hundreds of two-character codes.
39
+ */
40
+ readonly unitCode?: string;
41
+ /** A symbol a reader sees, e.g. `"m²"`. */
42
+ readonly unitText?: string;
43
+ }
44
+
45
+ export interface CreativeWorkInput {
46
+ readonly name: string;
47
+ /** This work's own page. */
48
+ readonly url: HttpsUrl;
49
+ readonly description?: string;
50
+ /** A picture of it, absolute. */
51
+ readonly image?: HttpsUrl;
52
+ /**
53
+ * What kind of thing it is, in words a reader would use.
54
+ *
55
+ * Already translated. lib holds no catalog and cannot turn a consumer's
56
+ * internal ids into names — a site passing its own slugs publishes
57
+ * `"sci-fi"` and `"live-act"` to a crawler, which is a fact about that
58
+ * site's database rather than about the work.
59
+ */
60
+ readonly genre?: readonly string[];
61
+ /** The `@id` of whoever made it. See `ProductInput.seller`. */
62
+ readonly creator?: BusinessId | OrganizationId;
63
+ /**
64
+ * The spec table, if the work has one.
65
+ *
66
+ * Omitted entirely rather than emitted empty when a work has no measured
67
+ * facts yet — an unbuilt room with a name and a picture. An empty
68
+ * `additionalProperty` claims a table exists and is blank.
69
+ */
70
+ readonly properties?: readonly CreativeWorkProperty[];
71
+ /** Further types alongside `CreativeWork`. See `ProductInput.alsoA`. */
72
+ readonly alsoA?: readonly string[];
73
+ }
74
+
75
+ /**
76
+ * Something the site made, described but not sold.
77
+ *
78
+ * The counterpart to `product`, and the distinction is the price: a `Product`
79
+ * exists in the graph to carry an `Offer`, so a thing with no price published
80
+ * as one is a product nobody can buy — an offer with no number is read as
81
+ * unknown availability rather than as "ask us". A room licensed to operators,
82
+ * a course, a film: `CreativeWork` says what it is and stops there.
83
+ *
84
+ * No `@id`. Nothing in the graph refers to a work — see the note in `ids.ts`
85
+ * for the test, and for why minting one before something points at it is a
86
+ * cost rather than a courtesy.
87
+ *
88
+ * Reference: <https://schema.org/CreativeWork>
89
+ */
90
+ export function creativeWork(work: CreativeWorkInput): JsonLdNode {
91
+ return {
92
+ "@type": schemaType(["CreativeWork", ...(work.alsoA ?? [])]),
93
+ name: work.name,
94
+ url: work.url,
95
+ ...(work.description === undefined
96
+ ? {}
97
+ : { description: work.description }),
98
+ ...(work.image === undefined ? {} : { image: work.image }),
99
+ ...(work.genre === undefined || work.genre.length === 0
100
+ ? {}
101
+ : { genre: work.genre }),
102
+ ...(work.creator === undefined
103
+ ? {}
104
+ : { creator: { "@id": work.creator } }),
105
+ ...(work.properties === undefined || work.properties.length === 0
106
+ ? {}
107
+ : { additionalProperty: work.properties.map(propertyValue) }),
108
+ };
109
+ }
110
+
111
+ function propertyValue(property: CreativeWorkProperty): JsonLdNode {
112
+ return {
113
+ "@type": "PropertyValue",
114
+ name: property.name,
115
+ value: {
116
+ ...quantitativeValue(property.value),
117
+ ...(property.unitCode === undefined
118
+ ? {}
119
+ : { unitCode: property.unitCode }),
120
+ ...(property.unitText === undefined
121
+ ? {}
122
+ : { unitText: property.unitText }),
123
+ },
124
+ };
125
+ }
@@ -32,6 +32,11 @@
32
32
  export { type ArticleInput, article } from "./article.ts";
33
33
  export { type BreadcrumbStep, breadcrumbList } from "./breadcrumb.ts";
34
34
  export { type LocalBusinessInput, localBusiness } from "./business.ts";
35
+ export {
36
+ type CreativeWorkInput,
37
+ type CreativeWorkProperty,
38
+ creativeWork,
39
+ } from "./creative-work.ts";
35
40
  export { type FaqEntry, faqPage } from "./faq.ts";
36
41
  export {
37
42
  type BusinessId,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isContiguous,
3
3
  type PriceTier,
4
+ type Quantity,
4
5
  quantitiesOf,
5
6
  quantityRange,
6
7
  } from "../money.ts";
@@ -20,9 +21,7 @@ import type { JsonLdNode } from "./node.ts";
20
21
  *
21
22
  * Reference: <https://schema.org/QuantitativeValue>
22
23
  */
23
- export function quantitativeValue(
24
- quantity: number | { readonly min: number; readonly max: number }
25
- ): JsonLdNode {
24
+ export function quantitativeValue(quantity: Quantity): JsonLdNode {
26
25
  return typeof quantity === "number"
27
26
  ? { "@type": "QuantitativeValue", value: quantity }
28
27
  : {
package/src/money.ts CHANGED
@@ -26,16 +26,32 @@ export interface Price {
26
26
  readonly currency: CurrencyCode;
27
27
  }
28
28
 
29
+ /** A span between two counts, both ends included. */
30
+ export interface QuantityRange {
31
+ readonly min: number;
32
+ readonly max: number;
33
+ }
34
+
29
35
  /**
30
- * What one quantity band costs.
36
+ * How many of something: an exact count, or a range.
37
+ *
38
+ * A scalar for the common case and an object when it genuinely varies, the same
39
+ * bargain `ThemeColor` strikes — the range alone would make the ordinary case
40
+ * noisy, and `{ min: 2, max: 2 }` five times over says nothing `2, 3, 4, 5, 6`
41
+ * does not.
31
42
  *
32
- * `quantity` takes a count or a range, because both occur and the range alone
33
- * would make the ordinary case noisy: `{ min: 2, max: 2 }` five times over says
34
- * nothing `2, 3, 4, 5, 6` does not. Same shape as `ThemeColor` a scalar for
35
- * the common case, an object when it genuinely varies.
43
+ * Named here, in the lowest module that needs it, because three unrelated
44
+ * things measure in it: a price band, a `QuantitativeValue`, and a spec on a
45
+ * `CreativeWork`. Spelled out at each it is three shapes that happen to match,
46
+ * and a caller moving a figure between them has to check that they still do.
47
+ */
48
+ export type Quantity = number | QuantityRange;
49
+
50
+ /**
51
+ * What one quantity band costs.
36
52
  */
37
53
  export interface PriceTier {
38
- readonly quantity: number | { readonly min: number; readonly max: number };
54
+ readonly quantity: Quantity;
39
55
  /**
40
56
  * What one unit costs at this quantity — not the total for the band.
41
57
  *
@@ -70,10 +86,7 @@ export interface TieredPrice {
70
86
  }
71
87
 
72
88
  /** The band a tier covers, with a bare count widened to a range of one. */
73
- export function tierRange(tier: PriceTier): {
74
- readonly min: number;
75
- readonly max: number;
76
- } {
89
+ export function tierRange(tier: PriceTier): QuantityRange {
77
90
  return typeof tier.quantity === "number"
78
91
  ? { min: tier.quantity, max: tier.quantity }
79
92
  : tier.quantity;
@@ -91,10 +104,9 @@ export function tierRange(tier: PriceTier): {
91
104
  * a caller wants is `formatQuantities` or `quantitiesFor`, which pick between
92
105
  * this and `quantitiesOf` by reading the tiers.
93
106
  */
94
- export function quantityRange(tiers: readonly [PriceTier, ...PriceTier[]]): {
95
- readonly min: number;
96
- readonly max: number;
97
- } {
107
+ export function quantityRange(
108
+ tiers: readonly [PriceTier, ...PriceTier[]]
109
+ ): QuantityRange {
98
110
  const first = tierRange(tiers[0]);
99
111
  const last = tiers.at(-1);
100
112
  return {