From e0757964cb48d6e00b4762396d35c7927693a46d Mon Sep 17 00:00:00 2001 From: pionere Date: Sat, 27 Dec 2025 15:33:25 +0100 Subject: [PATCH] validate sugarcube macros and html tags --- lib/sugarValidatorLib.js | 232 +++++++++++++++++++++++++++++++-------- 1 file changed, 189 insertions(+), 43 deletions(-) diff --git a/lib/sugarValidatorLib.js b/lib/sugarValidatorLib.js index fe2a9d9..92cd9e1 100644 --- a/lib/sugarValidatorLib.js +++ b/lib/sugarValidatorLib.js @@ -160,55 +160,201 @@ function validate(fileData, localStorage = {}) { } } - function findLastIfOpeningIndex(html) { - let maxIndex = html.length; - while (true) { - const index = html.substr(0, maxIndex).lastIndexOf('< fake success + var fake = 1; + if (stack.length != 0) { + const last = stack[stack.length-1]; + if (last.tag != tag || last.tagtype != subtype) { + addWarning([['Unmatched tag at ' + html.substring(pos, pos + 40)]], passage.header); + } else { + fake = 0; + } + } + if (fake) { + stack.push({}); + } + return true; + } + while (stack.length != 0) { + const last = stack[stack.length-1]; + if (last.tag != tag || last.tagtype != subtype) { + if (last.tagtype != 0) { + addWarning([['Unmatched tag at ' + html.substring(last.pos, last.pos + 40)]], passage.header); + stack.pop(); + continue; + } + break; } + return true; } + return false; } - - function matchIfs(html, passage) { + + function matchTags(html, passage) { + const htmlTags = ['div', 'b', 'strong', 'strike', 'u', 'i', 'li', 'ul', 'h1', 'h2', 'h3', 'p', 'table', 'tbody', 'th', 'tr', 'td', 'label', 'span', 'a', 'link', 'button', 'center']; + const sugarMacros = ['if', 'elseif', 'else', 'switch', 'case', 'default', 'for', 'do', 'nobr', 'silent', 'type', 'button', 'cycle', 'linkappend', 'linkprepend', 'linkreplace', 'link', 'listbox', 'append', 'prepend', 'replace', 'createaudiogroup', 'createplaylist', 'done', 'repeat', 'timed', 'widget', 'script', + 'silently', 'click', 'endsilently', 'endclick', + 'endif', 'endnobr','endfor', 'endscript', 'endbutton', 'endappend', 'endprepend', 'endreplace', 'endwidget']; + var cursor = 0; + var stack = []; while(true) { - const start = findLastIfOpeningIndex(html); - // If there is no <>') !== -1) return throwError('Unmatched <> found in passage', passage); - if (html.indexOf('<>') !== -1) return throwError('Unmatched <> found in passage', passage); - // If none of those still exist, we're good - return; + cursor = html.indexOf('<', cursor); + if (cursor === -1) { + digStackForTag(stack, '', -1, 0, html, passage); + const idx = stack.length-1; + if (idx >= 0) { + return throwError('Unmatched tag at ' + html.substring(stack[idx].pos, stack[idx].pos + 40), passage); + } + return; // done; } - // If there still is an <> - const end1 = html.indexOf('<>', start); - const end2 = html.indexOf('<>', start); - const end = (end2 === -1) ? end1 : (end1 === -1) ? end2 : Math.min(end1, end2); - if (end === -1) return throwError('Unmatched <> - const endOfIf = html.indexOf('>>', start); - // But make sure our < end) return throwError('Malformed <> - const elseIndex = content.indexOf('<>', start); - // If so, make sure there isn't a another <> or an <> - if (elseIndex !== -1) { - if (content.indexOf('<>', elseIndex + 1) !== -1) return throwError('Double <> found inside if block in passage', passage); - if (content.indexOf('<> found inside if block in passage', passage); + cursor++; + var next, type = 0, subtype = 0; + var nextPos = -1; + if (html.charAt(cursor) == '<') { + cursor++; + // double opening -> possible SugarCube macro + if (html.charAt(cursor) == '/') { + cursor++; + // macro closing + type = 1; + } + const current = html.substring(cursor, cursor + 32); + for (const tag of sugarMacros) { + if (current.startsWith(tag)) { + const nextChar = current.charAt(tag.length); + if (nextChar.match(/[^\w-]/)) { + next = tag; + nextPos = cursor-2; + if (type != 0) { + nextPos--; + if (nextChar != '>' || current.charAt(tag.length + 1) != '>') { + return throwError('Broken closing macro at ' + html.substring(nextPos, nextPos + 40), passage); + } + } + break; + } + } + } + } else { + // single opening -> possible html tag + subtype = 1; + if (html.charAt(cursor) == '/') { + cursor++; + // tag closing + type = 1; + } + const current = html.substring(cursor, cursor + 32); + for (const tag of htmlTags) { + if (current.startsWith(tag)) { + const nextChar = current.charAt(tag.length); + if (nextChar.match(/[^\w-]/)) { + next = tag; + nextPos = cursor-1; + if (type != 0) { + nextPos--; + if (nextChar != '>') { + return throwError('Broken closing tag at ' + html.substring(nextPos, nextPos + 40), passage); + } + } + break; + } + } + } + } + if (nextPos === -1) { + continue; + } + // convert mid-macros + // -- deprecated closing macros + if (next.startsWith('end')) { + if (type === 1) { + return throwError('Invalid tag at ' + html.substring(nextPos, nextPos + 40), passage); + } + type = 1; + next = next.substring(3); + } + // -- mid macros of if statements + if (next == 'else' || next == 'elseif') { + if (type === 1) { + return throwError('Invalid tag at ' + html.substring(nextPos, nextPos + 40), passage); + } + type = next == 'else' ? -2 : -1; + next = 'if'; + } + // -- mid macros of switch statements + if (next == 'case' || next == 'default') { + if (type === 1) { + return throwError('Invalid tag at ' + html.substring(nextPos, nextPos + 40), passage); + } + type = next == 'default' ? -4 : -3; + next = 'switch'; + } + // update/check stack + if (type == 0) { + // standard opening tag -> add to stack + stack.push({tag:next, type:0, tagtype:subtype, pos: nextPos}); + } else if (type < 0) { + // special sub-opening tags ('elseif' / 'else' , 'case' / 'default') + type = -type; + if (!digStackForTag(stack, next, subtype, nextPos, html, passage)) { + const hider = hiderTag(stack, next); + if (hider == null) { + if (type <= 2) { + // missing 'if'-branch + return throwError('Missing if-branch before ' + html.substring(nextPos, nextPos + 40), passage); + } else { + // missing 'switch'-branch + return throwError('Missing switch-branch before ' + html.substring(nextPos, nextPos + 40), passage); + } + } else { + // unmatched tags between (switch|case / case|default) or (if|elseif / else|elseif) tags + return throwError('Mangled tag at ' + html.substring(nextPos, nextPos + 40) + ', after unmatched tag at ' + html.substring(hider.pos, hider.pos + 40), passage); + } + } + const idx = stack.length-1; + if (stack[idx].type === 2 || stack[idx].type === 4) { + // 'else' / 'default' already found + if (type === 2) { + return throwError('Double <> found inside if block at ' + html.substring(nextPos, nextPos + 40), passage); + } else if (type === 1) { + return throwError('<> inside if block at ' + html.substring(nextPos, nextPos + 40), passage); + } else if (type === 4) { + return throwError('Double <> found inside switch block at ' + html.substring(nextPos, nextPos + 40), passage); + } else if (type === 3) { + return throwError('<> inside switch block at ' + html.substring(nextPos, nextPos + 40), passage); + } + } + stack[idx].type = type; + stack[idx].pos = nextPos; + } else { + // closing tags + if (!digStackForTag(stack, next, subtype, nextPos, html, passage)) { + const hider = hiderTag(stack, next); + if (hider == null) { + return throwError('Unmatched tag at ' + html.substring(nextPos, nextPos + 40), passage); + } else { + return throwError('Mangled tag at ' + html.substring(nextPos, nextPos + 40) + ', after unmatched tag at ' + html.substring(hider.pos, hider.pos + 40), passage); + } + } + stack.pop(); } - // Remove this if block from the HTML - html = html.substr(0, start) + html.substr(end + 7); + cursor = nextPos + 3; } } @@ -523,7 +669,7 @@ function validate(fileData, localStorage = {}) { try { matchQuotes(passage.content, passage); matchGTLT(passage.content, passage); - matchIfs(passage.content, passage); + matchTags(passage.content, passage); findDeprecatedInPassage(passage.content, passage); findInvalidConditions(passage.content, passage); checkInvalidWidgets(passage.content, passage);