social components inlay-proto.up.railway.app/
atproto components sdui

fold view/accepts into view, make binding scope explicit

+424 -330
+1 -1
generated/at/inlay.ts
··· 5 5 export * as Binding from "./inlay/Binding"; 6 6 export * as pack from "./inlay/pack"; 7 7 export * as Fragment from "./inlay/Fragment"; 8 + export * as Loading from "./inlay/Loading"; 8 9 export * as Maybe from "./inlay/Maybe"; 9 10 export * as Missing from "./inlay/Missing"; 10 - export * as Loading from "./inlay/Loading"; 11 11 export * as Slot from "./inlay/Slot"; 12 12 export * as Throw from "./inlay/Throw"; 13 13 export * as component from "./inlay/component";
+30 -67
generated/at/inlay/component.defs.ts
··· 27 27 | l.Unknown$TypedObject; 28 28 29 29 /** 30 - * What kinds of pages this component is a view for 30 + * What data types this component is a view for 31 31 */ 32 32 view?: ( 33 33 | l.$Typed<ViewRecord> 34 - | l.$Typed<ViewCollection> 35 - | l.$Typed<ViewIdentity> 34 + | l.$Typed<ViewPrimitive> 36 35 | l.Unknown$TypedObject 37 36 )[]; 38 - 39 - /** 40 - * Data types this component can render. Used for data-driven discovery. 41 - */ 42 - accepts?: AcceptsEntry[]; 43 37 44 38 /** 45 39 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins. ··· 81 75 l.typedUnion( 82 76 [ 83 77 l.typedRef<ViewRecord>((() => viewRecord) as any), 84 - l.typedRef<ViewCollection>((() => viewCollection) as any), 85 - l.typedRef<ViewIdentity>((() => viewIdentity) as any), 78 + l.typedRef<ViewPrimitive>((() => viewPrimitive) as any), 86 79 ], 87 80 false 88 81 ) 89 82 ) 90 - ), 91 - accepts: l.optional( 92 - l.array(l.ref<AcceptsEntry>((() => acceptsEntry) as any)) 93 83 ), 94 84 imports: l.optional(l.array(l.string({ format: "at-uri" }))), 95 85 via: l.optional( ··· 161 151 162 152 export { bodyTemplate }; 163 153 164 - /** Component is a view for individual records of a collection. Omit collection for a generic record view. */ 154 + /** Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages. */ 165 155 type ViewRecord = { 166 156 $type?: "at.inlay.component#viewRecord"; 167 157 ··· 169 159 * The collection this component views. Omit for any-collection. 170 160 */ 171 161 collection?: l.NsidString; 162 + 163 + /** 164 + * The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing. 165 + */ 166 + rkey?: string; 167 + 168 + /** 169 + * Which prop receives the AT URI. 170 + */ 171 + prop: string; 172 172 }; 173 173 174 174 export type { ViewRecord }; 175 175 176 - /** Component is a view for individual records of a collection. Omit collection for a generic record view. */ 176 + /** Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages. */ 177 177 const viewRecord = l.typedObject<ViewRecord>( 178 178 $nsid, 179 179 "viewRecord", 180 - l.object({ collection: l.optional(l.string({ format: "nsid" })) }) 180 + l.object({ 181 + collection: l.optional(l.string({ format: "nsid" })), 182 + rkey: l.optional(l.string({ maxLength: 512 })), 183 + prop: l.string({ maxLength: 256 }), 184 + }) 181 185 ); 182 186 183 187 export { viewRecord }; 184 188 185 - /** Component is a view for a collection listing. Omit collection for a generic collection view. */ 186 - type ViewCollection = { 187 - $type?: "at.inlay.component#viewCollection"; 189 + /** Component is a view for a primitive value type. */ 190 + type ViewPrimitive = { 191 + $type?: "at.inlay.component#viewPrimitive"; 188 192 189 193 /** 190 - * The collection this component lists. Omit for any-collection. 191 - */ 192 - collection?: l.NsidString; 193 - }; 194 - 195 - export type { ViewCollection }; 196 - 197 - /** Component is a view for a collection listing. Omit collection for a generic collection view. */ 198 - const viewCollection = l.typedObject<ViewCollection>( 199 - $nsid, 200 - "viewCollection", 201 - l.object({ collection: l.optional(l.string({ format: "nsid" })) }) 202 - ); 203 - 204 - export { viewCollection }; 205 - 206 - /** Component is a view for an identity (person/DID). */ 207 - type ViewIdentity = { $type?: "at.inlay.component#viewIdentity" }; 208 - 209 - export type { ViewIdentity }; 210 - 211 - /** Component is a view for an identity (person/DID). */ 212 - const viewIdentity = l.typedObject<ViewIdentity>( 213 - $nsid, 214 - "viewIdentity", 215 - l.object({}) 216 - ); 217 - 218 - export { viewIdentity }; 219 - 220 - /** Declares a data type this component can handle, routed to a specific prop. */ 221 - type AcceptsEntry = { 222 - $type?: "at.inlay.component#acceptsEntry"; 223 - 224 - /** 225 - * Lexicon field type. 194 + * Lexicon primitive type. 226 195 */ 227 196 type: 228 197 | "string" ··· 251 220 | l.UnknownString; 252 221 253 222 /** 254 - * Prop to bind matched data to. 223 + * Which prop receives the value. 255 224 */ 256 225 prop: string; 257 - 258 - /** 259 - * For at-uri strings, restricts to this collection. 260 - */ 261 - collection?: l.NsidString; 262 226 }; 263 227 264 - export type { AcceptsEntry }; 228 + export type { ViewPrimitive }; 265 229 266 - /** Declares a data type this component can handle, routed to a specific prop. */ 267 - const acceptsEntry = l.typedObject<AcceptsEntry>( 230 + /** Component is a view for a primitive value type. */ 231 + const viewPrimitive = l.typedObject<ViewPrimitive>( 268 232 $nsid, 269 - "acceptsEntry", 233 + "viewPrimitive", 270 234 l.object({ 271 235 type: l.string<{ 272 236 maxLength: 128; ··· 298 262 }>({ maxLength: 64 }) 299 263 ), 300 264 prop: l.string({ maxLength: 256 }), 301 - collection: l.optional(l.string({ format: "nsid" })), 302 265 }) 303 266 ); 304 267 305 - export { acceptsEntry }; 268 + export { viewPrimitive };
+17 -35
lexicons/at/inlay/component.json
··· 24 24 "type": "array", 25 25 "items": { 26 26 "type": "union", 27 - "refs": ["#viewRecord", "#viewCollection", "#viewIdentity"] 28 - }, 29 - "description": "What kinds of pages this component is a view for" 30 - }, 31 - "accepts": { 32 - "type": "array", 33 - "items": { 34 - "type": "ref", 35 - "ref": "#acceptsEntry" 27 + "refs": ["#viewRecord", "#viewPrimitive"] 36 28 }, 37 - "description": "Data types this component can render. Used for data-driven discovery." 29 + "description": "What data types this component is a view for" 38 30 }, 39 31 "imports": { 40 32 "type": "array", ··· 91 83 }, 92 84 "viewRecord": { 93 85 "type": "object", 94 - "description": "Component is a view for individual records of a collection. Omit collection for a generic record view.", 86 + "description": "Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages.", 87 + "required": ["prop"], 95 88 "properties": { 96 89 "collection": { 97 90 "type": "string", 98 91 "format": "nsid", 99 92 "description": "The collection this component views. Omit for any-collection." 100 - } 101 - } 102 - }, 103 - "viewCollection": { 104 - "type": "object", 105 - "description": "Component is a view for a collection listing. Omit collection for a generic collection view.", 106 - "properties": { 107 - "collection": { 93 + }, 94 + "rkey": { 108 95 "type": "string", 109 - "format": "nsid", 110 - "description": "The collection this component lists. Omit for any-collection." 96 + "maxLength": 512, 97 + "description": "The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing." 98 + }, 99 + "prop": { 100 + "type": "string", 101 + "maxLength": 256, 102 + "description": "Which prop receives the AT URI." 111 103 } 112 104 } 113 105 }, 114 - "viewIdentity": { 106 + "viewPrimitive": { 115 107 "type": "object", 116 - "description": "Component is a view for an identity (person/DID).", 117 - "properties": {} 118 - }, 119 - "acceptsEntry": { 120 - "type": "object", 121 - "description": "Declares a data type this component can handle, routed to a specific prop.", 108 + "description": "Component is a view for a primitive value type.", 122 109 "required": ["type", "prop"], 123 110 "properties": { 124 111 "type": { 125 112 "type": "string", 126 113 "maxLength": 128, 127 - "description": "Lexicon field type.", 114 + "description": "Lexicon primitive type.", 128 115 "knownValues": [ 129 116 "string", 130 117 "integer", ··· 155 142 "prop": { 156 143 "type": "string", 157 144 "maxLength": 256, 158 - "description": "Prop to bind matched data to." 159 - }, 160 - "collection": { 161 - "type": "string", 162 - "format": "nsid", 163 - "description": "For at-uri strings, restricts to this collection." 145 + "description": "Which prop receives the value." 164 146 } 165 147 } 166 148 }
+77 -81
packages/@inlay/render/src/index.ts
··· 19 19 import { validateProps } from "./validate.js"; 20 20 21 21 import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 22 + import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js"; 22 23 import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js"; 23 24 import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js"; 24 25 ··· 204 205 export function resolvePath(obj: unknown, path: string[]): unknown { 205 206 let current: unknown = obj; 206 207 for (const seg of path) { 207 - if (current == null || typeof current !== "object") return undefined; 208 + if (current == null) return undefined; 209 + if (typeof current === "string") { 210 + if (!current.startsWith("at://")) return undefined; 211 + try { 212 + const parsed = new AtUri(current); 213 + const uriParts: Record<string, string> = { 214 + did: parsed.host, 215 + collection: parsed.collection, 216 + rkey: parsed.rkey, 217 + }; 218 + current = uriParts[seg]; 219 + } catch { 220 + return undefined; 221 + } 222 + continue; 223 + } 224 + if (typeof current !== "object") return undefined; 208 225 if (!Object.hasOwn(current, seg)) return undefined; 209 226 current = (current as Record<string, unknown>)[seg]; 210 227 } ··· 240 257 const type = element.type; 241 258 let resolvedProps = props; 242 259 if (component.view) { 243 - resolvedProps = await expandBareDid(props, component, resolver); 260 + resolvedProps = expandBareDid(props, component); 244 261 } 245 262 246 263 if (validate) { ··· 332 349 return out; 333 350 }) as Record<string, unknown>; 334 351 335 - let scope: Record<string, unknown> = { ...slottedProps }; 352 + let scope: Record<string, unknown> = { props: slottedProps }; 336 353 let cache: CachePolicy | undefined; 337 - const uri = props.uri as string | undefined; 338 - if (uri && uri.startsWith("at://")) { 354 + 355 + // Find a matching viewRecord and resolve its AT URI prop 356 + const viewRecords = (component.view ?? []).filter((v) => 357 + viewRecordSchema.isTypeOf(v) 358 + ); 359 + 360 + for (const vr of viewRecords) { 361 + const prop = vr.prop; 362 + const uri = props[prop] as string | undefined; 363 + if (!uri || !uri.startsWith("at://")) { 364 + continue; 365 + } 339 366 const parsed = new AtUri(uri); 340 367 ensureValidNsid(parsed.collection); 341 - if (parsed.collection && parsed.rkey) { 342 - const viewRecord = component.view?.some( 343 - (v) => 344 - v.$type === "at.inlay.component#viewRecord" && 345 - (v as any).collection === parsed.collection 346 - ); 347 - if (viewRecord) { 348 - const built = await buildScope( 349 - parsed.host, 350 - parsed.collection, 351 - parsed.rkey, 352 - tree, 353 - resolver 354 - ); 355 - scope = { ...props, ...built.scope }; 356 - cache = built.cache; 357 - } 368 + if (!parsed.collection || !parsed.rkey) { 369 + continue; 370 + } 371 + if (vr.collection && vr.collection !== parsed.collection) { 372 + continue; 358 373 } 374 + const built = await buildRecord( 375 + parsed.host, 376 + parsed.collection, 377 + parsed.rkey, 378 + tree, 379 + resolver 380 + ); 381 + scope = { props: slottedProps, record: built.record }; 382 + cache = built.cache; 383 + break; 359 384 } 360 385 361 386 const node = resolveBindings(tree, scopeResolver(scope)); ··· 367 392 }; 368 393 } 369 394 370 - async function buildScope( 395 + async function buildRecord( 371 396 did: AtIdentifierString, 372 397 collection: NsidString, 373 398 rkey: string, 374 399 tree: unknown, 375 400 resolver: Resolver 376 - ): Promise<{ scope: Record<string, unknown>; cache?: CachePolicy }> { 401 + ): Promise<{ record: Record<string, unknown>; cache?: CachePolicy }> { 377 402 const recordUri: AtUriString = `at://${did}/${collection}/${rkey}`; 378 - const scope: Record<string, unknown> = { 379 - did, 380 - collection, 381 - rkey, 382 - uri: recordUri, 383 - }; 384 403 385 - // Skip record fetch if all bindings can be satisfied from URI parts alone 386 - if (tree && !needsRecord(tree, scope)) { 387 - return { scope }; 404 + // Skip record fetch if no bindings reference record.* 405 + if (tree && !needsRecord(tree)) { 406 + return { record: {} }; 388 407 } 389 408 390 - const record = await resolver.fetchRecord(recordUri); 391 - if (record && typeof record === "object") { 392 - const merged = Object.create(null) as Record<string, unknown>; 393 - Object.assign(merged, record, scope); 409 + const fetched = await resolver.fetchRecord(recordUri); 410 + if (fetched && typeof fetched === "object") { 394 411 return { 395 - scope: merged, 412 + record: fetched as Record<string, unknown>, 396 413 cache: { 397 414 tags: [{ $type: "at.inlay.defs#tagRecord", uri: recordUri }], 398 415 }, 399 416 }; 400 417 } 401 - return { scope }; 418 + return { record: {} }; 402 419 } 403 420 404 - /** Check if any Binding in the deserialized tree needs data beyond what scope provides. */ 405 - function needsRecord(tree: unknown, scope: Record<string, unknown>): boolean { 406 - let missed = false; 421 + /** Check if any Binding in the deserialized tree references record.* */ 422 + function needsRecord(tree: unknown): boolean { 423 + let found = false; 407 424 walkTree(tree, (obj, walk) => { 408 425 if (isValidElement(obj)) { 409 426 const el = obj as Element; 410 427 if (el.type === "at.inlay.Binding") { 411 428 const path = (el.props as Record<string, unknown>)?.path; 412 - if ( 413 - Array.isArray(path) && 414 - resolvePath(scope, path as string[]) == null 415 - ) { 416 - missed = true; 429 + if (Array.isArray(path) && path[0] === "record") { 430 + found = true; 417 431 } 418 432 return obj; 419 433 } ··· 421 435 for (const v of Object.values(obj)) walk(v); 422 436 return obj; 423 437 }); 424 - return missed; 438 + return found; 425 439 } 426 440 427 441 async function renderExternal( ··· 495 509 }; 496 510 } 497 511 498 - async function expandBareDid( 512 + function expandBareDid( 499 513 props: Record<string, unknown>, 500 - component: ComponentRecord, 501 - resolver: Resolver 502 - ): Promise<Record<string, unknown>> { 503 - const uri = props.uri as string | undefined; 504 - if (!uri || !uri.startsWith("did:")) return props; 505 - 514 + component: ComponentRecord 515 + ): Record<string, unknown> { 506 516 const view = component.view!; 507 - const hasIdentityView = view.some( 508 - (v) => v.$type === "at.inlay.component#viewIdentity" 509 - ); 510 - if (!hasIdentityView) return props; 511 517 512 - const viewRecord = view.find( 513 - (v) => 514 - v.$type === "at.inlay.component#viewRecord" && !!(v as any).collection 515 - ) as { collection: string } | undefined; 516 - if (!viewRecord) return props; 518 + // Find the first viewRecord with both collection and rkey — that enables DID expansion 519 + const entry = view 520 + .filter((v) => viewRecordSchema.isTypeOf(v)) 521 + .find((v) => v.collection && v.rkey); 522 + if (!entry) return props; 517 523 518 - const viewLex = (await resolver.resolveLexicon(viewRecord.collection)) as { 519 - defs?: Record<string, { key?: string }>; 520 - } | null; 524 + const prop = entry.prop ?? "uri"; 525 + const value = props[prop] as string | undefined; 526 + if (!value || !value.startsWith("did:")) return props; 521 527 522 - let identityRkey: string | null = null; 523 - if (viewLex?.defs?.main?.key?.startsWith("literal:")) { 524 - identityRkey = viewLex.defs.main.key.slice("literal:".length); 525 - } else if (!viewLex) { 526 - identityRkey = "self"; 527 - } 528 - 529 - if (identityRkey) { 530 - return { 531 - ...props, 532 - uri: `at://${uri}/${viewRecord.collection}/${identityRkey}`, 533 - }; 534 - } 535 - return props; 528 + return { 529 + ...props, 530 + [prop]: `at://${value}/${entry.collection}/${entry.rkey}`, 531 + }; 536 532 }
+84 -41
packages/@inlay/render/src/validate.ts
··· 1 1 import { Lexicons, type LexiconDoc } from "@atproto/lexicon"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 4 + import { 5 + viewRecord as viewRecordSchema, 6 + viewPrimitive as viewPrimitiveSchema, 7 + } from "../../../../generated/at/inlay/component.defs.js"; 4 8 import type { Resolver } from "./index.js"; 5 9 6 10 // --- Lexicon cache (module-level) --- 7 11 8 12 const lexiconCache = new Map<string, Lexicons>(); 9 13 14 + // --- Helpers --- 15 + 16 + function getViewPrimitives(component: ComponentRecord) { 17 + return (component.view ?? []).filter((v) => viewPrimitiveSchema.isTypeOf(v)); 18 + } 19 + 20 + function getViewRecords(component: ComponentRecord) { 21 + return (component.view ?? []).filter((v) => viewRecordSchema.isTypeOf(v)); 22 + } 23 + 10 24 // --- Public API --- 11 25 12 26 export async function validateProps( ··· 23 37 lexiconCache.set(type, lexicons); 24 38 } 25 39 lexicons.assertValidXrpcInput(type, props); 26 - } else if (component.accepts?.length) { 27 - const propEntries = new Map< 28 - string, 29 - { type: string; formats: Set<string> } 30 - >(); 31 - for (const accept of component.accepts) { 32 - if (!accept.prop) continue; 33 - const existing = propEntries.get(accept.prop); 34 - if (existing) { 35 - if (accept.format) existing.formats.add(accept.format); 36 - } else { 37 - const formats = new Set<string>(); 38 - if (accept.format) formats.add(accept.format); 39 - propEntries.set(accept.prop, { type: accept.type, formats }); 40 + } else { 41 + // Synthesize validation from view entries (viewPrimitive + viewRecord) 42 + const primitives = getViewPrimitives(component); 43 + const records = getViewRecords(component); 44 + 45 + if (primitives.length > 0 || records.length > 0) { 46 + const propEntries = new Map< 47 + string, 48 + { type: string; formats: Set<string> } 49 + >(); 50 + 51 + // viewPrimitive entries define typed props 52 + for (const vp of primitives) { 53 + if (!vp.prop) continue; 54 + const existing = propEntries.get(vp.prop); 55 + if (existing) { 56 + if (vp.format) existing.formats.add(vp.format); 57 + } else { 58 + const formats = new Set<string>(); 59 + if (vp.format) formats.add(vp.format); 60 + propEntries.set(vp.prop, { type: vp.type, formats }); 61 + } 62 + } 63 + 64 + // viewRecord entries imply a string prop (at-uri or did format) 65 + for (const vr of records) { 66 + const prop = vr.prop ?? "uri"; 67 + const existing = propEntries.get(prop); 68 + if (existing) { 69 + existing.formats.add("at-uri"); 70 + if (vr.rkey) existing.formats.add("did"); 71 + } else { 72 + const formats = new Set<string>(["at-uri"]); 73 + if (vr.rkey) formats.add("did"); 74 + propEntries.set(prop, { type: "string", formats }); 75 + } 76 + } 77 + 78 + if (propEntries.size > 0) { 79 + const properties: Record<string, { type: string; format?: string }> = 80 + {}; 81 + const required = [...propEntries.keys()]; 82 + for (const [prop, { type: t, formats }] of propEntries) { 83 + const format = unionFormats(formats); 84 + properties[prop] = format ? { type: t, format } : { type: t }; 85 + } 86 + const syntheticLex = { 87 + lexicon: 1, 88 + id: type, 89 + defs: { 90 + main: { 91 + type: "procedure", 92 + input: { 93 + encoding: "application/json", 94 + schema: { type: "object", required, properties }, 95 + }, 96 + }, 97 + }, 98 + } as unknown as LexiconDoc; 99 + const lexicons = new Lexicons([syntheticLex]); 100 + lexicons.assertValidXrpcInput(type, props); 40 101 } 41 102 } 42 - const properties: Record<string, { type: string; format?: string }> = {}; 43 - const required = [...propEntries.keys()]; 44 - for (const [prop, { type: t, formats }] of propEntries) { 45 - const format = unionFormats(formats); 46 - properties[prop] = format ? { type: t, format } : { type: t }; 47 - } 48 - const syntheticLex = { 49 - lexicon: 1, 50 - id: type, 51 - defs: { 52 - main: { 53 - type: "procedure", 54 - input: { 55 - encoding: "application/json", 56 - schema: { type: "object", required, properties }, 57 - }, 58 - }, 59 - }, 60 - } as unknown as LexiconDoc; 61 - const lexicons = new Lexicons([syntheticLex]); 62 - lexicons.assertValidXrpcInput(type, props); 63 103 } 64 104 65 - if (component.accepts?.length) { 105 + // Collection constraint checking from viewRecord entries 106 + const records = getViewRecords(component); 107 + if (records.length > 0) { 66 108 const allowedCollections = new Map<string, Set<string>>(); 67 - for (const accept of component.accepts) { 68 - if (!accept.prop || !accept.collection) continue; 69 - let set = allowedCollections.get(accept.prop); 109 + for (const vr of records) { 110 + if (!vr.collection) continue; 111 + const prop = vr.prop ?? "uri"; 112 + let set = allowedCollections.get(prop); 70 113 if (!set) { 71 114 set = new Set(); 72 - allowedCollections.set(accept.prop, set); 115 + allowedCollections.set(prop, set); 73 116 } 74 - set.add(accept.collection); 117 + set.add(vr.collection); 75 118 } 76 119 for (const [prop, allowed] of allowedCollections) { 77 120 const value = props[prop];
+215 -105
packages/@inlay/render/test/render.test.ts
··· 99 99 const Indirection = "test.app.Indirection" as const; 100 100 const MissingCard = "test.app.MissingCard" as const; 101 101 const View = "test.app.View" as const; 102 + const Timestamp = "test.app.Timestamp" as const; 102 103 const Loop = "test.infinite.Loop" as const; 103 104 104 105 // ============================================================================ ··· 136 137 h("div", { 137 138 ...el.props, 138 139 key: el.key, 139 - children: await walk((el.props as any)?.children), 140 + children: await walk((el.props as Record<string, unknown>)?.children), 140 141 }), 141 142 [Row]: async (el, walk) => 142 143 h("section", { 143 144 ...el.props, 144 145 key: el.key, 145 - children: await walk((el.props as any)?.children), 146 + children: await walk((el.props as Record<string, unknown>)?.children), 146 147 }), 147 148 [Grid]: async (el, walk) => 148 149 h("table", { 149 150 ...el.props, 150 151 key: el.key, 151 - children: await walk((el.props as any)?.children), 152 + children: await walk((el.props as Record<string, unknown>)?.children), 152 153 }), 153 154 [Text]: async (el, walk) => 154 155 h("span", { 155 156 ...el.props, 156 157 key: el.key, 157 - children: await walk((el.props as any)?.children), 158 + children: await walk((el.props as Record<string, unknown>)?.children), 158 159 }), 159 160 [Link]: async (el, walk) => 160 161 h("a", { 161 162 ...el.props, 162 163 key: el.key, 163 - children: await walk((el.props as any)?.children), 164 + children: await walk((el.props as Record<string, unknown>)?.children), 164 165 }), 165 166 [List]: async (el, walk) => 166 167 h("ul", { 167 168 ...el.props, 168 169 key: el.key, 169 - children: await walk((el.props as any)?.children), 170 + children: await walk((el.props as Record<string, unknown>)?.children), 170 171 }), 171 172 [Avatar]: async (el, walk) => 172 173 h("img", { 173 174 ...el.props, 174 175 key: el.key, 175 - children: await walk((el.props as any)?.children), 176 + children: await walk((el.props as Record<string, unknown>)?.children), 176 177 }), 177 178 [Cover]: async (el, walk) => 178 179 h("figure", { 179 180 ...el.props, 180 181 key: el.key, 181 - children: await walk((el.props as any)?.children), 182 + children: await walk((el.props as Record<string, unknown>)?.children), 182 183 }), 183 184 [Fragment]: async (el, walk) => 184 185 h("frag", { 185 186 key: el.key, 186 - children: await walk((el.props as any)?.children), 187 + children: await walk((el.props as Record<string, unknown>)?.children), 187 188 }), 188 189 [Throw]: async (el) => { 189 190 throw new Error((el.props as Record<string, unknown>)?.message as string); ··· 246 247 return await primitives[out.type](out, walk, outCtx); 247 248 } catch (e) { 248 249 if (e instanceof MissingError) throw e; 249 - return `[Error: ${(e as any).message}]`; 250 + return `[Error: ${(e as Error).message}]`; 250 251 } 251 252 } 252 253 return walkNode(out, options, outCtx, primitives); ··· 446 447 // 447 448 // A template component stores a serialized element tree in its body. 448 449 // render() deserializes the tree, resolves Binding placeholders against 449 - // the element's props, and returns the expanded subtree for further walking. 450 + // a namespaced scope: `props.*` for caller-provided values, `record.*` 451 + // for fetched atproto record fields. 450 452 // 451 453 // Templates can also fetch atproto records. When a component declares a 452 454 // `viewRecord` for a collection, and the element's `uri` prop points to 453 - // that collection, the record is fetched and merged into the binding scope. 454 - // 455 - // TODO: Currently props and record fields share namespace and can collide. 456 - // I haven't decided what to do about that. We could do `record.foo` instead. 455 + // that collection, the record is fetched and exposed under `record.*`. 457 456 458 457 describe("templates", () => { 459 458 it("expands static element tree", async () => { ··· 505 504 $(Row, { 506 505 children: [ 507 506 $(Text, { value: "Hello, " }), 508 - $(Text, { value: $(Binding, { path: ["name"] }) }), 507 + $(Text, { value: $(Binding, { path: ["props", "name"] }) }), 509 508 ], 510 509 }) 511 510 ), ··· 551 550 body: { 552 551 $type: "at.inlay.component#bodyTemplate", 553 552 node: serializeTree( 554 - $(Text, { value: $(Binding, { path: ["user", "displayName"] }) }) 553 + $(Text, { 554 + value: $(Binding, { path: ["props", "user", "displayName"] }), 555 + }) 555 556 ), 556 557 }, 557 558 imports: [HOST_PACK_URI], ··· 594 595 body: { 595 596 $type: "at.inlay.component#bodyTemplate", 596 597 node: serializeTree( 597 - $(Stack, { children: $(Binding, { path: ["children"] }) }) 598 + $(Stack, { children: $(Binding, { path: ["props", "children"] }) }) 598 599 ), 599 600 }, 600 601 imports: [HOST_PACK_URI], ··· 622 623 node: serializeTree( 623 624 $(Row, { 624 625 children: [ 625 - $(Binding, { path: ["left"] }), 626 - $(Binding, { path: ["right"] }), 626 + $(Binding, { path: ["props", "left"] }), 627 + $(Binding, { path: ["props", "right"] }), 627 628 ], 628 629 }) 629 630 ), ··· 658 659 node: serializeTree( 659 660 $(Row, { 660 661 children: [ 661 - $(Binding, { path: ["layout", "left"] }), 662 - $(Binding, { path: ["layout", "right"] }), 662 + $(Binding, { path: ["props", "layout", "left"] }), 663 + $(Binding, { path: ["props", "layout", "right"] }), 663 664 ], 664 665 }) 665 666 ), ··· 696 697 node: serializeTree( 697 698 $(Row, { 698 699 children: [ 699 - $(Binding, { path: ["children"] }), 700 - $(Binding, { path: ["children"] }), 700 + $(Binding, { path: ["props", "children"] }), 701 + $(Binding, { path: ["props", "children"] }), 701 702 ], 702 703 }) 703 704 ), ··· 752 753 type: PostCard, 753 754 body: { 754 755 $type: "at.inlay.component#bodyTemplate", 755 - node: serializeTree($(Text, { value: $(Binding, { path: ["text"] }) })), 756 + node: serializeTree( 757 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }) 758 + ), 756 759 }, 757 760 imports: [HOST_PACK_URI], 758 761 view: [ 759 762 { 760 763 $type: "at.inlay.component#viewRecord", 764 + prop: "uri", 761 765 collection: "app.bsky.feed.post", 762 766 }, 763 767 ], ··· 799 803 ]); 800 804 }); 801 805 802 - it("skips record fetch when bindings only use uri parts", async () => { 803 - // at://did:plc:alice/app.bsky.feed.post/123 is parsed into 804 - // { did, collection, rkey, uri }. A Binding for ["did"] is satisfied 805 - // from these parts alone — no record fetch needed. 806 + it("skips record fetch when bindings only use props", async () => { 807 + // A Binding for ["props", "uri", "did"] is resolved via AT URI dotted 808 + // access on the props.uri string — no record fetch needed. 806 809 const postCardComponent: ComponentRecord = { 807 810 $type: "at.inlay.component", 808 811 type: PostCard, 809 812 body: { 810 813 $type: "at.inlay.component#bodyTemplate", 811 - node: serializeTree($(Text, { value: $(Binding, { path: ["did"] }) })), 814 + node: serializeTree( 815 + $(Text, { value: $(Binding, { path: ["props", "uri", "did"] }) }) 816 + ), 812 817 }, 813 818 imports: [HOST_PACK_URI], 814 819 view: [ 815 820 { 816 821 $type: "at.inlay.component#viewRecord", 822 + prop: "uri", 817 823 collection: "app.bsky.feed.post", 818 824 }, 819 825 ], ··· 847 853 ]); 848 854 }); 849 855 850 - it("fetches record when binding needs data beyond uri parts", async () => { 851 - // A Binding for ["text"] can't be satisfied from uri parts alone 852 - // (did, collection, rkey, uri), so the record is fetched. 856 + it("fetches record when binding references record.*", async () => { 857 + // A Binding for ["record", "text"] requires fetching the record. 853 858 const postCardComponent: ComponentRecord = { 854 859 $type: "at.inlay.component", 855 860 type: PostCard, ··· 857 862 $type: "at.inlay.component#bodyTemplate", 858 863 node: serializeTree( 859 864 $(Stack, { 860 - children: $(Link, { uri: $(Binding, { path: ["text"] }) }), 865 + children: $(Link, { 866 + uri: $(Binding, { path: ["record", "text"] }), 867 + }), 861 868 }) 862 869 ), 863 870 }, ··· 865 872 view: [ 866 873 { 867 874 $type: "at.inlay.component#viewRecord", 875 + prop: "uri", 868 876 collection: "app.bsky.feed.post", 869 877 }, 870 878 ], ··· 1104 1112 $type: "at.inlay.component#bodyTemplate", 1105 1113 node: serializeTree( 1106 1114 $(Stack, { 1107 - children: $(Text, { value: $(Binding, { path: ["title"] }) }), 1115 + children: $(Text, { 1116 + value: $(Binding, { path: ["props", "title"] }), 1117 + }), 1108 1118 }) 1109 1119 ), 1110 1120 }, ··· 1118 1128 $type: "at.inlay.component#bodyTemplate", 1119 1129 node: serializeTree( 1120 1130 $(Row, { 1121 - children: $(Text, { value: $(Binding, { path: ["title"] }) }), 1131 + children: $(Text, { 1132 + value: $(Binding, { path: ["props", "title"] }), 1133 + }), 1122 1134 }) 1123 1135 ), 1124 1136 }, ··· 1305 1317 $(Stack, { 1306 1318 children: [ 1307 1319 $(Card, { title: "greeting's own" }), 1308 - $(Binding, { path: ["children"] }), 1320 + $(Binding, { path: ["props", "children"] }), 1309 1321 ], 1310 1322 }) 1311 1323 ), ··· 1377 1389 body: { 1378 1390 $type: "at.inlay.component#bodyTemplate", 1379 1391 node: serializeTree( 1380 - $(Stack, { children: $(Binding, { path: ["children"] }) }) 1392 + $(Stack, { children: $(Binding, { path: ["props", "children"] }) }) 1381 1393 ), 1382 1394 }, 1383 1395 imports: [HOST_PACK_URI] as AtUriString[], ··· 1439 1451 type: Indirection, 1440 1452 body: { 1441 1453 $type: "at.inlay.component#bodyTemplate", 1442 - node: serializeTree($(Binding, { path: ["children"] })), 1454 + node: serializeTree($(Binding, { path: ["props", "children"] })), 1443 1455 }, 1444 1456 imports: [], 1445 1457 }; ··· 1668 1680 $type: "at.inlay.component#bodyTemplate", 1669 1681 node: serializeTree( 1670 1682 $(Stack, { 1671 - children: $(Text, { value: $(Binding, { path: ["title"] }) }), 1683 + children: $(Text, { 1684 + value: $(Binding, { path: ["props", "title"] }), 1685 + }), 1672 1686 }) 1673 1687 ), 1674 1688 }, ··· 1969 1983 type: Layout, 1970 1984 body: { 1971 1985 $type: "at.inlay.component#bodyTemplate", 1972 - node: serializeTree($(Text, { value: $(Binding, { path: ["text"] }) })), 1986 + node: serializeTree( 1987 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }) 1988 + ), 1973 1989 }, 1974 1990 imports: ["at://did:plc:test/at.inlay.pack/child"] as AtUriString[], 1975 1991 view: [ 1976 1992 { 1977 1993 $type: "at.inlay.component#viewRecord", 1994 + prop: "uri", 1978 1995 collection: "app.bsky.feed.post", 1979 1996 }, 1980 1997 ], ··· 2189 2206 type: PostCard, 2190 2207 body: { 2191 2208 $type: "at.inlay.component#bodyTemplate", 2192 - node: serializeTree($(Text, { value: $(Binding, { path: ["text"] }) })), 2209 + node: serializeTree( 2210 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }) 2211 + ), 2193 2212 }, 2194 2213 imports: [HOST_PACK_URI], 2195 2214 view: [ 2196 2215 { 2197 2216 $type: "at.inlay.component#viewRecord", 2217 + prop: "uri", 2198 2218 collection: "app.bsky.feed.post", 2199 2219 }, 2200 2220 ], ··· 2346 2366 type: PostCard, 2347 2367 body: { 2348 2368 $type: "at.inlay.component#bodyTemplate", 2349 - node: serializeTree($(Text, { value: $(Binding, { path: ["text"] }) })), 2369 + node: serializeTree( 2370 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }) 2371 + ), 2350 2372 }, 2351 2373 imports: [HOST_PACK_URI], 2352 2374 view: [ 2353 2375 { 2354 2376 $type: "at.inlay.component#viewRecord", 2377 + prop: "uri", 2355 2378 collection: "app.bsky.feed.post", 2356 2379 }, 2357 2380 ], ··· 2477 2500 // 9. View identity — bare DID expansion 2478 2501 // ============================================================================ 2479 2502 // 2480 - // A component with view declarations (viewIdentity + viewRecord) accepts 2481 - // bare DIDs as the uri prop. The renderer expands the DID to a full 2482 - // AT URI using the collection from viewRecord and the rkey from the 2483 - // collection's lexicon (literal key) or "self" as default. 2503 + // A viewRecord with both collection and rkey enables DID expansion: 2504 + // bare DIDs on the view's prop are expanded to full AT URIs using the 2505 + // baked collection and rkey. No runtime lexicon lookup needed. 2484 2506 // 2485 2507 // This lets components like a profile card accept `uri="did:plc:alice"` 2486 2508 // instead of requiring the full `at://did:plc:alice/app.bsky.actor.profile/self`. 2487 - // 2488 - // TODO: I'm not entirely happy about this but it is very convenient in practice. 2489 2509 2490 2510 describe("view identity", () => { 2491 - it("expands bare DID to full AT URI using view declarations", async () => { 2511 + it("expands bare DID to full AT URI using viewRecord rkey", async () => { 2492 2512 const profile = "test.app.Profile" as const; 2493 2513 const profileComponent: ComponentRecord = { 2494 2514 $type: "at.inlay.component", ··· 2496 2516 body: { 2497 2517 $type: "at.inlay.component#bodyTemplate", 2498 2518 node: serializeTree( 2499 - $(Text, { value: $(Binding, { path: ["displayName"] }) }) 2519 + $(Text, { value: $(Binding, { path: ["record", "displayName"] }) }) 2500 2520 ), 2501 2521 }, 2502 2522 imports: [HOST_PACK_URI], 2503 2523 view: [ 2504 - { $type: "at.inlay.component#viewIdentity" }, 2505 2524 { 2506 2525 $type: "at.inlay.component#viewRecord", 2526 + prop: "uri", 2507 2527 collection: "app.bsky.actor.profile", 2528 + rkey: "self", 2508 2529 }, 2509 2530 ], 2510 2531 }; ··· 2515 2536 }, 2516 2537 }); 2517 2538 2518 - // resolveLexicon returns null → rkey defaults to "self" 2519 2539 const output = await renderToCompletion( 2520 2540 $(profile, { uri: "did:plc:alice" }), 2521 2541 options, ··· 2525 2545 assert.deepEqual(output, h("span", { value: "Alice" })); 2526 2546 }); 2527 2547 2528 - it("uses lexicon literal key when available", async () => { 2548 + it("uses baked rkey from viewRecord", async () => { 2529 2549 const profile = "test.app.Profile" as const; 2530 2550 const profileComponent: ComponentRecord = { 2531 2551 $type: "at.inlay.component", ··· 2533 2553 body: { 2534 2554 $type: "at.inlay.component#bodyTemplate", 2535 2555 node: serializeTree( 2536 - $(Text, { value: $(Binding, { path: ["displayName"] }) }) 2556 + $(Text, { value: $(Binding, { path: ["record", "displayName"] }) }) 2537 2557 ), 2538 2558 }, 2539 2559 imports: [HOST_PACK_URI], 2540 2560 view: [ 2541 - { $type: "at.inlay.component#viewIdentity" }, 2542 2561 { 2543 2562 $type: "at.inlay.component#viewRecord", 2563 + prop: "uri", 2544 2564 collection: "app.bsky.actor.profile", 2565 + rkey: "main", 2545 2566 }, 2546 2567 ], 2547 2568 }; 2548 2569 2549 - const { resolver, options } = world({ 2570 + const { options } = world({ 2550 2571 ["at://did:plc:alice/app.bsky.actor.profile/main"]: { 2551 2572 displayName: "Alice", 2552 2573 }, 2553 2574 }); 2554 2575 2555 - // Lexicon declares literal key "main" 2556 - resolver.resolveLexicon = async (nsid: string) => { 2557 - if (nsid === "app.bsky.actor.profile") { 2558 - return { defs: { main: { key: "literal:main" } } }; 2559 - } 2560 - return null; 2561 - }; 2562 - 2563 2576 const output = await renderToCompletion( 2564 2577 $(profile, { uri: "did:plc:alice" }), 2565 2578 options, ··· 2577 2590 body: { 2578 2591 $type: "at.inlay.component#bodyTemplate", 2579 2592 node: serializeTree( 2580 - $(Text, { value: $(Binding, { path: ["displayName"] }) }) 2593 + $(Text, { value: $(Binding, { path: ["record", "displayName"] }) }) 2581 2594 ), 2582 2595 }, 2583 2596 imports: [HOST_PACK_URI], 2584 2597 view: [ 2585 - { $type: "at.inlay.component#viewIdentity" }, 2586 2598 { 2587 2599 $type: "at.inlay.component#viewRecord", 2600 + prop: "uri", 2588 2601 collection: "app.bsky.actor.profile", 2602 + rkey: "self", 2589 2603 }, 2590 2604 ], 2591 2605 }; ··· 2607 2621 2608 2622 assert.deepEqual(output, h("span", { value: "Alice" })); 2609 2623 }); 2624 + 2625 + it("does not expand DID when viewRecord has no rkey", async () => { 2626 + const post = "test.app.Post" as const; 2627 + const postComponent: ComponentRecord = { 2628 + $type: "at.inlay.component", 2629 + type: post, 2630 + body: { 2631 + $type: "at.inlay.component#bodyTemplate", 2632 + node: serializeTree( 2633 + // Bind to uri — shows exactly what the component received 2634 + $(Text, { value: $(Binding, { path: ["props", "uri"] }) }) 2635 + ), 2636 + }, 2637 + imports: [HOST_PACK_URI], 2638 + view: [ 2639 + { 2640 + $type: "at.inlay.component#viewRecord", 2641 + prop: "uri", 2642 + collection: "app.bsky.feed.post", 2643 + }, 2644 + ], 2645 + }; 2646 + 2647 + const { resolver } = world(); 2648 + 2649 + const output = await renderToCompletion( 2650 + $(post, { uri: "did:plc:alice" }), 2651 + { resolver, validate: false }, 2652 + createContext(postComponent) 2653 + ); 2654 + 2655 + assert.deepEqual(output, h("span", { value: "did:plc:alice" })); 2656 + }); 2610 2657 }); 2611 2658 2612 2659 // ============================================================================ 2613 - // 10. Input validation — lexicon schemas and accepts 2660 + // 10. Input validation — lexicon schemas and view entries 2614 2661 // ============================================================================ 2615 2662 // 2616 2663 // Components can declare a published lexicon — the component type is an ··· 2618 2665 // checked against the schema before template expansion. Invalid props 2619 2666 // produce a Throw. 2620 2667 // 2621 - // (Separately, the `accepts` array on a component record is for editor 2622 - // tooling — "given data of type X, route it to prop Y." It powers 2623 - // palette filtering and autofill, and works even before the component 2624 - // is published or has a lexicon. If specified, it does get validated.) 2668 + // Without a published lexicon, validation is synthesized from view entries: 2669 + // viewRecord implies a string prop (at-uri format), and viewPrimitive 2670 + // entries define typed props directly. 2625 2671 2626 2672 describe("lexicon validation", () => { 2627 2673 it("validates props against published lexicon", async () => { ··· 2668 2714 opts, 2669 2715 ctx 2670 2716 ); 2671 - assert.ok(typeof ok !== "string", "valid props should not error"); 2717 + assert.deepEqual(ok, h("span", {})); 2672 2718 2673 2719 const bad = await renderToCompletion($(View, {}), opts, ctx); 2674 2720 assertError(bad, 'Input must have the property "uri"'); 2675 2721 }); 2676 2722 2677 - it("synthesizes validation from accepts", async () => { 2723 + it("synthesizes validation from viewRecord", async () => { 2678 2724 const cardComponent: ComponentRecord = { 2679 2725 $type: "at.inlay.component", 2680 2726 type: Card, ··· 2683 2729 node: { $: "$", type: Text, props: {} }, 2684 2730 }, 2685 2731 imports: [HOST_PACK_URI], 2686 - accepts: [{ prop: "uri", type: "string", format: "at-uri" }], 2732 + view: [ 2733 + { 2734 + $type: "at.inlay.component#viewRecord", 2735 + prop: "uri", 2736 + collection: "app.bsky.feed.post", 2737 + }, 2738 + ], 2687 2739 }; 2688 2740 2689 2741 const { options } = world(); ··· 2694 2746 options, 2695 2747 ctx 2696 2748 ); 2697 - assert.ok(typeof ok !== "string", "valid props should not error"); 2749 + assert.deepEqual(ok, h("span", {})); 2698 2750 2699 2751 const bad = await renderToCompletion( 2700 2752 $(Card, { uri: 123 as unknown as string }), 2701 2753 options, 2702 2754 ctx 2703 2755 ); 2704 - assert.ok(typeof bad === "string", "wrong type should produce error"); 2756 + assertError(bad, "Input/uri must be a string"); 2705 2757 }); 2706 2758 2707 - it("validates collection constraints from accepts", async () => { 2759 + it("synthesizes validation from viewPrimitive", async () => { 2760 + const timestampComponent: ComponentRecord = { 2761 + $type: "at.inlay.component", 2762 + type: Timestamp, 2763 + body: { 2764 + $type: "at.inlay.component#bodyTemplate", 2765 + node: { $: "$", type: Text, props: {} }, 2766 + }, 2767 + imports: [HOST_PACK_URI], 2768 + view: [ 2769 + { 2770 + $type: "at.inlay.component#viewPrimitive", 2771 + type: "string", 2772 + format: "datetime", 2773 + prop: "value", 2774 + }, 2775 + ], 2776 + }; 2777 + 2778 + const { options } = world(); 2779 + const ctx = createContext(timestampComponent); 2780 + 2781 + const ok = await renderToCompletion( 2782 + $(Timestamp, { value: "2026-01-01T00:00:00Z" }), 2783 + options, 2784 + ctx 2785 + ); 2786 + assert.deepEqual(ok, h("span", {})); 2787 + 2788 + const bad = await renderToCompletion( 2789 + $(Timestamp, { value: 123 as unknown as string }), 2790 + options, 2791 + ctx 2792 + ); 2793 + assertError(bad, "Input/value must be a string"); 2794 + }); 2795 + 2796 + it("validates collection constraints from viewRecord", async () => { 2708 2797 const cardComponent: ComponentRecord = { 2709 2798 $type: "at.inlay.component", 2710 2799 type: Card, ··· 2713 2802 node: { $: "$", type: Text, props: {} }, 2714 2803 }, 2715 2804 imports: [HOST_PACK_URI], 2716 - accepts: [ 2805 + view: [ 2717 2806 { 2807 + $type: "at.inlay.component#viewRecord", 2718 2808 prop: "uri", 2719 - type: "string", 2720 - format: "at-uri", 2721 2809 collection: "app.bsky.feed.post", 2722 2810 }, 2723 2811 ], ··· 2731 2819 options, 2732 2820 ctx 2733 2821 ); 2734 - assert.ok(typeof ok !== "string", "matching collection should not error"); 2822 + assert.deepEqual(ok, h("span", {})); 2735 2823 2736 2824 const bad = await renderToCompletion( 2737 2825 $(Card, { uri: "at://did:plc:alice/app.bsky.feed.like/456" }), ··· 2765 2853 type: MissingCard, 2766 2854 body: { 2767 2855 $type: "at.inlay.component#bodyTemplate", 2768 - node: serializeTree($(Text, { value: $(Binding, { path: ["text"] }) })), 2856 + node: serializeTree( 2857 + $(Text, { value: $(Binding, { path: ["props", "text"] }) }) 2858 + ), 2769 2859 }, 2770 2860 imports: [HOST_PACK_URI], 2771 2861 }; ··· 2804 2894 body: { 2805 2895 $type: "at.inlay.component#bodyTemplate", 2806 2896 node: serializeTree( 2807 - $(Text, { value: $(Binding, { path: ["title"] }) }) 2897 + $(Text, { value: $(Binding, { path: ["record", "title"] }) }) 2808 2898 ), 2809 2899 }, 2810 2900 imports: [HOST_PACK_URI], 2811 2901 view: [ 2812 2902 { 2813 2903 $type: "at.inlay.component#viewRecord", 2904 + prop: "uri", 2814 2905 collection: "app.bsky.feed.post", 2815 2906 }, 2816 2907 ], ··· 2836 2927 body: { 2837 2928 $type: "at.inlay.component#bodyTemplate", 2838 2929 node: serializeTree( 2839 - $(Stack, { children: $(Binding, { path: ["text"] }) }) 2930 + $(Stack, { children: $(Binding, { path: ["props", "text"] }) }) 2840 2931 ), 2841 2932 }, 2842 2933 imports: [HOST_PACK_URI], ··· 2847 2938 renderToCompletion($(Card, {}), options, createContext(cardComponent)), 2848 2939 (err: unknown) => { 2849 2940 if (!(err instanceof MissingError)) throw err; 2850 - assert.deepEqual(err.path, ["text"]); 2941 + assert.deepEqual(err.path, ["props", "text"]); 2851 2942 return true; 2852 2943 } 2853 2944 ); ··· 2862 2953 body: { 2863 2954 $type: "at.inlay.component#bodyTemplate", 2864 2955 node: serializeTree( 2865 - $(Link, { uri: $(Binding, { path: ["reply", "parent", "uri"] }) }) 2956 + $(Link, { 2957 + uri: $(Binding, { path: ["record", "reply", "parent", "uri"] }), 2958 + }) 2866 2959 ), 2867 2960 }, 2868 2961 imports: [HOST_PACK_URI], ··· 2873 2966 renderToCompletion($(Card, {}), options, createContext(cardComponent)), 2874 2967 (err: unknown) => { 2875 2968 if (!(err instanceof MissingError)) throw err; 2876 - assert.deepEqual(err.path, ["reply", "parent", "uri"]); 2969 + assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 2877 2970 return true; 2878 2971 } 2879 2972 ); ··· 2891 2984 $(Stack, { 2892 2985 children: [ 2893 2986 $(Link, { 2894 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 2987 + uri: $(Binding, { path: ["record", "reply", "parent", "uri"] }), 2895 2988 }), 2896 - $(Text, { value: $(Binding, { path: ["title"] }) }), 2989 + $(Text, { value: $(Binding, { path: ["record", "title"] }) }), 2897 2990 ], 2898 2991 }) 2899 2992 ), ··· 2902 2995 view: [ 2903 2996 { 2904 2997 $type: "at.inlay.component#viewRecord", 2998 + prop: "uri", 2905 2999 collection: "app.bsky.feed.post", 2906 3000 }, 2907 3001 ], ··· 2919 3013 ), 2920 3014 (err: unknown) => { 2921 3015 if (!(err instanceof MissingError)) throw err; 2922 - assert.deepEqual(err.path, ["reply", "parent", "uri"]); 3016 + assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 2923 3017 return true; 2924 3018 } 2925 3019 ); ··· 2929 3023 // <Stack> 2930 3024 // <Maybe> 2931 3025 // <Row> 2932 - // <Link uri={reply.parent.uri} /> <- missing, deep inside 3026 + // <Link uri={record.reply.parent.uri} /> <- missing, deep inside 2933 3027 // <Text value="visible" /> 2934 3028 // </Row> 2935 3029 // <Text value="also visible" /> ··· 2952 3046 $(Row, { 2953 3047 children: [ 2954 3048 $(Link, { 2955 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3049 + uri: $(Binding, { 3050 + path: ["record", "reply", "parent", "uri"], 3051 + }), 2956 3052 }), 2957 3053 $(Text, { value: "visible" }), 2958 3054 ], ··· 2969 3065 view: [ 2970 3066 { 2971 3067 $type: "at.inlay.component#viewRecord", 3068 + prop: "uri", 2972 3069 collection: "app.bsky.feed.post", 2973 3070 }, 2974 3071 ], ··· 3021 3118 it("Maybe catches missing binding in same template", async () => { 3022 3119 // <Stack> 3023 3120 // <Maybe fallback={<Text value="nope" />}> 3024 - // <Link uri={reply.parent.uri} /> <- missing 3121 + // <Link uri={record.reply.parent.uri} /> <- missing 3025 3122 // </Maybe> 3026 - // <Text value={text} /> <- present 3123 + // <Text value={record.text} /> <- present 3027 3124 // </Stack> 3028 3125 const postCardComponent: ComponentRecord = { 3029 3126 $type: "at.inlay.component", ··· 3037 3134 fallback: $(Text, { value: "nope" }), 3038 3135 children: [ 3039 3136 $(Link, { 3040 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3137 + uri: $(Binding, { 3138 + path: ["record", "reply", "parent", "uri"], 3139 + }), 3041 3140 }), 3042 3141 ], 3043 3142 }), 3044 - $(Text, { value: $(Binding, { path: ["text"] }) }), 3143 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }), 3045 3144 ], 3046 3145 }) 3047 3146 ), ··· 3050 3149 view: [ 3051 3150 { 3052 3151 $type: "at.inlay.component#viewRecord", 3152 + prop: "uri", 3053 3153 collection: "app.bsky.feed.post", 3054 3154 }, 3055 3155 ], ··· 3087 3187 node: serializeTree( 3088 3188 $(External, { 3089 3189 children: $(Link, { 3090 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3190 + uri: $(Binding, { path: ["record", "reply", "parent", "uri"] }), 3091 3191 }), 3092 3192 }) 3093 3193 ), ··· 3099 3199 view: [ 3100 3200 { 3101 3201 $type: "at.inlay.component#viewRecord", 3202 + prop: "uri", 3102 3203 collection: "app.bsky.feed.post", 3103 3204 }, 3104 3205 ], ··· 3126 3227 if (!(err instanceof MissingError)) { 3127 3228 throw err; 3128 3229 } 3129 - assert.deepEqual(err.path, ["reply", "parent", "uri"]); 3230 + assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 3130 3231 return true; 3131 3232 } 3132 3233 ); ··· 3151 3252 fallback: $(Text, { value: "nope" }), 3152 3253 children: $(External, { 3153 3254 children: $(Link, { 3154 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3255 + uri: $(Binding, { 3256 + path: ["record", "reply", "parent", "uri"], 3257 + }), 3155 3258 }), 3156 3259 }), 3157 3260 }), 3158 - $(Text, { value: $(Binding, { path: ["text"] }) }), 3261 + $(Text, { value: $(Binding, { path: ["record", "text"] }) }), 3159 3262 ], 3160 3263 }) 3161 3264 ), ··· 3167 3270 view: [ 3168 3271 { 3169 3272 $type: "at.inlay.component#viewRecord", 3273 + prop: "uri", 3170 3274 collection: "app.bsky.feed.post", 3171 3275 }, 3172 3276 ], ··· 3220 3324 children: $(External, { 3221 3325 children: $(External, { 3222 3326 children: $(Link, { 3223 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3327 + uri: $(Binding, { 3328 + path: ["record", "reply", "parent", "uri"], 3329 + }), 3224 3330 }), 3225 3331 }), 3226 3332 }), ··· 3234 3340 view: [ 3235 3341 { 3236 3342 $type: "at.inlay.component#viewRecord", 3343 + prop: "uri", 3237 3344 collection: "app.bsky.feed.post", 3238 3345 }, 3239 3346 ], ··· 3289 3396 $(Text, { value: "header" }), 3290 3397 $(Maybe, { 3291 3398 fallback: $(Text, { value: "inner" }), 3292 - children: $(Binding, { path: ["children"] }), 3399 + children: $(Binding, { path: ["props", "children"] }), 3293 3400 }), 3294 3401 ], 3295 3402 }) ··· 3310 3417 fallback: $(Text, { value: "outer" }), 3311 3418 children: $(SafeWrapper, { 3312 3419 children: $(Link, { 3313 - uri: $(Binding, { path: ["reply", "parent", "uri"] }), 3420 + uri: $(Binding, { 3421 + path: ["record", "reply", "parent", "uri"], 3422 + }), 3314 3423 }), 3315 3424 }), 3316 3425 }), ··· 3326 3435 view: [ 3327 3436 { 3328 3437 $type: "at.inlay.component#viewRecord", 3438 + prop: "uri", 3329 3439 collection: "app.bsky.feed.post", 3330 3440 }, 3331 3441 ], ··· 3423 3533 body: { 3424 3534 $type: "at.inlay.component#bodyTemplate", 3425 3535 node: serializeTree( 3426 - $(Fragment, { children: $(Binding, { path: ["children"] }) }) 3536 + $(Fragment, { children: $(Binding, { path: ["props", "children"] }) }) 3427 3537 ), 3428 3538 }, 3429 3539 imports: [HOST_PACK_URI],