const KEYWORDS = ['if', 'else', 'for', 'end'] const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 } const AND_OR_REGEXP = /\s+(AND|OR)\s+/i const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/ function buildTokens (elem, acc = []) { if (elem.nodeType === Node.TEXT_NODE) { if (elem.textContent) { const text = elem.textContent const re = /[[\]]/g let match let found = false while ((match = re.exec(text)) !== null) { found = true acc.push({ elem, value: match[0], textLength: text.length, index: match.index }) } if (!found) { acc.push({ elem, value: '', textLength: 0, index: 0 }) } } } else { for (const child of elem.childNodes) { buildTokens(child, acc) } } return acc } function tokensPair (cur, nxt) { if (cur.elem === nxt.elem) { return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === '' } else { return cur.elem.textContent.slice(cur.index + 1).trim() === '' && nxt.elem.textContent.slice(0, nxt.index).trim() === '' } } function buildTags (tokens) { const normalized = [] for (let i = 0; i < tokens.length - 1; i++) { const cur = tokens[i] const nxt = tokens[i + 1] if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) { normalized.push(['open', cur]) } else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) { normalized.push(['close', nxt]) } } const tags = [] for (let i = 0; i < normalized.length - 1; i++) { const [curOp, openToken] = normalized[i] const [nxtOp, closeToken] = normalized[i + 1] if (curOp === 'open' && nxtOp === 'close') { tags.push({ openToken, closeToken, value: '' }) } } return tags } function findTextNodesInBranch (elements, toElem, acc) { if (!elements || elements.length === 0) return acc for (const elem of elements) { if (elem.nodeType === Node.TEXT_NODE) { acc.push(elem) } else { findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc) } if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc } return acc } function findTextNodesBetween (fromElem, toElem, acc = []) { if (fromElem === toElem) return [fromElem] let currentElement = fromElem while (true) { const parent = currentElement.parentNode if (!parent) return acc const children = Array.from(parent.childNodes) const startIndex = children.indexOf(currentElement) if (startIndex === -1) return acc const elementsInBranch = children.slice(startIndex) findTextNodesInBranch(elementsInBranch, toElem, acc) if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc let p = elementsInBranch[0].parentNode while (p && !p.nextSibling) { p = p.parentNode } if (!p || !p.nextSibling) return acc currentElement = p.nextSibling } } function mapTagValues (tags) { for (const tag of tags) { const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem) for (const elem of textNodes) { let part if (tag.openToken.elem === elem && tag.closeToken.elem === elem) { part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1) } else if (tag.openToken.elem === elem) { part = elem.textContent.slice(tag.openToken.index) } else if (tag.closeToken.elem === elem) { part = elem.textContent.slice(0, tag.closeToken.index + 1) } else { part = elem.textContent } tag.value += part } } return tags } function parseTagTypeName (tagString) { const val = tagString.replace(/[[\]]/g, '').trim() const parts = val.split(':').map((s) => s.trim()) if (parts.length === 2 && KEYWORDS.includes(parts[0])) { return [parts[0], parts[1]] } else if (KEYWORDS.includes(val)) { return [val, null] } else { return ['var', val] } } function isSimpleVariable (str) { const s = str.trim() return !AND_OR_REGEXP.test(s) && !COMPARISON_OPERATORS_REGEXP.test(s) && !s.includes('(') && !s.includes('!') && !s.includes('&&') && !s.includes('||') && !s.startsWith('"') && !s.startsWith("'") && !/^-?\d/.test(s) && !/^(true|false)$/i.test(s) } function tokenizeCondition (str) { const tokens = [] let pos = 0 str = str.trim() while (pos < str.length) { const rest = str.slice(pos) let m if ((m = rest.match(/^\s+/))) { pos += m[0].length } else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) { tokens.push({ type: 'operator', value: m[1] }) pos += m[1].length } else if (rest[0] === '!') { tokens.push({ type: 'not', value: '!' }) pos += 1 } else if (rest[0] === '(') { tokens.push({ type: 'lparen', value: '(' }) pos += 1 } else if (rest[0] === ')') { tokens.push({ type: 'rparen', value: ')' }) pos += 1 } else if (rest.startsWith('&&')) { tokens.push({ type: 'and', value: 'AND' }) pos += 2 } else if ((m = rest.match(/^AND\b/i))) { tokens.push({ type: 'and', value: 'AND' }) pos += 3 } else if (rest.startsWith('||')) { tokens.push({ type: 'or', value: 'OR' }) pos += 2 } else if ((m = rest.match(/^OR\b/i))) { tokens.push({ type: 'or', value: 'OR' }) pos += 2 } else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) { tokens.push({ type: 'string', value: m[1] }) pos += m[0].length } else if ((m = rest.match(/^(-?\d+\.?\d*)/))) { tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) }) pos += m[1].length } else if ((m = rest.match(/^(true|false)\b/i))) { tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' }) pos += m[1].length } else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) { tokens.push({ type: 'variable', value: m[1] }) pos += m[1].length } else { pos += 1 } } return tokens } function parseOrExpr (tokens, pos) { let left, right ;[left, pos] = parseAndExpr(tokens, pos) while (pos < tokens.length && tokens[pos].type === 'or') { pos += 1 ;[right, pos] = parseAndExpr(tokens, pos) left = { type: 'or', left, right } } return [left, pos] } function parseAndExpr (tokens, pos) { let left, right ;[left, pos] = parsePrimary(tokens, pos) while (pos < tokens.length && tokens[pos].type === 'and') { pos += 1 ;[right, pos] = parsePrimary(tokens, pos) left = { type: 'and', left, right } } return [left, pos] } function parsePrimary (tokens, pos) { if (pos >= tokens.length) return [null, pos] if (tokens[pos].type === 'not') { const [child, p] = parsePrimary(tokens, pos + 1) return [{ type: 'not', child }, p] } if (tokens[pos].type === 'lparen') { const [node, p] = parseOrExpr(tokens, pos + 1) return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p] } return parseComparisonOrPresence(tokens, pos) } function parseComparisonOrPresence (tokens, pos) { if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos] const variableName = tokens[pos].value pos += 1 if (pos < tokens.length && tokens[pos].type === 'operator') { let operator = tokens[pos].value if (operator === '=') operator = '==' pos += 1 if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) { const valueToken = tokens[pos] return [{ type: 'comparison', variableName, operator, value: valueToken.value, valueIsVariable: valueToken.type === 'variable' }, pos + 1] } } return [{ type: 'presence', variableName }, pos] } function parseCondition (conditionString) { const stripped = conditionString.trim() if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) { return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } } } if (isSimpleVariable(stripped)) { return { type: 'presence', variableName: stripped } } const tokens = tokenizeCondition(stripped) const [ast] = parseOrExpr(tokens, 0) return ast } function extractConditionVariables (node, acc = []) { if (!node) return acc switch (node.type) { case 'or': case 'and': extractConditionVariables(node.left, acc) extractConditionVariables(node.right, acc) break case 'not': extractConditionVariables(node.child, acc) break case 'comparison': acc.push({ name: node.variableName, type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string')) }) if (node.valueIsVariable) { acc.push({ name: node.value, type: null }) } break case 'presence': acc.push({ name: node.variableName, type: 'boolean' }) break } return acc } const IRREGULAR_PLURALS = { people: 'person', men: 'man', women: 'woman', children: 'child', teeth: 'tooth', feet: 'foot', mice: 'mouse', geese: 'goose', oxen: 'ox', criteria: 'criterion', phenomena: 'phenomenon', alumni: 'alumnus', data: 'datum', media: 'medium' } function singularize (word) { if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word] if (word.endsWith('ies')) return word.slice(0, -3) + 'y' if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2) if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2) if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1) return word } function buildOperators (tags) { const operators = [] const stack = [{ children: operators, operator: null }] for (const tag of tags) { const [type, variableName] = parseTagTypeName(tag.value) switch (type) { case 'for': case 'if': { const operator = { type, variableName, tag, children: [] } if (type === 'if') { try { operator.condition = parseCondition(variableName) } catch (e) { // ignore parse errors } } stack[stack.length - 1].children.push(operator) stack.push({ children: operator.children, operator }) break } case 'else': { const current = stack[stack.length - 1] if (current.operator && current.operator.type === 'if') { current.operator.elseTag = tag current.operator.elseChildren = [] current.children = current.operator.elseChildren } break } case 'end': { const popped = stack.pop() if (popped.operator) { popped.operator.endTag = tag } break } case 'var': stack[stack.length - 1].children.push({ type, variableName, tag }) break } } return operators } function assignNestedSchema (propertiesHash, parentProperties, keyString, value) { const keys = keyString.split('.') const lastKey = keys.pop() let currentLevel = null if (keys.length > 0 && parentProperties[keys[0]]) { currentLevel = keys.reduce((current, key) => { if (!current[key]) current[key] = { type: 'object', properties: {} } return current[key].properties }, parentProperties) } if (!currentLevel) { currentLevel = keys.reduce((current, key) => { if (!current[key]) current[key] = { type: 'object', properties: {} } return current[key].properties }, propertiesHash) } currentLevel[lastKey] = value } function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) { const keys = keyString.split('.') const lastKey = keys.pop() let currentLevel = null if (keys.length > 0 && parentProperties[keys[0]]) { currentLevel = keys.reduce((current, key) => { if (!current[key]) current[key] = { type: 'object', properties: {} } return current[key].properties }, parentProperties) } if (!currentLevel) { currentLevel = keys.reduce((current, key) => { if (!current[key]) current[key] = { type: 'object', properties: {} } return current[key].properties }, propertiesHash) } const existing = currentLevel[lastKey] if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return currentLevel[lastKey] = { type: newType } } function processConditionVariables (condition, propertiesHash, parentProperties) { const variables = extractConditionVariables(condition) for (const varInfo of variables) { assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean') } } function processOperators (operators, propertiesHash = {}, parentProperties = {}) { if (!operators || operators.length === 0) return propertiesHash for (const op of operators) { switch (op.type) { case 'var': { if (!op.variableName.includes('.') && parentProperties[op.variableName]) { const item = parentProperties[op.variableName] if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) { delete item.properties item.type = 'string' } } else { assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' }) } break } case 'if': if (op.condition) { processConditionVariables(op.condition, propertiesHash, parentProperties) } processOperators(op.children, propertiesHash, parentProperties) processOperators(op.elseChildren, propertiesHash, parentProperties) break case 'for': { const parts = op.variableName.split('.') const singularKey = singularize(parts[parts.length - 1]) let itemProperties = parentProperties[singularKey]?.items itemProperties = itemProperties || propertiesHash[parts[0]]?.items itemProperties = itemProperties || { type: 'object', properties: {} } assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties }) processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties }) if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) { delete itemProperties.properties itemProperties.type = 'string' } break } } } return propertiesHash } function mergeSchemaProperties (target, source) { for (const key of Object.keys(source)) { if (!target[key]) { target[key] = source[key] } else if (target[key].type === 'object' && source[key].type === 'object') { if (!target[key].properties) target[key].properties = {} if (source[key].properties) { mergeSchemaProperties(target[key].properties, source[key].properties) } } else if (target[key].type === 'array' && source[key].type === 'array') { if (source[key].items && source[key].items.properties) { if (!target[key].items) { target[key].items = source[key].items } else if (target[key].items.properties) { mergeSchemaProperties(target[key].items.properties, source[key].items.properties) } } else if (source[key].items && !target[key].items) { target[key].items = source[key].items } } else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) { target[key] = source[key] } } return target } function buildVariablesSchema (dom) { const tokens = buildTokens(dom) const tags = mapTagValues(buildTags(tokens)) const operators = buildOperators(tags) return processOperators(operators) } export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }