@fedify/vocab-tools 2.2.0-dev.731 → 2.2.0-dev.766

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/class.test.ts CHANGED
@@ -81,6 +81,47 @@ test("generateClasses() imports Decimal helpers for xsd:decimal", async () => {
81
81
  match(entireCode, /parseDecimal\(v\["@value"\]\)/);
82
82
  });
83
83
 
84
+ test("generateClasses() emits $EntityType helpers for fedify:vocabEntityType", async () => {
85
+ const entireCode = await getEntityTypeFixtureCode();
86
+ match(
87
+ entireCode,
88
+ /export type \$EntityType =\s+\| typeof Entity\s+\| typeof ChildEntity\s+\| typeof Tombstone;/s,
89
+ );
90
+ match(
91
+ entireCode,
92
+ /const entityTypes: readonly \$EntityType\[\] = \[\s*Entity,\s*ChildEntity,\s*Tombstone,\s*\];/s,
93
+ );
94
+ match(
95
+ entireCode,
96
+ /const entityTypeSet: ReadonlySet<\$EntityType> = new Set\(entityTypes\);/,
97
+ );
98
+ match(
99
+ entireCode,
100
+ /export function isEntityType\(value: unknown\): value is \$EntityType/,
101
+ );
102
+ match(
103
+ entireCode,
104
+ /export function getEntityTypeById\(id: string \| URL\): \$EntityType \| undefined/,
105
+ );
106
+ match(
107
+ entireCode,
108
+ /const entityTypeIds: ReadonlyMap<string, \$EntityType> = new Map<string, \$EntityType>\(\s*\[\s*\["https:\/\/example.com\/entity", Entity\],\s*\["https:\/\/example.com\/child-entity", ChildEntity\],\s*\["https:\/\/example.com\/tombstone", Tombstone\],\s*\],\s*\);/s,
109
+ );
110
+ match(
111
+ entireCode,
112
+ /return entityTypeIds\.get\(typeof id === "string" \? id : id\?\.href\);/,
113
+ );
114
+ });
115
+
116
+ test("generateClasses() uses entity type helpers for fedify:vocabEntityType", async () => {
117
+ const entireCode = await getEntityTypeFixtureCode();
118
+ match(entireCode, /formerType\?: \$EntityType \| null;/);
119
+ match(entireCode, /formerTypes\?: \(\$EntityType\)\[\];/);
120
+ match(entireCode, /isEntityType\(values\.formerType\)/);
121
+ match(entireCode, /v\.typeId\.href/);
122
+ match(entireCode, /getEntityTypeById\(v\["@id"\]\)/);
123
+ });
124
+
84
125
  test("getDataCheck() uses canParseDecimal() for xsd:decimal", () => {
85
126
  const check = getDataCheck(
86
127
  "http://www.w3.org/2001/XMLSchema#decimal",
@@ -177,6 +218,64 @@ async function getDecimalFixtureCode() {
177
218
  return (await Array.fromAsync(generateClasses(types))).join("");
178
219
  }
179
220
 
221
+ async function getEntityTypeFixtureCode() {
222
+ const types: Record<string, TypeSchema> = {
223
+ "https://example.com/entity": {
224
+ name: "Entity",
225
+ uri: "https://example.com/entity",
226
+ compactName: "Entity",
227
+ entity: true,
228
+ description: "An entity.",
229
+ properties: [],
230
+ defaultContext:
231
+ "https://example.com/context" as TypeSchema["defaultContext"],
232
+ },
233
+ "https://example.com/child-entity": {
234
+ name: "ChildEntity",
235
+ uri: "https://example.com/child-entity",
236
+ compactName: "ChildEntity",
237
+ extends: "https://example.com/entity",
238
+ entity: true,
239
+ description: "A child entity.",
240
+ properties: [],
241
+ defaultContext:
242
+ "https://example.com/context" as TypeSchema["defaultContext"],
243
+ },
244
+ "https://example.com/value": {
245
+ name: "Value",
246
+ uri: "https://example.com/value",
247
+ compactName: "Value",
248
+ entity: false,
249
+ description: "A value type.",
250
+ properties: [],
251
+ defaultContext:
252
+ "https://example.com/context" as TypeSchema["defaultContext"],
253
+ },
254
+ "https://example.com/tombstone": {
255
+ name: "Tombstone",
256
+ uri: "https://example.com/tombstone",
257
+ compactName: "Tombstone",
258
+ extends: "https://example.com/entity",
259
+ entity: true,
260
+ description: "A tombstone.",
261
+ properties: [
262
+ {
263
+ singularName: "formerType",
264
+ pluralName: "formerTypes",
265
+ singularAccessor: true,
266
+ compactName: "formerType",
267
+ uri: "https://example.com/formerType",
268
+ description: "The former type.",
269
+ range: ["fedify:vocabEntityType"],
270
+ },
271
+ ],
272
+ defaultContext:
273
+ "https://example.com/context" as TypeSchema["defaultContext"],
274
+ },
275
+ };
276
+ return (await Array.fromAsync(generateClasses(types))).join("");
277
+ }
278
+
180
279
  async function changeNodeSnapshotPath() {
181
280
  const { snapshot } = await import("node:test");
182
281
  snapshot.setResolveSnapshotPath(
package/src/class.ts CHANGED
@@ -109,6 +109,56 @@ async function* generateClass(
109
109
  }
110
110
  }
111
111
 
112
+ function* generateEntityTypeHelpers(
113
+ sortedTypeUris: string[],
114
+ types: Record<string, TypeSchema>,
115
+ ): Iterable<string> {
116
+ const entityTypes = sortedTypeUris.filter((typeUri) => types[typeUri].entity)
117
+ .map((typeUri) => ({ name: types[typeUri].name, uri: typeUri }));
118
+ const entityTypeNames = entityTypes.map((entityType) => entityType.name);
119
+ const entityTypeUnion = entityTypeNames.length < 1
120
+ ? " never"
121
+ : `\n | typeof ${entityTypeNames.join("\n | typeof ")}`;
122
+ yield `/**
123
+ * Constructor types for all generated vocabulary entity classes.
124
+ */
125
+ export type $EntityType =${entityTypeUnion};
126
+
127
+ const entityTypes: readonly $EntityType[] = [
128
+ `;
129
+ for (const entityTypeName of entityTypeNames) {
130
+ yield ` ${entityTypeName},\n`;
131
+ }
132
+ yield `];
133
+
134
+ const entityTypeSet: ReadonlySet<$EntityType> = new Set(entityTypes);
135
+
136
+ const entityTypeIds: ReadonlyMap<string, $EntityType> = new Map<string, $EntityType>(
137
+ [
138
+ `;
139
+ for (const entityType of entityTypes) {
140
+ yield ` [${JSON.stringify(entityType.uri)}, ${entityType.name}],\n`;
141
+ }
142
+ yield ` ],
143
+ );
144
+
145
+ /**
146
+ * Checks whether the given value is a generated vocabulary entity class.
147
+ */
148
+ export function isEntityType(value: unknown): value is $EntityType {
149
+ return entityTypeSet.has(value as $EntityType);
150
+ }
151
+
152
+ /**
153
+ * Gets the generated vocabulary entity class for the given type URI.
154
+ */
155
+ export function getEntityTypeById(id: string | URL): $EntityType | undefined {
156
+ return entityTypeIds.get(typeof id === "string" ? id : id?.href);
157
+ }
158
+
159
+ `;
160
+ }
161
+
112
162
  /**
113
163
  * Generates the TypeScript classes from the given types.
114
164
  * @param types The types to generate classes from.
@@ -147,4 +197,5 @@ export async function* generateClasses(
147
197
  for (const typeUri of sorted) {
148
198
  for await (const code of generateClass(typeUri, types)) yield code;
149
199
  }
200
+ for (const code of generateEntityTypeHelpers(sorted, types)) yield code;
150
201
  }
package/src/codec.ts CHANGED
@@ -440,7 +440,8 @@ export async function* generateDecoder(
440
440
  `;
441
441
  }
442
442
  if (property.range.length == 1) {
443
- yield `${variable}.push(${
443
+ yield `
444
+ const decoded = ${
444
445
  getDecoder(
445
446
  property.range[0],
446
447
  types,
@@ -448,7 +449,9 @@ export async function* generateDecoder(
448
449
  "options",
449
450
  `(values["@id"] == null ? options.baseUrl : new URL(values["@id"]))`,
450
451
  )
451
- })`;
452
+ };
453
+ if (typeof decoded === "undefined") continue;
454
+ ${variable}.push(decoded);`;
452
455
  } else {
453
456
  yield `
454
457
  const decoded =
@@ -1,10 +1,12 @@
1
- import { deepStrictEqual, ok } from "node:assert";
1
+ import { deepStrictEqual, ok, throws } from "node:assert";
2
2
  import { test } from "node:test";
3
3
  import {
4
4
  hasSingularAccessor,
5
5
  isNonFunctionalProperty,
6
6
  type PropertySchema,
7
+ type TypeSchema,
7
8
  type TypeUri,
9
+ validateTypeSchemas,
8
10
  } from "./schema.ts";
9
11
 
10
12
  test(
@@ -201,3 +203,44 @@ test("Type guard combinations: untyped property", () => {
201
203
  ok(isNonFunctionalProperty(property));
202
204
  ok(!hasSingularAccessor(property));
203
205
  });
206
+
207
+ test("validateTypeSchemas() rejects mixed fedify:vocabEntityType ranges", () => {
208
+ const types: Record<string, TypeSchema> = {
209
+ "https://example.com/tombstone": {
210
+ name: "Tombstone",
211
+ uri: "https://example.com/tombstone" as TypeUri,
212
+ compactName: "Tombstone",
213
+ entity: true,
214
+ description: "A tombstone.",
215
+ properties: [
216
+ {
217
+ singularName: "formerType",
218
+ pluralName: "formerTypes",
219
+ uri: "https://example.com/formerType",
220
+ compactName: "formerType",
221
+ description: "The former type.",
222
+ range: [
223
+ "fedify:vocabEntityType",
224
+ "http://www.w3.org/2001/XMLSchema#anyURI",
225
+ ] as [TypeUri, TypeUri],
226
+ },
227
+ ],
228
+ defaultContext:
229
+ "https://example.com/context" as TypeSchema["defaultContext"],
230
+ },
231
+ };
232
+
233
+ throws(
234
+ () => validateTypeSchemas(types),
235
+ (error) => {
236
+ ok(error instanceof TypeError);
237
+ deepStrictEqual(
238
+ error.message,
239
+ "The property Tombstone.formerType cannot mix fedify:vocabEntityType " +
240
+ "with other range types because the generated decoder cannot " +
241
+ "disambiguate entity type references from ordinary IRIs.",
242
+ );
243
+ return true;
244
+ },
245
+ );
246
+ });
package/src/schema.ts CHANGED
@@ -242,6 +242,7 @@ export function hasSingularAccessor(property: PropertySchema): boolean {
242
242
 
243
243
  const XSD_STRING_URI = "http://www.w3.org/2001/XMLSchema#string";
244
244
  const XSD_DECIMAL_URI = "http://www.w3.org/2001/XMLSchema#decimal";
245
+ const FEDIFY_VOCAB_ENTITY_TYPE_URI = "fedify:vocabEntityType";
245
246
 
246
247
  /**
247
248
  * Validates schema combinations that cannot be represented safely by the
@@ -268,6 +269,17 @@ export function validateTypeSchemas(
268
269
  `generated encoder cannot disambiguate them at runtime.`,
269
270
  );
270
271
  }
272
+ if (
273
+ property.range.includes(FEDIFY_VOCAB_ENTITY_TYPE_URI) &&
274
+ property.range.length > 1
275
+ ) {
276
+ throw new TypeError(
277
+ `The property ${type.name}.${property.singularName} cannot mix ` +
278
+ `fedify:vocabEntityType with other range types because the ` +
279
+ `generated decoder cannot disambiguate entity type references ` +
280
+ `from ordinary IRIs.`,
281
+ );
282
+ }
271
283
  }
272
284
  }
273
285
  }
package/src/type.ts CHANGED
@@ -163,7 +163,7 @@ const scalarTypes: Record<string, ScalarType> = {
163
163
  return `${v}.href`;
164
164
  },
165
165
  dataCheck(v) {
166
- return `typeof ${v} === "object" && "@id" in ${v}
166
+ return `${v} != null && typeof ${v} === "object" && "@id" in ${v}
167
167
  && typeof ${v}["@id"] === "string"
168
168
  && ${v}["@id"] !== ""`;
169
169
  },
@@ -403,7 +403,7 @@ const scalarTypes: Record<string, ScalarType> = {
403
403
  return v;
404
404
  },
405
405
  dataCheck(v) {
406
- return `typeof ${v} === "object" && "@id" in ${v}
406
+ return `${v} != null && typeof ${v} === "object" && "@id" in ${v}
407
407
  && typeof ${v}["@id"] === "string"
408
408
  && ${v}["@id"].startsWith("https://w3id.org/security#")
409
409
  && [
@@ -437,6 +437,36 @@ const scalarTypes: Record<string, ScalarType> = {
437
437
  return `${v}["@value"]`;
438
438
  },
439
439
  },
440
+ "fedify:vocabEntityType": {
441
+ name: "$EntityType",
442
+ typeGuard(v) {
443
+ return `isEntityType(${v})`;
444
+ },
445
+ encoder(v) {
446
+ return `{ "@id": ${v}.typeId.href }`;
447
+ },
448
+ dataCheck(v) {
449
+ return `${v} != null && typeof ${v} === "object" && "@id" in ${v}
450
+ && typeof ${v}["@id"] === "string"
451
+ && ${v}["@id"] !== ""`;
452
+ },
453
+ decoder(v) {
454
+ return `(() => {
455
+ if (${v} == null || typeof ${v} !== "object" || !("@id" in ${v}) ||
456
+ typeof ${v}["@id"] !== "string" || ${v}["@id"] === "") {
457
+ return undefined;
458
+ }
459
+ const entityType = getEntityTypeById(${v}["@id"]);
460
+ if (entityType == null) {
461
+ getLogger(["fedify", "vocab"]).warn(
462
+ "Ignoring unknown vocabulary entity type reference: {typeId}",
463
+ { typeId: ${v}["@id"] },
464
+ );
465
+ }
466
+ return entityType;
467
+ })()`;
468
+ },
469
+ },
440
470
  };
441
471
 
442
472
  export function getTypeName(