@bespokeagentics/microdots-host 0.1.2 → 0.1.3
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 +2 -2
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +45 -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/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 +48 -7
- package/src/wireEngine.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bespokeagentics/microdots-host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@bespokeagentics/microdots-authoring": "0.1.1",
|
|
22
|
-
"@bespokeagentics/microdots-element": "0.1.
|
|
22
|
+
"@bespokeagentics/microdots-element": "0.1.2"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"effect": "4.0.0-rc.108"
|
|
@@ -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
|
@@ -58,6 +58,7 @@ export {
|
|
|
58
58
|
*/
|
|
59
59
|
export {
|
|
60
60
|
type WireState,
|
|
61
|
+
ANCHOR_SLOT_ID,
|
|
61
62
|
deriveWireState,
|
|
62
63
|
HostTopology,
|
|
63
64
|
PlacementCondition,
|
|
@@ -67,6 +68,7 @@ export {
|
|
|
67
68
|
TopologyOverview,
|
|
68
69
|
TopologyPlacement,
|
|
69
70
|
TopologyRoute,
|
|
71
|
+
layoutModeOf,
|
|
70
72
|
topologyRouteTable,
|
|
71
73
|
Wire,
|
|
72
74
|
WireEnv,
|
|
@@ -216,6 +218,45 @@ export {
|
|
|
216
218
|
pruneGeneratedContainers,
|
|
217
219
|
} from './slotDom.ts'
|
|
218
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Generated layout — the inversion of the rule above, for a host that
|
|
223
|
+
* OPTED IN (`layoutModeOf(topology) === 'generated'`): the data wins and
|
|
224
|
+
* `renderLayout` reconciles the page to it; `slotDom.ts` stays the
|
|
225
|
+
* authored-host fallback. `slotLayout.ts` is the one place kind→geometry
|
|
226
|
+
* lives — the values mirror the Pages canvas so both renderings of a
|
|
227
|
+
* manifest agree structurally. See
|
|
228
|
+
* `wiki/plans/active/microdots-platform-pages-generated-layout.md`.
|
|
229
|
+
*/
|
|
230
|
+
export {
|
|
231
|
+
type LayoutRow,
|
|
232
|
+
type StylePairs,
|
|
233
|
+
generatedSectionId,
|
|
234
|
+
layoutRows,
|
|
235
|
+
placementSpanStyle,
|
|
236
|
+
rowStyle,
|
|
237
|
+
sectionStyle,
|
|
238
|
+
slotStyle,
|
|
239
|
+
} from './slotLayout.ts'
|
|
240
|
+
|
|
241
|
+
export {
|
|
242
|
+
type RenderLayoutReport,
|
|
243
|
+
LAYOUT_ATTRIBUTE,
|
|
244
|
+
renderLayout,
|
|
245
|
+
} from './renderLayout.ts'
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Mounting at a DOM anchor — a CSS selector into host-owned markup instead of
|
|
249
|
+
* a declared slot. Resolve-after-paint, "not yet" observed via mutations,
|
|
250
|
+
* timeout as a returned outcome, own id-carrying container, adjacent by
|
|
251
|
+
* default and never `replaceChildren`. See
|
|
252
|
+
* `wiki/framework/mounting/anchor-mounting-into-host-owned-dom.md`.
|
|
253
|
+
*/
|
|
254
|
+
export {
|
|
255
|
+
type AnchorMountOptions,
|
|
256
|
+
type AnchorMountOutcome,
|
|
257
|
+
mountAtAnchor,
|
|
258
|
+
} from './anchorMount.ts'
|
|
259
|
+
|
|
219
260
|
/**
|
|
220
261
|
* Drag-resize for slots that declare a `resize` axis — the remaining half of
|
|
221
262
|
* the slot-manifest generalisation in
|
|
@@ -235,9 +276,10 @@ export {
|
|
|
235
276
|
} from './slotResize.ts'
|
|
236
277
|
|
|
237
278
|
/**
|
|
238
|
-
* The placement checks engine — Phase 5 work item 4:
|
|
239
|
-
*
|
|
240
|
-
*
|
|
279
|
+
* The placement checks engine — Phase 5 work item 4: the spec's seven checks
|
|
280
|
+
* as a pure lint over the resolution's output (check 5 as its unverifiable
|
|
281
|
+
* reporter only — no crawl exists). Unverifiable checks are reported, never
|
|
282
|
+
* silently skipped.
|
|
241
283
|
*/
|
|
242
284
|
export {
|
|
243
285
|
type PlacementCheckContext,
|
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
|
)
|
|
@@ -37,6 +37,7 @@ const placed = (
|
|
|
37
37
|
values: {},
|
|
38
38
|
span: Option.none(),
|
|
39
39
|
order: Option.none(),
|
|
40
|
+
selector: Option.none(),
|
|
40
41
|
state: 'active',
|
|
41
42
|
overriddenBy: [],
|
|
42
43
|
...overrides,
|
|
@@ -207,6 +208,77 @@ describe('checkPlacements — check 2: slot not in the theme', () => {
|
|
|
207
208
|
},
|
|
208
209
|
])
|
|
209
210
|
})
|
|
211
|
+
|
|
212
|
+
test('does not fire for an anchor — @anchor is the escape hatch, not a missing declaration', () => {
|
|
213
|
+
const report = checkPlacements(
|
|
214
|
+
[
|
|
215
|
+
placed('p1', 'promo-banner', '@anchor', {
|
|
216
|
+
selector: Option.some('#hero .cta-row'),
|
|
217
|
+
}),
|
|
218
|
+
],
|
|
219
|
+
contextOf([manifestOf('promo', ['promo-banner'])]),
|
|
220
|
+
)
|
|
221
|
+
expect(issuesFor(report, 'slot-not-in-theme')).toEqual([])
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// The failure mode: no crawl exists, so an anchor's resolution CANNOT be
|
|
226
|
+
// checked — and a screen that says nothing about it reads as green. Check 5
|
|
227
|
+
// ships as a reporter only: one unverifiable entry per distinct selector,
|
|
228
|
+
// never an issue, never a stored guess (the write-mode plan's D5).
|
|
229
|
+
describe('checkPlacements — check 5: anchor selectors are unverifiable', () => {
|
|
230
|
+
test('reports one unverifiable entry per distinct rendered selector', () => {
|
|
231
|
+
const report = checkPlacements(
|
|
232
|
+
[
|
|
233
|
+
placed('p1', 'promo-banner', '@anchor', {
|
|
234
|
+
selector: Option.some('#hero'),
|
|
235
|
+
}),
|
|
236
|
+
placed('p2', 'contact-form', '@anchor', {
|
|
237
|
+
selector: Option.some('#hero'),
|
|
238
|
+
}),
|
|
239
|
+
placed('p3', 'price-ticker', '@anchor', {
|
|
240
|
+
selector: Option.some('footer'),
|
|
241
|
+
}),
|
|
242
|
+
],
|
|
243
|
+
contextOf([
|
|
244
|
+
manifestOf('promo', ['promo-banner']),
|
|
245
|
+
manifestOf('contact', ['contact-form']),
|
|
246
|
+
manifestOf('price', ['price-ticker']),
|
|
247
|
+
]),
|
|
248
|
+
)
|
|
249
|
+
expect(
|
|
250
|
+
report.unverifiable.filter(
|
|
251
|
+
entry => entry.check === 'anchor-selector-unresolved',
|
|
252
|
+
),
|
|
253
|
+
).toEqual([
|
|
254
|
+
{
|
|
255
|
+
check: 'anchor-selector-unresolved',
|
|
256
|
+
reason: expect.stringContaining('"#hero"'),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
check: 'anchor-selector-unresolved',
|
|
260
|
+
reason: expect.stringContaining('"footer"'),
|
|
261
|
+
},
|
|
262
|
+
])
|
|
263
|
+
expect(issuesFor(report, 'anchor-selector-unresolved')).toEqual([])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('an entirely overridden anchor reports nothing — it renders for nobody', () => {
|
|
267
|
+
const report = checkPlacements(
|
|
268
|
+
[
|
|
269
|
+
placed('p1', 'promo-banner', '@anchor', {
|
|
270
|
+
selector: Option.some('#hero'),
|
|
271
|
+
state: 'overridden',
|
|
272
|
+
}),
|
|
273
|
+
],
|
|
274
|
+
contextOf([manifestOf('promo', ['promo-banner'])]),
|
|
275
|
+
)
|
|
276
|
+
expect(
|
|
277
|
+
report.unverifiable.filter(
|
|
278
|
+
entry => entry.check === 'anchor-selector-unresolved',
|
|
279
|
+
),
|
|
280
|
+
).toEqual([])
|
|
281
|
+
})
|
|
210
282
|
})
|
|
211
283
|
|
|
212
284
|
// The failure mode: a manifest-required attribute nobody sets means the
|
|
@@ -367,12 +439,12 @@ describe('checkPlacements — check 6: over the page-weight budget', () => {
|
|
|
367
439
|
// kill.
|
|
368
440
|
const report = checkPlacements(
|
|
369
441
|
[
|
|
370
|
-
placed('p1', 'workbench-
|
|
442
|
+
placed('p1', 'workbench-board', 'hero-slot'),
|
|
371
443
|
placed('p2', 'workbench-brief', 'side-slot'),
|
|
372
444
|
placed('p3', 'wiring-canvas', 'side-slot'),
|
|
373
445
|
],
|
|
374
446
|
contextOf([
|
|
375
|
-
manifestOf('workbench', ['workbench-
|
|
447
|
+
manifestOf('workbench', ['workbench-board', 'workbench-brief'], {
|
|
376
448
|
gzipBytes: 100 * KB,
|
|
377
449
|
}),
|
|
378
450
|
manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 70 * KB }),
|
|
@@ -384,12 +456,12 @@ describe('checkPlacements — check 6: over the page-weight budget', () => {
|
|
|
384
456
|
test('warns once over budget, naming the total and the largest bundle', () => {
|
|
385
457
|
const report = checkPlacements(
|
|
386
458
|
[
|
|
387
|
-
placed('p1', 'workbench-
|
|
459
|
+
placed('p1', 'workbench-board', 'hero-slot'),
|
|
388
460
|
placed('p2', 'workbench-brief', 'side-slot'),
|
|
389
461
|
placed('p3', 'wiring-canvas', 'side-slot'),
|
|
390
462
|
],
|
|
391
463
|
contextOf([
|
|
392
|
-
manifestOf('workbench', ['workbench-
|
|
464
|
+
manifestOf('workbench', ['workbench-board', 'workbench-brief'], {
|
|
393
465
|
gzipBytes: 100 * KB,
|
|
394
466
|
}),
|
|
395
467
|
manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 90 * KB }),
|
|
@@ -591,3 +663,107 @@ describe('checkPlacements — the reading-B override qualifier', () => {
|
|
|
591
663
|
).toEqual(['loser'])
|
|
592
664
|
})
|
|
593
665
|
})
|
|
666
|
+
|
|
667
|
+
describe('checkPlacements — check 8: over slot capacity', () => {
|
|
668
|
+
// Diagnostics, not enforcement: nothing at runtime refuses the third
|
|
669
|
+
// placement in a capacity-2 slot — the generated host stacks it — so the
|
|
670
|
+
// claim is DISAGREEMENT between the declaration and the placements, which
|
|
671
|
+
// stays true regardless of renderer behavior.
|
|
672
|
+
const capacityManifest: HostSlotManifest = {
|
|
673
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.1' },
|
|
674
|
+
slots: [
|
|
675
|
+
{ id: 'hero-slot', kind: 'band', row: 0, capacity: 2 },
|
|
676
|
+
{ id: 'main-slot', kind: 'grid', row: 1 },
|
|
677
|
+
],
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
test('warns once per overflowing slot, on the first placement past capacity in stacking order', () => {
|
|
681
|
+
const report = checkPlacements(
|
|
682
|
+
[
|
|
683
|
+
placed('p1', 'a-view', 'hero-slot', { order: Option.some(1) }),
|
|
684
|
+
placed('p2', 'b-view', 'hero-slot', { order: Option.some(2) }),
|
|
685
|
+
placed('p3', 'c-view', 'hero-slot', { order: Option.some(3) }),
|
|
686
|
+
placed('p4', 'd-view', 'hero-slot', { order: Option.some(4) }),
|
|
687
|
+
],
|
|
688
|
+
contextOf([], { slotManifest: capacityManifest }),
|
|
689
|
+
)
|
|
690
|
+
const issues = issuesFor(report, 'over-slot-capacity')
|
|
691
|
+
expect(issues.length).toBe(1)
|
|
692
|
+
expect(issues[0]?.placementId).toBe('p3')
|
|
693
|
+
expect(issues[0]?.severity).toBe('warning')
|
|
694
|
+
expect(issues[0]?.consequence).toContain('capacity 2')
|
|
695
|
+
expect(issues[0]?.consequence).toContain('4 placements')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
test('does not fire at or under capacity, or for a capacity-less slot', () => {
|
|
699
|
+
const report = checkPlacements(
|
|
700
|
+
[
|
|
701
|
+
placed('p1', 'a-view', 'hero-slot'),
|
|
702
|
+
placed('p2', 'b-view', 'hero-slot'),
|
|
703
|
+
placed('p3', 'c-view', 'main-slot'),
|
|
704
|
+
placed('p4', 'd-view', 'main-slot'),
|
|
705
|
+
placed('p5', 'e-view', 'main-slot'),
|
|
706
|
+
],
|
|
707
|
+
contextOf([], { slotManifest: capacityManifest }),
|
|
708
|
+
)
|
|
709
|
+
expect(issuesFor(report, 'over-slot-capacity')).toEqual([])
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
test('stays silent with no manifest — the check-2 unverifiable entry already covers it', () => {
|
|
713
|
+
const report = checkPlacements(
|
|
714
|
+
[
|
|
715
|
+
placed('p1', 'a-view', 'hero-slot'),
|
|
716
|
+
placed('p2', 'b-view', 'hero-slot'),
|
|
717
|
+
placed('p3', 'c-view', 'hero-slot'),
|
|
718
|
+
],
|
|
719
|
+
{ manifests: [], env: 'dev' },
|
|
720
|
+
)
|
|
721
|
+
expect(issuesFor(report, 'over-slot-capacity')).toEqual([])
|
|
722
|
+
expect(
|
|
723
|
+
report.unverifiable.filter(entry => entry.check === 'over-slot-capacity'),
|
|
724
|
+
).toEqual([])
|
|
725
|
+
})
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
describe('checkPlacements — check 9: a span in a non-grid slot', () => {
|
|
729
|
+
const kindsManifest: HostSlotManifest = {
|
|
730
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.1' },
|
|
731
|
+
slots: [
|
|
732
|
+
{ id: 'hero-slot', kind: 'band', row: 0 },
|
|
733
|
+
{ id: 'main-slot', kind: 'grid', row: 1 },
|
|
734
|
+
],
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
test('notes a dormant span in a band slot — the legal residue of a kind edit', () => {
|
|
738
|
+
const report = checkPlacements(
|
|
739
|
+
[
|
|
740
|
+
placed('p1', 'a-view', 'hero-slot', { span: Option.some(6) }),
|
|
741
|
+
placed('p2', 'b-view', 'main-slot', { span: Option.some(6) }),
|
|
742
|
+
],
|
|
743
|
+
contextOf([], { slotManifest: kindsManifest }),
|
|
744
|
+
)
|
|
745
|
+
const issues = issuesFor(report, 'span-in-non-grid')
|
|
746
|
+
expect(issues.length).toBe(1)
|
|
747
|
+
expect(issues[0]?.placementId).toBe('p1')
|
|
748
|
+
expect(issues[0]?.severity).toBe('note')
|
|
749
|
+
expect(issues[0]?.consequence).toContain('dormant')
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
test('does not fire without a span, in a grid, in an undeclared slot, or with no manifest', () => {
|
|
753
|
+
const noSpan = checkPlacements(
|
|
754
|
+
[placed('p1', 'a-view', 'hero-slot')],
|
|
755
|
+
contextOf([], { slotManifest: kindsManifest }),
|
|
756
|
+
)
|
|
757
|
+
expect(issuesFor(noSpan, 'span-in-non-grid')).toEqual([])
|
|
758
|
+
const undeclared = checkPlacements(
|
|
759
|
+
[placed('p2', 'b-view', 'phantom-slot', { span: Option.some(4) })],
|
|
760
|
+
contextOf([], { slotManifest: kindsManifest }),
|
|
761
|
+
)
|
|
762
|
+
expect(issuesFor(undeclared, 'span-in-non-grid')).toEqual([])
|
|
763
|
+
const bare = checkPlacements(
|
|
764
|
+
[placed('p3', 'c-view', 'hero-slot', { span: Option.some(4) })],
|
|
765
|
+
{ manifests: [], env: 'dev' },
|
|
766
|
+
)
|
|
767
|
+
expect(issuesFor(bare, 'span-in-non-grid')).toEqual([])
|
|
768
|
+
})
|
|
769
|
+
})
|