Skip to content

Commit 0d142eb

Browse files
authored
Declarative Config Spec Docs - Schema transform (#9654)
1 parent 2d89b60 commit 0d142eb

3 files changed

Lines changed: 690 additions & 0 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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+
'<': '&lt;',
95+
'>': '&gt;',
96+
'"': '&quot;',
97+
"'": '&#39;',
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

Comments
 (0)