@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.
@@ -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
+ }
@@ -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
+ }