|
| 1 | +/** |
| 2 | + * Configuration Schema Transformer |
| 3 | + * |
| 4 | + * Transforms the raw OpenTelemetry configuration JSON Schema into a simplified |
| 5 | + * format optimized for the configuration types accordion UI. |
| 6 | + * |
| 7 | + * Input: Raw JSON Schema from /schemas/opentelemetry_configuration.json |
| 8 | + * Output: Simplified structure for accordion rendering |
| 9 | + * |
| 10 | + * This module extracts type definitions, processes properties, and resolves constraints |
| 11 | + */ |
| 12 | + |
| 13 | +/** |
| 14 | + * Resolve type information from a property definition |
| 15 | + * Handles arrays, single values, and $ref pointers |
| 16 | + * @param {Object} propDef - Property definition from JSON Schema |
| 17 | + * @returns {string} Comma-delimited type string |
| 18 | + */ |
| 19 | +export function resolveType(propDef) { |
| 20 | + let types; |
| 21 | + |
| 22 | + if (Array.isArray(propDef.type)) { |
| 23 | + types = propDef.type; |
| 24 | + } else if (propDef.type) { |
| 25 | + types = [propDef.type]; |
| 26 | + } else if (propDef.$ref) { |
| 27 | + // Extract type name from reference like "#/$defs/TypeName" |
| 28 | + const refParts = propDef.$ref.split('/'); |
| 29 | + types = [refParts[refParts.length - 1]]; |
| 30 | + } else { |
| 31 | + types = ['object']; |
| 32 | + } |
| 33 | + |
| 34 | + return types.join(', '); |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * Build constraints string from property definition |
| 39 | + * Extracts validation rules like minimum, maximum, pattern, enum, etc. |
| 40 | + * @param {Object} propDef - Property definition from JSON Schema |
| 41 | + * @returns {string} Comma-delimited constraints string |
| 42 | + */ |
| 43 | +export function buildConstraints(propDef) { |
| 44 | + const parts = []; |
| 45 | + |
| 46 | + if (propDef.minimum !== undefined) { |
| 47 | + parts.push(`minimum: ${propDef.minimum}`); |
| 48 | + } |
| 49 | + if (propDef.maximum !== undefined) { |
| 50 | + parts.push(`maximum: ${propDef.maximum}`); |
| 51 | + } |
| 52 | + |
| 53 | + if (propDef.minLength !== undefined) { |
| 54 | + parts.push(`minLength: ${propDef.minLength}`); |
| 55 | + } |
| 56 | + if (propDef.maxLength !== undefined) { |
| 57 | + parts.push(`maxLength: ${propDef.maxLength}`); |
| 58 | + } |
| 59 | + if (propDef.pattern) { |
| 60 | + parts.push(`pattern: ${propDef.pattern}`); |
| 61 | + } |
| 62 | + |
| 63 | + if (propDef.enum) { |
| 64 | + const enumVals = propDef.enum.join(', '); |
| 65 | + parts.push(`enum: [${enumVals}]`); |
| 66 | + } |
| 67 | + |
| 68 | + if (propDef.minProperties !== undefined) { |
| 69 | + parts.push(`minProperties: ${propDef.minProperties}`); |
| 70 | + } |
| 71 | + if (propDef.maxProperties !== undefined) { |
| 72 | + parts.push(`maxProperties: ${propDef.maxProperties}`); |
| 73 | + } |
| 74 | + |
| 75 | + if (propDef.minItems !== undefined) { |
| 76 | + parts.push(`minItems: ${propDef.minItems}`); |
| 77 | + } |
| 78 | + if (propDef.maxItems !== undefined) { |
| 79 | + parts.push(`maxItems: ${propDef.maxItems}`); |
| 80 | + } |
| 81 | + |
| 82 | + return parts.join(', '); |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * Escape HTML entities to prevent XSS |
| 87 | + * Only allows a specific allowlist of tags (ul, ol, li, a) generated by this module |
| 88 | + * @param {string} text - Raw text to escape |
| 89 | + * @returns {string} HTML-safe escaped text |
| 90 | + */ |
| 91 | +export function escapeHtml(text) { |
| 92 | + const htmlEscapes = { |
| 93 | + '&': '&', |
| 94 | + '<': '<', |
| 95 | + '>': '>', |
| 96 | + '"': '"', |
| 97 | + "'": ''', |
| 98 | + }; |
| 99 | + return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Linkify URLs in PLAIN TEXT with proper HTML escaping |
| 104 | + * IMPORTANT: This function must only be called on plain text BEFORE any HTML generation. |
| 105 | + * It escapes all text content and only creates <a> tags for detected URLs. |
| 106 | + * |
| 107 | + * Security model: |
| 108 | + * 1. All non-URL text is HTML-escaped to prevent XSS |
| 109 | + * 2. URLs are escaped and wrapped in <a> tags (our only generated HTML at this stage) |
| 110 | + * 3. This function is called BEFORE wrapping text in list tags, preventing double-linkification |
| 111 | + * |
| 112 | + * @param {string} plainText - Raw plain text from schema (not HTML) |
| 113 | + * @returns {string} Escaped text with linkified URLs |
| 114 | + */ |
| 115 | +export function linkifyUrls(plainText) { |
| 116 | + // Regex matches URLs but stops at quotes/brackets to avoid matching inside attributes |
| 117 | + const urlRegex = /(https?:\/\/[^\s<>"]+)/g; |
| 118 | + const parts = []; |
| 119 | + let lastIndex = 0; |
| 120 | + let match; |
| 121 | + |
| 122 | + while ((match = urlRegex.exec(plainText)) !== null) { |
| 123 | + // Escape all text before the URL |
| 124 | + if (match.index > lastIndex) { |
| 125 | + parts.push(escapeHtml(plainText.substring(lastIndex, match.index))); |
| 126 | + } |
| 127 | + |
| 128 | + // Linkify the URL with escaping |
| 129 | + const url = match[0]; |
| 130 | + const escapedUrl = escapeHtml(url); |
| 131 | + parts.push( |
| 132 | + `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">${escapedUrl}</a>`, |
| 133 | + ); |
| 134 | + |
| 135 | + lastIndex = match.index + url.length; |
| 136 | + } |
| 137 | + |
| 138 | + // Escape any remaining text after the last URL |
| 139 | + if (lastIndex < plainText.length) { |
| 140 | + parts.push(escapeHtml(plainText.substring(lastIndex))); |
| 141 | + } |
| 142 | + |
| 143 | + return parts.join(''); |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Clean description text |
| 148 | + * Converts markdown lists to HTML, linkifies URLs, and normalizes whitespace |
| 149 | + * All text content is HTML-escaped to prevent XSS attacks |
| 150 | + * @param {string} description - Raw description text |
| 151 | + * @returns {string} Cleaned description with HTML formatting |
| 152 | + */ |
| 153 | +export function cleanDescription(description) { |
| 154 | + if (!description) return ''; |
| 155 | + |
| 156 | + let result = description.trim(); |
| 157 | + |
| 158 | + const lines = result.split('\n'); |
| 159 | + const processed = []; |
| 160 | + let currentList = null; |
| 161 | + let listType = null; |
| 162 | + |
| 163 | + for (let i = 0; i < lines.length; i++) { |
| 164 | + const line = lines[i].trim(); |
| 165 | + |
| 166 | + if (!line) continue; |
| 167 | + |
| 168 | + // Check for unordered list item (- or *) |
| 169 | + const unorderedMatch = line.match(/^[-*]\s+(.+)$/); |
| 170 | + if (unorderedMatch) { |
| 171 | + if (listType !== 'ul') { |
| 172 | + // Close any open list |
| 173 | + if (currentList) { |
| 174 | + processed.push(currentList); |
| 175 | + } |
| 176 | + currentList = { type: 'ul', items: [] }; |
| 177 | + listType = 'ul'; |
| 178 | + } |
| 179 | + currentList.items.push(unorderedMatch[1]); |
| 180 | + continue; |
| 181 | + } |
| 182 | + |
| 183 | + // Check for ordered list item (1. 2. etc.) |
| 184 | + const orderedMatch = line.match(/^\d+\.\s+(.+)$/); |
| 185 | + if (orderedMatch) { |
| 186 | + if (listType !== 'ol') { |
| 187 | + // Close any open list |
| 188 | + if (currentList) { |
| 189 | + processed.push(currentList); |
| 190 | + } |
| 191 | + currentList = { type: 'ol', items: [] }; |
| 192 | + listType = 'ol'; |
| 193 | + } |
| 194 | + currentList.items.push(orderedMatch[1]); |
| 195 | + continue; |
| 196 | + } |
| 197 | + |
| 198 | + // Not a list item - close any open list |
| 199 | + if (currentList) { |
| 200 | + processed.push(currentList); |
| 201 | + currentList = null; |
| 202 | + listType = null; |
| 203 | + } |
| 204 | + |
| 205 | + // Add non-list line |
| 206 | + processed.push(line); |
| 207 | + } |
| 208 | + |
| 209 | + // Close final list if still open |
| 210 | + if (currentList) { |
| 211 | + processed.push(currentList); |
| 212 | + } |
| 213 | + |
| 214 | + // Convert processed structure to HTML with proper escaping and URL linkification |
| 215 | + // Only generates allowlisted tags: ul, ol, li, a |
| 216 | + result = processed |
| 217 | + .map((item) => { |
| 218 | + if (typeof item === 'string') { |
| 219 | + return linkifyUrls(item); |
| 220 | + } else if (item.type === 'ul') { |
| 221 | + const items = item.items |
| 222 | + .map((i) => `<li>${linkifyUrls(i)}</li>`) |
| 223 | + .join(''); |
| 224 | + return `<ul>${items}</ul>`; |
| 225 | + } else if (item.type === 'ol') { |
| 226 | + const items = item.items |
| 227 | + .map((i) => `<li>${linkifyUrls(i)}</li>`) |
| 228 | + .join(''); |
| 229 | + return `<ol>${items}</ol>`; |
| 230 | + } |
| 231 | + return ''; |
| 232 | + }) |
| 233 | + .join(' '); |
| 234 | + |
| 235 | + // Normalize excessive whitespace between non-HTML content |
| 236 | + result = result.replace(/>\s+</g, '><'); // Remove whitespace between tags |
| 237 | + result = result.replace(/\s+/g, ' ').trim(); // Normalize other whitespace |
| 238 | + |
| 239 | + return result; |
| 240 | +} |
| 241 | + |
| 242 | +/** |
| 243 | + * Process a single property definition |
| 244 | + * @param {string} propName - Property name |
| 245 | + * @param {Object} propDef - Property definition from JSON Schema |
| 246 | + * @returns {Object} Simplified property object |
| 247 | + */ |
| 248 | +export function processProperty(propName, propDef) { |
| 249 | + return { |
| 250 | + name: propName, |
| 251 | + type: resolveType(propDef), |
| 252 | + default: propDef.default, |
| 253 | + constraints: buildConstraints(propDef), |
| 254 | + description: cleanDescription(propDef.description), |
| 255 | + }; |
| 256 | +} |
| 257 | + |
| 258 | +/** |
| 259 | + * Build type-level constraints string |
| 260 | + * @param {Object} typeDef - Type definition from JSON Schema |
| 261 | + * @returns {string} Formatted constraints string |
| 262 | + */ |
| 263 | +export function buildTypeConstraints(typeDef) { |
| 264 | + const parts = []; |
| 265 | + |
| 266 | + if (typeDef.additionalProperties === false) { |
| 267 | + parts.push('additionalProperties: false'); |
| 268 | + } |
| 269 | + |
| 270 | + if (typeDef.minProperties !== undefined) { |
| 271 | + parts.push(`minProperties: ${typeDef.minProperties}`); |
| 272 | + } |
| 273 | + |
| 274 | + if (typeDef.maxProperties !== undefined) { |
| 275 | + parts.push(`maxProperties: ${typeDef.maxProperties}`); |
| 276 | + } |
| 277 | + |
| 278 | + if (typeDef.required && typeDef.required.length > 0) { |
| 279 | + const requiredProps = typeDef.required.join(', '); |
| 280 | + parts.push(`Required properties: ${requiredProps}`); |
| 281 | + } |
| 282 | + |
| 283 | + if (parts.length === 0) return ''; |
| 284 | + |
| 285 | + return parts.join('. ') + '.'; |
| 286 | +} |
| 287 | + |
| 288 | +/** |
| 289 | + * Process a single type definition |
| 290 | + * @param {string} typeName - Type name |
| 291 | + * @param {Object} typeDef - Type definition from JSON Schema |
| 292 | + * @returns {Object} Simplified type object |
| 293 | + */ |
| 294 | +export function processType(typeName, typeDef) { |
| 295 | + const properties = []; |
| 296 | + let hasNoProperties = true; |
| 297 | + |
| 298 | + if (typeDef.properties && Object.keys(typeDef.properties).length > 0) { |
| 299 | + hasNoProperties = false; |
| 300 | + for (const [propName, propDef] of Object.entries(typeDef.properties)) { |
| 301 | + properties.push(processProperty(propName, propDef)); |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + return { |
| 306 | + id: typeName.toLowerCase(), |
| 307 | + name: typeName, |
| 308 | + isExperimental: typeName.startsWith('Experimental'), |
| 309 | + hasNoProperties, |
| 310 | + properties, |
| 311 | + constraints: buildTypeConstraints(typeDef), |
| 312 | + }; |
| 313 | +} |
| 314 | + |
| 315 | +/** |
| 316 | + * Extract and process all type definitions from JSON Schema |
| 317 | + * @param {Object} schema - Complete JSON Schema object |
| 318 | + * @returns {Array} Array of processed type objects |
| 319 | + */ |
| 320 | +export function extractTypes(schema) { |
| 321 | + if (!schema.$defs) { |
| 322 | + throw new Error('No $defs found in schema'); |
| 323 | + } |
| 324 | + |
| 325 | + const types = []; |
| 326 | + |
| 327 | + for (const [typeName, typeDef] of Object.entries(schema.$defs)) { |
| 328 | + // Skip private types (those starting with underscore) |
| 329 | + if (typeName.startsWith('_')) { |
| 330 | + continue; |
| 331 | + } |
| 332 | + |
| 333 | + types.push(processType(typeName, typeDef)); |
| 334 | + } |
| 335 | + |
| 336 | + types.sort((a, b) => a.name.localeCompare(b.name)); |
| 337 | + |
| 338 | + return types; |
| 339 | +} |
| 340 | + |
| 341 | +/** |
| 342 | + * Main transformation function |
| 343 | + * Transforms raw JSON Schema to simplified format for accordion UI |
| 344 | + * @param {Object} rawSchema - Raw JSON Schema object |
| 345 | + * @returns {Object} Simplified data structure { types: [...] } |
| 346 | + */ |
| 347 | +export function transformSchema(rawSchema) { |
| 348 | + const types = extractTypes(rawSchema); |
| 349 | + |
| 350 | + return { types }; |
| 351 | +} |
0 commit comments