@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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { Array, Option, Order } from 'effect'
|
|
2
|
+
|
|
3
|
+
import type { ManifestTag, MicroDotManifest } from '@bespokeagentics/microdots-element'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ResolvedPlacement,
|
|
7
|
+
conditionLabel,
|
|
8
|
+
conditionsOverlap,
|
|
9
|
+
} from './rules.ts'
|
|
10
|
+
import type { HostSlotManifest } from './slots.ts'
|
|
11
|
+
import type { WireEnv } from './wire.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The placement CHECKS — six of the spec's seven, run as a pure lint engine
|
|
15
|
+
* over `resolvePlacements`' output. Phase 5 work item 4 of
|
|
16
|
+
* `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`;
|
|
17
|
+
* the seven checks' severities and sentences are the handoff spec's, lifted
|
|
18
|
+
* verbatim where the fixture wrote them. Check 5 (anchor selector crawl) is
|
|
19
|
+
* deferred with anchors.
|
|
20
|
+
*
|
|
21
|
+
* Reading B's qualifier lands here exactly as
|
|
22
|
+
* `question-does-the-override-contest-consider-condition.md` predicted: a
|
|
23
|
+
* placement overridden ENTIRELY raises no issues — it renders for nobody, so
|
|
24
|
+
* its problems are not the user's problem — but a PARTIALLY overridden one is
|
|
25
|
+
* still checked, because it still renders for somebody.
|
|
26
|
+
*
|
|
27
|
+
* Derived fresh on every call, never stored — `deriveWireState`'s precedent.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type PlacementCheckId =
|
|
31
|
+
| 'not-deployed'
|
|
32
|
+
| 'slot-not-in-theme'
|
|
33
|
+
| 'required-attribute-unset'
|
|
34
|
+
| 'mounts-twice-overlapping'
|
|
35
|
+
| 'over-weight-budget'
|
|
36
|
+
| 'placed-twice-disjoint'
|
|
37
|
+
|
|
38
|
+
export type PlacementCheckSeverity = 'error' | 'warning' | 'note'
|
|
39
|
+
|
|
40
|
+
/** One finding: which check, on which placement, what happens if ignored,
|
|
41
|
+
* and what to do about it — the spec's inspector row, as data. */
|
|
42
|
+
export type PlacementIssue = {
|
|
43
|
+
readonly check: PlacementCheckId
|
|
44
|
+
readonly severity: PlacementCheckSeverity
|
|
45
|
+
readonly placementId: string
|
|
46
|
+
readonly consequence: string
|
|
47
|
+
readonly action: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A check that could NOT run — a missing slot manifest, a tag with no emitted
|
|
52
|
+
* manifest. Reported instead of silently skipped: an unverifiable check is
|
|
53
|
+
* not a green check, and the screen must be able to say so.
|
|
54
|
+
*/
|
|
55
|
+
export type UnverifiableCheck = {
|
|
56
|
+
readonly check: PlacementCheckId
|
|
57
|
+
readonly reason: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PlacementCheckReport = {
|
|
61
|
+
readonly issues: ReadonlyArray<PlacementIssue>
|
|
62
|
+
readonly unverifiable: ReadonlyArray<UnverifiableCheck>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type PlacementCheckContext = {
|
|
66
|
+
/** The emitted MicroDot manifests — whole manifests, not just tags, because
|
|
67
|
+
* check 1 reads `service.deployedApiUrl` and check 6 reads
|
|
68
|
+
* `bundle.gzipBytes`, both of which live beside the tags. */
|
|
69
|
+
readonly manifests: ReadonlyArray<MicroDotManifest>
|
|
70
|
+
/** Absent when the topology predates the slot manifest — check 2 is then
|
|
71
|
+
* reported unverifiable rather than silently green. */
|
|
72
|
+
readonly slotManifest?: HostSlotManifest
|
|
73
|
+
readonly env: WireEnv
|
|
74
|
+
/** The page-weight budget in KB (KiB). The spec's default is 180. */
|
|
75
|
+
readonly budgetKb?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_BUDGET_KB = 180
|
|
79
|
+
const BYTES_PER_KB = 1024
|
|
80
|
+
|
|
81
|
+
const kbOf = (bytes: number): number => Math.round(bytes / BYTES_PER_KB)
|
|
82
|
+
|
|
83
|
+
/** The manifest that ships a tag, with the tag's own surface beside it. */
|
|
84
|
+
const manifestForTag = (
|
|
85
|
+
manifests: ReadonlyArray<MicroDotManifest>,
|
|
86
|
+
tag: string,
|
|
87
|
+
): Option.Option<{
|
|
88
|
+
readonly manifest: MicroDotManifest
|
|
89
|
+
readonly tagSpec: ManifestTag
|
|
90
|
+
}> =>
|
|
91
|
+
Array.findFirst(manifests, manifest =>
|
|
92
|
+
Option.map(
|
|
93
|
+
Array.findFirst(manifest.tags, tagSpec => tagSpec.tag === tag),
|
|
94
|
+
tagSpec => ({ manifest, tagSpec }),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The DISTINCT tags among the rendered placements that no manifest ships.
|
|
100
|
+
*
|
|
101
|
+
* Deduped, because "no manifest for X" is a fact about the tag, not about the
|
|
102
|
+
* placement: three placements of one manifest-less tag on a route used to
|
|
103
|
+
* produce three byte-identical unverifiable lines in checks 1 and 3 while
|
|
104
|
+
* check 6 (which deduped from the start) produced one. An unverifiable list
|
|
105
|
+
* that repeats itself reads as three separate gaps and buries the others.
|
|
106
|
+
*/
|
|
107
|
+
const tagsWithoutManifest = (
|
|
108
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
109
|
+
manifests: ReadonlyArray<MicroDotManifest>,
|
|
110
|
+
): ReadonlyArray<string> =>
|
|
111
|
+
Array.dedupe(
|
|
112
|
+
Array.getSomes(
|
|
113
|
+
Array.map(rendered, (placement): Option.Option<string> =>
|
|
114
|
+
Option.isNone(manifestForTag(manifests, placement.tag))
|
|
115
|
+
? Option.some(placement.tag)
|
|
116
|
+
: Option.none(),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/* ============================================================
|
|
122
|
+
Check 1 — not deployed to this environment. Error.
|
|
123
|
+
|
|
124
|
+
Reachability is APPROXIMATED from the manifest, the phase's recorded
|
|
125
|
+
approximation refined by Phase 7: `dev` is always reachable (the local
|
|
126
|
+
service exists by construction), `preview`/`prod` are reachable iff the
|
|
127
|
+
manifest carries `service.deployedApiUrl`.
|
|
128
|
+
============================================================ */
|
|
129
|
+
|
|
130
|
+
const checkNotDeployed = (
|
|
131
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
132
|
+
context: PlacementCheckContext,
|
|
133
|
+
): PlacementCheckReport => {
|
|
134
|
+
if (context.env === 'dev') {
|
|
135
|
+
return { issues: [], unverifiable: [] }
|
|
136
|
+
}
|
|
137
|
+
const issues = Array.getSomes(
|
|
138
|
+
Array.map(rendered, (placement): Option.Option<PlacementIssue> =>
|
|
139
|
+
Option.flatMap(manifestForTag(context.manifests, placement.tag), found =>
|
|
140
|
+
found.manifest.service.deployedApiUrl === undefined
|
|
141
|
+
? Option.some({
|
|
142
|
+
check: 'not-deployed',
|
|
143
|
+
severity: 'error',
|
|
144
|
+
placementId: placement.id,
|
|
145
|
+
// The fixture's sentence: the build stops short of this
|
|
146
|
+
// environment. Under the manifest approximation a dot without a
|
|
147
|
+
// deployedApiUrl stops at dev.
|
|
148
|
+
consequence: `Build ${found.manifest.build} stops at dev. In this environment the tag resolves to nothing and the slot collapses.`,
|
|
149
|
+
action: `Open build ${found.manifest.build}`,
|
|
150
|
+
})
|
|
151
|
+
: Option.none(),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
const unverifiable = tagsWithoutManifest(rendered, context.manifests).map(
|
|
156
|
+
(tag): UnverifiableCheck => ({
|
|
157
|
+
check: 'not-deployed',
|
|
158
|
+
reason: `no manifest for ${tag} — deployment reachability cannot be verified`,
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
return { issues, unverifiable }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ============================================================
|
|
165
|
+
Check 2 — slot not in the theme. Error.
|
|
166
|
+
|
|
167
|
+
The message carries the manifest's `theme@version`, so "valid under an
|
|
168
|
+
earlier theme version" is sayable. An absent slot manifest makes the whole
|
|
169
|
+
check unverifiable — reported, never silently green.
|
|
170
|
+
============================================================ */
|
|
171
|
+
|
|
172
|
+
const checkSlotInTheme = (
|
|
173
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
174
|
+
slotManifest: HostSlotManifest | undefined,
|
|
175
|
+
): PlacementCheckReport => {
|
|
176
|
+
if (slotManifest === undefined) {
|
|
177
|
+
return {
|
|
178
|
+
issues: [],
|
|
179
|
+
unverifiable: [
|
|
180
|
+
{
|
|
181
|
+
check: 'slot-not-in-theme',
|
|
182
|
+
reason:
|
|
183
|
+
'the topology declares no slotManifest — slot checks skipped, not passed',
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const theme = `${slotManifest.theme.name}@${slotManifest.theme.version}`
|
|
189
|
+
const issues = Array.getSomes(
|
|
190
|
+
Array.map(rendered, (placement): Option.Option<PlacementIssue> =>
|
|
191
|
+
Array.some(slotManifest.slots, slot => slot.id === placement.slotId)
|
|
192
|
+
? Option.none()
|
|
193
|
+
: Option.some({
|
|
194
|
+
check: 'slot-not-in-theme',
|
|
195
|
+
severity: 'error',
|
|
196
|
+
placementId: placement.id,
|
|
197
|
+
consequence: `Slot ${placement.slotId} is not in ${theme}. The theme manifest declares ${slotManifest.slots.length} slots and this is not one of them. It was valid in an earlier theme version.`,
|
|
198
|
+
action: 'Move it to a declared slot',
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
return { issues, unverifiable: [] }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ============================================================
|
|
206
|
+
Check 3 — required attribute unset. Warning.
|
|
207
|
+
|
|
208
|
+
A manifest-`required` attribute with no default and `ownership: 'dot'`,
|
|
209
|
+
with no value in the resolved placement's supplied set. `environment` and
|
|
210
|
+
`host-input` attributes are exempt — the loader and the host supply those.
|
|
211
|
+
An empty-string value counts as unset: the loader drops empty overrides,
|
|
212
|
+
so `''` never reaches the element as a value.
|
|
213
|
+
============================================================ */
|
|
214
|
+
|
|
215
|
+
const checkRequiredAttributes = (
|
|
216
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
217
|
+
manifests: ReadonlyArray<MicroDotManifest>,
|
|
218
|
+
): PlacementCheckReport => {
|
|
219
|
+
const issues = Array.getSomes(
|
|
220
|
+
Array.map(rendered, (placement): Option.Option<PlacementIssue> =>
|
|
221
|
+
Option.flatMap(manifestForTag(manifests, placement.tag), found => {
|
|
222
|
+
const missing = Array.filter(
|
|
223
|
+
found.tagSpec.attributes,
|
|
224
|
+
attribute =>
|
|
225
|
+
attribute.required &&
|
|
226
|
+
attribute.default === undefined &&
|
|
227
|
+
attribute.ownership === 'dot' &&
|
|
228
|
+
(placement.values[attribute.name] === undefined ||
|
|
229
|
+
placement.values[attribute.name] === ''),
|
|
230
|
+
)
|
|
231
|
+
if (missing.length === 0) {
|
|
232
|
+
return Option.none()
|
|
233
|
+
}
|
|
234
|
+
const names = missing.map(attribute => attribute.name).join(', ')
|
|
235
|
+
const [isAre, itThem] =
|
|
236
|
+
missing.length > 1 ? ['are', 'them'] : ['is', 'it']
|
|
237
|
+
return Option.some({
|
|
238
|
+
check: 'required-attribute-unset',
|
|
239
|
+
severity: 'warning',
|
|
240
|
+
placementId: placement.id,
|
|
241
|
+
consequence: `${names} ${isAre} unset. ${placement.tag} declares ${itThem} required. Nothing on this route sets a value, so the element mounts and errors on first render.`,
|
|
242
|
+
action: 'Set a value above',
|
|
243
|
+
})
|
|
244
|
+
}),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
const unverifiable = tagsWithoutManifest(rendered, manifests).map(
|
|
248
|
+
(tag): UnverifiableCheck => ({
|
|
249
|
+
check: 'required-attribute-unset',
|
|
250
|
+
reason: `no manifest for ${tag} — its required attributes cannot be verified`,
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
return { issues, unverifiable }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ============================================================
|
|
257
|
+
Checks 4 and 7 — the same dot twice on the route. Warning / note.
|
|
258
|
+
|
|
259
|
+
Computed over the RESOLUTION's output, so reading B's cross-level twins
|
|
260
|
+
fire. A pair where one member overrides the other is NOT a twin pair: the
|
|
261
|
+
override already resolved that collision — only one of them renders for
|
|
262
|
+
any given visitor — and its badge, not a duplicate warning, is how the
|
|
263
|
+
screen tells that story. Every other same-tag pair splits on condition
|
|
264
|
+
overlap: overlapping → check 4 (two instances run side by side),
|
|
265
|
+
disjoint → check 7 (legitimate variant work).
|
|
266
|
+
============================================================ */
|
|
267
|
+
|
|
268
|
+
const overridePair = (a: ResolvedPlacement, b: ResolvedPlacement): boolean =>
|
|
269
|
+
Array.some(a.overriddenBy, override => override.winnerId === b.id) ||
|
|
270
|
+
Array.some(b.overriddenBy, override => override.winnerId === a.id)
|
|
271
|
+
|
|
272
|
+
const sourceName = (placement: ResolvedPlacement): string =>
|
|
273
|
+
placement.source._tag === 'route' ? 'this route' : placement.source.label
|
|
274
|
+
|
|
275
|
+
const checkTwins = (
|
|
276
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
277
|
+
): PlacementCheckReport => {
|
|
278
|
+
const issues = Array.getSomes(
|
|
279
|
+
Array.map(rendered, (placement): Option.Option<PlacementIssue> => {
|
|
280
|
+
const twins = Array.filter(
|
|
281
|
+
rendered,
|
|
282
|
+
other =>
|
|
283
|
+
other !== placement &&
|
|
284
|
+
other.tag === placement.tag &&
|
|
285
|
+
!overridePair(placement, other),
|
|
286
|
+
)
|
|
287
|
+
const clash = Array.filter(twins, other =>
|
|
288
|
+
conditionsOverlap(other.condition, placement.condition),
|
|
289
|
+
)
|
|
290
|
+
const firstClash = Array.head(clash)
|
|
291
|
+
if (Option.isSome(firstClash)) {
|
|
292
|
+
const other = firstClash.value
|
|
293
|
+
return Option.some({
|
|
294
|
+
check: 'mounts-twice-overlapping',
|
|
295
|
+
severity: 'warning',
|
|
296
|
+
placementId: placement.id,
|
|
297
|
+
consequence: `${placement.tag} is also placed in ${other.slotId} (${sourceName(other)}). Both conditions match the same visitor, so two instances run side by side.`,
|
|
298
|
+
action: 'Narrow one condition',
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
const firstTwin = Array.head(twins)
|
|
302
|
+
if (Option.isSome(firstTwin)) {
|
|
303
|
+
const other = firstTwin.value
|
|
304
|
+
return Option.some({
|
|
305
|
+
check: 'placed-twice-disjoint',
|
|
306
|
+
severity: 'note',
|
|
307
|
+
placementId: placement.id,
|
|
308
|
+
consequence: `The other placement renders for ${conditionLabel(other.condition).toLowerCase()}. Only one instance can mount per visit — legitimate variant work, stated so it doesn't read as a mistake.`,
|
|
309
|
+
action: 'No action needed',
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return Option.none()
|
|
313
|
+
}),
|
|
314
|
+
)
|
|
315
|
+
return { issues, unverifiable: [] }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ============================================================
|
|
319
|
+
Check 6 — over the page-weight budget. Warning.
|
|
320
|
+
|
|
321
|
+
Sums DISTINCT bundles' `gzipBytes` over the rendered placements —
|
|
322
|
+
distinct because `loadMicroDot` dedupes the fetch, so two tags from one
|
|
323
|
+
bundle (the workbench's three, bespoke-contact's two) weigh once, where
|
|
324
|
+
the fixture's per-placement sum would double-count. Names the largest.
|
|
325
|
+
The issue lands on the first placement of the largest bundle — the spec
|
|
326
|
+
shows it on the route inspector, and the largest contributor is the
|
|
327
|
+
actionable one.
|
|
328
|
+
============================================================ */
|
|
329
|
+
|
|
330
|
+
const checkWeightBudget = (
|
|
331
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
332
|
+
context: PlacementCheckContext,
|
|
333
|
+
): PlacementCheckReport => {
|
|
334
|
+
const budgetKb = context.budgetKb ?? DEFAULT_BUDGET_KB
|
|
335
|
+
const missingTags = tagsWithoutManifest(rendered, context.manifests)
|
|
336
|
+
const unverifiable: ReadonlyArray<UnverifiableCheck> =
|
|
337
|
+
missingTags.length === 0
|
|
338
|
+
? []
|
|
339
|
+
: [
|
|
340
|
+
{
|
|
341
|
+
check: 'over-weight-budget',
|
|
342
|
+
reason: `no manifest for ${missingTags.join(', ')} — the route weight is a lower bound`,
|
|
343
|
+
},
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
const contributors = Array.dedupeWith(
|
|
347
|
+
Array.getSomes(
|
|
348
|
+
Array.map(rendered, placement =>
|
|
349
|
+
Option.map(
|
|
350
|
+
manifestForTag(context.manifests, placement.tag),
|
|
351
|
+
found => found.manifest,
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
355
|
+
(a, b) => a.name === b.name,
|
|
356
|
+
)
|
|
357
|
+
const totalBytes = Array.reduce(
|
|
358
|
+
contributors,
|
|
359
|
+
0,
|
|
360
|
+
(total, manifest) => total + manifest.bundle.gzipBytes,
|
|
361
|
+
)
|
|
362
|
+
if (totalBytes <= budgetKb * BYTES_PER_KB) {
|
|
363
|
+
return { issues: [], unverifiable }
|
|
364
|
+
}
|
|
365
|
+
if (!Array.isReadonlyArrayNonEmpty(contributors)) {
|
|
366
|
+
// Over budget with no contributors is unreachable for a non-negative
|
|
367
|
+
// budget (no contributors ⇒ zero bytes) — but `Array.max` demands a
|
|
368
|
+
// non-empty array, and this guard proves it without an assertion.
|
|
369
|
+
return { issues: [], unverifiable }
|
|
370
|
+
}
|
|
371
|
+
const largestManifest = Array.max(
|
|
372
|
+
contributors,
|
|
373
|
+
Order.mapInput(
|
|
374
|
+
Order.Number,
|
|
375
|
+
(manifest: MicroDotManifest) => manifest.bundle.gzipBytes,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
const largestTags = new Set(largestManifest.tags.map(tagSpec => tagSpec.tag))
|
|
379
|
+
const carrier = Array.findFirst(rendered, placement =>
|
|
380
|
+
largestTags.has(placement.tag),
|
|
381
|
+
)
|
|
382
|
+
return Option.match(carrier, {
|
|
383
|
+
onNone: (): PlacementCheckReport => ({ issues: [], unverifiable }),
|
|
384
|
+
onSome: (placement): PlacementCheckReport => ({
|
|
385
|
+
issues: [
|
|
386
|
+
{
|
|
387
|
+
check: 'over-weight-budget',
|
|
388
|
+
severity: 'warning',
|
|
389
|
+
placementId: placement.id,
|
|
390
|
+
consequence: `Total bundle weight of everything mounting on this route is ${kbOf(totalBytes)} KB gzipped against a ${budgetKb} KB budget. The largest is ${largestManifest.name} at ${kbOf(largestManifest.bundle.gzipBytes)} KB.`,
|
|
391
|
+
action: `Remove or split ${largestManifest.name}, or raise the budget`,
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
unverifiable,
|
|
395
|
+
}),
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ============================================================
|
|
400
|
+
The engine.
|
|
401
|
+
============================================================ */
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Runs checks 1, 2, 3, 4, 6 and 7 over a resolution's output. Pure: same
|
|
405
|
+
* inputs, same report. Placements overridden ENTIRELY are excluded before
|
|
406
|
+
* any check runs — they render for nobody — while partially-overridden ones
|
|
407
|
+
* are checked like any other, the reading-B qualifier.
|
|
408
|
+
*/
|
|
409
|
+
export const checkPlacements = (
|
|
410
|
+
resolved: ReadonlyArray<ResolvedPlacement>,
|
|
411
|
+
context: PlacementCheckContext,
|
|
412
|
+
): PlacementCheckReport => {
|
|
413
|
+
const rendered = Array.filter(
|
|
414
|
+
resolved,
|
|
415
|
+
placement => placement.state !== 'overridden',
|
|
416
|
+
)
|
|
417
|
+
const reports = [
|
|
418
|
+
checkNotDeployed(rendered, context),
|
|
419
|
+
checkSlotInTheme(rendered, context.slotManifest),
|
|
420
|
+
checkRequiredAttributes(rendered, context.manifests),
|
|
421
|
+
checkTwins(rendered),
|
|
422
|
+
checkWeightBudget(rendered, context),
|
|
423
|
+
]
|
|
424
|
+
return {
|
|
425
|
+
issues: Array.flatMap(reports, report => report.issues),
|
|
426
|
+
unverifiable: Array.flatMap(reports, report => report.unverifiable),
|
|
427
|
+
}
|
|
428
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a host knows about one registered ELEMENT.
|
|
3
|
+
*
|
|
4
|
+
* The cardinality is the part that misleads: `bespoke-contact` is one MicroDot
|
|
5
|
+
* (one contract, one client, one service, one bundle) registering TWO elements,
|
|
6
|
+
* so five MicroDots produce six entries. `findEntry` is keyed by tag
|
|
7
|
+
* accordingly. See `wiki/_schema/glossary.md` for MicroDot vs element.
|
|
8
|
+
*
|
|
9
|
+
* A host knows five things about an element: its tag, where to fetch its bundle,
|
|
10
|
+
* where its API lives locally, where it lives deployed, and the attributes to
|
|
11
|
+
* apply on create. It imports no MicroDot code, so shipping a new version of a
|
|
12
|
+
* MicroDot never requires rebuilding the host.
|
|
13
|
+
*/
|
|
14
|
+
export type MicroDotEntry = {
|
|
15
|
+
readonly tag: string
|
|
16
|
+
readonly bundle: string
|
|
17
|
+
/** Port the service listens on under `bun run dev`. */
|
|
18
|
+
readonly localPort: number
|
|
19
|
+
/** Public HTTPS URL of the deployed service. ABSENT until the dot's first
|
|
20
|
+
* deploy — the manifest ruling — so a host may register a local-only dot
|
|
21
|
+
* (the workbench, until Phase 7's real deploys). `apiUrlFor` fails closed
|
|
22
|
+
* on a non-local page rather than mounting a dot that cannot reach its
|
|
23
|
+
* service. */
|
|
24
|
+
readonly deployedApiUrl?: string
|
|
25
|
+
/** Initial attributes applied when the element is created. */
|
|
26
|
+
readonly attributes: Readonly<Record<string, string>>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A host's whole registry: one entry per element it may mount. */
|
|
30
|
+
export type MicroDotRegistry = ReadonlyArray<MicroDotEntry>
|
|
31
|
+
|
|
32
|
+
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1']
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolved at runtime, not baked in at build time. A deployed HTTPS page cannot
|
|
36
|
+
* call http://localhost — browsers block it as mixed content — so the same build
|
|
37
|
+
* has to point at local services during `bun run dev` and at the deployed
|
|
38
|
+
* services everywhere else.
|
|
39
|
+
*/
|
|
40
|
+
export const apiUrlFor = (entry: MicroDotEntry, hostname: string): string => {
|
|
41
|
+
if (LOCAL_HOSTNAMES.includes(hostname)) {
|
|
42
|
+
return `http://localhost:${entry.localPort}`
|
|
43
|
+
}
|
|
44
|
+
if (entry.deployedApiUrl === undefined) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`"${entry.tag}" has no deployedApiUrl and this page is not local — the dot cannot reach its service from here`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return entry.deployedApiUrl
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A function OF a registry, not of a module-level literal.
|
|
54
|
+
*
|
|
55
|
+
* The demo host's registry used to be a `const` in the same file as this lookup,
|
|
56
|
+
* which made the lookup unusable by any other host — including the cross-origin
|
|
57
|
+
* demo, which therefore grew its own registry-free copy of the loader. Taking
|
|
58
|
+
* the table as an argument is the whole of the extraction.
|
|
59
|
+
*/
|
|
60
|
+
export const findEntry = (
|
|
61
|
+
registry: MicroDotRegistry,
|
|
62
|
+
tag: string,
|
|
63
|
+
): MicroDotEntry | undefined => registry.find(entry => entry.tag === tag)
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Array, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where one MicroDot element goes: its tag and the slot that holds it.
|
|
5
|
+
*
|
|
6
|
+
* NOT a "mount point" — that name belongs to `@bespokeagentics/microdots-element`'s
|
|
7
|
+
* `mountPoint`, which returns the container Foldkit renders into and which
|
|
8
|
+
* `embed` destroys at first paint. A `Placement` is durable authored
|
|
9
|
+
* configuration; the container is transient. Four nested things, named once
|
|
10
|
+
* here:
|
|
11
|
+
*
|
|
12
|
+
* section (route reveals) > slot (Placement.slotId) > element (tag) > container
|
|
13
|
+
*
|
|
14
|
+
* `slot` here is a plain `<div id="…-slot">` in the host's markup. It is NOT a
|
|
15
|
+
* Web Components `<slot>`: nothing in this repo calls `attachShadow`, so there
|
|
16
|
+
* is no shadow root for a real slot to project into.
|
|
17
|
+
*
|
|
18
|
+
* **This is two fields of the twelve the Platform's `Placement` carries** — no
|
|
19
|
+
* environment, condition, span, order, loading strategy, attribute values or
|
|
20
|
+
* anchor selector. See
|
|
21
|
+
* `wiki/framework/composition/gap-placement-is-source-code-not-data.md`.
|
|
22
|
+
*/
|
|
23
|
+
export type Placement = {
|
|
24
|
+
readonly tag: string
|
|
25
|
+
readonly slotId: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A pseudo-route.
|
|
30
|
+
*
|
|
31
|
+
* Routing is hash-based on purpose: a host built as a static bundle served from
|
|
32
|
+
* a CDN-shaped origin needs no server rewrite for `#/chat` the way `/chat`
|
|
33
|
+
* would. A route reveals its sections and mounts its MicroDots; everything else
|
|
34
|
+
* on the page stays put.
|
|
35
|
+
*/
|
|
36
|
+
export type RouteDefinition = {
|
|
37
|
+
readonly path: string
|
|
38
|
+
readonly label: string
|
|
39
|
+
readonly title: string
|
|
40
|
+
readonly sectionIds: ReadonlyArray<string>
|
|
41
|
+
readonly mounts: ReadonlyArray<Placement>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A host's whole route table, plus the route an unknown path lands on.
|
|
46
|
+
*
|
|
47
|
+
* Every function below takes one of these rather than closing over a
|
|
48
|
+
* module-level literal, which is the difference between "the demo host's
|
|
49
|
+
* routes" and "a route table". The demo host builds one in
|
|
50
|
+
* `apps/host/src/routes.ts` and passes it in.
|
|
51
|
+
*/
|
|
52
|
+
export type RouteTable = {
|
|
53
|
+
readonly routes: ReadonlyArray<RouteDefinition>
|
|
54
|
+
/** Where an unrecognised path goes. Usually the overview. */
|
|
55
|
+
readonly fallback: RouteDefinition
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const hrefFor = (route: RouteDefinition): string => `#${route.path}`
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `#/price` → `/price`. Also accepts `#price`, a trailing slash and a query
|
|
62
|
+
* string, because a hand-typed or shared URL arrives in all of those shapes.
|
|
63
|
+
*
|
|
64
|
+
* Independent of any table, so it takes none.
|
|
65
|
+
*/
|
|
66
|
+
export const parseRoutePath = (hash: string): string => {
|
|
67
|
+
const afterHash = hash.startsWith('#') ? hash.slice(1) : hash
|
|
68
|
+
const beforeQuery = Option.getOrElse(
|
|
69
|
+
Option.fromNullishOr(afterHash.split('?')[0]),
|
|
70
|
+
() => '',
|
|
71
|
+
)
|
|
72
|
+
const trimmed = beforeQuery.replace(/\/+$/, '')
|
|
73
|
+
|
|
74
|
+
if (trimmed === '') {
|
|
75
|
+
return '/'
|
|
76
|
+
}
|
|
77
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every section the router owns — the ones it hides when a route excludes them. */
|
|
81
|
+
export const allSectionIds = (table: RouteTable): ReadonlyArray<string> =>
|
|
82
|
+
Array.dedupe(Array.flatMap(table.routes, route => route.sectionIds))
|
|
83
|
+
|
|
84
|
+
export const findRoute = (
|
|
85
|
+
table: RouteTable,
|
|
86
|
+
path: string,
|
|
87
|
+
): Option.Option<RouteDefinition> =>
|
|
88
|
+
Array.findFirst(table.routes, route => route.path === path)
|
|
89
|
+
|
|
90
|
+
/** An unknown path lands on the fallback rather than a blank page. */
|
|
91
|
+
export const routeForHash = (
|
|
92
|
+
table: RouteTable,
|
|
93
|
+
hash: string,
|
|
94
|
+
): RouteDefinition =>
|
|
95
|
+
Option.getOrElse(findRoute(table, parseRoutePath(hash)), () => table.fallback)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The placement for a tag, searched across the whole table.
|
|
99
|
+
*
|
|
100
|
+
* Searched across every route rather than only the fallback: a host whose
|
|
101
|
+
* fallback does not mount everything would otherwise be unable to find a
|
|
102
|
+
* placement for a tag that only appears on one route. The first match wins,
|
|
103
|
+
* which matches the previous behaviour for a table whose fallback is the
|
|
104
|
+
* union of the others.
|
|
105
|
+
*/
|
|
106
|
+
export const findPlacement = (
|
|
107
|
+
table: RouteTable,
|
|
108
|
+
tag: string,
|
|
109
|
+
): Option.Option<Placement> =>
|
|
110
|
+
Array.findFirst(
|
|
111
|
+
Array.flatMap(table.routes, route => route.mounts),
|
|
112
|
+
mount => mount.tag === tag,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Registry tags no route mounts.
|
|
117
|
+
*
|
|
118
|
+
* Adding a MicroDot without adding a route used to be impossible — the host
|
|
119
|
+
* mounted the whole registry — and would now be invisible: the element simply
|
|
120
|
+
* never appears. A host is expected to throw on a non-empty result at startup.
|
|
121
|
+
*/
|
|
122
|
+
export const unroutedTags = (
|
|
123
|
+
table: RouteTable,
|
|
124
|
+
tags: ReadonlyArray<string>,
|
|
125
|
+
): ReadonlyArray<string> => {
|
|
126
|
+
const routed = new Set(
|
|
127
|
+
Array.flatMap(table.routes, route => route.mounts.map(mount => mount.tag)),
|
|
128
|
+
)
|
|
129
|
+
return Array.filter(tags, tag => !routed.has(tag))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Builds a table whose fallback shows everything the component routes show.
|
|
134
|
+
*
|
|
135
|
+
* The landing route derived rather than repeated is what keeps a brokered demo
|
|
136
|
+
* honest: two MicroDots wired to each other are only legible with both halves on
|
|
137
|
+
* screen.
|
|
138
|
+
*/
|
|
139
|
+
export const makeRouteTable = (
|
|
140
|
+
componentRoutes: ReadonlyArray<RouteDefinition>,
|
|
141
|
+
overview: Omit<RouteDefinition, 'sectionIds' | 'mounts'> & {
|
|
142
|
+
readonly leadingSectionIds?: ReadonlyArray<string>
|
|
143
|
+
},
|
|
144
|
+
): RouteTable => {
|
|
145
|
+
const fallback: RouteDefinition = {
|
|
146
|
+
path: overview.path,
|
|
147
|
+
label: overview.label,
|
|
148
|
+
title: overview.title,
|
|
149
|
+
sectionIds: [
|
|
150
|
+
...(overview.leadingSectionIds ?? []),
|
|
151
|
+
...Array.flatMap(componentRoutes, route => route.sectionIds),
|
|
152
|
+
],
|
|
153
|
+
mounts: Array.flatMap(componentRoutes, route => route.mounts),
|
|
154
|
+
}
|
|
155
|
+
return { routes: Array.prepend(componentRoutes, fallback), fallback }
|
|
156
|
+
}
|