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