624 lines
22 KiB
TypeScript
624 lines
22 KiB
TypeScript
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) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[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>`;
|
||
}
|