@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 +6 -6
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +56 -3
- package/src/mountedRegistry.test.ts +1 -0
- package/src/mounting.test.ts +23 -0
- package/src/mounting.ts +10 -1
- package/src/placementChecks.test.ts +180 -4
- package/src/placementChecks.ts +132 -7
- package/src/registry.test.ts +289 -0
- package/src/registry.ts +158 -0
- package/src/registryFromManifest.test.ts +126 -0
- package/src/registryFromManifest.ts +92 -0
- package/src/renderLayout.test.ts +263 -0
- package/src/renderLayout.ts +345 -0
- package/src/rules.test.ts +85 -0
- package/src/rules.ts +9 -0
- package/src/slotLayout.test.ts +127 -0
- package/src/slotLayout.ts +113 -0
- package/src/slots.test.ts +37 -3
- package/src/slots.ts +10 -1
- package/src/wire.test.ts +80 -0
- package/src/wire.ts +60 -7
- package/src/wireEngine.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bespokeagentics/microdots-host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MicroDots host shell
|
|
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": "
|
|
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.
|
|
22
|
-
"@bespokeagentics/microdots-element": "0.
|
|
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.
|
|
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
|
+
}
|
package/src/fillComposition.ts
CHANGED
|
@@ -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:
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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'
|
package/src/mounting.test.ts
CHANGED
|
@@ -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' &&
|
|
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
|
)
|