@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.
- package/docs/client-scripts.md +19 -1
- package/package.json +2 -2
- package/src/astro/filters.ts +55 -14
- package/src/index.ts +5 -0
- package/src/jsonld/creative-work.ts +125 -0
- package/src/jsonld/index.ts +5 -0
- package/src/jsonld/quantity.ts +2 -3
- package/src/money.ts +26 -14
package/docs/client-scripts.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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",
|
package/src/astro/filters.ts
CHANGED
|
@@ -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,
|
|
83
|
+
* What one field contributes, in the state.
|
|
82
84
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
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]:
|
|
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]:
|
|
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:
|
|
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):
|
|
272
|
-
(fields[name]?.kind === "flag" ? false : "") as
|
|
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
|
-
|
|
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
|
+
}
|
package/src/jsonld/index.ts
CHANGED
|
@@ -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,
|
package/src/jsonld/quantity.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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:
|
|
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(
|
|
95
|
-
readonly
|
|
96
|
-
|
|
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 {
|