diff --git a/assets/js/lib/configSchemaTransform.mjs b/assets/js/lib/configSchemaTransform.mjs
new file mode 100644
index 000000000000..a6ddc4e487a8
--- /dev/null
+++ b/assets/js/lib/configSchemaTransform.mjs
@@ -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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ };
+ 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 tags for detected URLs.
+ *
+ * Security model:
+ * 1. All non-URL text is HTML-escaped to prevent XSS
+ * 2. URLs are escaped and wrapped in 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(
+ `${escapedUrl}`,
+ );
+
+ 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) => `
${linkifyUrls(i)}`)
+ .join('');
+ return ``;
+ } else if (item.type === 'ol') {
+ const items = item.items
+ .map((i) => `${linkifyUrls(i)}`)
+ .join('');
+ return `${items}
`;
+ }
+ return '';
+ })
+ .join(' ');
+
+ // Normalize excessive whitespace between non-HTML content
+ result = result.replace(/>\s+<'); // 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 };
+}
diff --git a/assets/js/lib/configSchemaTransform.test.mjs b/assets/js/lib/configSchemaTransform.test.mjs
new file mode 100644
index 000000000000..b46d0dec7933
--- /dev/null
+++ b/assets/js/lib/configSchemaTransform.test.mjs
@@ -0,0 +1,338 @@
+/**
+ * Unit tests for Configuration Schema Transformer
+ */
+
+import { test, describe } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ resolveType,
+ buildConstraints,
+ cleanDescription,
+ processProperty,
+ buildTypeConstraints,
+ processType,
+ extractTypes,
+ transformSchema,
+ escapeHtml,
+ linkifyUrls,
+} from './configSchemaTransform.mjs';
+
+describe('resolveType', () => {
+ test('resolves types correctly', () => {
+ const cases = [
+ [{ type: 'string' }, 'string'],
+ [{ type: ['string', 'null'] }, 'string, null'],
+ [{ $ref: '#/$defs/LogRecordProcessor' }, 'LogRecordProcessor'],
+ [{ description: 'Some property' }, 'object'],
+ ];
+
+ cases.forEach(([input, expected]) => {
+ assert.equal(resolveType(input), expected);
+ });
+ });
+});
+
+describe('buildConstraints', () => {
+ test('builds constraints correctly', () => {
+ const cases = [
+ [{ minimum: 1, maximum: 100 }, 'minimum: 1, maximum: 100'],
+ [{ minLength: 1, maxLength: 50 }, 'minLength: 1, maxLength: 50'],
+ [{ pattern: '^[a-z]+$' }, 'pattern: ^[a-z]+$'],
+ [
+ { enum: ['debug', 'info', 'warn', 'error'] },
+ 'enum: [debug, info, warn, error]',
+ ],
+ [
+ { minProperties: 1, maxProperties: 10 },
+ 'minProperties: 1, maxProperties: 10',
+ ],
+ [{ minItems: 1, maxItems: 5 }, 'minItems: 1, maxItems: 5'],
+ [{ type: 'string' }, ''],
+ ];
+
+ cases.forEach(([input, expected]) => {
+ assert.equal(buildConstraints(input), expected);
+ });
+ });
+});
+
+describe('escapeHtml', () => {
+ test('escapes HTML entities', () => {
+ assert.equal(
+ escapeHtml(''),
+ '<script>alert("xss")</script>',
+ );
+ assert.equal(escapeHtml('5 > 3 & 2 < 4'), '5 > 3 & 2 < 4');
+ assert.equal(escapeHtml('it\'s a "test"'), 'it's a "test"');
+ });
+
+ test('returns unchanged text without special characters', () => {
+ assert.equal(escapeHtml('plain text'), 'plain text');
+ });
+});
+
+describe('linkifyUrls', () => {
+ test('linkifies URLs and escapes surrounding text', () => {
+ const input = 'See https://example.com for ';
+ const result = linkifyUrls(input);
+ assert.ok(result.includes(' {
+ const input = 'Visit https://example.com or https://test.org';
+ const result = linkifyUrls(input);
+ const matches = result.match(/ {
+ assert.equal(
+ linkifyUrls(''),
+ '<script>alert(1)</script>',
+ );
+ });
+});
+
+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 = '- First
- Second
- Third
';
+ assert.equal(cleanDescription(input), expected);
+ });
+
+ test('linkifies URLs with correct attributes', () => {
+ const result = cleanDescription('See https://example.com for details');
+ assert.match(
+ result,
+ /]*target="_blank"[^>]*rel="noopener noreferrer"/,
+ );
+ });
+
+ test('handles mixed content with lists and text', () => {
+ const input = 'Introduction:\n- Item 1\n- Item 2\nConclusion';
+ const result = cleanDescription(input);
+ assert.ok(result.includes('Introduction:'));
+ assert.ok(result.includes(''));
+ assert.ok(result.includes('Conclusion'));
+ });
+
+ test('escapes malicious HTML in plain text', () => {
+ const input = 'Check for info';
+ const result = cleanDescription(input);
+ assert.ok(result.includes('<script>'));
+ assert.ok(!result.includes('';
+ const result = cleanDescription(input);
+ assert.ok(result.includes(''));
+ });
+});
+
+describe('processProperty', () => {
+ test('processes complete property definition', () => {
+ const propDef = {
+ type: 'string',
+ description: 'The log level',
+ default: 'info',
+ enum: ['debug', 'info', 'warn', 'error'],
+ };
+
+ const result = processProperty('level', propDef);
+
+ assert.equal(result.name, 'level');
+ assert.equal(result.type, 'string');
+ assert.equal(result.default, 'info');
+ assert.equal(result.constraints, 'enum: [debug, info, warn, error]');
+ assert.equal(result.description, 'The log level');
+ });
+});
+
+describe('buildTypeConstraints', () => {
+ test('handles individual constraints', () => {
+ const cases = [
+ [{ additionalProperties: false }, 'additionalProperties: false.'],
+ [{ required: ['name', 'type'] }, 'Required properties: name, type.'],
+ [
+ { minProperties: 1, maxProperties: 5 },
+ 'minProperties: 1. maxProperties: 5.',
+ ],
+ [{ properties: {} }, ''],
+ ];
+
+ cases.forEach(([input, expected]) => {
+ assert.equal(buildTypeConstraints(input), expected);
+ });
+ });
+
+ test('combines multiple constraints', () => {
+ const typeDef = {
+ additionalProperties: false,
+ required: ['id'],
+ minProperties: 1,
+ };
+ const result = buildTypeConstraints(typeDef);
+ assert.ok(result.includes('additionalProperties: false'));
+ assert.ok(result.includes('Required properties: id'));
+ assert.ok(result.includes('minProperties: 1'));
+ });
+});
+
+describe('processType', () => {
+ test('processes type with properties', () => {
+ const typeDef = {
+ properties: {
+ name: { type: 'string' },
+ enabled: { type: 'boolean', default: false },
+ },
+ required: ['name'],
+ };
+
+ const result = processType('LoggerProvider', typeDef);
+
+ assert.equal(result.id, 'loggerprovider');
+ assert.equal(result.name, 'LoggerProvider');
+ assert.equal(result.isExperimental, false);
+ assert.equal(result.hasNoProperties, false);
+ assert.equal(result.properties.length, 2);
+ assert.equal(result.properties[0].name, 'name');
+ assert.equal(result.properties[1].name, 'enabled');
+ });
+
+ test('marks experimental types', () => {
+ const typeDef = { properties: {} };
+ const result = processType('ExperimentalFeature', typeDef);
+ assert.equal(result.isExperimental, true);
+ });
+
+ test('handles type with no properties', () => {
+ const typeDef = {};
+ const result = processType('EmptyType', typeDef);
+ assert.equal(result.hasNoProperties, true);
+ assert.equal(result.properties.length, 0);
+ });
+});
+
+describe('extractTypes', () => {
+ test('extracts and processes all types from schema', () => {
+ const schema = {
+ $defs: {
+ TypeA: { properties: { foo: { type: 'string' } } },
+ TypeB: { properties: { bar: { type: 'number' } } },
+ _PrivateType: { properties: { hidden: { type: 'boolean' } } },
+ },
+ };
+
+ const result = extractTypes(schema);
+
+ assert.equal(result.length, 2); // _PrivateType should be filtered out
+ assert.equal(result[0].name, 'TypeA');
+ assert.equal(result[1].name, 'TypeB');
+ });
+
+ test('sorts types alphabetically', () => {
+ const schema = {
+ $defs: {
+ Zebra: { properties: {} },
+ Apple: { properties: {} },
+ Mango: { properties: {} },
+ },
+ };
+
+ const result = extractTypes(schema);
+
+ assert.equal(result[0].name, 'Apple');
+ assert.equal(result[1].name, 'Mango');
+ assert.equal(result[2].name, 'Zebra');
+ });
+
+ test('throws error when $defs is missing', () => {
+ const schema = { properties: {} };
+ assert.throws(() => extractTypes(schema), {
+ message: 'No $defs found in schema',
+ });
+ });
+
+ test('filters out private types starting with underscore', () => {
+ const schema = {
+ $defs: {
+ PublicType: { properties: {} },
+ _InternalType: { properties: {} },
+ __PrivateType: { properties: {} },
+ },
+ };
+
+ const result = extractTypes(schema);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0].name, 'PublicType');
+ });
+});
+
+describe('transformSchema', () => {
+ test('transforms complete schema successfully', () => {
+ const rawSchema = {
+ $defs: {
+ LoggerProvider: {
+ properties: {
+ processors: {
+ type: 'array',
+ items: { $ref: '#/$defs/LogRecordProcessor' },
+ },
+ },
+ required: ['processors'],
+ },
+ LogRecordProcessor: {
+ properties: {
+ type: { type: 'string' },
+ },
+ },
+ },
+ };
+
+ const result = transformSchema(rawSchema);
+
+ assert.ok(result.types);
+ assert.equal(result.types.length, 2);
+ // Types are sorted alphabetically
+ assert.equal(result.types[0].name, 'LoggerProvider');
+ assert.equal(result.types[1].name, 'LogRecordProcessor');
+ });
+
+ test('throws error for invalid schema', () => {
+ const invalidSchema = { properties: {} }; // Missing $defs
+
+ assert.throws(() => transformSchema(invalidSchema), {
+ message: 'No $defs found in schema',
+ });
+ });
+});
diff --git a/package.json b/package.json
index 59b410e44910..b5c21d11a0f3 100644
--- a/package.json
+++ b/package.json
@@ -139,6 +139,7 @@
"test:compound-tests": "npm run seq -- check:code-excerpts $(CMD_SKIP=collector-sync npm -s run _list:test:compound-tests)",
"test:edge-functions:live": "npm run _test:ef:live --",
"test:edge-functions": "node --test \"netlify/edge-functions/**/*.test.ts\"",
+ "test:config-schema-tests": "node --test \"assets/js/**/*.test.mjs\"",
"test:local-tools": "find scripts -type f \\( -name '*.test.mjs' -o -name '*.test.js' \\) -print0 | xargs -0 node --test",
"test:redirects:live": "npm run _test:redirects:live --",
"test": "CMD_SKIP=collector-sync npm run test:base",