From 9c946a84b712d03fff07a730f1ff5b366248b956 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Tue, 14 Apr 2026 10:42:54 -0400 Subject: [PATCH 01/11] add core js and tests for declarative config pages --- assets/js/configSchemaTransform.js | 326 +++++++++++++++++++ assets/js/configSchemaTransform.test.js | 335 ++++++++++++++++++++ assets/js/shared/accordionUtils.js | 173 ++++++++++ content-modules/opentelemetry-configuration | 1 + package.json | 2 + 5 files changed, 837 insertions(+) create mode 100644 assets/js/configSchemaTransform.js create mode 100644 assets/js/configSchemaTransform.test.js create mode 100644 assets/js/shared/accordionUtils.js create mode 160000 content-modules/opentelemetry-configuration diff --git a/assets/js/configSchemaTransform.js b/assets/js/configSchemaTransform.js new file mode 100644 index 000000000000..2dc3d1e86627 --- /dev/null +++ b/assets/js/configSchemaTransform.js @@ -0,0 +1,326 @@ +/** + * Configuration Schema Transformer - Client-Side Version + * + * Transforms the raw OpenTelemetry configuration JSON Schema into a simplified + * format optimized for the configuration types accordion UI. + * + * Input: Raw JSON Schema from /schemas/opentelemetry_configuration.json + * Output: Simplified structure for accordion rendering + * + * This module extracts type definitions, processes properties, resolves constraints, + * and generates human-readable default text + * + * @module configSchemaTransform + */ + +/** + * Resolve type information from a property definition + * Handles arrays, single values, and $ref pointers + * @param {Object} propDef - Property definition from JSON Schema + * @returns {string} Comma-delimited type string + */ +export function resolveType(propDef) { + let types = []; + + if (Array.isArray(propDef.type)) { + types = propDef.type; + } else if (propDef.type) { + types = [propDef.type]; + } else if (propDef.$ref) { + // Extract type name from reference like "#/$defs/TypeName" + const refParts = propDef.$ref.split('/'); + types = [refParts[refParts.length - 1]]; + } else { + types = ['object']; + } + + return types.join(', '); +} + +/** + * Build constraints string from property definition + * Extracts validation rules like minimum, maximum, pattern, enum, etc. + * @param {Object} propDef - Property definition from JSON Schema + * @returns {string} Comma-delimited constraints string + */ +export function buildConstraints(propDef) { + const parts = []; + + if (propDef.minimum !== undefined) { + parts.push(`minimum: ${propDef.minimum}`); + } + if (propDef.maximum !== undefined) { + parts.push(`maximum: ${propDef.maximum}`); + } + + if (propDef.minLength !== undefined) { + parts.push(`minLength: ${propDef.minLength}`); + } + if (propDef.maxLength !== undefined) { + parts.push(`maxLength: ${propDef.maxLength}`); + } + if (propDef.pattern) { + parts.push(`pattern: ${propDef.pattern}`); + } + + if (propDef.enum) { + const enumVals = propDef.enum.join(', '); + parts.push(`enum: [${enumVals}]`); + } + + if (propDef.minProperties !== undefined) { + parts.push(`minProperties: ${propDef.minProperties}`); + } + if (propDef.maxProperties !== undefined) { + parts.push(`maxProperties: ${propDef.maxProperties}`); + } + + if (propDef.minItems !== undefined) { + parts.push(`minItems: ${propDef.minItems}`); + } + if (propDef.maxItems !== undefined) { + parts.push(`maxItems: ${propDef.maxItems}`); + } + + return parts.join(', '); +} + +/** + * Generate human-readable default behavior text + * @param {Object} propDef - Property definition from JSON Schema + * @returns {string} Default behavior description + */ +export function generateDefaultText(propDef) { + if (propDef.default !== undefined) { + return `If omitted, ${propDef.default} is used.`; + } + + const types = Array.isArray(propDef.type) ? propDef.type : [propDef.type]; + if (types.includes('null')) { + return 'If omitted or null, default behavior applies.'; + } + + return 'If omitted, default behavior applies.'; +} + +/** + * Clean description text + * Converts markdown lists to HTML, linkifies URLs, and normalizes whitespace + * @param {string} description - Raw description text + * @returns {string} Cleaned description with HTML formatting + */ +export function cleanDescription(description) { + if (!description) return ''; + + let result = description.trim(); + + // Split into lines for list processing + const lines = result.split('\n'); + const processed = []; + let currentList = null; + let listType = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines + if (!line) continue; + + // Check for unordered list item (- or *) + const unorderedMatch = line.match(/^[-*]\s+(.+)$/); + if (unorderedMatch) { + if (listType !== 'ul') { + // Close any open list + if (currentList) { + processed.push(currentList); + } + currentList = { type: 'ul', items: [] }; + listType = 'ul'; + } + currentList.items.push(unorderedMatch[1]); + continue; + } + + // Check for ordered list item (1. 2. etc.) + const orderedMatch = line.match(/^\d+\.\s+(.+)$/); + if (orderedMatch) { + if (listType !== 'ol') { + // Close any open list + if (currentList) { + processed.push(currentList); + } + currentList = { type: 'ol', items: [] }; + listType = 'ol'; + } + currentList.items.push(orderedMatch[1]); + continue; + } + + // Not a list item - close any open list + if (currentList) { + processed.push(currentList); + currentList = null; + listType = null; + } + + // Add non-list line + processed.push(line); + } + + // Close final list if still open + if (currentList) { + processed.push(currentList); + } + + // Convert processed structure to HTML + result = processed.map(item => { + if (typeof item === 'string') { + return item; + } else if (item.type === 'ul') { + const items = item.items.map(i => `
  • ${i}
  • `).join(''); + return ``; + } else if (item.type === 'ol') { + const items = item.items.map(i => `
  • ${i}
  • `).join(''); + return `
      ${items}
    `; + } + return ''; + }).join(' '); + + // Linkify URLs after list processing + result = result.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => { + // Don't linkify if already in an href attribute + return `${url}`; + }); + + // Normalize excessive whitespace between non-HTML content + result = result.replace(/>\s+<'); // Remove whitespace between tags + result = result.replace(/\s+/g, ' ').trim(); // Normalize other whitespace + + // Debug logging for descriptions with HTML + if (result.includes('<')) { + console.log('cleanDescription output with HTML:', result.substring(0, 200)); + } + + return result; +} + +/** + * Process a single property definition + * @param {string} propName - Property name + * @param {Object} propDef - Property definition from JSON Schema + * @returns {Object} Simplified property object + */ +export function processProperty(propName, propDef) { + return { + name: propName, + type: resolveType(propDef), + default: generateDefaultText(propDef), + constraints: buildConstraints(propDef), + description: cleanDescription(propDef.description), + }; +} + +/** + * Build type-level constraints string + * @param {Object} typeDef - Type definition from JSON Schema + * @returns {string} Formatted constraints string + */ +export function buildTypeConstraints(typeDef) { + const parts = []; + + if (typeDef.additionalProperties === false) { + parts.push('additionalProperties: false'); + } + + if (typeDef.minProperties !== undefined) { + parts.push(`minProperties: ${typeDef.minProperties}`); + } + + if (typeDef.maxProperties !== undefined) { + parts.push(`maxProperties: ${typeDef.maxProperties}`); + } + + if (typeDef.required && typeDef.required.length > 0) { + const requiredProps = typeDef.required.join(', '); + parts.push(`Required properties: ${requiredProps}`); + } + + if (parts.length === 0) return ''; + + // Join with periods and add trailing period + return parts.join('. ') + '.'; +} + +/** + * Process a single type definition + * @param {string} typeName - Type name + * @param {Object} typeDef - Type definition from JSON Schema + * @returns {Object} Simplified type object + */ +export function processType(typeName, typeDef) { + const properties = []; + let hasNoProperties = true; + + // Process properties if they exist + if (typeDef.properties) { + hasNoProperties = false; + for (const [propName, propDef] of Object.entries(typeDef.properties)) { + properties.push(processProperty(propName, propDef)); + } + } + + return { + id: typeName.toLowerCase(), + name: typeName, + isExperimental: typeName.startsWith('Experimental'), + hasNoProperties, + properties, + constraints: buildTypeConstraints(typeDef), + }; +} + +/** + * Extract and process all type definitions from JSON Schema + * @param {Object} schema - Complete JSON Schema object + * @returns {Array} Array of processed type objects + */ +export function extractTypes(schema) { + if (!schema.$defs) { + throw new Error('No $defs found in schema'); + } + + const types = []; + + for (const [typeName, typeDef] of Object.entries(schema.$defs)) { + // Skip private types (those starting with underscore) + if (typeName.startsWith('_')) { + continue; + } + + types.push(processType(typeName, typeDef)); + } + + // Sort by name + types.sort((a, b) => a.name.localeCompare(b.name)); + + return types; +} + +/** + * Main transformation function + * Transforms raw JSON Schema to simplified format for accordion UI + * @param {Object} rawSchema - Raw JSON Schema object + * @returns {Object} Simplified data structure { types: [...] } + */ +export function transformSchema(rawSchema) { + try { + // Extract and process types + const types = extractTypes(rawSchema); + + // Create output structure + return { types }; + } catch (error) { + console.error('Error during schema transformation:', error); + throw error; + } +} diff --git a/assets/js/configSchemaTransform.test.js b/assets/js/configSchemaTransform.test.js new file mode 100644 index 000000000000..19f438644c97 --- /dev/null +++ b/assets/js/configSchemaTransform.test.js @@ -0,0 +1,335 @@ +/** + * Unit tests for Configuration Schema Transformer + * Uses Node.js built-in test runner (node:test) + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + resolveType, + buildConstraints, + generateDefaultText, + cleanDescription, + processProperty, + buildTypeConstraints, + processType, + extractTypes, + transformSchema, +} from './configSchemaTransform.js'; + +describe('resolveType', () => { + test('handles single type string', () => { + const propDef = { type: 'string' }; + assert.equal(resolveType(propDef), 'string'); + }); + + test('handles array of types', () => { + const propDef = { type: ['string', 'null'] }; + assert.equal(resolveType(propDef), 'string, null'); + }); + + test('handles $ref pointer', () => { + const propDef = { $ref: '#/$defs/LogRecordProcessor' }; + assert.equal(resolveType(propDef), 'LogRecordProcessor'); + }); + + test('defaults to object when no type or $ref', () => { + const propDef = { description: 'Some property' }; + assert.equal(resolveType(propDef), 'object'); + }); +}); + +describe('buildConstraints', () => { + test('handles numeric constraints', () => { + const propDef = { minimum: 1, maximum: 100 }; + assert.equal(buildConstraints(propDef), 'minimum: 1, maximum: 100'); + }); + + test('handles string length constraints', () => { + const propDef = { minLength: 1, maxLength: 50 }; + assert.equal(buildConstraints(propDef), 'minLength: 1, maxLength: 50'); + }); + + test('handles pattern constraint', () => { + const propDef = { pattern: '^[a-z]+$' }; + assert.equal(buildConstraints(propDef), 'pattern: ^[a-z]+$'); + }); + + test('handles enum constraint', () => { + const propDef = { enum: ['debug', 'info', 'warn', 'error'] }; + assert.equal( + buildConstraints(propDef), + 'enum: [debug, info, warn, error]', + ); + }); + + test('handles object property constraints', () => { + const propDef = { minProperties: 1, maxProperties: 10 }; + assert.equal( + buildConstraints(propDef), + 'minProperties: 1, maxProperties: 10', + ); + }); + + test('handles array item constraints', () => { + const propDef = { minItems: 1, maxItems: 5 }; + assert.equal(buildConstraints(propDef), 'minItems: 1, maxItems: 5'); + }); + + test('returns empty string when no constraints', () => { + const propDef = { type: 'string' }; + assert.equal(buildConstraints(propDef), ''); + }); +}); + +describe('generateDefaultText', () => { + test('uses explicit default value', () => { + const propDef = { default: 'info' }; + assert.equal(generateDefaultText(propDef), 'If omitted, info is used.'); + }); + + test('handles null type', () => { + const propDef = { type: ['string', 'null'] }; + assert.equal( + generateDefaultText(propDef), + 'If omitted or null, default behavior applies.', + ); + }); + + test('generic message for no default', () => { + const propDef = { type: 'string' }; + assert.equal( + generateDefaultText(propDef), + 'If omitted, default behavior applies.', + ); + }); +}); + +describe('cleanDescription', () => { + test('returns empty string for null/undefined', () => { + assert.equal(cleanDescription(null), ''); + assert.equal(cleanDescription(undefined), ''); + assert.equal(cleanDescription(''), ''); + }); + + test('trims whitespace', () => { + assert.equal(cleanDescription(' Hello world '), 'Hello world'); + }); + + test('converts markdown unordered list to HTML', () => { + const input = '- Item 1\n- Item 2\n- Item 3'; + const expected = ''; + assert.equal(cleanDescription(input), expected); + }); + + test('converts markdown ordered list to HTML', () => { + const input = '1. First\n2. Second\n3. Third'; + const expected = '
    1. First
    2. Second
    3. Third
    '; + assert.equal(cleanDescription(input), expected); + }); + + test('linkifies URLs', () => { + const input = 'See https://example.com for details'; + const result = cleanDescription(input); + assert.ok(result.includes(' { + const input = 'Introduction:\n- Item 1\n- Item 2\nConclusion'; + const result = cleanDescription(input); + assert.ok(result.includes('Introduction:')); + assert.ok(result.includes('