import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import { getToolWebviewStyles, singletonPanel } from './shared'; const store: { panel: vscode.WebviewPanel | null } = { panel: null }; type Severity = 'requirement' | 'best-practice'; interface MraidIssue { id: string; severity: Severity; title: string; detail: string; line?: number; reference: string; } interface MraidResult { file: string; ok: boolean; issues: MraidIssue[]; } interface MraidRule { id: string; severity: Severity; title: string; detail: string; reference: string; test: (ctx: ScanContext) => MraidIssue | null; } interface ScanContext { html: string; scriptText: string; lowerHtml: string; lowerScript: string; mraidCallLines: Map; } export function openMraidChecker(_context: vscode.ExtensionContext) { const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker'); if (!isNew) return; panel.webview.html = getHtml(); panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'ready') { const ws = vscode.workspace.workspaceFolders?.[0]; if (ws) { const distPath = path.join(ws.uri.fsPath, 'dist'); if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) { const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath); panel.webview.postMessage({ type: 'filesPicked', paths: files }); } } } else if (msg.type === 'pickFolder') { const picked = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, openLabel: 'Select Folder', }); if (picked && picked[0]) { const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath); panel.webview.postMessage({ type: 'filesPicked', paths: files }); } } else if (msg.type === 'pickFiles') { const picked = await vscode.window.showOpenDialog({ canSelectFolders: false, canSelectFiles: true, canSelectMany: true, openLabel: 'Select Files', filters: { 'HTML': ['html', 'htm'] }, }); if (picked && picked.length) { panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) }); } } else if (msg.type === 'scan') { const folder: string | undefined = msg.folder; const files: string[] | undefined = msg.files; let targets: string[] = []; let baseDir = ''; let skipped = 0; let skippedReason = ''; let noTargetsError = 'No HTML files were found.'; if (files && files.length) { const existing = files.filter(f => fs.existsSync(f)); targets = existing; skipped = files.length - existing.length; skippedReason = 'missing file(s).'; noTargetsError = 'No selected HTML files were found.'; baseDir = targets.length ? commonBaseDir(targets) : ''; } else if (folder && fs.existsSync(folder)) { const allHtmlFiles = await collectHtmlFiles(folder); targets = allHtmlFiles.filter(isInSupportedMraidFolderPath); skipped = allHtmlFiles.length - targets.length; skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.'; noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.'; baseDir = folder; } else { panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' }); return; } if (targets.length === 0) { panel.webview.postMessage({ type: 'results', results: [], skipped, skippedReason, error: noTargetsError, }); return; } const results: MraidResult[] = []; for (const file of targets) { try { const content = fs.readFileSync(file, 'utf8'); const issues = checkMraid(content); const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file; results.push({ file: display, ok: issues.length === 0, issues }); } catch { // Skip unreadable files, matching the scanner pattern used elsewhere. } } panel.webview.postMessage({ type: 'results', results, skipped, skippedReason }); } }); } function commonBaseDir(files: string[]): string { if (files.length === 0) return ''; if (files.length === 1) return path.dirname(files[0]); const split = files.map(f => path.dirname(f).split(path.sep)); const first = split[0]; let i = 0; while (i < first.length && split.every(parts => parts[i] === first[i])) i++; return first.slice(0, i).join(path.sep); } async function collectHtmlFiles(root: string): Promise { const out: string[] = []; const stack: string[] = [root]; while (stack.length) { const dir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (e.name === 'node_modules' || e.name === '.git') continue; stack.push(full); } else if (e.isFile() && /\.html?$/i.test(e.name)) { out.push(full); } } } return out; } function checkMraid(html: string): MraidIssue[] { const scriptText = extractScriptText(html); const ctx: ScanContext = { html, scriptText, lowerHtml: html.toLowerCase(), lowerScript: scriptText.toLowerCase(), mraidCallLines: collectMraidCallLines(html), }; return MRAID_RULES .map(rule => rule.test(ctx)) .filter((issue): issue is MraidIssue => issue !== null); } function extractScriptText(html: string): string { const scripts: string[] = []; const scriptRegex = /]*>([\s\S]*?)<\/script>/gi; let m: RegExpExecArray | null; while ((m = scriptRegex.exec(html)) !== null) { scripts.push(m[1]); } return scripts.join('\n'); } function collectMraidCallLines(html: string): Map { const calls = new Map(); const lines = html.split(/\r?\n/); const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g; for (let i = 0; i < lines.length; i++) { let m: RegExpExecArray | null; while ((m = callRegex.exec(lines[i])) !== null) { if (!calls.has(m[1])) calls.set(m[1], i + 1); } } return calls; } function isInSupportedMraidFolderPath(filePath: string): boolean { const folderParts = path.dirname(filePath) .split(/[\\/]+/) .map(part => part.toLowerCase()); return folderParts.some(part => [ 'al', 'applovin', 'app-lovin', 'is', 'ironsource', 'iron-source', 'iron_source', 'un', 'unity', 'unityads', 'unity-ads', 'unity_ads', ].includes(part)); } function hasMraidReference(ctx: ScanContext): boolean { return /\bmraid\b/i.test(ctx.html); } function hasMraidCall(ctx: ScanContext, method: string): boolean { return ctx.mraidCallLines.has(method); } function mraidLine(ctx: ScanContext, method: string): number | undefined { return ctx.mraidCallLines.get(method); } function issue(rule: MraidRule, line?: number): MraidIssue { return { id: rule.id, severity: rule.severity, title: rule.title, detail: rule.detail, line, reference: rule.reference, }; } function hasReadyGate(ctx: ScanContext): boolean { return ( /mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) || /mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) || /['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText) ); } function hasEventListener(ctx: ScanContext, eventName: string): boolean { const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i'); return pattern.test(ctx.scriptText); } function hasSupportCheck(ctx: ScanContext, feature: string): boolean { const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i'); return pattern.test(ctx.scriptText); } function usesDirectBrowserNavigation(ctx: ScanContext): boolean { return ( /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) || /\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText) ); } const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/'; const MRAID_RULES: MraidRule[] = [ { id: 'mraid-reference', severity: 'requirement', title: 'MRAID API reference not found', detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.', reference: MRAID_REFERENCE, test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']), }, { id: 'ready-gate', severity: 'requirement', title: 'MRAID ready state is not guarded', detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.', reference: MRAID_REFERENCE, test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']), }, { id: 'error-listener', severity: 'best-practice', title: 'No MRAID error listener found', detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.', reference: MRAID_REFERENCE, test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']), }, { id: 'viewability', severity: 'best-practice', title: 'Viewability handling not found', detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.', reference: MRAID_REFERENCE, test: (ctx) => { const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange'); return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']); }, }, { id: 'open-api', severity: 'requirement', title: 'Browser navigation used instead of mraid.open', detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container.', reference: MRAID_REFERENCE, test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null, }, { id: 'close-api', severity: 'best-practice', title: 'No mraid.close call found', detail: 'If the creative renders its own close control, wire that control to mraid.close().', reference: MRAID_REFERENCE, test: (ctx) => { const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html); return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null; }, }, { id: 'expand-support', severity: 'requirement', title: 'mraid.expand used without supports check', detail: 'Check mraid.supports("expand") before using expand behavior.', reference: MRAID_REFERENCE, test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null, }, { id: 'resize-support', severity: 'requirement', title: 'mraid.resize used without supports check', detail: 'Check mraid.supports("resize") before using resize behavior.', reference: MRAID_REFERENCE, test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null, }, { id: 'orientation-support', severity: 'best-practice', title: 'Orientation properties set without supports check', detail: 'Check mraid.supports("orientation") before requiring orientation behavior.', reference: MRAID_REFERENCE, test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null, }, { id: 'size-change', severity: 'best-practice', title: 'No sizeChange listener found', detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.', reference: MRAID_REFERENCE, test: (ctx) => { const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties'); return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null; }, }, { id: 'state-change', severity: 'best-practice', title: 'No stateChange listener found', detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.', reference: MRAID_REFERENCE, test: (ctx) => { const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close'); return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null; }, }, ]; const MRAID_RULES_BY_ID = MRAID_RULES.reduce>((acc, rule) => { acc[rule.id] = rule; return acc; }, {}); function getHtml(): string { return `

MRAID Checker

Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.

Inputs

`; }