@bespokeagentics/microdots-host 0.1.2 → 0.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bespokeagentics/microdots-host",
3
- "version": "0.1.2",
4
- "description": "MicroDots host shell registry, loader, slot manifest and placement checks for mounting MicroDot custom elements. Raw TypeScript source; consume with Bun or Vite.",
3
+ "version": "0.2.0",
4
+ "description": "MicroDots host shell \u2014 registry, loader, slot manifest and placement checks for mounting MicroDot custom elements. Raw TypeScript source; consume with Bun or Vite.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -10,7 +10,7 @@
10
10
  "directory": "packages/microdots-host"
11
11
  },
12
12
  "publishConfig": {
13
- "access": "restricted"
13
+ "access": "public"
14
14
  },
15
15
  "main": "./src/index.ts",
16
16
  "exports": {
@@ -18,10 +18,10 @@
18
18
  "./composition": "./src/compositionSpec.ts"
19
19
  },
20
20
  "dependencies": {
21
- "@bespokeagentics/microdots-authoring": "0.1.1",
22
- "@bespokeagentics/microdots-element": "0.1.1"
21
+ "@bespokeagentics/microdots-authoring": "0.2.0",
22
+ "@bespokeagentics/microdots-element": "0.2.0"
23
23
  },
24
24
  "peerDependencies": {
25
- "effect": "4.0.0-rc.108"
25
+ "effect": "4.0.0-rc.112"
26
26
  }
27
27
  }
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, test } from 'vitest'
2
+
3
+ import { mountAtAnchor } from './anchorMount.ts'
4
+
5
+ /**
6
+ * Anchors mount into host-owned DOM. The properties that fail silently when
7
+ * broken, each pinned here:
8
+ *
9
+ * 1. The container carries an id — Foldkit defects without one and
10
+ * `Runtime.embed` swallows the defect (blank, clean console).
11
+ * 2. The anchor's own children are never touched — the anchor belongs to the
12
+ * host, and `replaceChildren` on it is the loader's stated never.
13
+ * 3. "Not yet" resolves once the anchor appears; "not there" returns a
14
+ * `timed-out` OUTCOME, never a throw and never silence.
15
+ *
16
+ * `bundleUrl` points at an already-defined tag in every case: `loadMicroDot`
17
+ * short-circuits when `customElements.get(tag)` exists, so no import happens
18
+ * and the tests stay hermetic.
19
+ */
20
+
21
+ const TAG = 'anchor-test-dot'
22
+
23
+ // Define the tag once — loadMicroDot then never fetches the bundle.
24
+ if (customElements.get(TAG) === undefined) {
25
+ customElements.define(TAG, class extends HTMLElement {})
26
+ }
27
+
28
+ beforeEach(() => {
29
+ document.body.innerHTML = ''
30
+ })
31
+
32
+ describe('mountAtAnchor', () => {
33
+ test('mounts adjacent to an existing anchor, container id and all', async () => {
34
+ document.body.innerHTML =
35
+ '<section id="hero"><span class="kept">host copy</span></section>'
36
+ const outcome = await mountAtAnchor({
37
+ selector: '#hero',
38
+ tag: TAG,
39
+ bundleUrl: '/unused.js',
40
+ apiUrl: 'http://localhost:9999',
41
+ attributes: { symbol: 'FOLD' },
42
+ })
43
+ expect(outcome._tag).toBe('resolved')
44
+ if (outcome._tag !== 'resolved') return
45
+ // Adjacent (default 'after'): the container is the anchor's next sibling,
46
+ // and the anchor's own children are untouched.
47
+ expect(document.querySelector('#hero + div')).toBe(outcome.container)
48
+ expect(document.querySelector('#hero .kept')?.textContent).toBe('host copy')
49
+ expect(outcome.container.id).not.toBe('')
50
+ expect(outcome.element.tagName.toLowerCase()).toBe(TAG)
51
+ expect(outcome.element.getAttribute('api-url')).toBe(
52
+ 'http://localhost:9999',
53
+ )
54
+ expect(outcome.element.getAttribute('symbol')).toBe('FOLD')
55
+ })
56
+
57
+ test("'inside' appends — the anchor's existing children stay", async () => {
58
+ document.body.innerHTML = '<div id="panel"><p id="host-p">host</p></div>'
59
+ const outcome = await mountAtAnchor({
60
+ selector: '#panel',
61
+ tag: TAG,
62
+ bundleUrl: '/unused.js',
63
+ apiUrl: 'http://localhost:9999',
64
+ position: 'inside',
65
+ })
66
+ expect(outcome._tag).toBe('resolved')
67
+ if (outcome._tag !== 'resolved') return
68
+ expect(outcome.container.parentElement?.id).toBe('panel')
69
+ expect(document.querySelector('#host-p')?.textContent).toBe('host')
70
+ })
71
+
72
+ test('a LATE anchor mounts when it appears — "not yet" is not "not there"', async () => {
73
+ const pending = mountAtAnchor({
74
+ selector: '#late',
75
+ tag: TAG,
76
+ bundleUrl: '/unused.js',
77
+ apiUrl: 'http://localhost:9999',
78
+ timeoutMs: 2_000,
79
+ })
80
+ // The anchor appears after the mount was requested — the embedding-demo
81
+ // shape, whose whole page is written by script after load.
82
+ const late = document.createElement('div')
83
+ late.id = 'late'
84
+ document.body.appendChild(late)
85
+ const outcome = await pending
86
+ expect(outcome._tag).toBe('resolved')
87
+ if (outcome._tag !== 'resolved') return
88
+ expect(document.querySelector('#late + div')).toBe(outcome.container)
89
+ })
90
+
91
+ test('no match by the timeout returns timed-out and mounts nothing', async () => {
92
+ const outcome = await mountAtAnchor({
93
+ selector: '#never',
94
+ tag: TAG,
95
+ bundleUrl: '/unused.js',
96
+ apiUrl: 'http://localhost:9999',
97
+ timeoutMs: 50,
98
+ })
99
+ expect(outcome._tag).toBe('timed-out')
100
+ if (outcome._tag !== 'timed-out') return
101
+ expect(outcome.selector).toBe('#never')
102
+ expect(document.querySelector(TAG)).toBeNull()
103
+ })
104
+
105
+ test('two mounts of one tag get distinct container ids', async () => {
106
+ document.body.innerHTML = '<div id="a"></div><div id="b"></div>'
107
+ const first = await mountAtAnchor({
108
+ selector: '#a',
109
+ tag: TAG,
110
+ bundleUrl: '/unused.js',
111
+ apiUrl: 'http://localhost:9999',
112
+ })
113
+ const second = await mountAtAnchor({
114
+ selector: '#b',
115
+ tag: TAG,
116
+ bundleUrl: '/unused.js',
117
+ apiUrl: 'http://localhost:9999',
118
+ })
119
+ expect(first._tag).toBe('resolved')
120
+ expect(second._tag).toBe('resolved')
121
+ if (first._tag !== 'resolved' || second._tag !== 'resolved') return
122
+ expect(first.container.id).not.toBe(second.container.id)
123
+ })
124
+ })
@@ -0,0 +1,153 @@
1
+ import { loadMicroDot, createMicroDot } from './loader.ts'
2
+
3
+ /**
4
+ * Mounting a MicroDot at a DOM ANCHOR — a CSS selector into host-owned markup
5
+ * — instead of a declared slot. The Pages write-mode plan's D4; the design
6
+ * record is `wiki/framework/mounting/anchor-mounting-into-host-owned-dom.md`.
7
+ *
8
+ * The rules that shape this module, each with its reason:
9
+ *
10
+ * - **Resolve after paint.** The anchor is somebody else's element, typically
11
+ * built by the host's own script (the embedding-demo writes its whole page
12
+ * via `outerHTML` in `startHost()`), so `querySelector` before
13
+ * `DOMContentLoaded` proves nothing.
14
+ * - **"Not yet" is not "not there".** A missing anchor gets a
15
+ * `MutationObserver` until it appears or the timeout runs out. The two
16
+ * outcomes are distinguishable in the return value.
17
+ * - **A timeout is a RETURNED outcome plus a console line, never a throw and
18
+ * never silence.** A swallowed rejection here is this repo's signature
19
+ * blank-with-clean-console.
20
+ * - **Own container, with an id.** Foldkit keys HMR preservation off the
21
+ * container id and defects silently without one (`mountPoint.ts`), and the
22
+ * anchor itself must never be handed to `Runtime.embed`.
23
+ * - **Adjacent, never inside by default — and never `replaceChildren`.** The
24
+ * anchor is host-owned; `loader.ts:80-83` states the rule for slots and it
25
+ * binds twice as hard for an element the host may re-render.
26
+ */
27
+
28
+ export type AnchorMountOutcome =
29
+ | {
30
+ readonly _tag: 'resolved'
31
+ readonly element: HTMLElement
32
+ readonly container: HTMLElement
33
+ }
34
+ | {
35
+ readonly _tag: 'timed-out'
36
+ readonly selector: string
37
+ readonly waitedMs: number
38
+ }
39
+
40
+ export type AnchorMountOptions = {
41
+ readonly selector: string
42
+ readonly tag: string
43
+ readonly bundleUrl: string
44
+ readonly apiUrl: string
45
+ readonly attributes?: Readonly<Record<string, string>>
46
+ /** Where the container lands relative to the anchor. Default `'after'` —
47
+ * adjacent placement never disturbs the anchor's own children. */
48
+ readonly position?: 'before' | 'after' | 'inside'
49
+ /** How long to wait for a late anchor before giving up. Default 10s. */
50
+ readonly timeoutMs?: number
51
+ }
52
+
53
+ const DEFAULT_TIMEOUT_MS = 10_000
54
+
55
+ /** Ids handed out so far, per tag — a counter, never a live-DOM count, so an
56
+ * unmounted instance's id is never reused (the `mountPoint.ts` precedent). */
57
+ const handedOut = new Map<string, number>()
58
+
59
+ const anchorContainerId = (tag: string): string => {
60
+ const next = handedOut.get(tag) ?? 0
61
+ handedOut.set(tag, next + 1)
62
+ return `microdots-anchor-${tag}-${String(next)}`
63
+ }
64
+
65
+ const afterDomReady = (): Promise<void> =>
66
+ document.readyState === 'loading'
67
+ ? new Promise(resolve => {
68
+ document.addEventListener('DOMContentLoaded', () => resolve(), {
69
+ once: true,
70
+ })
71
+ })
72
+ : Promise.resolve()
73
+
74
+ /** The anchor now, or the anchor as soon as a mutation produces one, or
75
+ * `null` at the timeout. */
76
+ const awaitAnchor = (
77
+ selector: string,
78
+ timeoutMs: number,
79
+ ): Promise<Element | null> => {
80
+ const immediate = document.querySelector(selector)
81
+ if (immediate !== null) return Promise.resolve(immediate)
82
+ return new Promise(resolve => {
83
+ const observer = new MutationObserver(() => {
84
+ const found = document.querySelector(selector)
85
+ if (found !== null) {
86
+ clearTimeout(timer)
87
+ observer.disconnect()
88
+ resolve(found)
89
+ }
90
+ })
91
+ const timer = setTimeout(() => {
92
+ observer.disconnect()
93
+ resolve(null)
94
+ }, timeoutMs)
95
+ observer.observe(document.body, { childList: true, subtree: true })
96
+ })
97
+ }
98
+
99
+ const placeContainer = (
100
+ anchor: Element,
101
+ container: HTMLElement,
102
+ position: 'before' | 'after' | 'inside',
103
+ ): void => {
104
+ if (position === 'inside') {
105
+ // Inside is APPEND — the anchor's existing children are host-owned and
106
+ // stay exactly where they are. Never `replaceChildren` here.
107
+ anchor.appendChild(container)
108
+ return
109
+ }
110
+ anchor.insertAdjacentElement(
111
+ position === 'before' ? 'beforebegin' : 'afterend',
112
+ container,
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Resolves the selector (waiting for a late anchor), loads the bundle in
118
+ * parallel with the wait, then mounts the configured element inside its own
119
+ * id-carrying container adjacent to (or inside) the anchor.
120
+ */
121
+ export const mountAtAnchor = async (
122
+ options: AnchorMountOptions,
123
+ ): Promise<AnchorMountOutcome> => {
124
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
125
+ const position = options.position ?? 'after'
126
+ const startedAt = performance.now()
127
+
128
+ // The bundle fetch does not depend on the anchor existing — start it now so
129
+ // a late anchor mounts fast once it appears.
130
+ const loading = loadMicroDot(options.bundleUrl, options.tag)
131
+
132
+ await afterDomReady()
133
+ const anchor = await awaitAnchor(options.selector, timeoutMs)
134
+ if (anchor === null) {
135
+ const waitedMs = Math.round(performance.now() - startedAt)
136
+ console.warn(
137
+ `mountAtAnchor: no element matched "${options.selector}" within ${String(waitedMs)}ms — <${options.tag}> not mounted`,
138
+ )
139
+ return { _tag: 'timed-out', selector: options.selector, waitedMs }
140
+ }
141
+
142
+ await loading
143
+ const element = createMicroDot(
144
+ options.tag,
145
+ options.apiUrl,
146
+ options.attributes ?? {},
147
+ )
148
+ const container = document.createElement('div')
149
+ container.id = anchorContainerId(options.tag)
150
+ container.appendChild(element)
151
+ placeContainer(anchor, container, position)
152
+ return { _tag: 'resolved', element, container }
153
+ }
@@ -83,7 +83,7 @@ export type FillCompositionFailure =
83
83
  }
84
84
 
85
85
  export class FillCompositionError extends Error {
86
- readonly name = 'FillCompositionError'
86
+ override readonly name = 'FillCompositionError'
87
87
  /**
88
88
  * Declared and assigned, NOT a `constructor(readonly failure: …)` parameter
89
89
  * property. A parameter property is TS syntax that needs code GENERATED for
package/src/index.ts CHANGED
@@ -22,6 +22,11 @@
22
22
  export {
23
23
  type MicroDotEntry,
24
24
  type MicroDotRegistry,
25
+ TopologyRegistryEntry,
26
+ bundleAlreadyDefined,
27
+ isAllowedBundle,
28
+ refusedRegistryRows,
29
+ resolveRegistry,
25
30
  apiUrlFor,
26
31
  findEntry,
27
32
  } from './registry.ts'
@@ -58,6 +63,7 @@ export {
58
63
  */
59
64
  export {
60
65
  type WireState,
66
+ ANCHOR_SLOT_ID,
61
67
  deriveWireState,
62
68
  HostTopology,
63
69
  PlacementCondition,
@@ -67,6 +73,7 @@ export {
67
73
  TopologyOverview,
68
74
  TopologyPlacement,
69
75
  TopologyRoute,
76
+ layoutModeOf,
70
77
  topologyRouteTable,
71
78
  Wire,
72
79
  WireEnv,
@@ -216,6 +223,45 @@ export {
216
223
  pruneGeneratedContainers,
217
224
  } from './slotDom.ts'
218
225
 
226
+ /**
227
+ * Generated layout — the inversion of the rule above, for a host that
228
+ * OPTED IN (`layoutModeOf(topology) === 'generated'`): the data wins and
229
+ * `renderLayout` reconciles the page to it; `slotDom.ts` stays the
230
+ * authored-host fallback. `slotLayout.ts` is the one place kind→geometry
231
+ * lives — the values mirror the Pages canvas so both renderings of a
232
+ * manifest agree structurally. See
233
+ * `wiki/plans/active/microdots-platform-pages-generated-layout.md`.
234
+ */
235
+ export {
236
+ type LayoutRow,
237
+ type StylePairs,
238
+ generatedSectionId,
239
+ layoutRows,
240
+ placementSpanStyle,
241
+ rowStyle,
242
+ sectionStyle,
243
+ slotStyle,
244
+ } from './slotLayout.ts'
245
+
246
+ export {
247
+ type RenderLayoutReport,
248
+ LAYOUT_ATTRIBUTE,
249
+ renderLayout,
250
+ } from './renderLayout.ts'
251
+
252
+ /**
253
+ * Mounting at a DOM anchor — a CSS selector into host-owned markup instead of
254
+ * a declared slot. Resolve-after-paint, "not yet" observed via mutations,
255
+ * timeout as a returned outcome, own id-carrying container, adjacent by
256
+ * default and never `replaceChildren`. See
257
+ * `wiki/framework/mounting/anchor-mounting-into-host-owned-dom.md`.
258
+ */
259
+ export {
260
+ type AnchorMountOptions,
261
+ type AnchorMountOutcome,
262
+ mountAtAnchor,
263
+ } from './anchorMount.ts'
264
+
219
265
  /**
220
266
  * Drag-resize for slots that declare a `resize` axis — the remaining half of
221
267
  * the slot-manifest generalisation in
@@ -235,9 +281,10 @@ export {
235
281
  } from './slotResize.ts'
236
282
 
237
283
  /**
238
- * The placement checks engine — Phase 5 work item 4: six of the spec's seven
239
- * checks as a pure lint over the resolution's output (check 5 defers with
240
- * anchors). Unverifiable checks are reported, never silently skipped.
284
+ * The placement checks engine — Phase 5 work item 4: the spec's seven checks
285
+ * as a pure lint over the resolution's output (check 5 as its unverifiable
286
+ * reporter only — no crawl exists). Unverifiable checks are reported, never
287
+ * silently skipped.
241
288
  */
242
289
  export {
243
290
  type PlacementCheckContext,
@@ -280,3 +327,9 @@ export {
280
327
  postWireTraces,
281
328
  startTracePublisher,
282
329
  } from './tracePublisher.ts'
330
+
331
+ export {
332
+ DEFAULT_CDN_BASE_URL,
333
+ missingRegistryRows,
334
+ registryEntryFromManifest,
335
+ } from './registryFromManifest.ts'
@@ -32,6 +32,7 @@ const placement = (tag: string): ResolvedPlacement => ({
32
32
  values: {},
33
33
  span: Option.none(),
34
34
  order: Option.none(),
35
+ selector: Option.none(),
35
36
  state: 'active',
36
37
  overriddenBy: [],
37
38
  })
@@ -182,6 +182,29 @@ describe('pendingMounts', () => {
182
182
  'winner',
183
183
  ])
184
184
  })
185
+
186
+ test('drops an anchor placement — the slot loop must never ensureSlot("@anchor")', () => {
187
+ const topology = topologyOf([
188
+ route('/home', [
189
+ { id: 'slotted', tag: 'promo-banner', slotId: 'hero-slot' },
190
+ {
191
+ id: 'anchored',
192
+ tag: 'price-ticker',
193
+ slotId: '@anchor',
194
+ selector: '#hero .cta-row',
195
+ },
196
+ ]),
197
+ ])
198
+ const resolved = resolvePlacements(topology, '/home', 'dev')
199
+ // The anchor is IN the resolution — screens render it — but not in the
200
+ // pending mounts: a shell's slot loop feeding `@anchor` into `ensureSlot`
201
+ // would fabricate a `<div id="@anchor">`. Anchors mount only through
202
+ // `mountAtAnchor`, in a host that opts in.
203
+ expect(resolved.map(item => item.id)).toEqual(['slotted', 'anchored'])
204
+ expect(pendingMounts(resolved, new Set()).map(item => item.id)).toEqual([
205
+ 'slotted',
206
+ ])
207
+ })
185
208
  })
186
209
 
187
210
  // The failure mode this function exists for, verbatim from the D1 extraction:
package/src/mounting.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Array } from 'effect'
2
2
 
3
3
  import type { ResolvedPlacement } from './rules.ts'
4
+ import { ANCHOR_SLOT_ID } from './wire.ts'
4
5
 
5
6
  /**
6
7
  * What a shell does with a resolution's output on the way to the DOM — the
@@ -39,6 +40,12 @@ import type { ResolvedPlacement } from './rules.ts'
39
40
  * matching the order the shells append in.
40
41
  *
41
42
  * Fully overridden placements are dropped first: they render for nobody.
43
+ *
44
+ * ANCHOR placements are dropped too: the slot loop's `ensureSlot` would
45
+ * otherwise fabricate a `<div id="@anchor">` — a lie. Anchors mount through
46
+ * `mountAtAnchor` in a host that opts in (the embedding-demo precedent);
47
+ * neither first-party shell does, and a shell that wants to must call it
48
+ * itself rather than have the slot loop guess.
42
49
  */
43
50
  export const pendingMounts = (
44
51
  resolved: ReadonlyArray<ResolvedPlacement>,
@@ -48,7 +55,9 @@ export const pendingMounts = (
48
55
  Array.filter(
49
56
  resolved,
50
57
  placement =>
51
- placement.state !== 'overridden' && !mountedTags.has(placement.tag),
58
+ placement.state !== 'overridden' &&
59
+ placement.slotId !== ANCHOR_SLOT_ID &&
60
+ !mountedTags.has(placement.tag),
52
61
  ),
53
62
  (a, b) => a.tag === b.tag,
54
63
  )