Skip to content
Merged
351 changes: 351 additions & 0 deletions assets/js/configSchemaTransform.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
/**
* Configuration Schema Transformer
*
* 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, and resolves constraints
*/

/**
* 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(', ');
}

/**
* Escape HTML entities to prevent XSS
* Only allows a specific allowlist of tags (ul, ol, li, a) generated by this module
* @param {string} text - Raw text to escape
* @returns {string} HTML-safe escaped text
*/
export function escapeHtml(text) {
const htmlEscapes = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
}

/**
* Linkify URLs in PLAIN TEXT with proper HTML escaping
* IMPORTANT: This function must only be called on plain text BEFORE any HTML generation.
* It escapes all text content and only creates <a> tags for detected URLs.
*
* Security model:
* 1. All non-URL text is HTML-escaped to prevent XSS
* 2. URLs are escaped and wrapped in <a> tags (our only generated HTML at this stage)
* 3. This function is called BEFORE wrapping text in list tags, preventing double-linkification
*
* @param {string} plainText - Raw plain text from schema (not HTML)
* @returns {string} Escaped text with linkified URLs
*/
export function linkifyUrls(plainText) {
// Regex matches URLs but stops at quotes/brackets to avoid matching inside attributes
const urlRegex = /(https?:\/\/[^\s<>"]+)/g;
const parts = [];
let lastIndex = 0;
let match;

while ((match = urlRegex.exec(plainText)) !== null) {
// Escape all text before the URL
if (match.index > lastIndex) {
parts.push(escapeHtml(plainText.substring(lastIndex, match.index)));
}

// Linkify the URL with escaping
const url = match[0];
const escapedUrl = escapeHtml(url);
parts.push(
`<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">${escapedUrl}</a>`,
);

lastIndex = match.index + url.length;
}

// Escape any remaining text after the last URL
if (lastIndex < plainText.length) {
parts.push(escapeHtml(plainText.substring(lastIndex)));
}

return parts.join('');
}

/**
* Clean description text
* Converts markdown lists to HTML, linkifies URLs, and normalizes whitespace
* All text content is HTML-escaped to prevent XSS attacks
* @param {string} description - Raw description text
* @returns {string} Cleaned description with HTML formatting
*/
export function cleanDescription(description) {
if (!description) return '';

let result = description.trim();

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();

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 with proper escaping and URL linkification
// Only generates allowlisted tags: ul, ol, li, a
result = processed
.map((item) => {
if (typeof item === 'string') {
return linkifyUrls(item);
} else if (item.type === 'ul') {
const items = item.items
.map((i) => `<li>${linkifyUrls(i)}</li>`)
.join('');
return `<ul>${items}</ul>`;
} else if (item.type === 'ol') {
const items = item.items
.map((i) => `<li>${linkifyUrls(i)}</li>`)
.join('');
return `<ol>${items}</ol>`;
}
return '';
})
.join(' ');

// Normalize excessive whitespace between non-HTML content
result = result.replace(/>\s+</g, '><'); // Remove whitespace between tags
result = result.replace(/\s+/g, ' ').trim(); // Normalize other whitespace

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: propDef.default,
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 '';

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;

if (typeDef.properties && Object.keys(typeDef.properties).length > 0) {
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));
}

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) {
const types = extractTypes(rawSchema);

return { types };
}
Loading
Loading