Files
vsix-hpl-toolbox/src/tools/mraidChecker.ts
2026-05-28 00:59:37 +08:00

624 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>;
}
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<string[]> {
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 = /<script\b[^>]*>([\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<string, number> {
const calls = new Map<string, number>();
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<Record<string, MraidRule>>((acc, rule) => {
acc[rule.id] = rule;
return acc;
}, {});
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
${getToolWebviewStyles()}
.issues { margin: 0; padding: 0; }
.issue { list-style: none; margin: 0 0 8px; }
.issue:last-child { margin-bottom: 0; }
.severity-badge {
display: inline-flex;
min-width: 82px;
justify-content: center;
margin-right: 6px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--tool-border);
font-size: 11px;
}
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
.issue-title { font-weight: 600; }
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
.selected-list { margin-top: 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">MRAID Checker</h2>
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div>
</main>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = [];
let selectedPage = 0;
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function escapeText(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody>' +
'</table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' });
});
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFolder = '';
pickedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles });
} else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
vscode.postMessage({ type: 'scan', folder });
}
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = '';
pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'results') {
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length;
const withIssues = msg.results.filter(r => !r.ok).length;
const skipped = Number(msg.skipped || 0);
const skippedReason = msg.skippedReason || 'file(s).';
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) {
const row = document.createElement('tr');
const file = document.createElement('td');
file.className = 'mono wrap';
file.textContent = r.file;
const issues = document.createElement('td');
issues.className = 'wrap';
if (!r.ok) {
const list = document.createElement('ul');
list.className = 'issues';
for (const i of r.issues) {
const item = document.createElement('li');
item.className = 'issue';
const line = i.line ? ' line ' + i.line + ':' : '';
item.innerHTML =
'<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item);
}
issues.appendChild(list);
} else {
issues.innerHTML = '<span class="badge ok">OK</span>';
}
row.append(file, issues);
tbody.appendChild(row);
}
}
});
</script>
</body>
</html>`;
}