···2727 | l.Unknown$TypedObject;
28282929 /**
3030- * What data types this component is a view for
3030+ * What data this component views and which prop receives it
3131 */
3232- view?: (
3333- | l.$Typed<ViewRecord>
3434- | l.$Typed<ViewPrimitive>
3535- | l.Unknown$TypedObject
3636- )[];
3232+ view?: View;
37333834 /**
3935 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···7066 false
7167 )
7268 ),
7373- view: l.optional(
7474- l.array(
7575- l.typedUnion(
7676- [
7777- l.typedRef<ViewRecord>((() => viewRecord) as any),
7878- l.typedRef<ViewPrimitive>((() => viewPrimitive) as any),
7979- ],
8080- false
8181- )
8282- )
8383- ),
6969+ view: l.optional(l.ref<View>((() => view) as any)),
8470 imports: l.optional(l.array(l.string({ format: "at-uri" }))),
8571 via: l.optional(
8672 l.typedUnion(
···151137152138export { bodyTemplate };
153139154154-/** 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. */
140140+/** Declares what data this component views and which prop receives it. */
141141+type View = {
142142+ $type?: "at.inlay.component#view";
143143+144144+ /**
145145+ * Which component prop receives the view data.
146146+ */
147147+ prop: string;
148148+149149+ /**
150150+ * Data types this view accepts.
151151+ */
152152+ accepts: (
153153+ | l.$Typed<ViewRecord>
154154+ | l.$Typed<ViewPrimitive>
155155+ | l.Unknown$TypedObject
156156+ )[];
157157+};
158158+159159+export type { View };
160160+161161+/** Declares what data this component views and which prop receives it. */
162162+const view = l.typedObject<View>(
163163+ $nsid,
164164+ "view",
165165+ l.object({
166166+ prop: l.string({ maxLength: 256 }),
167167+ accepts: l.array(
168168+ l.typedUnion(
169169+ [
170170+ l.typedRef<ViewRecord>((() => viewRecord) as any),
171171+ l.typedRef<ViewPrimitive>((() => viewPrimitive) as any),
172172+ ],
173173+ false
174174+ ),
175175+ { minLength: 1 }
176176+ ),
177177+ })
178178+);
179179+180180+export { view };
181181+182182+/** View accepts 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. */
155183type ViewRecord = {
156184 $type?: "at.inlay.component#viewRecord";
157185···164192 * The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing.
165193 */
166194 rkey?: string;
167167-168168- /**
169169- * Which prop receives the AT URI.
170170- */
171171- prop: string;
172195};
173196174197export type { ViewRecord };
175198176176-/** 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. */
199199+/** View accepts 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. */
177200const viewRecord = l.typedObject<ViewRecord>(
178201 $nsid,
179202 "viewRecord",
180203 l.object({
181204 collection: l.optional(l.string({ format: "nsid" })),
182205 rkey: l.optional(l.string({ maxLength: 512 })),
183183- prop: l.string({ maxLength: 256 }),
184206 })
185207);
186208187209export { viewRecord };
188210189189-/** Component is a view for a primitive value type. */
211211+/** View accepts a primitive value type. */
190212type ViewPrimitive = {
191213 $type?: "at.inlay.component#viewPrimitive";
192214···218240 | "record-key"
219241 | "tid"
220242 | l.UnknownString;
221221-222222- /**
223223- * Which prop receives the value.
224224- */
225225- prop: string;
226243};
227244228245export type { ViewPrimitive };
229246230230-/** Component is a view for a primitive value type. */
247247+/** View accepts a primitive value type. */
231248const viewPrimitive = l.typedObject<ViewPrimitive>(
232249 $nsid,
233250 "viewPrimitive",
···261278 ];
262279 }>({ maxLength: 64 })
263280 ),
264264- prop: l.string({ maxLength: 256 }),
265281 })
266282);
267283
+27-20
lexicons/at/inlay/component.json
···2121 "description": "How this component is rendered. Omit for primitives rendered by the host."
2222 },
2323 "view": {
2424- "type": "array",
2525- "items": {
2626- "type": "union",
2727- "refs": ["#viewRecord", "#viewPrimitive"]
2828- },
2929- "description": "What data types this component is a view for"
2424+ "type": "ref",
2525+ "ref": "#view",
2626+ "description": "What data this component views and which prop receives it"
3027 },
3128 "imports": {
3229 "type": "array",
···8178 }
8279 }
8380 },
8181+ "view": {
8282+ "type": "object",
8383+ "description": "Declares what data this component views and which prop receives it.",
8484+ "required": ["prop", "accepts"],
8585+ "properties": {
8686+ "prop": {
8787+ "type": "string",
8888+ "maxLength": 256,
8989+ "description": "Which component prop receives the view data."
9090+ },
9191+ "accepts": {
9292+ "type": "array",
9393+ "minLength": 1,
9494+ "items": {
9595+ "type": "union",
9696+ "refs": ["#viewRecord", "#viewPrimitive"]
9797+ },
9898+ "description": "Data types this view accepts."
9999+ }
100100+ }
101101+ },
84102 "viewRecord": {
85103 "type": "object",
8686- "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.",
8787- "required": ["prop"],
104104+ "description": "View accepts 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.",
88105 "properties": {
89106 "collection": {
90107 "type": "string",
···95112 "type": "string",
96113 "maxLength": 512,
97114 "description": "The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing."
9898- },
9999- "prop": {
100100- "type": "string",
101101- "maxLength": 256,
102102- "description": "Which prop receives the AT URI."
103115 }
104116 }
105117 },
106118 "viewPrimitive": {
107119 "type": "object",
108108- "description": "Component is a view for a primitive value type.",
109109- "required": ["type", "prop"],
120120+ "description": "View accepts a primitive value type.",
121121+ "required": ["type"],
110122 "properties": {
111123 "type": {
112124 "type": "string",
···138150 "record-key",
139151 "tid"
140152 ]
141141- },
142142- "prop": {
143143- "type": "string",
144144- "maxLength": 256,
145145- "description": "Which prop receives the value."
146153 }
147154 }
148155 }
+64-43
packages/@inlay/render/src/index.ts
···1818} from "@atproto/syntax";
1919import { validateProps } from "./validate.js";
20202121-import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
2121+import type {
2222+ Main as ComponentRecord,
2323+ View,
2424+} from "../../../../generated/at/inlay/component.defs.js";
2225import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
2326import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
2427import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
···205208export function resolvePath(obj: unknown, path: string[]): unknown {
206209 let current: unknown = obj;
207210 for (const seg of path) {
208208- if (current == null) return undefined;
211211+ if (current == null) {
212212+ return undefined;
213213+ }
209214 if (typeof current === "string") {
210210- if (!current.startsWith("at://")) return undefined;
215215+ if (!current.startsWith("at://")) {
216216+ return undefined;
217217+ }
211218 try {
212219 const parsed = new AtUri(current);
213220 const uriParts: Record<string, string> = {
···221228 }
222229 continue;
223230 }
224224- if (typeof current !== "object") return undefined;
225225- if (!Object.hasOwn(current, seg)) return undefined;
231231+ if (typeof current !== "object") {
232232+ return undefined;
233233+ }
234234+ if (!Object.hasOwn(current, seg)) {
235235+ return undefined;
236236+ }
226237 current = (current as Record<string, unknown>)[seg];
227238 }
228239 return current;
···257268 const type = element.type;
258269 let resolvedProps = props;
259270 if (component.view) {
260260- resolvedProps = expandBareDid(props, component);
271271+ resolvedProps = expandBareDid(props, component.view);
261272 }
262273263274 if (validate) {
···295306296307 for (let i = 0; i < importStack.length; i++) {
297308 const pack = (await packPromises[i]) as PackRecord | null;
298298- if (!pack || !pack.exports) continue;
309309+ if (!pack || !pack.exports) {
310310+ continue;
311311+ }
299312300313 const entry = pack.exports.find((e) => e.type === nsid);
301301- if (!entry) continue;
314314+ if (!entry) {
315315+ continue;
316316+ }
302317303318 const component = (await resolver.fetchRecord(
304319 entry.component
305320 )) as ComponentRecord | null;
306306- if (!component) continue;
321321+ if (!component) {
322322+ continue;
323323+ }
307324308325 return {
309326 componentUri: entry.component,
···353370 let cache: CachePolicy | undefined;
354371355372 // Find a matching viewRecord and resolve its AT URI prop
356356- const viewRecords = (component.view ?? []).filter((v) =>
357357- viewRecordSchema.isTypeOf(v)
358358- );
373373+ if (component.view) {
374374+ const { prop, accepts } = component.view;
375375+ const viewRecords = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
359376360360- for (const vr of viewRecords) {
361361- const prop = vr.prop;
362362- const uri = props[prop] as string | undefined;
363363- if (!uri || !uri.startsWith("at://")) {
364364- continue;
377377+ for (const vr of viewRecords) {
378378+ const uri = props[prop] as string | undefined;
379379+ if (!uri || !uri.startsWith("at://")) {
380380+ continue;
381381+ }
382382+ const parsed = new AtUri(uri);
383383+ ensureValidNsid(parsed.collection);
384384+ if (!parsed.collection || !parsed.rkey) {
385385+ continue;
386386+ }
387387+ if (vr.collection && vr.collection !== parsed.collection) {
388388+ continue;
389389+ }
390390+ const built = await buildRecord(
391391+ parsed.host,
392392+ parsed.collection,
393393+ parsed.rkey,
394394+ tree,
395395+ resolver
396396+ );
397397+ scope = { props: slottedProps, record: built.record };
398398+ cache = built.cache;
399399+ break;
365400 }
366366- const parsed = new AtUri(uri);
367367- ensureValidNsid(parsed.collection);
368368- if (!parsed.collection || !parsed.rkey) {
369369- continue;
370370- }
371371- if (vr.collection && vr.collection !== parsed.collection) {
372372- continue;
373373- }
374374- const built = await buildRecord(
375375- parsed.host,
376376- parsed.collection,
377377- parsed.rkey,
378378- tree,
379379- resolver
380380- );
381381- scope = { props: slottedProps, record: built.record };
382382- cache = built.cache;
383383- break;
384401 }
385402386403 const node = resolveBindings(tree, scopeResolver(scope));
···454471 const refSlots = new Set<object>();
455472 const wireProps = serializeTree(props, (el) => {
456473 if (el.type === "at.inlay.Slot") {
457457- if (refSlots.has(el)) return el;
474474+ if (refSlots.has(el)) {
475475+ return el;
476476+ }
458477 throw new Error("Unexpected Slot in props");
459478 }
460479 const id = String(refs.size);
···511530512531function expandBareDid(
513532 props: Record<string, unknown>,
514514- component: ComponentRecord
533533+ view: View
515534): Record<string, unknown> {
516516- const view = component.view!;
535535+ const { prop, accepts } = view;
517536518537 // Find the first viewRecord with both collection and rkey — that enables DID expansion
519519- const entry = view
538538+ const entry = accepts
520539 .filter((v) => viewRecordSchema.isTypeOf(v))
521540 .find((v) => v.collection && v.rkey);
522522- if (!entry) return props;
523523-524524- const prop = entry.prop ?? "uri";
541541+ if (!entry) {
542542+ return props;
543543+ }
525544 const value = props[prop] as string | undefined;
526526- if (!value || !value.startsWith("did:")) return props;
545545+ if (!value || !value.startsWith("did:")) {
546546+ return props;
547547+ }
527548528549 return {
529550 ...props,
+43-42
packages/@inlay/render/src/validate.ts
···11111212const lexiconCache = new Map<string, Lexicons>();
13131414-// --- Helpers ---
1515-1616-function getViewPrimitives(component: ComponentRecord) {
1717- return (component.view ?? []).filter((v) => viewPrimitiveSchema.isTypeOf(v));
1818-}
1919-2020-function getViewRecords(component: ComponentRecord) {
2121- return (component.view ?? []).filter((v) => viewRecordSchema.isTypeOf(v));
2222-}
2323-2414// --- Public API ---
25152616export async function validateProps(
···3727 lexiconCache.set(type, lexicons);
3828 }
3929 lexicons.assertValidXrpcInput(type, props);
4040- } else {
3030+ } else if (component.view) {
4131 // Synthesize validation from view entries (viewPrimitive + viewRecord)
4242- const primitives = getViewPrimitives(component);
4343- const records = getViewRecords(component);
3232+ const { prop: viewProp, accepts } = component.view;
3333+ const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v));
3434+ const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
44354536 if (primitives.length > 0 || records.length > 0) {
4637 const propEntries = new Map<
···50415142 // viewPrimitive entries define typed props
5243 for (const vp of primitives) {
5353- if (!vp.prop) continue;
5454- const existing = propEntries.get(vp.prop);
4444+ const existing = propEntries.get(viewProp);
5545 if (existing) {
5646 if (vp.format) existing.formats.add(vp.format);
5747 } else {
5848 const formats = new Set<string>();
5949 if (vp.format) formats.add(vp.format);
6060- propEntries.set(vp.prop, { type: vp.type, formats });
5050+ propEntries.set(viewProp, { type: vp.type, formats });
6151 }
6252 }
63536454 // viewRecord entries imply a string prop (at-uri or did format)
6555 for (const vr of records) {
6666- const prop = vr.prop ?? "uri";
6767- const existing = propEntries.get(prop);
5656+ const existing = propEntries.get(viewProp);
6857 if (existing) {
6958 existing.formats.add("at-uri");
7059 if (vr.rkey) existing.formats.add("did");
7160 } else {
7261 const formats = new Set<string>(["at-uri"]);
7362 if (vr.rkey) formats.add("did");
7474- propEntries.set(prop, { type: "string", formats });
6363+ propEntries.set(viewProp, { type: "string", formats });
7564 }
7665 }
7766···10392 }
1049310594 // Collection constraint checking from viewRecord entries
106106- const records = getViewRecords(component);
107107- if (records.length > 0) {
108108- const allowedCollections = new Map<string, Set<string>>();
109109- for (const vr of records) {
110110- if (!vr.collection) continue;
111111- const prop = vr.prop ?? "uri";
112112- let set = allowedCollections.get(prop);
113113- if (!set) {
114114- set = new Set();
115115- allowedCollections.set(prop, set);
9595+ if (component.view) {
9696+ const { prop: collectionProp, accepts } = component.view;
9797+ const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
9898+ if (records.length > 0) {
9999+ const allowedCollections = new Map<string, Set<string>>();
100100+ for (const vr of records) {
101101+ if (!vr.collection) {
102102+ continue;
103103+ }
104104+ let set = allowedCollections.get(collectionProp);
105105+ if (!set) {
106106+ set = new Set();
107107+ allowedCollections.set(collectionProp, set);
108108+ }
109109+ set.add(vr.collection);
116110 }
117117- set.add(vr.collection);
118118- }
119119- for (const [prop, allowed] of allowedCollections) {
120120- const value = props[prop];
121121- if (typeof value !== "string" || !value.startsWith("at://")) continue;
122122- const parsed = new AtUri(value);
123123- if (!parsed.collection) continue;
124124- if (!allowed.has(parsed.collection)) {
125125- throw new Error(
126126- `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}`
127127- );
111111+ for (const [prop, allowed] of allowedCollections) {
112112+ const value = props[prop];
113113+ if (typeof value !== "string" || !value.startsWith("at://")) {
114114+ continue;
115115+ }
116116+ const parsed = new AtUri(value);
117117+ if (!parsed.collection) {
118118+ continue;
119119+ }
120120+ if (!allowed.has(parsed.collection)) {
121121+ throw new Error(
122122+ `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}`
123123+ );
124124+ }
128125 }
129126 }
130127 }
···140137141138function unionFormats(formats: Set<string>): string | undefined {
142139 const arr = [...formats];
143143- if (arr.length <= 1) return arr[0];
140140+ if (arr.length <= 1) {
141141+ return arr[0];
142142+ }
144143 const ancestorSets = arr.map(
145144 (f) => new Set([f, ...(FORMAT_ANCESTORS[f] ?? [])])
146145 );
147146 const common = [...ancestorSets[0]].filter((f) =>
148147 ancestorSets.every((s) => s.has(f))
149148 );
150150- if (common.length === 0) return undefined;
149149+ if (common.length === 0) {
150150+ return undefined;
151151+ }
151152 if (common.length === 1) return common[0];
152153 return common.find(
153154 (f) =>