···27 | l.Unknown$TypedObject;
2829 /**
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- )[];
3738 /**
39 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···70 false
71 )
72 ),
73- view: l.optional(
74- l.array(
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(
86 l.typedUnion(
···151152export { bodyTemplate };
153154-/** 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. */
000000000000000000000000000000000000000000155type ViewRecord = {
156 $type?: "at.inlay.component#viewRecord";
157···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};
173174export type { ViewRecord };
175176-/** 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. */
177const 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);
186187export { viewRecord };
188189-/** Component is a view for a primitive value type. */
190type ViewPrimitive = {
191 $type?: "at.inlay.component#viewPrimitive";
192···218 | "record-key"
219 | "tid"
220 | l.UnknownString;
221-222- /**
223- * Which prop receives the value.
224- */
225- prop: string;
226};
227228export type { ViewPrimitive };
229230-/** Component is a view for a primitive value type. */
231const viewPrimitive = l.typedObject<ViewPrimitive>(
232 $nsid,
233 "viewPrimitive",
···261 ];
262 }>({ maxLength: 64 })
263 ),
264- prop: l.string({ maxLength: 256 }),
265 })
266);
267
···27 | l.Unknown$TypedObject;
2829 /**
30+ * What data this component views and which prop receives it
31 */
32+ view?: View;
00003334 /**
35 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···66 false
67 )
68 ),
69+ view: l.optional(l.ref<View>((() => view) as any)),
000000000070 imports: l.optional(l.array(l.string({ format: "at-uri" }))),
71 via: l.optional(
72 l.typedUnion(
···137138export { bodyTemplate };
139140+/** Declares what data this component views and which prop receives it. */
141+type View = {
142+ $type?: "at.inlay.component#view";
143+144+ /**
145+ * Which component prop receives the view data.
146+ */
147+ prop: string;
148+149+ /**
150+ * Data types this view accepts.
151+ */
152+ accepts: (
153+ | l.$Typed<ViewRecord>
154+ | l.$Typed<ViewPrimitive>
155+ | l.Unknown$TypedObject
156+ )[];
157+};
158+159+export type { View };
160+161+/** Declares what data this component views and which prop receives it. */
162+const view = l.typedObject<View>(
163+ $nsid,
164+ "view",
165+ l.object({
166+ prop: l.string({ maxLength: 256 }),
167+ accepts: l.array(
168+ l.typedUnion(
169+ [
170+ l.typedRef<ViewRecord>((() => viewRecord) as any),
171+ l.typedRef<ViewPrimitive>((() => viewPrimitive) as any),
172+ ],
173+ false
174+ ),
175+ { minLength: 1 }
176+ ),
177+ })
178+);
179+180+export { view };
181+182+/** 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. */
183type ViewRecord = {
184 $type?: "at.inlay.component#viewRecord";
185···192 * The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing.
193 */
194 rkey?: string;
00000195};
196197export type { ViewRecord };
198199+/** 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. */
200const viewRecord = l.typedObject<ViewRecord>(
201 $nsid,
202 "viewRecord",
203 l.object({
204 collection: l.optional(l.string({ format: "nsid" })),
205 rkey: l.optional(l.string({ maxLength: 512 })),
0206 })
207);
208209export { viewRecord };
210211+/** View accepts a primitive value type. */
212type ViewPrimitive = {
213 $type?: "at.inlay.component#viewPrimitive";
214···240 | "record-key"
241 | "tid"
242 | l.UnknownString;
00000243};
244245export type { ViewPrimitive };
246247+/** View accepts a primitive value type. */
248const viewPrimitive = l.typedObject<ViewPrimitive>(
249 $nsid,
250 "viewPrimitive",
···278 ];
279 }>({ maxLength: 64 })
280 ),
0281 })
282);
283
+27-20
lexicons/at/inlay/component.json
···21 "description": "How this component is rendered. Omit for primitives rendered by the host."
22 },
23 "view": {
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",
···81 }
82 }
83 },
00000000000000000000084 "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",
···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",
···138 "record-key",
139 "tid"
140 ]
141- },
142- "prop": {
143- "type": "string",
144- "maxLength": 256,
145- "description": "Which prop receives the value."
146 }
147 }
148 }
···21 "description": "How this component is rendered. Omit for primitives rendered by the host."
22 },
23 "view": {
24+ "type": "ref",
25+ "ref": "#view",
26+ "description": "What data this component views and which prop receives it"
00027 },
28 "imports": {
29 "type": "array",
···78 }
79 }
80 },
81+ "view": {
82+ "type": "object",
83+ "description": "Declares what data this component views and which prop receives it.",
84+ "required": ["prop", "accepts"],
85+ "properties": {
86+ "prop": {
87+ "type": "string",
88+ "maxLength": 256,
89+ "description": "Which component prop receives the view data."
90+ },
91+ "accepts": {
92+ "type": "array",
93+ "minLength": 1,
94+ "items": {
95+ "type": "union",
96+ "refs": ["#viewRecord", "#viewPrimitive"]
97+ },
98+ "description": "Data types this view accepts."
99+ }
100+ }
101+ },
102 "viewRecord": {
103 "type": "object",
104+ "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.",
0105 "properties": {
106 "collection": {
107 "type": "string",
···112 "type": "string",
113 "maxLength": 512,
114 "description": "The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing."
00000115 }
116 }
117 },
118 "viewPrimitive": {
119 "type": "object",
120+ "description": "View accepts a primitive value type.",
121+ "required": ["type"],
122 "properties": {
123 "type": {
124 "type": "string",
···150 "record-key",
151 "tid"
152 ]
00000153 }
154 }
155 }
+64-43
packages/@inlay/render/src/index.ts
···18} from "@atproto/syntax";
19import { validateProps } from "./validate.js";
2021-import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
00022import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
23import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
24import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
···205export function resolvePath(obj: unknown, path: string[]): unknown {
206 let current: unknown = obj;
207 for (const seg of path) {
208- if (current == null) return undefined;
00209 if (typeof current === "string") {
210- if (!current.startsWith("at://")) return undefined;
00211 try {
212 const parsed = new AtUri(current);
213 const uriParts: Record<string, string> = {
···221 }
222 continue;
223 }
224- if (typeof current !== "object") return undefined;
225- if (!Object.hasOwn(current, seg)) return undefined;
0000226 current = (current as Record<string, unknown>)[seg];
227 }
228 return current;
···257 const type = element.type;
258 let resolvedProps = props;
259 if (component.view) {
260- resolvedProps = expandBareDid(props, component);
261 }
262263 if (validate) {
···295296 for (let i = 0; i < importStack.length; i++) {
297 const pack = (await packPromises[i]) as PackRecord | null;
298- if (!pack || !pack.exports) continue;
00299300 const entry = pack.exports.find((e) => e.type === nsid);
301- if (!entry) continue;
00302303 const component = (await resolver.fetchRecord(
304 entry.component
305 )) as ComponentRecord | null;
306- if (!component) continue;
00307308 return {
309 componentUri: entry.component,
···353 let cache: CachePolicy | undefined;
354355 // Find a matching viewRecord and resolve its AT URI prop
356- const viewRecords = (component.view ?? []).filter((v) =>
357- viewRecordSchema.isTypeOf(v)
358- );
359360- 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;
000000000000000000365 }
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 }
385386 const node = resolveBindings(tree, scopeResolver(scope));
···454 const refSlots = new Set<object>();
455 const wireProps = serializeTree(props, (el) => {
456 if (el.type === "at.inlay.Slot") {
457- if (refSlots.has(el)) return el;
00458 throw new Error("Unexpected Slot in props");
459 }
460 const id = String(refs.size);
···511512function expandBareDid(
513 props: Record<string, unknown>,
514- component: ComponentRecord
515): Record<string, unknown> {
516- const view = component.view!;
517518 // 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;
00527528 return {
529 ...props,
···18} from "@atproto/syntax";
19import { validateProps } from "./validate.js";
2021+import type {
22+ Main as ComponentRecord,
23+ View,
24+} from "../../../../generated/at/inlay/component.defs.js";
25import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
26import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
27import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
···208export function resolvePath(obj: unknown, path: string[]): unknown {
209 let current: unknown = obj;
210 for (const seg of path) {
211+ if (current == null) {
212+ return undefined;
213+ }
214 if (typeof current === "string") {
215+ if (!current.startsWith("at://")) {
216+ return undefined;
217+ }
218 try {
219 const parsed = new AtUri(current);
220 const uriParts: Record<string, string> = {
···228 }
229 continue;
230 }
231+ if (typeof current !== "object") {
232+ return undefined;
233+ }
234+ if (!Object.hasOwn(current, seg)) {
235+ return undefined;
236+ }
237 current = (current as Record<string, unknown>)[seg];
238 }
239 return current;
···268 const type = element.type;
269 let resolvedProps = props;
270 if (component.view) {
271+ resolvedProps = expandBareDid(props, component.view);
272 }
273274 if (validate) {
···306307 for (let i = 0; i < importStack.length; i++) {
308 const pack = (await packPromises[i]) as PackRecord | null;
309+ if (!pack || !pack.exports) {
310+ continue;
311+ }
312313 const entry = pack.exports.find((e) => e.type === nsid);
314+ if (!entry) {
315+ continue;
316+ }
317318 const component = (await resolver.fetchRecord(
319 entry.component
320 )) as ComponentRecord | null;
321+ if (!component) {
322+ continue;
323+ }
324325 return {
326 componentUri: entry.component,
···370 let cache: CachePolicy | undefined;
371372 // Find a matching viewRecord and resolve its AT URI prop
373+ if (component.view) {
374+ const { prop, accepts } = component.view;
375+ const viewRecords = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
376377+ for (const vr of viewRecords) {
378+ const uri = props[prop] as string | undefined;
379+ if (!uri || !uri.startsWith("at://")) {
380+ continue;
381+ }
382+ const parsed = new AtUri(uri);
383+ ensureValidNsid(parsed.collection);
384+ if (!parsed.collection || !parsed.rkey) {
385+ continue;
386+ }
387+ if (vr.collection && vr.collection !== parsed.collection) {
388+ continue;
389+ }
390+ const built = await buildRecord(
391+ parsed.host,
392+ parsed.collection,
393+ parsed.rkey,
394+ tree,
395+ resolver
396+ );
397+ scope = { props: slottedProps, record: built.record };
398+ cache = built.cache;
399+ break;
400 }
000000000000000000401 }
402403 const node = resolveBindings(tree, scopeResolver(scope));
···471 const refSlots = new Set<object>();
472 const wireProps = serializeTree(props, (el) => {
473 if (el.type === "at.inlay.Slot") {
474+ if (refSlots.has(el)) {
475+ return el;
476+ }
477 throw new Error("Unexpected Slot in props");
478 }
479 const id = String(refs.size);
···530531function expandBareDid(
532 props: Record<string, unknown>,
533+ view: View
534): Record<string, unknown> {
535+ const { prop, accepts } = view;
536537 // Find the first viewRecord with both collection and rkey — that enables DID expansion
538+ const entry = accepts
539 .filter((v) => viewRecordSchema.isTypeOf(v))
540 .find((v) => v.collection && v.rkey);
541+ if (!entry) {
542+ return props;
543+ }
544 const value = props[prop] as string | undefined;
545+ if (!value || !value.startsWith("did:")) {
546+ return props;
547+ }
548549 return {
550 ...props,
+43-42
packages/@inlay/render/src/validate.ts
···1112const lexiconCache = new Map<string, Lexicons>();
1314-// --- 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 ---
2526export 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);
04445 if (primitives.length > 0 || records.length > 0) {
46 const propEntries = new Map<
···5051 // 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 }
6364 // 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···103 }
104105 // 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);
00000116 }
117- set.add(vr.collection);
118- }
119- for (const [prop, allowed] of allowedCollections) {
120- const value = props[prop];
121- if (typeof value !== "string" || !value.startsWith("at://")) continue;
122- const parsed = new AtUri(value);
123- if (!parsed.collection) continue;
124- if (!allowed.has(parsed.collection)) {
125- throw new Error(
126- `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}`
127- );
000128 }
129 }
130 }
···140141function unionFormats(formats: Set<string>): string | undefined {
142 const arr = [...formats];
143- if (arr.length <= 1) return arr[0];
00144 const ancestorSets = arr.map(
145 (f) => new Set([f, ...(FORMAT_ANCESTORS[f] ?? [])])
146 );
147 const common = [...ancestorSets[0]].filter((f) =>
148 ancestorSets.every((s) => s.has(f))
149 );
150- if (common.length === 0) return undefined;
00151 if (common.length === 1) return common[0];
152 return common.find(
153 (f) =>
···1112const lexiconCache = new Map<string, Lexicons>();
13000000000014// --- Public API ---
1516export async function validateProps(
···27 lexiconCache.set(type, lexicons);
28 }
29 lexicons.assertValidXrpcInput(type, props);
30+ } else if (component.view) {
31 // Synthesize validation from view entries (viewPrimitive + viewRecord)
32+ const { prop: viewProp, accepts } = component.view;
33+ const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v));
34+ const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
3536 if (primitives.length > 0 || records.length > 0) {
37 const propEntries = new Map<
···4142 // viewPrimitive entries define typed props
43 for (const vp of primitives) {
44+ const existing = propEntries.get(viewProp);
045 if (existing) {
46 if (vp.format) existing.formats.add(vp.format);
47 } else {
48 const formats = new Set<string>();
49 if (vp.format) formats.add(vp.format);
50+ propEntries.set(viewProp, { type: vp.type, formats });
51 }
52 }
5354 // viewRecord entries imply a string prop (at-uri or did format)
55 for (const vr of records) {
56+ const existing = propEntries.get(viewProp);
057 if (existing) {
58 existing.formats.add("at-uri");
59 if (vr.rkey) existing.formats.add("did");
60 } else {
61 const formats = new Set<string>(["at-uri"]);
62 if (vr.rkey) formats.add("did");
63+ propEntries.set(viewProp, { type: "string", formats });
64 }
65 }
66···92 }
9394 // Collection constraint checking from viewRecord entries
95+ if (component.view) {
96+ const { prop: collectionProp, accepts } = component.view;
97+ const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
98+ if (records.length > 0) {
99+ const allowedCollections = new Map<string, Set<string>>();
100+ for (const vr of records) {
101+ if (!vr.collection) {
102+ continue;
103+ }
104+ let set = allowedCollections.get(collectionProp);
105+ if (!set) {
106+ set = new Set();
107+ allowedCollections.set(collectionProp, set);
108+ }
109+ set.add(vr.collection);
110 }
111+ for (const [prop, allowed] of allowedCollections) {
112+ const value = props[prop];
113+ if (typeof value !== "string" || !value.startsWith("at://")) {
114+ continue;
115+ }
116+ const parsed = new AtUri(value);
117+ if (!parsed.collection) {
118+ continue;
119+ }
120+ if (!allowed.has(parsed.collection)) {
121+ throw new Error(
122+ `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}`
123+ );
124+ }
125 }
126 }
127 }
···137138function unionFormats(formats: Set<string>): string | undefined {
139 const arr = [...formats];
140+ if (arr.length <= 1) {
141+ return arr[0];
142+ }
143 const ancestorSets = arr.map(
144 (f) => new Set([f, ...(FORMAT_ANCESTORS[f] ?? [])])
145 );
146 const common = [...ancestorSets[0]].filter((f) =>
147 ancestorSets.every((s) => s.has(f))
148 );
149+ if (common.length === 0) {
150+ return undefined;
151+ }
152 if (common.length === 1) return common[0];
153 return common.find(
154 (f) =>