@bespokeagentics/microdots-host 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/package.json +25 -0
- package/src/index.ts +144 -0
- package/src/loader.ts +119 -0
- package/src/mounting.test.ts +214 -0
- package/src/mounting.ts +90 -0
- package/src/placementChecks.test.ts +593 -0
- package/src/placementChecks.ts +428 -0
- package/src/registry.ts +63 -0
- package/src/routes.ts +156 -0
- package/src/rules.test.ts +598 -0
- package/src/rules.ts +448 -0
- package/src/slots.test.ts +46 -0
- package/src/slots.ts +58 -0
- package/src/wire.test.ts +631 -0
- package/src/wire.ts +560 -0
- package/src/wireEngine.test.ts +810 -0
- package/src/wireEngine.ts +468 -0
package/src/rules.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { Array, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
HostTopology,
|
|
5
|
+
PlacementCondition,
|
|
6
|
+
PlacementSpan,
|
|
7
|
+
RouteRule,
|
|
8
|
+
TopologyPlacement,
|
|
9
|
+
WireEnv,
|
|
10
|
+
} from './wire.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rules, THE matcher, and the reading-B resolution — Phase 5 work item 3 of
|
|
14
|
+
* `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`.
|
|
15
|
+
*
|
|
16
|
+
* The `RouteRule` record itself is schema and lives with the other topology
|
|
17
|
+
* records in `./wire.ts`; this module is the behaviour: `ruleMatches` (the one
|
|
18
|
+
* matcher, ruling 1 — the runtime and the Pages screen's match counts both
|
|
19
|
+
* call it, nothing else may reimplement it) and `resolvePlacements` (the
|
|
20
|
+
* reading-B override resolution of
|
|
21
|
+
* `wiki/framework/composition/question-does-the-override-contest-consider-condition.md`).
|
|
22
|
+
*
|
|
23
|
+
* Everything here derives fresh from the topology — nothing below is ever
|
|
24
|
+
* stored, for the same reason `deriveWireState` stores nothing: a stored
|
|
25
|
+
* resolution is stale the moment a rule or a placement changes without it
|
|
26
|
+
* being touched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/* ============================================================
|
|
30
|
+
The matcher.
|
|
31
|
+
============================================================ */
|
|
32
|
+
|
|
33
|
+
/** `'/docs/rpc-contracts'` → `['docs', 'rpc-contracts']`; `'/'` → `[]`. */
|
|
34
|
+
const segmentsOf = (path: string): ReadonlyArray<string> =>
|
|
35
|
+
path.split('/').filter(segment => segment !== '')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pattern semantics, pinned: only the `/*` SUFFIX wildcard exists. Stripping
|
|
39
|
+
* it leaves a prefix, and the path matches when it continues past that prefix
|
|
40
|
+
* through a `/` — so `/blog/*` matches `/blog/effect-first-ports` and
|
|
41
|
+
* `/blog/a/b` but NOT `/blog` itself, and `/*` (empty prefix) matches every
|
|
42
|
+
* normalized path. A pattern without the suffix is an exact match.
|
|
43
|
+
*/
|
|
44
|
+
const patternMatches = (pattern: string, path: string): boolean => {
|
|
45
|
+
if (pattern.endsWith('/*')) {
|
|
46
|
+
const prefix = pattern.slice(0, -2)
|
|
47
|
+
return path.startsWith(`${prefix}/`)
|
|
48
|
+
}
|
|
49
|
+
return pattern === path
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Dynamic semantics, pinned: segment-wise, equal length. A `:param` segment
|
|
54
|
+
* matches exactly ONE segment — `/docs/:slug` matches `/docs/anything` but
|
|
55
|
+
* not `/docs` and not `/docs/a/b`; a template wanting two segments says so
|
|
56
|
+
* (`/docs/:a/:b`). Literal segments match exactly.
|
|
57
|
+
*/
|
|
58
|
+
const templateMatches = (template: string, path: string): boolean => {
|
|
59
|
+
const templateSegments = segmentsOf(template)
|
|
60
|
+
const pathSegments = segmentsOf(path)
|
|
61
|
+
if (templateSegments.length !== pathSegments.length) {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
return Array.every(
|
|
65
|
+
Array.zip(templateSegments, pathSegments),
|
|
66
|
+
([templateSegment, pathSegment]) =>
|
|
67
|
+
templateSegment.startsWith(':') || templateSegment === pathSegment,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* THE matcher (ruling 1). Serves all three rule kinds; used by the runtime
|
|
73
|
+
* resolution below AND by the Pages screen's per-rule match counts, so the
|
|
74
|
+
* screen cannot claim a count the runtime disagrees with.
|
|
75
|
+
*/
|
|
76
|
+
export const ruleMatches = (rule: RouteRule, path: string): boolean => {
|
|
77
|
+
switch (rule.kind) {
|
|
78
|
+
case 'pattern':
|
|
79
|
+
return patternMatches(rule.pattern, path)
|
|
80
|
+
case 'group':
|
|
81
|
+
return Array.contains(rule.paths, path)
|
|
82
|
+
case 'dynamic':
|
|
83
|
+
return templateMatches(rule.template, path)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ============================================================
|
|
88
|
+
Conditions — the overlap algebra the resolution and the checks share.
|
|
89
|
+
============================================================ */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A condition with every axis explicit — the derived counterpart of the
|
|
93
|
+
* record's `PlacementCondition`, where an absent axis means `'any'`. Derived
|
|
94
|
+
* at resolution time rather than stored, so a record only ever says what
|
|
95
|
+
* narrows.
|
|
96
|
+
*/
|
|
97
|
+
export type ResolvedCondition = {
|
|
98
|
+
readonly who: string
|
|
99
|
+
readonly device: string
|
|
100
|
+
readonly locale: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const resolveCondition = (
|
|
104
|
+
condition: PlacementCondition | undefined,
|
|
105
|
+
): ResolvedCondition => ({
|
|
106
|
+
who: condition?.who ?? 'any',
|
|
107
|
+
device: condition?.device ?? 'any',
|
|
108
|
+
locale: condition?.locale ?? 'any',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
/** One axis overlaps when either side is `'any'` or they are equal — the
|
|
112
|
+
* fixture's `axisOk`, verbatim. */
|
|
113
|
+
const axisOverlaps = (a: string, b: string): boolean =>
|
|
114
|
+
a === 'any' || b === 'any' || a === b
|
|
115
|
+
|
|
116
|
+
/** Conditions overlap when ALL three axes do — the fixture's `overlaps`. */
|
|
117
|
+
export const conditionsOverlap = (
|
|
118
|
+
a: ResolvedCondition,
|
|
119
|
+
b: ResolvedCondition,
|
|
120
|
+
): boolean =>
|
|
121
|
+
axisOverlaps(a.who, b.who) &&
|
|
122
|
+
axisOverlaps(a.device, b.device) &&
|
|
123
|
+
axisOverlaps(a.locale, b.locale)
|
|
124
|
+
|
|
125
|
+
/** The narrower of two overlapping axes. Only meaningful when the axes
|
|
126
|
+
* overlap — `'any'` yields the other side, equals yield themselves. */
|
|
127
|
+
const axisIntersection = (a: string, b: string): string => (a === 'any' ? b : a)
|
|
128
|
+
|
|
129
|
+
/** The region two OVERLAPPING conditions share — what an override badge
|
|
130
|
+
* names ("overridden for fr"), per reading B's requirement that the loser's
|
|
131
|
+
* badge says which visitors it lost. */
|
|
132
|
+
export const conditionIntersection = (
|
|
133
|
+
a: ResolvedCondition,
|
|
134
|
+
b: ResolvedCondition,
|
|
135
|
+
): ResolvedCondition => ({
|
|
136
|
+
who: axisIntersection(a.who, b.who),
|
|
137
|
+
device: axisIntersection(a.device, b.device),
|
|
138
|
+
locale: axisIntersection(a.locale, b.locale),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/** Whether `winner` covers ALL of `loser` — `'any'` or equal on every axis.
|
|
142
|
+
* Covered entirely means the loser renders for nobody. */
|
|
143
|
+
const conditionCovers = (
|
|
144
|
+
winner: ResolvedCondition,
|
|
145
|
+
loser: ResolvedCondition,
|
|
146
|
+
): boolean =>
|
|
147
|
+
(winner.who === 'any' || winner.who === loser.who) &&
|
|
148
|
+
(winner.device === 'any' || winner.device === loser.device) &&
|
|
149
|
+
(winner.locale === 'any' || winner.locale === loser.locale)
|
|
150
|
+
|
|
151
|
+
/** The non-`'any'` axes joined for a badge or a sentence — the fixture's
|
|
152
|
+
* `condLabel`, generalised to free axis values. `'everyone'` when nothing
|
|
153
|
+
* narrows. */
|
|
154
|
+
export const conditionLabel = (condition: ResolvedCondition): string => {
|
|
155
|
+
const parts = Array.filter(
|
|
156
|
+
[condition.who, condition.device, condition.locale],
|
|
157
|
+
axis => axis !== 'any',
|
|
158
|
+
)
|
|
159
|
+
return parts.length === 0 ? 'everyone' : parts.join(' · ')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ============================================================
|
|
163
|
+
The reading-B resolution.
|
|
164
|
+
============================================================ */
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Where a resolved placement was declared. `rule` carries the label alongside
|
|
168
|
+
* the id so a badge or a check sentence can name the rule the way a human
|
|
169
|
+
* does ("from Product pages") without a join back into the topology — never
|
|
170
|
+
* show a storage key where a human expects a name.
|
|
171
|
+
*/
|
|
172
|
+
export type PlacementSource =
|
|
173
|
+
| { readonly _tag: 'route' }
|
|
174
|
+
| { readonly _tag: 'rule'; readonly ruleId: string; readonly label: string }
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One override this placement lost: who beat it, and WHERE. `where` is the
|
|
178
|
+
* overlap of the two conditions — under reading B the winner only takes that
|
|
179
|
+
* region, so the badge names it ("overridden for fr") rather than claiming
|
|
180
|
+
* the whole placement. `entire` is true when the overlap covers this
|
|
181
|
+
* placement's whole condition — it renders for nobody.
|
|
182
|
+
*/
|
|
183
|
+
export type PlacementOverride = {
|
|
184
|
+
readonly by: PlacementSource
|
|
185
|
+
readonly winnerId: string
|
|
186
|
+
readonly where: ResolvedCondition
|
|
187
|
+
readonly entire: boolean
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* - `active` — renders wherever its condition holds.
|
|
192
|
+
* - `partially-overridden` — a later layer took part of its region; it still
|
|
193
|
+
* renders for everybody outside the overlap, so
|
|
194
|
+
* the checks still check it.
|
|
195
|
+
* - `overridden` — some later placement covers its whole region;
|
|
196
|
+
* it renders for nobody and raises no issues.
|
|
197
|
+
*/
|
|
198
|
+
export type ResolvedState = 'active' | 'partially-overridden' | 'overridden'
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* One placement as the RESOLUTION sees it: record defaults made explicit
|
|
202
|
+
* (condition axes to `'any'`, `envs` to all three, `values` to `{}`),
|
|
203
|
+
* provenance attached, override state decided. The losers of an override stay
|
|
204
|
+
* in this list — the screen renders them struck-through, not deleted — which
|
|
205
|
+
* is why `state` exists instead of the losers being filtered out.
|
|
206
|
+
*
|
|
207
|
+
* `id` is UNIQUE within one resolution and stable across re-resolutions of the
|
|
208
|
+
* same topology — the Pages screen selects by it across polls and env
|
|
209
|
+
* switches, `overriddenBy.winnerId` matches on it, and every check issue is
|
|
210
|
+
* routed to a placement by it, so a shared id is simultaneously two cards
|
|
211
|
+
* highlighted for one selection, one inspector merging two placements' issues,
|
|
212
|
+
* and a check-4 warning silently suppressed (`overridePair` matches
|
|
213
|
+
* `winnerId === other.id`).
|
|
214
|
+
*
|
|
215
|
+
* It is the declared id when the record has one AND that id is declared
|
|
216
|
+
* exactly once in this resolution; otherwise it is (or is qualified by) a
|
|
217
|
+
* synthesized fallback — `/price|0|route:price-ticker@main-slot#0`: route
|
|
218
|
+
* path, layer, source key, tag, slot, index within its layer. See
|
|
219
|
+
* `resolvedIdOf` for the collision rule.
|
|
220
|
+
*/
|
|
221
|
+
export type ResolvedPlacement = {
|
|
222
|
+
readonly id: string
|
|
223
|
+
readonly tag: string
|
|
224
|
+
readonly slotId: string
|
|
225
|
+
readonly source: PlacementSource
|
|
226
|
+
readonly condition: ResolvedCondition
|
|
227
|
+
readonly envs: ReadonlyArray<WireEnv>
|
|
228
|
+
readonly values: Readonly<Record<string, string>>
|
|
229
|
+
readonly span: Option.Option<PlacementSpan>
|
|
230
|
+
readonly order: Option.Option<number>
|
|
231
|
+
readonly state: ResolvedState
|
|
232
|
+
readonly overriddenBy: ReadonlyArray<PlacementOverride>
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ALL_ENVS: ReadonlyArray<WireEnv> = ['dev', 'preview', 'prod']
|
|
236
|
+
|
|
237
|
+
/** A placement mid-resolution: flattened out of its layer, defaults applied,
|
|
238
|
+
* id not yet settled, contest not yet run. */
|
|
239
|
+
type LayeredPlacement = {
|
|
240
|
+
readonly layer: number
|
|
241
|
+
/** The record's own `id`, or `undefined` for a Phase-4 record without one. */
|
|
242
|
+
readonly declaredId: string | undefined
|
|
243
|
+
/** The synthesized key — unique within a resolution BY CONSTRUCTION, see
|
|
244
|
+
* `fallbackId`. */
|
|
245
|
+
readonly fallbackId: string
|
|
246
|
+
readonly source: PlacementSource
|
|
247
|
+
readonly condition: ResolvedCondition
|
|
248
|
+
readonly placement: TopologyPlacement
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** …with its id settled against the whole layered set. */
|
|
252
|
+
type IdentifiedPlacement = LayeredPlacement & { readonly id: string }
|
|
253
|
+
|
|
254
|
+
const sourceKey = (source: PlacementSource): string =>
|
|
255
|
+
source._tag === 'route' ? 'route' : source.ruleId
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The synthesized key, unique within one resolution by construction.
|
|
259
|
+
*
|
|
260
|
+
* `layer` is what makes that a proof rather than a hope: layers are numbered
|
|
261
|
+
* 0…n over the MATCHING rules in declared order with the route's own layer
|
|
262
|
+
* last, so `(layer, index)` addresses exactly one placement — which is why the
|
|
263
|
+
* layer number is here even though `sourceKey` reads better. Without it a rule
|
|
264
|
+
* whose id is literally `"route"`, or two rules sharing an id, synthesize the
|
|
265
|
+
* route layer's keys verbatim.
|
|
266
|
+
*
|
|
267
|
+
* `path` is what makes it unique ACROSS routes, which the demo host's derived
|
|
268
|
+
* overview needs: that route resolves as the union of every component route's
|
|
269
|
+
* resolution, and a rule placement matching all six of them would otherwise
|
|
270
|
+
* carry one id six times.
|
|
271
|
+
*
|
|
272
|
+
* `index` is within the UNFILTERED layer, so a placement's id does not move
|
|
273
|
+
* when the env filter drops a sibling — the Pages screen keeps its selection
|
|
274
|
+
* across an env switch.
|
|
275
|
+
*/
|
|
276
|
+
const fallbackId = (
|
|
277
|
+
path: string,
|
|
278
|
+
layer: number,
|
|
279
|
+
source: PlacementSource,
|
|
280
|
+
placement: TopologyPlacement,
|
|
281
|
+
index: number,
|
|
282
|
+
): string =>
|
|
283
|
+
`${path}|${layer}|${sourceKey(source)}:${placement.tag}@${placement.slotId}#${index}`
|
|
284
|
+
|
|
285
|
+
const layerPlacements = (
|
|
286
|
+
path: string,
|
|
287
|
+
layer: number,
|
|
288
|
+
source: PlacementSource,
|
|
289
|
+
placements: ReadonlyArray<TopologyPlacement>,
|
|
290
|
+
): ReadonlyArray<LayeredPlacement> =>
|
|
291
|
+
placements.map((placement, index) => ({
|
|
292
|
+
layer,
|
|
293
|
+
declaredId: placement.id,
|
|
294
|
+
fallbackId: fallbackId(path, layer, source, placement, index),
|
|
295
|
+
source,
|
|
296
|
+
condition: resolveCondition(placement.condition),
|
|
297
|
+
placement,
|
|
298
|
+
}))
|
|
299
|
+
|
|
300
|
+
/** How many times each DECLARED id appears across the whole layered set —
|
|
301
|
+
* computed before the env filter so the collision verdict, and therefore the
|
|
302
|
+
* id, does not change when the environment does. */
|
|
303
|
+
const declaredIdCounts = (
|
|
304
|
+
items: ReadonlyArray<LayeredPlacement>,
|
|
305
|
+
): ReadonlyMap<string, number> => {
|
|
306
|
+
const counts = new Map<string, number>()
|
|
307
|
+
items.forEach(item => {
|
|
308
|
+
if (item.declaredId !== undefined) {
|
|
309
|
+
counts.set(item.declaredId, (counts.get(item.declaredId) ?? 0) + 1)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
return counts
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The collision rule, chosen deliberately (2026-08-18 review finding 2):
|
|
317
|
+
* **duplicate declared ids are DISAMBIGUATED, symmetrically, not rejected and
|
|
318
|
+
* not first-wins.**
|
|
319
|
+
*
|
|
320
|
+
* - Rejecting an authoring typo would turn it into a blank page in both
|
|
321
|
+
* shells' `activate` — this repo's signature failure mode — for a mistake
|
|
322
|
+
* that has a perfectly good rendering.
|
|
323
|
+
* - A check reporting the duplicate would still hand every consumer the
|
|
324
|
+
* colliding ids; the contest's `winnerId` matching and the checks' own
|
|
325
|
+
* issue routing would stay wrong, and the shells never run the checks.
|
|
326
|
+
* - First-wins would let one of the pair keep the declared id, which reads as
|
|
327
|
+
* authoritative and hides that a second placement is masquerading as it.
|
|
328
|
+
*
|
|
329
|
+
* So BOTH members of a collision are qualified with their synthesized key.
|
|
330
|
+
* The result is deterministic in the topology alone: re-resolving the same
|
|
331
|
+
* topology yields the same ids, so the screen's selection survives the poll.
|
|
332
|
+
*/
|
|
333
|
+
const resolvedIdOf = (
|
|
334
|
+
item: LayeredPlacement,
|
|
335
|
+
counts: ReadonlyMap<string, number>,
|
|
336
|
+
): string => {
|
|
337
|
+
if (item.declaredId === undefined) {
|
|
338
|
+
return item.fallbackId
|
|
339
|
+
}
|
|
340
|
+
return (counts.get(item.declaredId) ?? 0) > 1
|
|
341
|
+
? `${item.declaredId}~${item.fallbackId}`
|
|
342
|
+
: item.declaredId
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The reading-B resolution — the function
|
|
347
|
+
* `wiki/framework/composition/question-does-the-override-contest-consider-condition.md`
|
|
348
|
+
* describes, pinned:
|
|
349
|
+
*
|
|
350
|
+
* 1. Layers broadest-first: every matching rule in DECLARED order, then the
|
|
351
|
+
* route's own placements last. A topology with no `rules` key resolves to
|
|
352
|
+
* exactly its route mounts — the identity, byte-identical to Phase 4.
|
|
353
|
+
* 2. A placement is filtered out when its `envs` (default all three) exclude
|
|
354
|
+
* `env` — a placement that does not exist in this environment cannot
|
|
355
|
+
* render here and cannot override anything here either.
|
|
356
|
+
* 3. The contest: a placement in a strictly LATER layer with the same
|
|
357
|
+
* `(tag, slotId)` whose condition overlaps an earlier one overrides it —
|
|
358
|
+
* but only WHERE they overlap. The loser survives where not overlapped
|
|
359
|
+
* (`partially-overridden`), and is `overridden` only when some winner
|
|
360
|
+
* covers its whole condition. Two placements in the SAME layer never
|
|
361
|
+
* override each other — they are peers at one specificity, and colliding
|
|
362
|
+
* peers are the checks' business (checks 4/7), not the contest's.
|
|
363
|
+
* 4. Losers are KEPT in the output with provenance and `overriddenBy` — the
|
|
364
|
+
* spec renders them struck-through with a badge naming the condition, not
|
|
365
|
+
* deleted.
|
|
366
|
+
*/
|
|
367
|
+
export const resolvePlacements = (
|
|
368
|
+
topology: HostTopology,
|
|
369
|
+
path: string,
|
|
370
|
+
env: WireEnv,
|
|
371
|
+
): ReadonlyArray<ResolvedPlacement> => {
|
|
372
|
+
const matchingRules = Array.filter(topology.rules ?? [], rule =>
|
|
373
|
+
ruleMatches(rule, path),
|
|
374
|
+
)
|
|
375
|
+
const routeMounts = Option.match(
|
|
376
|
+
Array.findFirst(topology.routes, route => route.path === path),
|
|
377
|
+
{
|
|
378
|
+
onNone: (): ReadonlyArray<TopologyPlacement> => [],
|
|
379
|
+
onSome: route => route.mounts,
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
const ruleLayers = matchingRules.map((rule, index) =>
|
|
384
|
+
layerPlacements(
|
|
385
|
+
path,
|
|
386
|
+
index,
|
|
387
|
+
{ _tag: 'rule', ruleId: rule.id, label: rule.label },
|
|
388
|
+
rule.placements,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
const routeLayer = layerPlacements(
|
|
392
|
+
path,
|
|
393
|
+
matchingRules.length,
|
|
394
|
+
{ _tag: 'route' },
|
|
395
|
+
routeMounts,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
// Ids settle over the WHOLE layered set, before the env filter — see
|
|
399
|
+
// `declaredIdCounts`. Every downstream reference (`winnerId`, the checks'
|
|
400
|
+
// `placementId`, the screen's selection) then names exactly one placement.
|
|
401
|
+
const all = [...Array.flatten(ruleLayers), ...routeLayer]
|
|
402
|
+
const counts = declaredIdCounts(all)
|
|
403
|
+
const layered: ReadonlyArray<IdentifiedPlacement> = Array.filter(
|
|
404
|
+
all.map(item => ({ ...item, id: resolvedIdOf(item, counts) })),
|
|
405
|
+
item => Array.contains(item.placement.envs ?? ALL_ENVS, env),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return layered.map(item => {
|
|
409
|
+
// `getSomes` over `map`, not `filterMap` — v4's `filterMap` wants a
|
|
410
|
+
// `Result` and silently drops `Option`s (the AGENTS.md trap).
|
|
411
|
+
const overriddenBy = Array.getSomes(
|
|
412
|
+
Array.map(layered, (other): Option.Option<PlacementOverride> =>
|
|
413
|
+
other.layer > item.layer &&
|
|
414
|
+
other.placement.tag === item.placement.tag &&
|
|
415
|
+
other.placement.slotId === item.placement.slotId &&
|
|
416
|
+
conditionsOverlap(other.condition, item.condition)
|
|
417
|
+
? Option.some({
|
|
418
|
+
by: other.source,
|
|
419
|
+
winnerId: other.id,
|
|
420
|
+
where: conditionIntersection(other.condition, item.condition),
|
|
421
|
+
entire: conditionCovers(other.condition, item.condition),
|
|
422
|
+
})
|
|
423
|
+
: Option.none(),
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
const state: ResolvedState = Array.some(
|
|
427
|
+
overriddenBy,
|
|
428
|
+
override => override.entire,
|
|
429
|
+
)
|
|
430
|
+
? 'overridden'
|
|
431
|
+
: overriddenBy.length > 0
|
|
432
|
+
? 'partially-overridden'
|
|
433
|
+
: 'active'
|
|
434
|
+
return {
|
|
435
|
+
id: item.id,
|
|
436
|
+
tag: item.placement.tag,
|
|
437
|
+
slotId: item.placement.slotId,
|
|
438
|
+
source: item.source,
|
|
439
|
+
condition: item.condition,
|
|
440
|
+
envs: item.placement.envs ?? ALL_ENVS,
|
|
441
|
+
values: item.placement.values ?? {},
|
|
442
|
+
span: Option.fromNullishOr(item.placement.span),
|
|
443
|
+
order: Option.fromNullishOr(item.placement.order),
|
|
444
|
+
state,
|
|
445
|
+
overriddenBy,
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Option, Schema as S } from 'effect'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { HostSlotManifest } from './slots.ts'
|
|
5
|
+
|
|
6
|
+
const decodeManifest = S.decodeUnknownOption(HostSlotManifest)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A real-shaped manifest, exercising every slot kind: the demo host's
|
|
10
|
+
* per-section `band`s, the platform's three `rail`s (fixed-width sidebar per
|
|
11
|
+
* `apps/platform/index.html`), a `bar`, and a `grid` — the grid kind is
|
|
12
|
+
* fixture-only per the design page ("no invented grid slots" on real hosts).
|
|
13
|
+
*/
|
|
14
|
+
const RAW_MANIFEST = {
|
|
15
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '1.4.0' },
|
|
16
|
+
slots: [
|
|
17
|
+
{ id: 'top-bar', kind: 'bar', row: 0 },
|
|
18
|
+
{ id: 'hero-band', kind: 'band', row: 1 },
|
|
19
|
+
{ id: 'app-list-slot', kind: 'rail', row: 2, width: '280px' },
|
|
20
|
+
{ id: 'main-grid', kind: 'grid', row: 2, capacity: 4 },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The failure mode: the slot manifest is what placement check 2 joins
|
|
25
|
+
// `slotId` against — a manifest the schema silently mangles (a dropped
|
|
26
|
+
// `width`, a defaulted `capacity`) or loosely admits (an invented kind) makes
|
|
27
|
+
// check 2 lie in both directions: real slots reported missing, or misspelled
|
|
28
|
+
// kinds laid out as something they are not.
|
|
29
|
+
describe('HostSlotManifest', () => {
|
|
30
|
+
test('decodes a real-shaped manifest byte-identically', () => {
|
|
31
|
+
const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
|
|
32
|
+
// `toEqual(Option.some(parsed))` pins that decoding neither drops the
|
|
33
|
+
// optional `width`/`capacity` keys nor invents defaults for them.
|
|
34
|
+
expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('rejects a slot whose kind is not bar, band, rail or grid', () => {
|
|
38
|
+
const parsed: unknown = JSON.parse(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
theme: RAW_MANIFEST.theme,
|
|
41
|
+
slots: [{ id: 'top-ribbon', kind: 'ribbon', row: 0 }],
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
expect(Option.isNone(decodeManifest(parsed))).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
})
|
package/src/slots.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The host SLOT MANIFEST — a host declaring, as data, where MicroDots may go.
|
|
5
|
+
*
|
|
6
|
+
* Phase 5 makes placement data the way Phase 4 made wires data, and the slot
|
|
7
|
+
* manifest is the half the HOST owns: the theme's declared slots, versioned.
|
|
8
|
+
* It lives inside `host-topology.json` as `HostTopology.slotManifest` rather
|
|
9
|
+
* than in a second per-host file — the topology is already "a whole host, as
|
|
10
|
+
* data", and a second file would be a second thing to drift. See
|
|
11
|
+
* `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`
|
|
12
|
+
* work item 1.
|
|
13
|
+
*
|
|
14
|
+
* The `theme.version` is load-bearing: placement check 2's "valid under an
|
|
15
|
+
* earlier theme version" sentence cannot exist without a version to have been
|
|
16
|
+
* earlier than.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What shape a slot is, which decides how placements lay out inside it:
|
|
21
|
+
*
|
|
22
|
+
* - `bar` / `band` — a full-width horizontal strip; placements stack, position
|
|
23
|
+
* is `order`.
|
|
24
|
+
* - `rail` — a fixed-width vertical column; placements stack full-width.
|
|
25
|
+
* - `grid` — twelve columns; placements carry a `span` (12/8/6/4/3 of 12).
|
|
26
|
+
*/
|
|
27
|
+
export const SlotKind = S.Literals(['bar', 'band', 'rail', 'grid'])
|
|
28
|
+
export type SlotKind = typeof SlotKind.Type
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One declared slot. `row` is the vertical band the slot occupies — slots
|
|
32
|
+
* sharing a row sit side by side (the fixture's sidebar / main / aside).
|
|
33
|
+
* `width` is a CSS length for a fixed `rail` (`'280px'`); `capacity` is how
|
|
34
|
+
* many placements the slot is meant to hold. Both `optionalKey` (not
|
|
35
|
+
* `optional`) so the decoded Type is exact-optional under
|
|
36
|
+
* `exactOptionalPropertyTypes`, matching the rest of the topology schemas.
|
|
37
|
+
*/
|
|
38
|
+
export const SlotSpec = S.Struct({
|
|
39
|
+
id: S.String,
|
|
40
|
+
kind: SlotKind,
|
|
41
|
+
row: S.Number,
|
|
42
|
+
width: S.optionalKey(S.String),
|
|
43
|
+
capacity: S.optionalKey(S.Number),
|
|
44
|
+
})
|
|
45
|
+
export type SlotSpec = typeof SlotSpec.Type
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The whole manifest: which theme (at which version) declared which slots.
|
|
49
|
+
* Placement check 2 joins `TopologyPlacement.slotId` against `slots[].id`; a
|
|
50
|
+
* topology WITHOUT a slot manifest predates this record, and the checks
|
|
51
|
+
* degrade honestly — slot checks are reported unverifiable, never silently
|
|
52
|
+
* green (see `./placementChecks.ts`).
|
|
53
|
+
*/
|
|
54
|
+
export const HostSlotManifest = S.Struct({
|
|
55
|
+
theme: S.Struct({ name: S.String, version: S.String }),
|
|
56
|
+
slots: S.Array(SlotSpec),
|
|
57
|
+
})
|
|
58
|
+
export type HostSlotManifest = typeof HostSlotManifest.Type
|