Skip to content

Commit 5803cab

Browse files
committed
fix(scripts): updates for multi-value enums and other schema additions
1 parent 37d7eae commit 5803cab

6 files changed

Lines changed: 154 additions & 12 deletions

File tree

scripts/lib/generate-conformance.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ function extractNodeTypes(oxa: Record<string, unknown>): string[] {
5050
function walk(node: unknown): void {
5151
if (node && typeof node === "object" && !Array.isArray(node)) {
5252
const obj = node as Record<string, unknown>;
53-
if (typeof obj.type === "string") {
53+
// Only OXA node discriminators are capitalized; nested payload fields such
54+
// as CSL's `type: "article-journal"` are not conformance node types.
55+
if (typeof obj.type === "string" && /^[A-Z]/.test(obj.type)) {
5456
types.add(obj.type);
5557
}
5658
for (const value of Object.values(obj)) {
@@ -64,7 +66,18 @@ function extractNodeTypes(oxa: Record<string, unknown>): string[] {
6466
}
6567

6668
walk(oxa);
67-
return Array.from(types).sort();
69+
70+
const rootType = typeof oxa.type === "string" ? oxa.type : undefined;
71+
const sortedTypes = Array.from(types).sort();
72+
73+
if (rootType && types.has(rootType)) {
74+
// The first node type is treated as the primary type for docs examples.
75+
// Keep nested node types deterministic, but do not let them sort ahead of
76+
// the root node (for example, Cite inside CiteGroup).
77+
return [rootType, ...sortedTypes.filter((type) => type !== rootType)];
78+
}
79+
80+
return sortedTypes;
6881
}
6982

7083
/**

scripts/lib/generate-docs.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs";
99
import { join } from "path";
1010

11-
import { manifest, cases, type TestCase } from "@oxa/conformance";
12-
1311
import { loadMergedSchema } from "./schema.js";
1412

1513
const OUTPUT_DIR = join(import.meta.dirname, "../../docs/schema");
1614
const INDEX_FILE = join(OUTPUT_DIR, "index.md");
15+
const CONFORMANCE_DIR = join(
16+
import.meta.dirname,
17+
"../../packages/oxa-conformance",
18+
);
19+
const MANIFEST_FILE = join(CONFORMANCE_DIR, "manifest.json");
1720

1821
interface SchemaProperty {
1922
type?: string;
@@ -36,6 +39,21 @@ interface SchemaDefinition {
3639
required?: string[];
3740
}
3841

42+
interface TestCase {
43+
formats: Record<string, unknown>;
44+
}
45+
46+
interface ManifestCase {
47+
id: string;
48+
path: string;
49+
nodeTypes: string[];
50+
}
51+
52+
interface Manifest {
53+
formats: string[];
54+
cases: ManifestCase[];
55+
}
56+
3957
export async function generateDocs(): Promise<void> {
4058
// Preserve index.md if it exists
4159
let indexContent: string | null = null;
@@ -56,12 +74,13 @@ export async function generateDocs(): Promise<void> {
5674

5775
const schema = loadMergedSchema();
5876
const definitions = schema.definitions as Record<string, SchemaDefinition>;
59-
const testCases = loadTestCases();
77+
const { manifest, cases } = loadConformanceData();
78+
const testCases = loadTestCases(manifest, cases);
6079

6180
// Generate documentation for object types (non-union types)
6281
for (const [name, def] of Object.entries(definitions)) {
6382
if (!def.anyOf && def.type === "object") {
64-
const content = generateDocContent(name, def, testCases);
83+
const content = generateDocContent(name, def, testCases, manifest);
6584
const filePath = join(OUTPUT_DIR, `${name.toLowerCase()}.md`);
6685
writeFileSync(filePath, content);
6786
console.log(`Generated ${filePath}`);
@@ -83,6 +102,7 @@ function generateDocContent(
83102
name: string,
84103
def: SchemaDefinition,
85104
testCases: Map<string, TestCase>,
105+
manifest: Manifest,
86106
): string {
87107
const lines: string[] = [];
88108

@@ -111,6 +131,10 @@ function generateDocContent(
111131
} else if (prop.enum && prop.enum.length === 1) {
112132
// Single-element enum (same as const)
113133
lines.push(`__${propName}__: _string_, ("${prop.enum[0]}")`);
134+
} else if (prop.enum && prop.enum.length > 1) {
135+
lines.push(
136+
`__${propName}__: _string_, (${prop.enum.map((value) => `"${value}"`).join(" | ")})`,
137+
);
114138
} else if (prop.type === "array" && prop.items) {
115139
const arrayType = getArrayItemType(prop.items);
116140
lines.push(`__${propName}__: __array__ ("${arrayType}")`);
@@ -137,7 +161,7 @@ function generateDocContent(
137161
// Add test case example if available
138162
const testCase = testCases.get(name.toLowerCase());
139163
if (testCase) {
140-
lines.push(generateTestCaseSection(testCase));
164+
lines.push(generateTestCaseSection(testCase, manifest));
141165
}
142166

143167
return lines.join("\n");
@@ -213,7 +237,27 @@ function getArrayItemType(items: { $ref?: string; type?: string }): string {
213237
return "unknown";
214238
}
215239

216-
function loadTestCases(): Map<string, TestCase> {
240+
function loadConformanceData(): {
241+
manifest: Manifest;
242+
cases: Record<string, TestCase>;
243+
} {
244+
const manifest = JSON.parse(readFileSync(MANIFEST_FILE, "utf-8")) as Manifest;
245+
const cases = Object.fromEntries(
246+
manifest.cases.map((caseInfo) => [
247+
caseInfo.id,
248+
JSON.parse(
249+
readFileSync(join(CONFORMANCE_DIR, caseInfo.path), "utf-8"),
250+
) as TestCase,
251+
]),
252+
);
253+
254+
return { manifest, cases };
255+
}
256+
257+
function loadTestCases(
258+
manifest: Manifest,
259+
cases: Record<string, TestCase>,
260+
): Map<string, TestCase> {
217261
const testCases = new Map<string, TestCase>();
218262

219263
// Filter for *-basic test cases
@@ -257,7 +301,10 @@ const FORMAT_LANGUAGES: Record<string, string> = {
257301
jats: "xml",
258302
};
259303

260-
function generateTestCaseSection(testCase: TestCase): string {
304+
function generateTestCaseSection(
305+
testCase: TestCase,
306+
manifest: Manifest,
307+
): string {
261308
const lines: string[] = [];
262309

263310
lines.push("### Example");

scripts/lib/generate-lexicon.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ function generateDocumentLexicon(
346346
function convertPropertyToLexicon(
347347
prop: SchemaProperty,
348348
): Record<string, unknown> | null {
349+
// Handle string enums
350+
if (prop.enum && prop.enum.length > 1) {
351+
return { type: "string", knownValues: prop.enum };
352+
}
353+
349354
// Handle arrays
350355
if (prop.type === "array" && prop.items) {
351356
if (prop.items.type === "string") {
@@ -415,7 +420,7 @@ function extractNonStructuralProperties(
415420
): { properties: Record<string, unknown>; required: string[] } | null {
416421
if (!def.properties) return null;
417422

418-
const skip = new Set(["type", "children"]);
423+
const skip = new Set(["type", "children", "prefix", "suffix"]);
419424
if (isFacetFeature) {
420425
// Facet features don't carry id/classes/data — these are node-level
421426
// concerns that don't translate to byte-range annotations

scripts/lib/generate-py.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ function getPythonType(
236236
return `Literal["${prop.enum[0]}"]`;
237237
}
238238

239+
// Handle multi-value enums as Literal unions
240+
if (prop.enum && prop.enum.length > 1) {
241+
return `Literal[${prop.enum.map((value) => `"${value}"`).join(", ")}]`;
242+
}
243+
239244
// Handle $ref
240245
if (prop.$ref) {
241246
const typeName = prop.$ref.replace("#/definitions/", "");

scripts/lib/generate-rs.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ export async function generateRs(): Promise<void> {
7575
"",
7676
];
7777

78+
// Generate property enum types first so structs can use them.
79+
for (const [name, def] of Object.entries(definitions)) {
80+
if (!def.anyOf && def.type === "object") {
81+
for (const [propName, prop] of Object.entries(def.properties || {})) {
82+
if (prop.enum && prop.enum.length > 1) {
83+
lines.push(...generatePropertyEnum(name, propName, prop));
84+
lines.push("");
85+
}
86+
}
87+
}
88+
}
89+
7890
// Generate struct types first (non-union types)
7991
for (const [name, def] of Object.entries(definitions)) {
8092
if (!def.anyOf && def.type === "object") {
@@ -123,7 +135,7 @@ function generateStruct(name: string, def: SchemaDefinition): string[] {
123135
lines.push(` /// ${prop.description}`);
124136
}
125137

126-
const rustType = getRustType(prop);
138+
const rustType = getRustType(name, propName, prop);
127139
const isRequired = required.has(propName);
128140

129141
// Handle the "type" field which is a reserved keyword in Rust
@@ -152,6 +164,30 @@ function generateStruct(name: string, def: SchemaDefinition): string[] {
152164
return lines;
153165
}
154166

167+
function generatePropertyEnum(
168+
ownerName: string,
169+
propName: string,
170+
prop: SchemaProperty,
171+
): string[] {
172+
const lines: string[] = [];
173+
const enumName = getPropertyEnumName(ownerName, propName);
174+
175+
if (prop.description) {
176+
lines.push(`/// ${prop.description}`);
177+
}
178+
179+
lines.push("#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]");
180+
lines.push(`pub enum ${enumName} {`);
181+
182+
for (const value of prop.enum || []) {
183+
lines.push(` #[serde(rename = "${value}")]`);
184+
lines.push(` ${toPascalCase(value)},`);
185+
}
186+
187+
lines.push("}");
188+
return lines;
189+
}
190+
155191
function generateEnum(name: string, def: SchemaDefinition): string[] {
156192
const lines: string[] = [];
157193

@@ -180,7 +216,11 @@ function generateEnum(name: string, def: SchemaDefinition): string[] {
180216
return lines;
181217
}
182218

183-
function getRustType(prop: SchemaProperty): string {
219+
function getRustType(
220+
ownerName: string,
221+
propName: string,
222+
prop: SchemaProperty,
223+
): string {
184224
// Handle const values (type discriminator) - use MustBe!
185225
if (prop.const) {
186226
return `MustBe!("${prop.const}")`;
@@ -191,6 +231,11 @@ function getRustType(prop: SchemaProperty): string {
191231
return `MustBe!("${prop.enum[0]}")`;
192232
}
193233

234+
// Handle multi-value enums as named Rust enums
235+
if (prop.enum && prop.enum.length > 1) {
236+
return getPropertyEnumName(ownerName, propName);
237+
}
238+
194239
// Handle $ref
195240
if (prop.$ref) {
196241
const typeName = prop.$ref.replace("#/definitions/", "");
@@ -229,6 +274,28 @@ function getRustType(prop: SchemaProperty): string {
229274
}
230275
}
231276

277+
function getPropertyEnumName(ownerName: string, propName: string): string {
278+
return `${ownerName}${toPascalCase(propName)}`;
279+
}
280+
281+
function toPascalCase(s: string): string {
282+
const result = s
283+
.split(/[^A-Za-z0-9]+/)
284+
.filter(Boolean)
285+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
286+
.join("");
287+
288+
if (result.length === 0) {
289+
return "Value";
290+
}
291+
292+
if (/^[0-9]/.test(result)) {
293+
return `Value${result}`;
294+
}
295+
296+
return result;
297+
}
298+
232299
function toSnakeCase(s: string): string {
233300
return s
234301
.replace(/([A-Z])/g, "_$1")

scripts/lib/generate-ts.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ function getTypeScriptType(prop: SchemaProperty): string {
156156
return `"${prop.enum[0]}"`;
157157
}
158158

159+
// Handle multi-value enums as literal unions
160+
if (prop.enum && prop.enum.length > 1) {
161+
return prop.enum.map((value) => `"${value}"`).join(" | ");
162+
}
163+
159164
// Handle $ref
160165
if (prop.$ref) {
161166
return prop.$ref.replace("#/definitions/", "");

0 commit comments

Comments
 (0)