social components
inlay-proto.up.railway.app/
atproto
components
sdui
1import { Lexicons, type LexiconDoc } from "@atproto/lexicon";
2import { AtUri } from "@atproto/syntax";
3import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
4import {
5 viewRecord as viewRecordSchema,
6 viewPrimitive as viewPrimitiveSchema,
7} from "../../../../generated/at/inlay/component.defs.js";
8import type { Resolver } from "./index.js";
9
10// --- Lexicon cache (module-level) ---
11
12const lexiconCache = new Map<string, Lexicons>();
13
14// --- Public API ---
15
16export async function validateProps(
17 type: string,
18 props: Record<string, unknown>,
19 component: ComponentRecord,
20 resolver: Resolver
21): Promise<Record<string, unknown>> {
22 let result: Record<string, unknown> = props;
23
24 const lex = await resolver.resolveLexicon(type);
25 if (lex) {
26 let lexicons = lexiconCache.get(type);
27 if (!lexicons) {
28 lexicons = await buildLexicons(lex as Record<string, unknown>, resolver);
29 lexiconCache.set(type, lexicons);
30 }
31 result = lexicons.assertValidXrpcInput(type, props) as Record<
32 string,
33 unknown
34 >;
35 } else if (component.view) {
36 // Synthesize validation from view entries (viewPrimitive + viewRecord)
37 const { prop: viewProp, accepts } = component.view;
38 const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v));
39 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
40
41 if (primitives.length > 0 || records.length > 0) {
42 const propEntries = new Map<
43 string,
44 { type: string; formats: Set<string> }
45 >();
46
47 // viewPrimitive entries define typed props
48 for (const vp of primitives) {
49 const existing = propEntries.get(viewProp);
50 if (existing) {
51 if (vp.format) existing.formats.add(vp.format);
52 } else {
53 const formats = new Set<string>();
54 if (vp.format) formats.add(vp.format);
55 propEntries.set(viewProp, { type: vp.type, formats });
56 }
57 }
58
59 // viewRecord entries imply a string prop (at-uri or did format)
60 for (const vr of records) {
61 const existing = propEntries.get(viewProp);
62 if (existing) {
63 existing.formats.add("at-uri");
64 if (vr.rkey) existing.formats.add("did");
65 } else {
66 const formats = new Set<string>(["at-uri"]);
67 if (vr.rkey) formats.add("did");
68 propEntries.set(viewProp, { type: "string", formats });
69 }
70 }
71
72 if (propEntries.size > 0) {
73 const properties: Record<string, { type: string; format?: string }> =
74 {};
75 const required = [...propEntries.keys()];
76 for (const [prop, { type: t, formats }] of propEntries) {
77 const format = unionFormats(formats);
78 properties[prop] = format ? { type: t, format } : { type: t };
79 }
80 const syntheticLex = {
81 lexicon: 1,
82 id: type,
83 defs: {
84 main: {
85 type: "procedure",
86 input: {
87 encoding: "application/json",
88 schema: { type: "object", required, properties },
89 },
90 },
91 },
92 } as unknown as LexiconDoc;
93 const lexicons = new Lexicons([syntheticLex]);
94 result = lexicons.assertValidXrpcInput(type, props) as Record<
95 string,
96 unknown
97 >;
98 }
99 }
100 }
101
102 // Collection constraint checking from viewRecord entries
103 if (component.view) {
104 const { prop: collectionProp, accepts } = component.view;
105 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
106 if (records.length > 0) {
107 const allowedCollections = new Map<string, Set<string>>();
108 for (const vr of records) {
109 if (!vr.collection) {
110 continue;
111 }
112 let set = allowedCollections.get(collectionProp);
113 if (!set) {
114 set = new Set();
115 allowedCollections.set(collectionProp, set);
116 }
117 set.add(vr.collection);
118 }
119 for (const [prop, allowed] of allowedCollections) {
120 const value = result[prop];
121 if (typeof value !== "string" || !value.startsWith("at://")) {
122 continue;
123 }
124 const parsed = new AtUri(value);
125 if (!parsed.collection) {
126 continue;
127 }
128 if (!allowed.has(parsed.collection)) {
129 throw new Error(
130 `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}`
131 );
132 }
133 }
134 }
135 }
136
137 return result;
138}
139
140// --- Internals ---
141
142const FORMAT_ANCESTORS: Record<string, string[]> = {
143 did: ["uri", "at-identifier"],
144 "at-uri": ["uri"],
145 handle: ["at-identifier"],
146};
147
148function unionFormats(formats: Set<string>): string | undefined {
149 const arr = [...formats];
150 if (arr.length <= 1) {
151 return arr[0];
152 }
153 const ancestorSets = arr.map(
154 (f) => new Set([f, ...(FORMAT_ANCESTORS[f] ?? [])])
155 );
156 const common = [...ancestorSets[0]].filter((f) =>
157 ancestorSets.every((s) => s.has(f))
158 );
159 if (common.length === 0) {
160 return undefined;
161 }
162 if (common.length === 1) return common[0];
163 return common.find(
164 (f) =>
165 !common.some((g) => g !== f && (FORMAT_ANCESTORS[g] ?? []).includes(f))
166 );
167}
168
169function collectRefNsids(obj: unknown, out = new Set<string>()): Set<string> {
170 if (!obj || typeof obj !== "object") return out;
171 const o = obj as Record<string, unknown>;
172 if (o.type === "ref" && typeof o.ref === "string") {
173 const nsid = (o.ref as string).split("#")[0];
174 if (nsid) out.add(nsid);
175 }
176 if (o.type === "union" && Array.isArray(o.refs)) {
177 for (const ref of o.refs) {
178 if (typeof ref === "string") {
179 const nsid = ref.split("#")[0];
180 if (nsid) out.add(nsid);
181 }
182 }
183 }
184 for (const val of Object.values(o)) {
185 if (Array.isArray(val)) {
186 val.forEach((v) => collectRefNsids(v, out));
187 } else if (val && typeof val === "object") {
188 collectRefNsids(val, out);
189 }
190 }
191 return out;
192}
193
194async function buildLexicons(
195 root: Record<string, unknown>,
196 resolver: Resolver
197): Promise<Lexicons> {
198 const loaded = new Map<string, Record<string, unknown>>();
199 loaded.set((root as { id: string }).id, root);
200
201 let pending = collectRefNsids(root);
202 while (pending.size > 0) {
203 const newNsids = [...pending].filter((nsid) => !loaded.has(nsid));
204 if (newNsids.length === 0) break;
205 const docs = await Promise.all(
206 newNsids.map((nsid) => resolver.resolveLexicon(nsid))
207 );
208 const nextPending = new Set<string>();
209 for (let i = 0; i < newNsids.length; i++) {
210 const doc = docs[i] as Record<string, unknown> | null;
211 if (doc) {
212 loaded.set(newNsids[i], doc);
213 collectRefNsids(doc, nextPending);
214 }
215 }
216 pending = nextPending;
217 }
218
219 const docs = [...loaded.values()].map(
220 (d) => JSON.parse(JSON.stringify(d)) as LexiconDoc
221 );
222 return new Lexicons(docs);
223}