social components inlay.at
atproto components sdui
86
fork

Configure Feed

Select the types of activity you want to include in your feed.

prepare props

+265 -26
+20 -18
packages/@inlay/render/src/index.ts
··· 205 205 scope: Record<string, unknown> 206 206 ): (path: string[]) => unknown { 207 207 return (path) => { 208 + if (path.length === 0) throw new Error("Binding path must not be empty"); 209 + const ns = path[0]; 210 + if (ns !== "props" && ns !== "record") { 211 + const name = path.join("."); 212 + // Check if the unqualified name exists under props or record for a hint 213 + const hints: string[] = []; 214 + if (scope.props != null && typeof scope.props === "object") { 215 + if (Object.hasOwn(scope.props as object, ns)) 216 + hints.push(`props.${name}`); 217 + } 218 + if (scope.record != null && typeof scope.record === "object") { 219 + if (Object.hasOwn(scope.record as object, ns)) 220 + hints.push(`record.${name}`); 221 + } 222 + let msg = `Invalid binding {${name}}: bindings must start with "props" or "record" (e.g. {props.${name}} or {record.${name}}).`; 223 + if (hints.length > 0) { 224 + msg += ` Did you mean {${hints.join("} or {")}}?`; 225 + } 226 + throw new Error(msg); 227 + } 208 228 const value = resolvePath(scope, path); 209 229 if (value == null) return $("at.inlay.Missing", { path }); 210 230 return value; 211 231 }; 212 - } 213 - 214 - /** Walk resolved props and throw MissingError for any lingering Missing elements. */ 215 - function throwIfMissingInProps(props: Record<string, unknown>): void { 216 - walkTree(props, (obj, walk) => { 217 - if (isValidElement(obj)) { 218 - if ((obj as Element).type === "at.inlay.Missing") { 219 - const path = ((obj as Element).props as Record<string, unknown>)?.path; 220 - throw new MissingError( 221 - Array.isArray(path) ? (path as string[]) : ["?"] 222 - ); 223 - } 224 - return obj; // opaque 225 - } 226 - for (const v of Object.values(obj)) walk(v); 227 - return obj; 228 - }); 229 232 } 230 233 231 234 export function resolvePath(obj: unknown, path: string[]): unknown { ··· 291 294 292 295 // Primitive: no body, host renders directly 293 296 if (!component.body) { 294 - throwIfMissingInProps(resolvedProps); 295 297 return { 296 298 node: null, 297 299 context: {
+20 -8
packages/@inlay/render/src/validate.ts
··· 1 1 import { Lexicons, type LexiconDoc, jsonToLex } from "@atproto/lexicon"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { walkTree, isValidElement } from "@inlay/core"; 4 + import type { Element } from "@inlay/core"; 4 5 import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 5 6 import { 6 7 viewRecord as viewRecordSchema, 7 8 viewPrimitive as viewPrimitiveSchema, 8 9 } from "../../../../generated/at/inlay/component.defs.js"; 9 - import type { Resolver } from "./index.js"; 10 + import { MissingError, type Resolver } from "./index.js"; 10 11 11 12 // --- Lexicon cache (module-level) --- 12 13 13 14 const lexiconCache = new Map<string, Lexicons>(); 14 15 15 - // --- Hydration --- 16 + // --- Prep --- 16 17 17 18 /** 18 - * Hydrate JSON blob refs, CID links, and byte arrays into their 19 - * class instances (BlobRef, CID, Uint8Array) so that @atproto/lexicon 20 - * validation accepts them. Elements are opaque and skipped. 19 + * Prepare props for lexicon validation: 20 + * 1. Throw MissingError for any lingering at.inlay.Missing elements (so Maybe 21 + * can catch them) before they hit validation and produce confusing type errors. 22 + * 2. Hydrate JSON blob refs, CID links, and byte arrays into class instances 23 + * (BlobRef, CID, Uint8Array) that @atproto/lexicon validation accepts. 24 + * Elements are opaque and skipped. 21 25 */ 22 - function hydrateProps(props: Record<string, unknown>): Record<string, unknown> { 26 + function prepareProps(props: Record<string, unknown>): Record<string, unknown> { 23 27 return walkTree(props, (obj, walk) => { 24 - if (isValidElement(obj)) return obj; 28 + if (isValidElement(obj)) { 29 + if ((obj as Element).type === "at.inlay.Missing") { 30 + const path = ((obj as Element).props as Record<string, unknown>)?.path; 31 + throw new MissingError( 32 + Array.isArray(path) ? (path as string[]) : ["?"] 33 + ); 34 + } 35 + return obj; 36 + } 25 37 if ( 26 38 obj["$type"] === "blob" || 27 39 obj["$link"] !== undefined || ··· 43 55 component: ComponentRecord, 44 56 resolver: Resolver 45 57 ): Promise<Record<string, unknown>> { 46 - let result: Record<string, unknown> = hydrateProps(props); 58 + let result: Record<string, unknown> = prepareProps(props); 47 59 48 60 const lex = await resolver.resolveLexicon(type); 49 61 if (lex) {
+225
packages/@inlay/render/test/render.test.ts
··· 3058 3058 }) 3059 3059 ); 3060 3060 }); 3061 + 3062 + it("missing binding in blob prop throws MissingError before validation", async () => { 3063 + // A template with Maybe wrapping Cover that binds src to a missing field. 3064 + // Without the fix, the Missing element in src would hit blob validation 3065 + // and produce "should be a blob ref" instead of MissingError. 3066 + const profileComponent: ComponentRecord = { 3067 + $type: "at.inlay.component", 3068 + body: { 3069 + $type: "at.inlay.component#bodyTemplate", 3070 + node: serializeTree( 3071 + $(Maybe, { 3072 + children: $(Cover, { 3073 + src: $(Binding, { path: ["record", "banner"] }), 3074 + did: $(Binding, { path: ["props", "uri", "$did"] }), 3075 + }), 3076 + fallback: $(Cover, { 3077 + did: $(Binding, { path: ["props", "uri", "$did"] }), 3078 + }), 3079 + }) 3080 + ), 3081 + }, 3082 + imports: [HOST_DID], 3083 + view: { 3084 + $type: "at.inlay.component#view", 3085 + prop: "uri", 3086 + accepts: [ 3087 + { 3088 + $type: "at.inlay.component#viewRecord", 3089 + collection: "app.bsky.actor.profile", 3090 + rkey: "self", 3091 + }, 3092 + ], 3093 + }, 3094 + }; 3095 + 3096 + // Record WITHOUT banner — the binding will resolve to Missing 3097 + const profileRecord = { 3098 + displayName: "Alice", 3099 + }; 3100 + 3101 + const { resolver } = world({ 3102 + [`at://${APP_DID}/at.inlay.component/${View}`]: profileComponent, 3103 + ["at://did:plc:alice/app.bsky.actor.profile/self"]: profileRecord, 3104 + }); 3105 + 3106 + resolver.resolveLexicon = async (nsid: string) => { 3107 + if (nsid === Cover) { 3108 + return { 3109 + lexicon: 1, 3110 + id: Cover, 3111 + defs: { 3112 + main: { 3113 + type: "procedure", 3114 + input: { 3115 + encoding: "application/json", 3116 + schema: { 3117 + type: "object", 3118 + required: ["did"], 3119 + properties: { 3120 + src: { type: "blob" }, 3121 + did: { type: "string", format: "did" }, 3122 + }, 3123 + }, 3124 + }, 3125 + }, 3126 + }, 3127 + }; 3128 + } 3129 + return null; 3130 + }; 3131 + 3132 + let fallbackRendered = false; 3133 + const result = await renderToCompletion( 3134 + $(View, { uri: "at://did:plc:alice/app.bsky.actor.profile/self" }), 3135 + { resolver }, 3136 + createContext( 3137 + profileComponent, 3138 + `at://${APP_DID}/at.inlay.component/${View}` 3139 + ), 3140 + { 3141 + [Cover]: async (el) => { 3142 + const props = el.props as Record<string, unknown>; 3143 + if (!props.src) fallbackRendered = true; 3144 + return h("figure", { hasSrc: props.src != null }); 3145 + }, 3146 + [Maybe]: async (el, walk) => { 3147 + const p = (el.props ?? {}) as Record<string, unknown>; 3148 + try { 3149 + return await walk(p.children); 3150 + } catch (e) { 3151 + if (e instanceof MissingError) { 3152 + return p.fallback != null ? walk(p.fallback) : null; 3153 + } 3154 + throw e; 3155 + } 3156 + }, 3157 + } 3158 + ); 3159 + 3160 + // Maybe should catch MissingError and render the fallback Cover (no src) 3161 + assert.ok(fallbackRendered, "fallback Cover should have rendered"); 3162 + assert.deepEqual(result, h("figure", { hasSrc: false })); 3163 + }); 3061 3164 }); 3062 3165 3063 3166 // ============================================================================ ··· 3856 3959 h("span", { value: "sibling" }), 3857 3960 ], 3858 3961 }) 3962 + ); 3963 + }); 3964 + 3965 + it("unqualified binding throws with namespace hint", async () => { 3966 + // {displayName} instead of {props.displayName} — should error, not silently miss 3967 + const cardComponent: ComponentRecord = { 3968 + $type: "at.inlay.component", 3969 + body: { 3970 + $type: "at.inlay.component#bodyTemplate", 3971 + node: serializeTree( 3972 + $(Text, { value: $(Binding, { path: ["displayName"] }) }) 3973 + ), 3974 + }, 3975 + imports: [HOST_DID], 3976 + }; 3977 + 3978 + const { options } = world(); 3979 + const output = await renderToCompletion( 3980 + $(Card, { displayName: "Alice" }), 3981 + options, 3982 + createContext(cardComponent, `at://${APP_DID}/at.inlay.component/${Card}`) 3983 + ); 3984 + assert.ok(output instanceof Output, "expected an Output node"); 3985 + assert.equal(output.tag, "error"); 3986 + assert.ok( 3987 + output.attrs.message 3988 + ?.toString() 3989 + .includes('bindings must start with "props" or "record"'), 3990 + `expected namespace error, got: ${output.attrs.message}` 3991 + ); 3992 + assert.ok( 3993 + output.attrs.message 3994 + ?.toString() 3995 + .includes("Did you mean {props.displayName}"), 3996 + `expected hint, got: ${output.attrs.message}` 3997 + ); 3998 + }); 3999 + 4000 + it("unqualified binding suggests both namespaces when key exists in both", async () => { 4001 + // Pass a prop called "name" and also have a record with "name" — 4002 + // the hint should suggest both props.name and record.name. 4003 + const cardComponent: ComponentRecord = { 4004 + $type: "at.inlay.component", 4005 + body: { 4006 + $type: "at.inlay.component#bodyTemplate", 4007 + node: serializeTree( 4008 + $(Stack, { 4009 + children: [ 4010 + // A proper record binding so the record gets fetched 4011 + $(Text, { value: $(Binding, { path: ["record", "title"] }) }), 4012 + // The bad unqualified binding 4013 + $(Text, { value: $(Binding, { path: ["name"] }) }), 4014 + ], 4015 + }) 4016 + ), 4017 + }, 4018 + imports: [HOST_DID], 4019 + view: { 4020 + $type: "at.inlay.component#view", 4021 + prop: "uri", 4022 + accepts: [ 4023 + { 4024 + $type: "at.inlay.component#viewRecord", 4025 + collection: "app.bsky.feed.post", 4026 + }, 4027 + ], 4028 + }, 4029 + }; 4030 + 4031 + const { options } = world({ 4032 + ["at://did:plc:alice/app.bsky.feed.post/123"]: { 4033 + title: "Hello", 4034 + name: "Alice", 4035 + }, 4036 + }); 4037 + const output = await renderToCompletion( 4038 + $(Card, { 4039 + uri: "at://did:plc:alice/app.bsky.feed.post/123", 4040 + name: "Bob", 4041 + }), 4042 + options, 4043 + createContext(cardComponent, `at://${APP_DID}/at.inlay.component/${Card}`) 4044 + ); 4045 + assert.ok(output instanceof Output, "expected an Output node"); 4046 + assert.equal(output.tag, "error"); 4047 + assert.ok( 4048 + output.attrs.message 4049 + ?.toString() 4050 + .includes("Did you mean {props.name} or {record.name}"), 4051 + `expected both hints, got: ${output.attrs.message}` 4052 + ); 4053 + }); 4054 + 4055 + it("unqualified binding with no matching key gives no hint", async () => { 4056 + const cardComponent: ComponentRecord = { 4057 + $type: "at.inlay.component", 4058 + body: { 4059 + $type: "at.inlay.component#bodyTemplate", 4060 + node: serializeTree( 4061 + $(Text, { value: $(Binding, { path: ["unknown"] }) }) 4062 + ), 4063 + }, 4064 + imports: [HOST_DID], 4065 + }; 4066 + 4067 + const { options } = world(); 4068 + const output = await renderToCompletion( 4069 + $(Card, {}), 4070 + options, 4071 + createContext(cardComponent, `at://${APP_DID}/at.inlay.component/${Card}`) 4072 + ); 4073 + assert.ok(output instanceof Output, "expected an Output node"); 4074 + assert.equal(output.tag, "error"); 4075 + assert.ok( 4076 + output.attrs.message 4077 + ?.toString() 4078 + .includes('bindings must start with "props" or "record"'), 4079 + `expected namespace error, got: ${output.attrs.message}` 4080 + ); 4081 + assert.ok( 4082 + !output.attrs.message?.toString().includes("Did you mean"), 4083 + `expected no hint, got: ${output.attrs.message}` 3859 4084 ); 3860 4085 }); 3861 4086 });