Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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