@oceanum/eidos 0.9.0 → 0.9.2

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.
@@ -1,609 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import crypto from "crypto";
4
- import https from "https";
5
- import fs from "fs";
6
-
7
- /**
8
- * Schema bundler for EIDOS schemas
9
- *
10
- * Key objectives:
11
- * - Bundle: Combine a root schema and all its referenced external schemas into a single file
12
- * - Deduplicate: Each unique definition appears only once in the final $defs section
13
- * - Generate Keys: Create unique PascalCase keys derived from title/filename + path hash for uniqueness
14
- * - Rewrite Refs: Update all $ref pointers to use the new canonical $defs keys
15
- * - Exclude Vega: Replace Vega/Vega-Lite schemas with simple PlotSpec placeholder
16
- */
17
-
18
- class SchemaBundler {
19
- constructor() {
20
- this.definitions = new Map(); // newKey -> definition
21
- this.keyMapping = new Map(); // original ref -> new key
22
- this.seenKeys = new Set(); // track used keys to avoid collisions
23
- this.definitionHashes = new Map(); // hash -> key for deduplication by content
24
- }
25
-
26
- /**
27
- * Generate a hash for a definition to check for duplicates
28
- * @param {Object} definition - The schema definition
29
- * @returns {string} - Hash of the definition
30
- */
31
- hashDefinition(definition) {
32
- // Create a stable hash by stringifying the definition in a consistent way
33
- return crypto.createHash('md5').update(JSON.stringify(definition, Object.keys(definition).sort())).digest('hex');
34
- }
35
-
36
- /**
37
- * Generate a unique PascalCase key for a definition, with proper deduplication
38
- * @param {string} originalKey - The original definition key
39
- * @param {Object} definition - The schema definition
40
- * @param {string} path - The path where this definition was found
41
- * @returns {string} - Unique PascalCase key or existing key if duplicate
42
- */
43
- generateKey(originalKey, definition, path) {
44
- // First check if we've already seen this exact definition
45
- const defHash = this.hashDefinition(definition);
46
- if (this.definitionHashes.has(defHash)) {
47
- // This is a duplicate definition, return the existing key
48
- return this.definitionHashes.get(defHash);
49
- }
50
-
51
- let baseName = '';
52
-
53
- // First try to get the title from the definition
54
- if (definition && definition.title) {
55
- baseName = definition.title;
56
- } else {
57
- // Use the original key as fallback
58
- baseName = originalKey;
59
- }
60
-
61
- // Convert to PascalCase
62
- let pascalCase = baseName
63
- .replace(/[^a-zA-Z0-9]/g, ' ')
64
- .split(' ')
65
- .filter(Boolean)
66
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
67
- .join('');
68
-
69
- // Ensure it starts with a letter
70
- if (!/^[A-Z]/.test(pascalCase)) {
71
- pascalCase = 'Schema' + pascalCase;
72
- }
73
-
74
- // Only add hash if we have a key name conflict (different definition, same name)
75
- if (this.seenKeys.has(pascalCase)) {
76
- const pathHash = crypto.createHash('md5').update(path).digest('hex').substring(0, 8);
77
- pascalCase = `${pascalCase}${pathHash.charAt(0).toUpperCase()}${pathHash.slice(1)}`;
78
- }
79
-
80
- this.seenKeys.add(pascalCase);
81
- this.definitionHashes.set(defHash, pascalCase);
82
- return pascalCase;
83
- }
84
-
85
- /**
86
- * Check if a reference is related to Vega/Vega-Lite
87
- * @param {string} ref - Reference string
88
- * @returns {boolean}
89
- */
90
- isVegaSchema(ref) {
91
- if (typeof ref !== 'string') {
92
- return false;
93
- }
94
-
95
- const lower = ref.toLowerCase();
96
- return lower.includes('vega-lite') ||
97
- lower.includes('vega/') ||
98
- lower.includes('vega.json') ||
99
- lower.includes('vega-schema') ||
100
- lower.includes('vega-lite-schema') ||
101
- lower.includes('/vega') ||
102
- lower.includes('vegaspec') ||
103
- lower.includes('spec') && lower.includes('vega');
104
- }
105
-
106
- /**
107
- * Create a placeholder for Vega schemas
108
- * @returns {Object} - PlotSpec placeholder definition
109
- */
110
- createVegaPlaceholder() {
111
- return {
112
- type: 'object',
113
- title: 'PlotSpec',
114
- description: 'Placeholder for Vega/Vega-Lite plot specifications',
115
- properties: {
116
- $schema: {
117
- type: 'string',
118
- description: 'The Vega/Vega-Lite schema URL'
119
- },
120
- data: {
121
- type: 'object',
122
- description: 'The data specification'
123
- },
124
- mark: {
125
- type: ['string', 'object'],
126
- description: 'The mark type or definition'
127
- },
128
- encoding: {
129
- type: 'object',
130
- description: 'The encoding specification'
131
- },
132
- config: {
133
- type: 'object',
134
- description: 'The configuration options'
135
- }
136
- },
137
- additionalProperties: true
138
- };
139
- }
140
-
141
- /**
142
- * Check if we should recurse into a definition based on its path/context
143
- * @param {string} path - Definition path
144
- * @param {string} key - Definition key
145
- * @returns {boolean}
146
- */
147
- shouldRecurseIntoDefinition(path, key) {
148
- // Don't recurse into Vega schemas at all
149
- if (this.isVegaSchema(key) || this.isVegaSchema(path)) {
150
- return false;
151
- }
152
-
153
- // Only recurse into EIDOS-specific definitions that might contain nested node types
154
- // This includes paths like /$defs/node which contains the oneOf with World, Plot, etc.
155
- return true;
156
- }
157
-
158
- /**
159
- * Recursively extract all definitions from a schema object
160
- * @param {Object} obj - Schema object to search
161
- * @param {Map} collectedDefs - Map to collect definitions
162
- * @param {string} currentPath - Current path for tracking definition locations
163
- */
164
- extractDefinitions(obj, collectedDefs, currentPath = '') {
165
- if (!obj || typeof obj !== 'object') {
166
- return;
167
- }
168
-
169
- if (Array.isArray(obj)) {
170
- obj.forEach((item, index) => {
171
- this.extractDefinitions(item, collectedDefs, `${currentPath}[${index}]`);
172
- });
173
- return;
174
- }
175
-
176
- // Check for $defs or definitions at this level
177
- if (obj.$defs) {
178
- for (const [key, def] of Object.entries(obj.$defs)) {
179
- const defPath = `${currentPath}/$defs/${key}`;
180
- const normalizedKey = decodeURIComponent(key);
181
- collectedDefs.set(defPath, {
182
- definition: def,
183
- originalKey: normalizedKey,
184
- fullPath: defPath
185
- });
186
-
187
- // Only recursively extract from EIDOS-specific definitions
188
- if (this.shouldRecurseIntoDefinition(defPath, normalizedKey)) {
189
- this.extractDefinitions(def, collectedDefs, defPath);
190
- }
191
- }
192
- }
193
-
194
- if (obj.definitions) {
195
- for (const [key, def] of Object.entries(obj.definitions)) {
196
- const defPath = `${currentPath}/definitions/${key}`;
197
- const normalizedKey = decodeURIComponent(key);
198
- collectedDefs.set(defPath, {
199
- definition: def,
200
- originalKey: normalizedKey,
201
- fullPath: defPath
202
- });
203
-
204
- // Only recursively extract from EIDOS-specific definitions
205
- if (this.shouldRecurseIntoDefinition(defPath, normalizedKey)) {
206
- this.extractDefinitions(def, collectedDefs, defPath);
207
- }
208
- }
209
- }
210
-
211
- // Recursively search all properties
212
- for (const [key, value] of Object.entries(obj)) {
213
- if (key !== '$defs' && key !== 'definitions') {
214
- this.extractDefinitions(value, collectedDefs, `${currentPath}/${key}`);
215
- }
216
- }
217
- }
218
-
219
- /**
220
- * Recursively collect all $refs in an object
221
- * @param {*} obj - Object to search
222
- * @param {Set} refs - Set to collect refs into
223
- */
224
- collectRefs(obj, refs) {
225
- if (!obj || typeof obj !== 'object') {
226
- return;
227
- }
228
-
229
- if (Array.isArray(obj)) {
230
- obj.forEach(item => this.collectRefs(item, refs));
231
- return;
232
- }
233
-
234
- for (const [key, value] of Object.entries(obj)) {
235
- if (key === '$ref' && typeof value === 'string') {
236
- refs.add(decodeURIComponent(value));
237
- } else {
238
- this.collectRefs(value, refs);
239
- }
240
- }
241
- }
242
-
243
- /**
244
- * Rewrite all $ref pointers in a schema to use new keys
245
- * @param {Object} obj - Object to process
246
- * @returns {Object} - Processed object with updated refs
247
- */
248
- rewriteRefs(obj) {
249
- if (!obj || typeof obj !== 'object') {
250
- return obj;
251
- }
252
-
253
- if (Array.isArray(obj)) {
254
- return obj.map(item => this.rewriteRefs(item));
255
- }
256
-
257
- const result = {};
258
- for (const [key, value] of Object.entries(obj)) {
259
- if (key === '$ref' && typeof value === 'string') {
260
- const normalizedRef = decodeURIComponent(value);
261
- const newKey = this.keyMapping.get(normalizedRef);
262
-
263
- if (newKey) {
264
- result[key] = `#/$defs/${newKey}`;
265
- } else {
266
- // Keep original ref if no mapping found
267
- result[key] = value;
268
- }
269
- } else {
270
- result[key] = this.rewriteRefs(value);
271
- }
272
- }
273
-
274
- return result;
275
- }
276
-
277
- /**
278
- * Fetch a schema from URL or file path
279
- * @param {string} schemaPath - URL or file path
280
- * @returns {Promise<Object>} - Parsed schema
281
- */
282
- async fetchSchema(schemaPath) {
283
- if (schemaPath.startsWith('http://') || schemaPath.startsWith('https://')) {
284
- return new Promise((resolve, reject) => {
285
- https.get(schemaPath, (res) => {
286
- let data = '';
287
- res.on('data', (chunk) => data += chunk);
288
- res.on('end', () => {
289
- try {
290
- resolve(JSON.parse(data));
291
- } catch (err) {
292
- reject(err);
293
- }
294
- });
295
- }).on('error', reject);
296
- });
297
- } else {
298
- const data = fs.readFileSync(schemaPath, 'utf8');
299
- return JSON.parse(data);
300
- }
301
- }
302
-
303
- /**
304
- * Recursively collect all external schemas referenced by the root schema
305
- * @param {Object} schema - Schema to analyze
306
- * @param {string} baseUrl - Base URL for resolving relative refs
307
- * @param {Map} schemas - Map to store all loaded schemas
308
- * @param {Set} visited - Set to track visited URLs to prevent loops
309
- * @returns {Promise<void>}
310
- */
311
- async collectSchemas(schema, baseUrl, schemas, visited = new Set()) {
312
- if (!schema || typeof schema !== 'object') {
313
- return;
314
- }
315
-
316
- if (Array.isArray(schema)) {
317
- for (const item of schema) {
318
- await this.collectSchemas(item, baseUrl, schemas, visited);
319
- }
320
- return;
321
- }
322
-
323
- for (const [key, value] of Object.entries(schema)) {
324
- if (key === '$ref' && typeof value === 'string' && !value.startsWith('#/')) {
325
- // External reference
326
- const refUrl = new URL(value, baseUrl).href;
327
-
328
- // Skip Vega schemas entirely - don't even fetch them
329
- if (this.isVegaSchema(refUrl)) {
330
- console.log(`Skipping Vega schema: ${refUrl}`);
331
- continue;
332
- }
333
-
334
- if (!visited.has(refUrl) && !schemas.has(refUrl)) {
335
- visited.add(refUrl);
336
- try {
337
- const refSchema = await this.fetchSchema(refUrl);
338
- schemas.set(refUrl, refSchema);
339
- // Recursively collect schemas from this schema (but not if it's Vega)
340
- if (!this.isVegaSchema(refUrl)) {
341
- await this.collectSchemas(refSchema, refUrl, schemas, visited);
342
- }
343
- } catch (err) {
344
- console.warn(`Failed to fetch ${refUrl}: ${err.message}`);
345
- }
346
- }
347
- } else if (typeof value === 'object') {
348
- await this.collectSchemas(value, baseUrl, schemas, visited);
349
- }
350
- }
351
- }
352
-
353
- /**
354
- * Extract a type name from a schema URL
355
- * @param {string} url - Schema URL
356
- * @returns {string} - Type name in PascalCase
357
- */
358
- extractTypeNameFromUrl(url) {
359
- const parts = url.split('/');
360
- const filename = parts[parts.length - 1];
361
- if (filename.endsWith('.json')) {
362
- const typeName = filename.replace('.json', '');
363
- // Convert to PascalCase
364
- return typeName.charAt(0).toUpperCase() + typeName.slice(1);
365
- }
366
- return 'UnknownType';
367
- }
368
-
369
- /**
370
- * Inline external references in a schema and create named definitions for inlined content
371
- * @param {Object} schema - Schema to process
372
- * @param {string} baseUrl - Base URL for resolving references
373
- * @param {Map} allSchemas - Map of all loaded schemas
374
- * @param {Map} inlinedDefs - Map to collect inlined definitions
375
- * @param {Set} visited - Set of visited URLs to prevent circular references
376
- * @returns {Object} - Schema with inlined references
377
- */
378
- inlineExternalRefs(schema, baseUrl, allSchemas, inlinedDefs = new Map(), visited = new Set()) {
379
- if (!schema || typeof schema !== 'object') {
380
- return schema;
381
- }
382
-
383
- if (Array.isArray(schema)) {
384
- return schema.map(item => this.inlineExternalRefs(item, baseUrl, allSchemas, inlinedDefs, visited));
385
- }
386
-
387
- const result = {};
388
- for (const [key, value] of Object.entries(schema)) {
389
- if (key === '$ref' && typeof value === 'string' && !value.startsWith('#/')) {
390
- // External reference - replace with actual schema content
391
- const refUrl = new URL(value, baseUrl).href;
392
-
393
- // Skip Vega schemas - replace with PlotSpec placeholder
394
- if (this.isVegaSchema(refUrl)) {
395
- return this.createVegaPlaceholder();
396
- }
397
-
398
- // Prevent circular references
399
- if (visited.has(refUrl)) {
400
- console.warn(`Warning: Circular reference detected: ${refUrl}`);
401
- // Create a reference to the type name instead
402
- const typeName = this.extractTypeNameFromUrl(refUrl);
403
- return { $ref: `#/$defs/${typeName}` };
404
- }
405
-
406
- if (allSchemas.has(refUrl)) {
407
- const referencedSchema = allSchemas.get(refUrl);
408
- const typeName = this.extractTypeNameFromUrl(refUrl);
409
-
410
- // Store the inlined definition with the type name
411
- if (!inlinedDefs.has(typeName)) {
412
- const newVisited = new Set(visited);
413
- newVisited.add(refUrl);
414
- const inlinedContent = this.inlineExternalRefs(referencedSchema, refUrl, allSchemas, inlinedDefs, newVisited);
415
- inlinedDefs.set(typeName, inlinedContent);
416
- }
417
-
418
- // Return a reference to the newly created definition
419
- return { $ref: `#/$defs/${typeName}` };
420
- } else {
421
- console.warn(`Warning: External reference not found: ${refUrl}`);
422
- // Keep the reference as-is if we can't resolve it
423
- result[key] = value;
424
- }
425
- } else {
426
- result[key] = this.inlineExternalRefs(value, baseUrl, allSchemas, inlinedDefs, visited);
427
- }
428
- }
429
-
430
- return result;
431
- }
432
-
433
- /**
434
- * Bundle a schema from a root schema URL/path
435
- * @param {string} rootSchemaPath - Path or URL to the root schema
436
- * @returns {Promise<Object>} - Bundled schema
437
- */
438
- async bundle(rootSchemaPath) {
439
- console.log(`📦 Starting schema bundling from: ${rootSchemaPath}`);
440
-
441
- try {
442
- // First, load the root schema and collect all external schemas
443
- console.log('📥 Loading root schema and collecting external references...');
444
- const rootSchema = await this.fetchSchema(rootSchemaPath);
445
- const baseUrl = rootSchemaPath.startsWith('http') ? rootSchemaPath : new URL(rootSchemaPath, import.meta.url).href;
446
-
447
- const allSchemas = new Map();
448
- allSchemas.set(baseUrl, rootSchema);
449
- await this.collectSchemas(rootSchema, baseUrl, allSchemas);
450
-
451
- console.log(`Collected ${allSchemas.size} schemas`);
452
-
453
- // Inline all external references into the root schema
454
- console.log('🔄 Inlining external references...');
455
- const inlinedDefs = new Map();
456
- const inlinedSchema = this.inlineExternalRefs(rootSchema, baseUrl, allSchemas, inlinedDefs);
457
-
458
- // Extract ALL definitions from all schemas (original approach)
459
- console.log('🔄 Processing definitions from all schemas...');
460
-
461
- const collectedDefs = new Map();
462
- for (const [schemaUrl, schema] of allSchemas) {
463
- this.extractDefinitions(schema, collectedDefs, schemaUrl);
464
- }
465
-
466
- // Also add the inlined definitions (World, Plot, Document, etc.) as separate named definitions
467
- for (const [typeName, typeDef] of inlinedDefs) {
468
- collectedDefs.set(`/$defs/${typeName}`, {
469
- definition: typeDef,
470
- originalKey: typeName,
471
- fullPath: `/$defs/${typeName}`
472
- });
473
- }
474
-
475
- console.log(`Found ${collectedDefs.size} definitions from all schemas + inlined types`);
476
-
477
- // Collect all refs in the inlined schema (we'll rewrite these)
478
- const allRefs = new Set();
479
- this.collectRefs(inlinedSchema, allRefs);
480
- console.log(`Found ${allRefs.size} total references in inlined schema`);
481
-
482
- // Process all definitions and create mappings
483
- for (const [defPath, { definition, originalKey, fullPath }] of collectedDefs) {
484
- if (this.isVegaSchema(originalKey)) {
485
- // Handle Vega schemas with PlotSpec placeholder
486
- if (!this.definitions.has('PlotSpec')) {
487
- this.keyMapping.set(`#/$defs/${originalKey}`, 'PlotSpec');
488
- this.keyMapping.set(`#/definitions/${originalKey}`, 'PlotSpec');
489
- this.definitions.set('PlotSpec', this.createVegaPlaceholder());
490
- }
491
- } else {
492
- // Generate unique PascalCase key for this definition (handles deduplication)
493
- const newKey = this.generateKey(originalKey, definition, fullPath);
494
-
495
- // Map all possible reference formats for this definition
496
- this.keyMapping.set(`#/$defs/${originalKey}`, newKey);
497
- this.keyMapping.set(`#/definitions/${originalKey}`, newKey);
498
-
499
- // Only store the definition if it's not already stored (deduplication)
500
- if (!this.definitions.has(newKey)) {
501
- this.definitions.set(newKey, definition);
502
- }
503
- }
504
- }
505
-
506
- // Handle any unmapped refs by trying to find their definitions
507
- for (const ref of allRefs) {
508
- if (!this.keyMapping.has(ref)) {
509
- if (this.isVegaSchema(ref)) {
510
- this.keyMapping.set(ref, 'PlotSpec');
511
- if (!this.definitions.has('PlotSpec')) {
512
- this.definitions.set('PlotSpec', this.createVegaPlaceholder());
513
- }
514
- } else {
515
- // Try to find the definition for this ref
516
- const refParts = ref.split('/');
517
- const refKey = refParts[refParts.length - 1];
518
- const cleanRefKey = decodeURIComponent(refKey);
519
-
520
- // Look for a matching definition
521
- let foundDef = null;
522
- for (const [defPath, { definition, originalKey }] of collectedDefs) {
523
- if (originalKey === cleanRefKey || decodeURIComponent(originalKey) === cleanRefKey) {
524
- foundDef = definition;
525
- break;
526
- }
527
- }
528
-
529
- if (foundDef) {
530
- const newKey = this.generateKey(cleanRefKey, foundDef, ref);
531
- this.keyMapping.set(ref, newKey);
532
- // Only store if not already stored (deduplication)
533
- if (!this.definitions.has(newKey)) {
534
- this.definitions.set(newKey, foundDef);
535
- }
536
- }
537
- }
538
- }
539
- }
540
-
541
- console.log(`✅ Created ${this.definitions.size} unique definitions with ${this.keyMapping.size} reference mappings`);
542
-
543
- // Create the final schema with rewritten references
544
- console.log('🔧 Rewriting references...');
545
-
546
- const finalSchema = this.rewriteRefs({
547
- ...inlinedSchema,
548
- $defs: undefined, // Remove original $defs
549
- definitions: undefined // Remove original definitions
550
- });
551
-
552
- // Add the new flattened $defs section with PascalCase keys
553
- finalSchema.$defs = {};
554
- for (const [key, definition] of this.definitions) {
555
- finalSchema.$defs[key] = this.rewriteRefs(definition);
556
- }
557
-
558
- // Always ensure PlotSpec placeholder exists
559
- if (!finalSchema.$defs.PlotSpec) {
560
- console.log('Adding PlotSpec placeholder definition');
561
- finalSchema.$defs.PlotSpec = this.createVegaPlaceholder();
562
- }
563
-
564
- console.log(`✅ Schema bundling completed! Generated ${Object.keys(finalSchema.$defs).length} definitions.`);
565
-
566
- return finalSchema;
567
-
568
- } catch (error) {
569
- console.error('❌ Error during schema bundling:', error);
570
- throw error;
571
- }
572
- }
573
- }
574
-
575
- /**
576
- * Export function for bundling schemas
577
- * @param {string} rootSchemaPath - Path or URL to the root schema
578
- * @returns {Promise<Object>} - Bundled schema
579
- */
580
- export async function bundle(rootSchemaPath) {
581
- const bundler = new SchemaBundler();
582
- return await bundler.bundle(rootSchemaPath);
583
- }
584
-
585
- // CLI support - run bundler if called directly
586
- if (import.meta.url === `file://${process.argv[1]}` ||
587
- import.meta.url.endsWith(process.argv[1])) {
588
-
589
- const rootSchema = process.argv[2] || 'https://schemas.oceanum.io/eidos/root.json';
590
- const outputFile = process.argv[3];
591
-
592
- console.log('🚀 Running schema bundler CLI...');
593
-
594
- try {
595
- const result = await bundle(rootSchema);
596
-
597
- if (outputFile) {
598
- const fs = await import('fs');
599
- fs.writeFileSync(outputFile, JSON.stringify(result, null, 2));
600
- console.log(`📄 Bundled schema written to: ${outputFile}`);
601
- } else {
602
- console.log('📄 Bundled schema:');
603
- console.log(JSON.stringify(result, null, 2));
604
- }
605
- } catch (error) {
606
- console.error('❌ CLI bundling failed:', error);
607
- process.exit(1);
608
- }
609
- }