···55export * as Binding from "./inlay/Binding";
66export * as pack from "./inlay/pack";
77export * as Fragment from "./inlay/Fragment";
88+export * as Loading from "./inlay/Loading";
89export * as Maybe from "./inlay/Maybe";
910export * as Missing from "./inlay/Missing";
1010-export * as Loading from "./inlay/Loading";
1111export * as Slot from "./inlay/Slot";
1212export * as Throw from "./inlay/Throw";
1313export * as component from "./inlay/component";
+30-67
generated/at/inlay/component.defs.ts
···2727 | l.Unknown$TypedObject;
28282929 /**
3030- * What kinds of pages this component is a view for
3030+ * What data types this component is a view for
3131 */
3232 view?: (
3333 | l.$Typed<ViewRecord>
3434- | l.$Typed<ViewCollection>
3535- | l.$Typed<ViewIdentity>
3434+ | l.$Typed<ViewPrimitive>
3635 | l.Unknown$TypedObject
3736 )[];
3838-3939- /**
4040- * Data types this component can render. Used for data-driven discovery.
4141- */
4242- accepts?: AcceptsEntry[];
43374438 /**
4539 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···8175 l.typedUnion(
8276 [
8377 l.typedRef<ViewRecord>((() => viewRecord) as any),
8484- l.typedRef<ViewCollection>((() => viewCollection) as any),
8585- l.typedRef<ViewIdentity>((() => viewIdentity) as any),
7878+ l.typedRef<ViewPrimitive>((() => viewPrimitive) as any),
8679 ],
8780 false
8881 )
8982 )
9090- ),
9191- accepts: l.optional(
9292- l.array(l.ref<AcceptsEntry>((() => acceptsEntry) as any))
9383 ),
9484 imports: l.optional(l.array(l.string({ format: "at-uri" }))),
9585 via: l.optional(
···161151162152export { bodyTemplate };
163153164164-/** Component is a view for individual records of a collection. Omit collection for a generic record view. */
154154+/** 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. */
165155type ViewRecord = {
166156 $type?: "at.inlay.component#viewRecord";
167157···169159 * The collection this component views. Omit for any-collection.
170160 */
171161 collection?: l.NsidString;
162162+163163+ /**
164164+ * The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing.
165165+ */
166166+ rkey?: string;
167167+168168+ /**
169169+ * Which prop receives the AT URI.
170170+ */
171171+ prop: string;
172172};
173173174174export type { ViewRecord };
175175176176-/** Component is a view for individual records of a collection. Omit collection for a generic record view. */
176176+/** 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. */
177177const viewRecord = l.typedObject<ViewRecord>(
178178 $nsid,
179179 "viewRecord",
180180- l.object({ collection: l.optional(l.string({ format: "nsid" })) })
180180+ l.object({
181181+ collection: l.optional(l.string({ format: "nsid" })),
182182+ rkey: l.optional(l.string({ maxLength: 512 })),
183183+ prop: l.string({ maxLength: 256 }),
184184+ })
181185);
182186183187export { viewRecord };
184188185185-/** Component is a view for a collection listing. Omit collection for a generic collection view. */
186186-type ViewCollection = {
187187- $type?: "at.inlay.component#viewCollection";
189189+/** Component is a view for a primitive value type. */
190190+type ViewPrimitive = {
191191+ $type?: "at.inlay.component#viewPrimitive";
188192189193 /**
190190- * The collection this component lists. Omit for any-collection.
191191- */
192192- collection?: l.NsidString;
193193-};
194194-195195-export type { ViewCollection };
196196-197197-/** Component is a view for a collection listing. Omit collection for a generic collection view. */
198198-const viewCollection = l.typedObject<ViewCollection>(
199199- $nsid,
200200- "viewCollection",
201201- l.object({ collection: l.optional(l.string({ format: "nsid" })) })
202202-);
203203-204204-export { viewCollection };
205205-206206-/** Component is a view for an identity (person/DID). */
207207-type ViewIdentity = { $type?: "at.inlay.component#viewIdentity" };
208208-209209-export type { ViewIdentity };
210210-211211-/** Component is a view for an identity (person/DID). */
212212-const viewIdentity = l.typedObject<ViewIdentity>(
213213- $nsid,
214214- "viewIdentity",
215215- l.object({})
216216-);
217217-218218-export { viewIdentity };
219219-220220-/** Declares a data type this component can handle, routed to a specific prop. */
221221-type AcceptsEntry = {
222222- $type?: "at.inlay.component#acceptsEntry";
223223-224224- /**
225225- * Lexicon field type.
194194+ * Lexicon primitive type.
226195 */
227196 type:
228197 | "string"
···251220 | l.UnknownString;
252221253222 /**
254254- * Prop to bind matched data to.
223223+ * Which prop receives the value.
255224 */
256225 prop: string;
257257-258258- /**
259259- * For at-uri strings, restricts to this collection.
260260- */
261261- collection?: l.NsidString;
262226};
263227264264-export type { AcceptsEntry };
228228+export type { ViewPrimitive };
265229266266-/** Declares a data type this component can handle, routed to a specific prop. */
267267-const acceptsEntry = l.typedObject<AcceptsEntry>(
230230+/** Component is a view for a primitive value type. */
231231+const viewPrimitive = l.typedObject<ViewPrimitive>(
268232 $nsid,
269269- "acceptsEntry",
233233+ "viewPrimitive",
270234 l.object({
271235 type: l.string<{
272236 maxLength: 128;
···298262 }>({ maxLength: 64 })
299263 ),
300264 prop: l.string({ maxLength: 256 }),
301301- collection: l.optional(l.string({ format: "nsid" })),
302265 })
303266);
304267305305-export { acceptsEntry };
268268+export { viewPrimitive };
+17-35
lexicons/at/inlay/component.json
···2424 "type": "array",
2525 "items": {
2626 "type": "union",
2727- "refs": ["#viewRecord", "#viewCollection", "#viewIdentity"]
2828- },
2929- "description": "What kinds of pages this component is a view for"
3030- },
3131- "accepts": {
3232- "type": "array",
3333- "items": {
3434- "type": "ref",
3535- "ref": "#acceptsEntry"
2727+ "refs": ["#viewRecord", "#viewPrimitive"]
3628 },
3737- "description": "Data types this component can render. Used for data-driven discovery."
2929+ "description": "What data types this component is a view for"
3830 },
3931 "imports": {
4032 "type": "array",
···9183 },
9284 "viewRecord": {
9385 "type": "object",
9494- "description": "Component is a view for individual records of a collection. Omit collection for a generic record view.",
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"],
9588 "properties": {
9689 "collection": {
9790 "type": "string",
9891 "format": "nsid",
9992 "description": "The collection this component views. Omit for any-collection."
100100- }
101101- }
102102- },
103103- "viewCollection": {
104104- "type": "object",
105105- "description": "Component is a view for a collection listing. Omit collection for a generic collection view.",
106106- "properties": {
107107- "collection": {
9393+ },
9494+ "rkey": {
10895 "type": "string",
109109- "format": "nsid",
110110- "description": "The collection this component lists. Omit for any-collection."
9696+ "maxLength": 512,
9797+ "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."
111103 }
112104 }
113105 },
114114- "viewIdentity": {
106106+ "viewPrimitive": {
115107 "type": "object",
116116- "description": "Component is a view for an identity (person/DID).",
117117- "properties": {}
118118- },
119119- "acceptsEntry": {
120120- "type": "object",
121121- "description": "Declares a data type this component can handle, routed to a specific prop.",
108108+ "description": "Component is a view for a primitive value type.",
122109 "required": ["type", "prop"],
123110 "properties": {
124111 "type": {
125112 "type": "string",
126113 "maxLength": 128,
127127- "description": "Lexicon field type.",
114114+ "description": "Lexicon primitive type.",
128115 "knownValues": [
129116 "string",
130117 "integer",
···155142 "prop": {
156143 "type": "string",
157144 "maxLength": 256,
158158- "description": "Prop to bind matched data to."
159159- },
160160- "collection": {
161161- "type": "string",
162162- "format": "nsid",
163163- "description": "For at-uri strings, restricts to this collection."
145145+ "description": "Which prop receives the value."
164146 }
165147 }
166148 }
+77-81
packages/@inlay/render/src/index.ts
···1919import { validateProps } from "./validate.js";
20202121import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
2222+import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
2223import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
2324import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
2425···204205export function resolvePath(obj: unknown, path: string[]): unknown {
205206 let current: unknown = obj;
206207 for (const seg of path) {
207207- if (current == null || typeof current !== "object") return undefined;
208208+ if (current == null) return undefined;
209209+ if (typeof current === "string") {
210210+ if (!current.startsWith("at://")) return undefined;
211211+ try {
212212+ const parsed = new AtUri(current);
213213+ const uriParts: Record<string, string> = {
214214+ did: parsed.host,
215215+ collection: parsed.collection,
216216+ rkey: parsed.rkey,
217217+ };
218218+ current = uriParts[seg];
219219+ } catch {
220220+ return undefined;
221221+ }
222222+ continue;
223223+ }
224224+ if (typeof current !== "object") return undefined;
208225 if (!Object.hasOwn(current, seg)) return undefined;
209226 current = (current as Record<string, unknown>)[seg];
210227 }
···240257 const type = element.type;
241258 let resolvedProps = props;
242259 if (component.view) {
243243- resolvedProps = await expandBareDid(props, component, resolver);
260260+ resolvedProps = expandBareDid(props, component);
244261 }
245262246263 if (validate) {
···332349 return out;
333350 }) as Record<string, unknown>;
334351335335- let scope: Record<string, unknown> = { ...slottedProps };
352352+ let scope: Record<string, unknown> = { props: slottedProps };
336353 let cache: CachePolicy | undefined;
337337- const uri = props.uri as string | undefined;
338338- if (uri && uri.startsWith("at://")) {
354354+355355+ // Find a matching viewRecord and resolve its AT URI prop
356356+ const viewRecords = (component.view ?? []).filter((v) =>
357357+ viewRecordSchema.isTypeOf(v)
358358+ );
359359+360360+ 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;
365365+ }
339366 const parsed = new AtUri(uri);
340367 ensureValidNsid(parsed.collection);
341341- if (parsed.collection && parsed.rkey) {
342342- const viewRecord = component.view?.some(
343343- (v) =>
344344- v.$type === "at.inlay.component#viewRecord" &&
345345- (v as any).collection === parsed.collection
346346- );
347347- if (viewRecord) {
348348- const built = await buildScope(
349349- parsed.host,
350350- parsed.collection,
351351- parsed.rkey,
352352- tree,
353353- resolver
354354- );
355355- scope = { ...props, ...built.scope };
356356- cache = built.cache;
357357- }
368368+ if (!parsed.collection || !parsed.rkey) {
369369+ continue;
370370+ }
371371+ if (vr.collection && vr.collection !== parsed.collection) {
372372+ continue;
358373 }
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;
359384 }
360385361386 const node = resolveBindings(tree, scopeResolver(scope));
···367392 };
368393}
369394370370-async function buildScope(
395395+async function buildRecord(
371396 did: AtIdentifierString,
372397 collection: NsidString,
373398 rkey: string,
374399 tree: unknown,
375400 resolver: Resolver
376376-): Promise<{ scope: Record<string, unknown>; cache?: CachePolicy }> {
401401+): Promise<{ record: Record<string, unknown>; cache?: CachePolicy }> {
377402 const recordUri: AtUriString = `at://${did}/${collection}/${rkey}`;
378378- const scope: Record<string, unknown> = {
379379- did,
380380- collection,
381381- rkey,
382382- uri: recordUri,
383383- };
384403385385- // Skip record fetch if all bindings can be satisfied from URI parts alone
386386- if (tree && !needsRecord(tree, scope)) {
387387- return { scope };
404404+ // Skip record fetch if no bindings reference record.*
405405+ if (tree && !needsRecord(tree)) {
406406+ return { record: {} };
388407 }
389408390390- const record = await resolver.fetchRecord(recordUri);
391391- if (record && typeof record === "object") {
392392- const merged = Object.create(null) as Record<string, unknown>;
393393- Object.assign(merged, record, scope);
409409+ const fetched = await resolver.fetchRecord(recordUri);
410410+ if (fetched && typeof fetched === "object") {
394411 return {
395395- scope: merged,
412412+ record: fetched as Record<string, unknown>,
396413 cache: {
397414 tags: [{ $type: "at.inlay.defs#tagRecord", uri: recordUri }],
398415 },
399416 };
400417 }
401401- return { scope };
418418+ return { record: {} };
402419}
403420404404-/** Check if any Binding in the deserialized tree needs data beyond what scope provides. */
405405-function needsRecord(tree: unknown, scope: Record<string, unknown>): boolean {
406406- let missed = false;
421421+/** Check if any Binding in the deserialized tree references record.* */
422422+function needsRecord(tree: unknown): boolean {
423423+ let found = false;
407424 walkTree(tree, (obj, walk) => {
408425 if (isValidElement(obj)) {
409426 const el = obj as Element;
410427 if (el.type === "at.inlay.Binding") {
411428 const path = (el.props as Record<string, unknown>)?.path;
412412- if (
413413- Array.isArray(path) &&
414414- resolvePath(scope, path as string[]) == null
415415- ) {
416416- missed = true;
429429+ if (Array.isArray(path) && path[0] === "record") {
430430+ found = true;
417431 }
418432 return obj;
419433 }
···421435 for (const v of Object.values(obj)) walk(v);
422436 return obj;
423437 });
424424- return missed;
438438+ return found;
425439}
426440427441async function renderExternal(
···495509 };
496510}
497511498498-async function expandBareDid(
512512+function expandBareDid(
499513 props: Record<string, unknown>,
500500- component: ComponentRecord,
501501- resolver: Resolver
502502-): Promise<Record<string, unknown>> {
503503- const uri = props.uri as string | undefined;
504504- if (!uri || !uri.startsWith("did:")) return props;
505505-514514+ component: ComponentRecord
515515+): Record<string, unknown> {
506516 const view = component.view!;
507507- const hasIdentityView = view.some(
508508- (v) => v.$type === "at.inlay.component#viewIdentity"
509509- );
510510- if (!hasIdentityView) return props;
511517512512- const viewRecord = view.find(
513513- (v) =>
514514- v.$type === "at.inlay.component#viewRecord" && !!(v as any).collection
515515- ) as { collection: string } | undefined;
516516- if (!viewRecord) return props;
518518+ // Find the first viewRecord with both collection and rkey — that enables DID expansion
519519+ const entry = view
520520+ .filter((v) => viewRecordSchema.isTypeOf(v))
521521+ .find((v) => v.collection && v.rkey);
522522+ if (!entry) return props;
517523518518- const viewLex = (await resolver.resolveLexicon(viewRecord.collection)) as {
519519- defs?: Record<string, { key?: string }>;
520520- } | null;
524524+ const prop = entry.prop ?? "uri";
525525+ const value = props[prop] as string | undefined;
526526+ if (!value || !value.startsWith("did:")) return props;
521527522522- let identityRkey: string | null = null;
523523- if (viewLex?.defs?.main?.key?.startsWith("literal:")) {
524524- identityRkey = viewLex.defs.main.key.slice("literal:".length);
525525- } else if (!viewLex) {
526526- identityRkey = "self";
527527- }
528528-529529- if (identityRkey) {
530530- return {
531531- ...props,
532532- uri: `at://${uri}/${viewRecord.collection}/${identityRkey}`,
533533- };
534534- }
535535- return props;
528528+ return {
529529+ ...props,
530530+ [prop]: `at://${value}/${entry.collection}/${entry.rkey}`,
531531+ };
536532}
+84-41
packages/@inlay/render/src/validate.ts
···11import { Lexicons, type LexiconDoc } from "@atproto/lexicon";
22import { AtUri } from "@atproto/syntax";
33import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
44+import {
55+ viewRecord as viewRecordSchema,
66+ viewPrimitive as viewPrimitiveSchema,
77+} from "../../../../generated/at/inlay/component.defs.js";
48import type { Resolver } from "./index.js";
59610// --- Lexicon cache (module-level) ---
711812const lexiconCache = new Map<string, Lexicons>();
9131414+// --- 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+1024// --- Public API ---
11251226export async function validateProps(
···2337 lexiconCache.set(type, lexicons);
2438 }
2539 lexicons.assertValidXrpcInput(type, props);
2626- } else if (component.accepts?.length) {
2727- const propEntries = new Map<
2828- string,
2929- { type: string; formats: Set<string> }
3030- >();
3131- for (const accept of component.accepts) {
3232- if (!accept.prop) continue;
3333- const existing = propEntries.get(accept.prop);
3434- if (existing) {
3535- if (accept.format) existing.formats.add(accept.format);
3636- } else {
3737- const formats = new Set<string>();
3838- if (accept.format) formats.add(accept.format);
3939- propEntries.set(accept.prop, { type: accept.type, formats });
4040+ } else {
4141+ // Synthesize validation from view entries (viewPrimitive + viewRecord)
4242+ const primitives = getViewPrimitives(component);
4343+ const records = getViewRecords(component);
4444+4545+ if (primitives.length > 0 || records.length > 0) {
4646+ const propEntries = new Map<
4747+ string,
4848+ { type: string; formats: Set<string> }
4949+ >();
5050+5151+ // viewPrimitive entries define typed props
5252+ for (const vp of primitives) {
5353+ if (!vp.prop) continue;
5454+ const existing = propEntries.get(vp.prop);
5555+ if (existing) {
5656+ if (vp.format) existing.formats.add(vp.format);
5757+ } else {
5858+ const formats = new Set<string>();
5959+ if (vp.format) formats.add(vp.format);
6060+ propEntries.set(vp.prop, { type: vp.type, formats });
6161+ }
6262+ }
6363+6464+ // viewRecord entries imply a string prop (at-uri or did format)
6565+ for (const vr of records) {
6666+ const prop = vr.prop ?? "uri";
6767+ const existing = propEntries.get(prop);
6868+ if (existing) {
6969+ existing.formats.add("at-uri");
7070+ if (vr.rkey) existing.formats.add("did");
7171+ } else {
7272+ const formats = new Set<string>(["at-uri"]);
7373+ if (vr.rkey) formats.add("did");
7474+ propEntries.set(prop, { type: "string", formats });
7575+ }
7676+ }
7777+7878+ if (propEntries.size > 0) {
7979+ const properties: Record<string, { type: string; format?: string }> =
8080+ {};
8181+ const required = [...propEntries.keys()];
8282+ for (const [prop, { type: t, formats }] of propEntries) {
8383+ const format = unionFormats(formats);
8484+ properties[prop] = format ? { type: t, format } : { type: t };
8585+ }
8686+ const syntheticLex = {
8787+ lexicon: 1,
8888+ id: type,
8989+ defs: {
9090+ main: {
9191+ type: "procedure",
9292+ input: {
9393+ encoding: "application/json",
9494+ schema: { type: "object", required, properties },
9595+ },
9696+ },
9797+ },
9898+ } as unknown as LexiconDoc;
9999+ const lexicons = new Lexicons([syntheticLex]);
100100+ lexicons.assertValidXrpcInput(type, props);
40101 }
41102 }
4242- const properties: Record<string, { type: string; format?: string }> = {};
4343- const required = [...propEntries.keys()];
4444- for (const [prop, { type: t, formats }] of propEntries) {
4545- const format = unionFormats(formats);
4646- properties[prop] = format ? { type: t, format } : { type: t };
4747- }
4848- const syntheticLex = {
4949- lexicon: 1,
5050- id: type,
5151- defs: {
5252- main: {
5353- type: "procedure",
5454- input: {
5555- encoding: "application/json",
5656- schema: { type: "object", required, properties },
5757- },
5858- },
5959- },
6060- } as unknown as LexiconDoc;
6161- const lexicons = new Lexicons([syntheticLex]);
6262- lexicons.assertValidXrpcInput(type, props);
63103 }
641046565- if (component.accepts?.length) {
105105+ // Collection constraint checking from viewRecord entries
106106+ const records = getViewRecords(component);
107107+ if (records.length > 0) {
66108 const allowedCollections = new Map<string, Set<string>>();
6767- for (const accept of component.accepts) {
6868- if (!accept.prop || !accept.collection) continue;
6969- let set = allowedCollections.get(accept.prop);
109109+ for (const vr of records) {
110110+ if (!vr.collection) continue;
111111+ const prop = vr.prop ?? "uri";
112112+ let set = allowedCollections.get(prop);
70113 if (!set) {
71114 set = new Set();
7272- allowedCollections.set(accept.prop, set);
115115+ allowedCollections.set(prop, set);
73116 }
7474- set.add(accept.collection);
117117+ set.add(vr.collection);
75118 }
76119 for (const [prop, allowed] of allowedCollections) {
77120 const value = props[prop];