Compare commits
1 Commits
d384c1be3a
...
mraid-chec
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ad1e14ca |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, Daily Update. Local HTML Host.",
|
||||
"version": "0.1.5",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
@@ -30,6 +30,10 @@
|
||||
"command": "hplToolbox.openBase64Scanner",
|
||||
"title": "HPL Toolbox: Open Base64 Scanner"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openMraidChecker",
|
||||
"title": "HPL Toolbox: Open MRAID Checker"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openDailyUpdate",
|
||||
"title": "HPL Toolbox: Open Daily Update"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LauncherViewProvider } from './launcherView';
|
||||
import { openPlecUpload } from './tools/plecUpload';
|
||||
import { openApplovinUpload } from './tools/applovinUpload';
|
||||
import { openBase64Scanner } from './tools/base64Scanner';
|
||||
import { openMraidChecker } from './tools/mraidChecker';
|
||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
||||
import { openSendToMobile } from './tools/sendToMobile';
|
||||
|
||||
@@ -11,6 +12,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||
|
||||
@@ -1,28 +1,86 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||
|
||||
interface ToolDefinition {
|
||||
command: string;
|
||||
title: string;
|
||||
description: string;
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
const TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
command: 'hplToolbox.openPlecUpload',
|
||||
title: 'PLEC Upload',
|
||||
description: 'Upload HTML to internal PLEC server',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openApplovinUpload',
|
||||
title: 'AppLovin Demo Upload',
|
||||
description: 'Upload to p.applov.in (QR preview)',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openBase64Scanner',
|
||||
title: 'Base64 Scanner',
|
||||
description: 'Find non-base64 assets in HTML',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openMraidChecker',
|
||||
title: 'MRAID Checker',
|
||||
description: 'Check MRAID requirements and best practices',
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openDailyUpdate',
|
||||
title: 'Daily Update',
|
||||
description: 'Compose & copy a daily status',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openSendToMobile',
|
||||
title: 'Send To Mobile',
|
||||
description: 'Share HTML to a phone via LAN + QR',
|
||||
},
|
||||
];
|
||||
|
||||
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
resolveWebviewView(view: vscode.WebviewView) {
|
||||
view.webview.options = { enableScripts: true };
|
||||
const version = this.context.extension.packageJSON.version as string;
|
||||
view.webview.html = getHtml(version);
|
||||
view.webview.onDidReceiveMessage((msg) => {
|
||||
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||
view.webview.html = getHtml(version, betaToolsEnabled);
|
||||
view.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||
vscode.commands.executeCommand(msg.command);
|
||||
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||
view.webview.html = getHtml(version, msg.enabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(version: string): string {
|
||||
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
const visibleTools = TOOLS.filter(tool => betaToolsEnabled || !tool.beta);
|
||||
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 8px; }
|
||||
body {
|
||||
min-height: calc(100vh - 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--vscode-font-family);
|
||||
color: var(--vscode-foreground);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
||||
.tools { flex: 1; }
|
||||
.tool-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
@@ -34,31 +92,48 @@ function getHtml(version: string): string {
|
||||
.tool-btn:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
.tool-title { display: flex; align-items: center; gap: 6px; }
|
||||
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||
.beta-badge {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.beta-toggle {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
.beta-toggle button {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.beta-toggle button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>HPL Toolbox ${version}</h3>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
|
||||
PLEC Upload
|
||||
<span class="tool-desc">Upload HTML to internal PLEC server</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
|
||||
AppLovin Demo Upload
|
||||
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
|
||||
Base64 Scanner
|
||||
<span class="tool-desc">Find non-base64 assets in HTML</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
|
||||
Daily Update
|
||||
<span class="tool-desc">Compose & copy a daily status</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openSendToMobile">
|
||||
Send To Mobile
|
||||
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
|
||||
</button>
|
||||
<div class="tools">
|
||||
${toolButtons}
|
||||
</div>
|
||||
<div class="beta-toggle">
|
||||
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||
@@ -66,7 +141,28 @@ function getHtml(version: string): string {
|
||||
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
||||
});
|
||||
});
|
||||
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderToolButton(tool: ToolDefinition): string {
|
||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
|
||||
496
src/tools/mraidChecker.ts
Normal file
496
src/tools/mraidChecker.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { 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()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
}
|
||||
} 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;
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
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,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
});
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
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>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
|
||||
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
|
||||
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
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;
|
||||
clearFiles();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const total = msg.results.length;
|
||||
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(msg.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
|
||||
'<span class="file-name">' + escapeText(r.file) + '</span>' +
|
||||
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
|
||||
'</div>';
|
||||
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="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);
|
||||
}
|
||||
row.appendChild(list);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user