mraid checker
This commit is contained in:
@@ -73,18 +73,19 @@ const offY = (viewH - 1920 * s) / 2
|
||||
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||
```
|
||||
1. ExitApi.exit() → GoogleAds (gg)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb) · Moloco (mo)
|
||||
3. Luna.Unity.Playable → Unity (un)
|
||||
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||
5. window.install() → Mintegral (mtg)
|
||||
6. window.openAppStore() → (runtime fallback)
|
||||
7. window.clickTag → Moloco (mo)
|
||||
7. window.clickTag → Moloco (mo) fallback
|
||||
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
|
||||
11. window.open(storeUrl) → Fallback (all others)
|
||||
```
|
||||
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||
MRAID CTA calls must be guarded: require `typeof mraid.open === 'function'`; treat missing `mraid.getState()` as `ready`; if state is still `loading`, fall through to `window.open(storeUrl)`.
|
||||
|
||||
## Analytics — SDK Priority
|
||||
```
|
||||
@@ -107,15 +108,63 @@ gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCT
|
||||
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||
| Network | Mechanism |
|
||||
|---|---|
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events; also shared MRAID exposure/viewability when `mraid.js` is present |
|
||||
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||
| Applovin / Ironsource / Unity | MRAID 3.0 `exposureChange`, MRAID 2.0 fallback `viewableChange` / `isViewable`, and `audioVolumeChange` (gated in `initMraid`) |
|
||||
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||
|
||||
### MRAID 3.0 Requirements & Best Practices
|
||||
MRAID networks are **Applovin (`al`)**, **Ironsource (`is`)**, and **Unity (`un`)**. All three must request `<script src="mraid.js"></script>` early in the generated HTML. Unity still uses Luna first for CTA/lifecycle where available, but MRAID must exist as the standards fallback.
|
||||
|
||||
Creative requirements from the MRAID 3.0 spec and Best Practices Guide:
|
||||
- Request `mraid.js` as early as possible, exactly once, either with `<script src="mraid.js"></script>` or DOM insertion. Do not rely on the container to inject it automatically.
|
||||
- Wait for both DOM readiness and MRAID readiness before starting rich media behavior. Use `document.readyState` / `DOMContentLoaded` or `load` for the DOM side.
|
||||
- Use `mraid.getState()` together with `mraid.addEventListener('ready', ...)` so the creative still starts when the container fires `ready` before the listener attaches.
|
||||
- Guard access to `mraid` and MRAID methods with `typeof` checks. Treat missing optional methods as unsupported and fall back gracefully.
|
||||
- Use `mraid.open(url)` for MRAID clickthroughs. Avoid `<a href>`, `location.href` / `assign` / `replace`, and unguarded `window.open`.
|
||||
- Include `<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">` or an equivalent mobile viewport tag.
|
||||
- Do not assume nested iframes can access native MRAID. If nested iframe access is required, the outer frame must provide its own bridge.
|
||||
- Use `exposureChange` as the MRAID 3.0 viewability signal, then degrade to `viewableChange` / `isViewable()` for MRAID 2.0 compatibility.
|
||||
- Listen for `audioVolumeChange`; `null` is valid and must be ignored. Only apply volume math when the value is numeric.
|
||||
- Listen for `error`; if the ad cannot run or assets fail, gracefully degrade or call `mraid.unload()` instead of showing a broken/blank ad.
|
||||
- Call `setResizeProperties()` before `resize()`. Do not use `resize()` for full-screen takeovers; use `expand()` for that case.
|
||||
- Avoid resizing or expanding interstitials. Check placement with `getPlacementType()` when one creative can serve inline and interstitial placements.
|
||||
- Avoid `useCustomClose()` and custom close indicators in MRAID 3.0. The host provides the close control.
|
||||
- Avoid two-part expandable ads (`expand(url)`); use self-contained one-part expandables.
|
||||
- Call `supports()` before optional/native features such as `storePicture`, `createCalendarEvent`, `getLocation`, VPAID, SMS/tel, or inline video behavior.
|
||||
- Use `playVideo()` for native video playback cases instead of `mraid.open(videoUrl)`. Use HTML5 `<video>` for inline playback when supported.
|
||||
|
||||
Implementation requirements for `networks.ts` / `networks.js`:
|
||||
- Export `initMraid(timeoutMs = 2000, detectTimeoutMs = 500)` and call it at module load.
|
||||
- If `window.mraid` is missing at module load, poll briefly (`detectTimeoutMs`, default 500 ms) for late container injection before resolving as no-MRAID.
|
||||
- If MRAID exists and `mraid.getState() === 'loading'`, wait for `mraid.addEventListener('ready', ...)`; self-resolve after `timeoutMs` (default 2000 ms) so startup never hangs.
|
||||
- `main` / boot must `await initMraid()` before constructing `new Phaser.Game(...)`.
|
||||
- On setup, seed cached visibility from `mraid.isViewable()` when available.
|
||||
- Register MRAID callbacks once, before any Phaser scene is ready:
|
||||
- `mraid.addEventListener('error', (message, action) => ...)` — log or gracefully handle failed/unsupported MRAID calls.
|
||||
- `mraid.addEventListener('stateChange', state => ...)` — required when using state-changing MRAID behavior such as `close`, `expand`, or `resize`.
|
||||
- `mraid.addEventListener('exposureChange', exposedPercentage => ...)` — MRAID 3.0 primary viewability. Pause when `exposedPercentage <= 0`; resume when positive.
|
||||
- `mraid.addEventListener('viewableChange', viewable => ...)` — MRAID 2.0 compatibility fallback.
|
||||
- `mraid.addEventListener('audioVolumeChange', pct => ...)` — only apply `pct / 100` when `typeof pct === 'number'`.
|
||||
- In `bindLifecycle(scene)`, apply cached hidden/non-exposed MRAID state immediately once a scene exists.
|
||||
- If the creative renders its own close control, wire that control to a guarded `mraid.close()` helper and call `notifyGameClose()` before closing.
|
||||
|
||||
## Build
|
||||
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||
MRAID networks (`al`, `is`, `un`) inject exactly one `<script src="mraid.js"></script>` request.
|
||||
If built output contains `console.error` from Phaser or bundled node modules, prefer dropping it at bundle time instead of editing library source. Add this option to the shared Vite config, usually `vite.shared.js`:
|
||||
|
||||
```js
|
||||
export default {
|
||||
esbuild: {
|
||||
pure: ['console.error'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Keep project-authored error handling meaningful: use visible fallback UI, `console.warn`, or guarded debug logging where needed.
|
||||
|
||||
| Network | Tag | Injected | Zipped | Included |
|
||||
|---|---|---|---|---|
|
||||
@@ -124,7 +173,7 @@ Build script strips `type="module"` and `crossorigin` — ad networks reject ES
|
||||
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||
| Facebook | fb | — | — | ✓ |
|
||||
| Unity | un | — | — | ✓ |
|
||||
| Unity | un | `mraid.js` | — | ✓ |
|
||||
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||
| Moloco | mo | — | — | ✓ |
|
||||
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||
@@ -146,7 +195,6 @@ dist/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||
Google/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||
index.zip
|
||||
Ironsource/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||
…one HTML or ZIP per network…
|
||||
@@ -213,6 +261,7 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
||||
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||
| Built output contains library `console.error` | If `console.error` comes from Phaser or bundled node modules, add `esbuild: { pure: ['console.error'] }` in `vite.shared.js` so it is dropped at bundle time. Do not edit library source. |
|
||||
| Audio blocked | Gate on `pointerdown` |
|
||||
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||
@@ -227,6 +276,46 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
||||
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||
| MRAID network missing `mraid.js` | MRAID 3.0 creatives must request `mraid.js` early. Inject `<script src="mraid.js"></script>` exactly once for Applovin (`al`), Ironsource (`is`), and Unity (`un`), even if the container may also expose runtime MRAID objects. |
|
||||
| MRAID listener wired too late | Previewers can probe MRAID before any Phaser scene is ready — if listeners are wired only inside `bindLifecycle` / scene `create()`, the creative can miss `ready`, `exposureChange`, or `viewableChange`. Fix: call `initMraid()` at `networks.ts` module-load time, guarded by `getState() === 'loading'` → `addEventListener('ready', setupMraid)` vs immediate call. Cache `_mraidViewable` / `_mraidExposed`; in `bindLifecycle` apply cached hidden/non-exposed state immediately. |
|
||||
| MRAID 3.0 `exposureChange` not handled | MRAID 3.0 containers dispatch `exposureChange` with `exposedPercentage` directly instead of — or in addition to — `viewableChange`. If only listening to `viewableChange`, the ad may never receive pause signals on newer AL/IS/Unity builds. Register both: `mraid.addEventListener('exposureChange', exposedPercentage => onExposure(exposedPercentage > 0))` and `mraid.addEventListener('viewableChange', onViewable)` inside `initMraid()`. Seed initial state from `mraid.isViewable()` on ready. |
|
||||
| MRAID 3.0 `audioVolumeChange` not handled | MRAID 3.0 containers fire `audioVolumeChange` with a volume level (0–100) or `null` when unavailable. Without a listener, the ad ignores host-level mute/unmute signals; if `null` is divided by 100, the ad can mute accidentally. Register `mraid.addEventListener('audioVolumeChange', v => { if (typeof v === 'number') scene.sound.setVolume(v / 100) })` in `initMraid()`. |
|
||||
| MRAID `error` listener missing | Validators expect failed or unsupported MRAID calls to be observable. Register `mraid.addEventListener('error', (message, action) => console.warn('[MRAID error]', { message, action }))` during early MRAID setup. |
|
||||
| MRAID `stateChange` listener missing | If the creative uses state-changing container behavior such as `mraid.close()`, `expand`, or `resize`, validators expect a `stateChange` listener. Register `mraid.addEventListener('stateChange', state => console.log('[MRAID stateChange]', state))` with the other early MRAID callbacks. |
|
||||
| Custom close control not wired to MRAID | If the creative renders its own close button, the button must call a guarded close helper: `notifyGameClose()` then `mraid.close()` when `typeof mraid.close === 'function'` and `mraid.getState() !== 'loading'`. Do not use CTA/install buttons as close controls. |
|
||||
| MRAID init not awaited before Phaser boot | If `new Phaser.Game()` is created before `initMraid()` resolves, the scene may fire `create()` before MRAID state is known — `bindLifecycle` then cannot apply a cached hidden/paused state. Make `boot()` async and `await initMraid()` before constructing the Phaser game. `initMraid()` must self-resolve after a timeout (~2 s) so a missing or non-firing MRAID container never blocks startup. |
|
||||
| Late MRAID injection not handled | Some containers inject `window.mraid` after the script executes — a synchronous `typeof window.mraid !== 'undefined'` check at module load returns false even though MRAID will be present. After the immediate check fails, poll briefly (~500 ms) before concluding no-MRAID, then proceed. |
|
||||
| Unsafe optional MRAID API calls | `useCustomClose()` was removed in MRAID 3.0, and APIs such as `expand`, `resize`, `storePicture`, and `createCalendarEvent` are optional/capability-dependent. Do not call them unless the project intentionally supports the feature and guards every call with `typeof mraid.method === 'function'` / `mraid.supports(...)` where relevant. |
|
||||
|
||||
## Ironsource — Runtime Analysis
|
||||
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||
|
||||
## Moloco — Upload Checklist & CTA Implementation
|
||||
Creative specs: HTML5 (`.html` / `.htm`), < 5 MB, portrait + landscape.
|
||||
|
||||
Before uploading to Moloco Ads, confirm all three:
|
||||
- [ ] No `XMLHttpRequest` anywhere in the ad (Moloco rejects it outright)
|
||||
- [ ] CTA calls `FbPlayableAd.onCTAClick()` with no parameters — Moloco uses this for store redirect (not `window.clickTag`)
|
||||
- [ ] No JavaScript redirects in ad file assets
|
||||
|
||||
### CTA implementation options
|
||||
|
||||
**Option 1 — event listener**
|
||||
```html
|
||||
<button id="ctaButton">Install</button>
|
||||
<script>
|
||||
document.getElementById('ctaButton').addEventListener('click', function() {
|
||||
FbPlayableAd.onCTAClick();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Option 2 — inline onClick**
|
||||
```html
|
||||
<button onclick="FbPlayableAd.onCTAClick()">Install</button>
|
||||
```
|
||||
|
||||
Moloco's validator requires `FbPlayableAd.onCTAClick()` — this is why it shares priority #2 in the CTA chain with Facebook. `window.clickTag` is kept as a fallback at priority #7.
|
||||
|
||||
***EOF***
|
||||
[05/28/2026]
|
||||
|
||||
BIN
aiAssets/MRAID-3.0-Best-Practices-Guide.pdf
Normal file
BIN
aiAssets/MRAID-3.0-Best-Practices-Guide.pdf
Normal file
Binary file not shown.
BIN
aiAssets/MRAID_3.0_FINAL_June_2018.pdf
Normal file
BIN
aiAssets/MRAID_3.0_FINAL_June_2018.pdf
Normal file
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.6.exe
vendored
BIN
dist/hpl-toolbox-0.1.6.exe
vendored
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.6.vsix
vendored
BIN
dist/hpl-toolbox-0.1.6.vsix
vendored
Binary file not shown.
@@ -32,6 +32,7 @@ interface MraidRule {
|
||||
}
|
||||
|
||||
interface ScanContext {
|
||||
filePath: string;
|
||||
html: string;
|
||||
scriptText: string;
|
||||
lowerHtml: string;
|
||||
@@ -118,7 +119,7 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
for (const file of targets) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const issues = checkMraid(content);
|
||||
const issues = checkMraid(content, file);
|
||||
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
|
||||
results.push({ file: display, ok: issues.length === 0, issues });
|
||||
} catch {
|
||||
@@ -164,9 +165,10 @@ async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkMraid(html: string): MraidIssue[] {
|
||||
function checkMraid(html: string, filePath = ''): MraidIssue[] {
|
||||
const scriptText = extractScriptText(html);
|
||||
const ctx: ScanContext = {
|
||||
filePath,
|
||||
html,
|
||||
scriptText,
|
||||
lowerHtml: html.toLowerCase(),
|
||||
@@ -226,6 +228,14 @@ function hasMraidReference(ctx: ScanContext): boolean {
|
||||
return /\bmraid\b/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function countMraidJsReferences(ctx: ScanContext): number {
|
||||
return (ctx.html.match(/\bmraid\.js\b/gi) || []).length;
|
||||
}
|
||||
|
||||
function hasViewportMeta(ctx: ScanContext): boolean {
|
||||
return /<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||
return ctx.mraidCallLines.has(method);
|
||||
}
|
||||
@@ -253,6 +263,77 @@ function hasReadyGate(ctx: ScanContext): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasDomReadyGate(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/document\s*\.\s*readyState/i.test(ctx.scriptText) ||
|
||||
/DOMContentLoaded/i.test(ctx.scriptText) ||
|
||||
/\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
function hasReadyListenerAndStateFallback(ctx: ScanContext): boolean {
|
||||
return hasEventListener(ctx, 'ready') && hasMraidCall(ctx, 'getState');
|
||||
}
|
||||
|
||||
function hasMraidTypeofGuard(ctx: ScanContext): boolean {
|
||||
return /typeof\s+(?:window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasMraidFunctionGuard(ctx: ScanContext, method: string): boolean {
|
||||
const escaped = method.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`typeof\\s+(?:window\\s*\\.\\s*)?mraid\\s*\\.\\s*${escaped}\\s*={2,3}\\s*['"]function['"]|typeof\\s+(?:window\\s*\\.\\s*)?mraid\\s*\\.\\s*${escaped}\\s*!={1,2}\\s*['"]undefined['"]`, 'i').test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasMraidReadyTimeout(ctx: ScanContext): boolean {
|
||||
return /setTimeout\s*\(/i.test(ctx.scriptText) && /\bmraid\b/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasLateMraidDetection(ctx: ScanContext): boolean {
|
||||
return /setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\(/i.test(ctx.scriptText) && /window\s*\.\s*mraid|\bmraid\b/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasMraidOpenGuard(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]/i.test(ctx.scriptText) ||
|
||||
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
function hasMraidOpenLoadingGuard(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||
/['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText) ||
|
||||
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]/i.test(ctx.scriptText) ||
|
||||
(hasMraidCall(ctx, 'getState') && /\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b/i.test(ctx.scriptText)) ||
|
||||
(hasMraidCall(ctx, 'getState') && /\b\w+\s*={2,3}\s*['"]loading['"][\s\S]{0,500}\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText))
|
||||
);
|
||||
}
|
||||
|
||||
function hasAudioVolumeNullSafeHandling(ctx: ScanContext): boolean {
|
||||
return hasEventListener(ctx, 'audioVolumeChange') && (/typeof\s+\w+\s*={2,3}\s*['"]number['"]/i.test(ctx.scriptText) || /!=\s*null|!==\s*null/i.test(ctx.scriptText));
|
||||
}
|
||||
|
||||
function hasLifecycleSignal(ctx: ScanContext, name: string): boolean {
|
||||
return new RegExp(`\\b${name}\\b`, 'i').test(ctx.html);
|
||||
}
|
||||
|
||||
function hasUnsupportedHyperlink(ctx: ScanContext): boolean {
|
||||
return /<a\b[^>]*\bhref\s*=/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function hasTwoPartExpand(ctx: ScanContext): boolean {
|
||||
return /mraid\s*\.\s*expand\s*\(\s*[^)\s]/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function opensVideoUrl(ctx: ScanContext): boolean {
|
||||
return /mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(?:['"`?#)]|$)/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function isInMraidNetworkPath(ctx: ScanContext): boolean {
|
||||
if (!ctx.filePath) return true;
|
||||
return isInSupportedMraidFolderPath(ctx.filePath) || /_(al|is|un)\.html?$/i.test(path.basename(ctx.filePath));
|
||||
}
|
||||
|
||||
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
@@ -264,15 +345,32 @@ function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
return /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
|
||||
function usesLocationNavigation(ctx: ScanContext): boolean {
|
||||
return /\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide.';
|
||||
|
||||
const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'mraid-js-missing',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.js reference not found',
|
||||
detail: 'MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => isInMraidNetworkPath(ctx) && countMraidJsReferences(ctx) === 0 ? issue(MRAID_RULES_BY_ID['mraid-js-missing']) : null,
|
||||
},
|
||||
{
|
||||
id: 'mraid-js-duplicate',
|
||||
severity: 'requirement',
|
||||
title: 'Multiple mraid.js references found',
|
||||
detail: 'Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => countMraidJsReferences(ctx) > 1 ? issue(MRAID_RULES_BY_ID['mraid-js-duplicate']) : null,
|
||||
},
|
||||
{
|
||||
id: 'mraid-reference',
|
||||
severity: 'requirement',
|
||||
@@ -281,6 +379,30 @@ const MRAID_RULES: MraidRule[] = [
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||
},
|
||||
{
|
||||
id: 'viewport-meta',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewport meta tag not found',
|
||||
detail: 'MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasViewportMeta(ctx) ? null : issue(MRAID_RULES_BY_ID['viewport-meta']),
|
||||
},
|
||||
{
|
||||
id: 'mraid-typeof-guard',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID object is not guarded',
|
||||
detail: 'Guard MRAID access with typeof mraid !== "undefined" or an equivalent window.mraid guard before making MRAID calls.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) && !hasMraidTypeofGuard(ctx) ? issue(MRAID_RULES_BY_ID['mraid-typeof-guard']) : null,
|
||||
},
|
||||
{
|
||||
id: 'dom-ready-gate',
|
||||
severity: 'requirement',
|
||||
title: 'DOM readiness is not guarded',
|
||||
detail: 'MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasDomReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['dom-ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'ready-gate',
|
||||
severity: 'requirement',
|
||||
@@ -289,6 +411,30 @@ const MRAID_RULES: MraidRule[] = [
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'ready-state-fallback',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID ready listener/state fallback pair not found',
|
||||
detail: 'Use both mraid.addEventListener("ready", ...) and mraid.getState() so startup works when ready fires before the listener attaches.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyListenerAndStateFallback(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-state-fallback']),
|
||||
},
|
||||
{
|
||||
id: 'ready-timeout-fallback',
|
||||
severity: 'best-practice',
|
||||
title: 'MRAID ready timeout fallback not found',
|
||||
detail: 'A timeout fallback prevents startup hangs when a container never fires ready.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasMraidReadyTimeout(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-timeout-fallback']),
|
||||
},
|
||||
{
|
||||
id: 'late-mraid-detection',
|
||||
severity: 'best-practice',
|
||||
title: 'Late MRAID injection detection not found',
|
||||
detail: 'Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasLateMraidDetection(ctx) ? null : issue(MRAID_RULES_BY_ID['late-mraid-detection']),
|
||||
},
|
||||
{
|
||||
id: 'error-listener',
|
||||
severity: 'best-practice',
|
||||
@@ -300,21 +446,45 @@ const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'viewability',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewability handling not found',
|
||||
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
|
||||
title: 'MRAID viewability fallback chain not found',
|
||||
detail: 'Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
|
||||
const hasVisibilitySignal = hasEventListener(ctx, 'exposureChange') && (hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange'));
|
||||
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'audio-volume-change',
|
||||
severity: 'best-practice',
|
||||
title: 'audioVolumeChange handling not found',
|
||||
detail: 'Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasAudioVolumeNullSafeHandling(ctx) ? null : issue(MRAID_RULES_BY_ID['audio-volume-change']),
|
||||
},
|
||||
{
|
||||
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.',
|
||||
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
test: (ctx) => hasUnsupportedHyperlink(ctx) || usesLocationNavigation(ctx) || (usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open')) ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
},
|
||||
{
|
||||
id: 'open-guarded-fallback',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.open guarded fallback not found',
|
||||
detail: 'Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'open') && (!hasMraidOpenGuard(ctx) || !hasMraidOpenLoadingGuard(ctx) || !usesDirectBrowserNavigation(ctx)) ? issue(MRAID_RULES_BY_ID['open-guarded-fallback'], mraidLine(ctx, 'open')) : null,
|
||||
},
|
||||
{
|
||||
id: 'open-video-url',
|
||||
severity: 'best-practice',
|
||||
title: 'mraid.open appears to target video media',
|
||||
detail: 'Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => opensVideoUrl(ctx) && !hasMraidCall(ctx, 'playVideo') ? issue(MRAID_RULES_BY_ID['open-video-url'], mraidLine(ctx, 'open')) : null,
|
||||
},
|
||||
{
|
||||
id: 'close-api',
|
||||
@@ -327,29 +497,93 @@ const MRAID_RULES: MraidRule[] = [
|
||||
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unload-fallback',
|
||||
severity: 'best-practice',
|
||||
title: 'No mraid.unload graceful failure path found',
|
||||
detail: 'Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) && !hasMraidCall(ctx, 'unload') ? issue(MRAID_RULES_BY_ID['unload-fallback']) : null,
|
||||
},
|
||||
{
|
||||
id: 'resize-properties',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.resize used without setResizeProperties',
|
||||
detail: 'Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidCall(ctx, 'setResizeProperties') ? issue(MRAID_RULES_BY_ID['resize-properties'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'use-custom-close',
|
||||
severity: 'best-practice',
|
||||
title: 'Deprecated useCustomClose found',
|
||||
detail: 'MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'useCustomClose') || /useCustomClose/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['use-custom-close'], mraidLine(ctx, 'useCustomClose')) : null,
|
||||
},
|
||||
{
|
||||
id: 'two-part-expand',
|
||||
severity: 'best-practice',
|
||||
title: 'Two-part expand appears to be used',
|
||||
detail: 'Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasTwoPartExpand(ctx) ? issue(MRAID_RULES_BY_ID['two-part-expand'], mraidLine(ctx, 'expand')) : null,
|
||||
},
|
||||
{
|
||||
id: 'expand-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.expand used without supports check',
|
||||
detail: 'Check mraid.supports("expand") before using expand behavior.',
|
||||
title: 'mraid.expand used without function guard',
|
||||
detail: 'Guard mraid.expand with a function check 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,
|
||||
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasMraidFunctionGuard(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.',
|
||||
title: 'mraid.resize used without function guard',
|
||||
detail: 'Guard mraid.resize with a function check 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,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidFunctionGuard(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'store-picture-support',
|
||||
severity: 'requirement',
|
||||
title: 'storePicture used without supports check',
|
||||
detail: 'Call mraid.supports("storePicture") before mraid.storePicture(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'storePicture') && !hasSupportCheck(ctx, 'storePicture') ? issue(MRAID_RULES_BY_ID['store-picture-support'], mraidLine(ctx, 'storePicture')) : null,
|
||||
},
|
||||
{
|
||||
id: 'calendar-support',
|
||||
severity: 'requirement',
|
||||
title: 'createCalendarEvent used without supports check',
|
||||
detail: 'Call mraid.supports("calendar") before mraid.createCalendarEvent(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'createCalendarEvent') && !hasSupportCheck(ctx, 'calendar') ? issue(MRAID_RULES_BY_ID['calendar-support'], mraidLine(ctx, 'createCalendarEvent')) : null,
|
||||
},
|
||||
{
|
||||
id: 'location-support',
|
||||
severity: 'requirement',
|
||||
title: 'getLocation used without supports check',
|
||||
detail: 'Call mraid.supports("location") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'getLocation') && !hasSupportCheck(ctx, 'location') ? issue(MRAID_RULES_BY_ID['location-support'], mraidLine(ctx, 'getLocation')) : null,
|
||||
},
|
||||
{
|
||||
id: 'vpaid-support',
|
||||
severity: 'requirement',
|
||||
title: 'VPAID integration used without supports check',
|
||||
detail: 'Call mraid.supports("vpaid") before mraid.initVpaid(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'initVpaid') && !hasSupportCheck(ctx, 'vpaid') ? issue(MRAID_RULES_BY_ID['vpaid-support'], mraidLine(ctx, 'initVpaid')) : null,
|
||||
},
|
||||
{
|
||||
id: 'orientation-support',
|
||||
severity: 'best-practice',
|
||||
title: 'Orientation properties set without supports check',
|
||||
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
|
||||
title: 'Orientation properties set without function guard',
|
||||
detail: 'Guard mraid.setOrientationProperties with a function check 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,
|
||||
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasMraidFunctionGuard(ctx, 'setOrientationProperties') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||
},
|
||||
{
|
||||
id: 'size-change',
|
||||
@@ -373,6 +607,35 @@ const MRAID_RULES: MraidRule[] = [
|
||||
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output-script-hygiene',
|
||||
severity: 'requirement',
|
||||
title: 'Rejected script attributes found',
|
||||
detail: 'Playable output should not contain type="module" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => /<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)/i.test(ctx.html) ? issue(MRAID_RULES_BY_ID['output-script-hygiene']) : null,
|
||||
},
|
||||
{
|
||||
id: 'console-error',
|
||||
severity: 'best-practice',
|
||||
title: 'console.error found',
|
||||
detail: 'The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => /\bconsole\s*\.\s*error\s*\(/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['console-error']) : null,
|
||||
},
|
||||
{
|
||||
id: 'lifecycle-stubs',
|
||||
severity: 'requirement',
|
||||
title: 'Playable lifecycle signals not found',
|
||||
detail: 'Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const missing = ['gameReady', 'gameStart', 'gameEnd', 'gameClose'].filter(name => !hasLifecycleSignal(ctx, name));
|
||||
if (!missing.length) return null;
|
||||
const rule = MRAID_RULES_BY_ID['lifecycle-stubs'];
|
||||
return { ...issue(rule), detail: `${rule.detail} Missing: ${missing.join(', ')}.` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||
@@ -412,7 +675,7 @@ ${getToolWebviewStyles()}
|
||||
<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>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
|
||||
@@ -34,6 +34,7 @@ type mraidRule struct {
|
||||
}
|
||||
|
||||
type mraidContext struct {
|
||||
FilePath string
|
||||
HTML string
|
||||
ScriptText string
|
||||
MraidCallLines map[string]int
|
||||
@@ -43,7 +44,7 @@ func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<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>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||
</header>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
@@ -271,7 +272,7 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
issues := checkMraid(string(data))
|
||||
issues := checkMraid(string(data), file)
|
||||
display := file
|
||||
if baseDir != "" {
|
||||
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||
@@ -286,17 +287,33 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var (
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidJsRx = regexp.MustCompile(`(?i)\bmraid\.js\b`)
|
||||
mraidViewportRx = regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidHyperlinkRx = regexp.MustCompile(`(?i)<a\b[^>]*\bhref\s*=`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
mraidDomReadyRx = regexp.MustCompile(`(?i)document\s*\.\s*readyState|DOMContentLoaded|\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)`)
|
||||
mraidTypeofGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]`)
|
||||
mraidReadyTimeoutRx = regexp.MustCompile(`(?is)setTimeout\s*\(.*mraid|mraid.*setTimeout\s*\(`)
|
||||
mraidLateDetectionRx = regexp.MustCompile(`(?i)(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\().*(window\s*\.\s*mraid|\bmraid\b)|(window\s*\.\s*mraid|\bmraid\b).*(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\()`)
|
||||
mraidOpenGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]`)
|
||||
mraidOpenLoadingGuardRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]`)
|
||||
mraidVariableLoadingGuardRx = regexp.MustCompile(`(?i)\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b`)
|
||||
mraidLoadingFallbackRx = regexp.MustCompile(`(?is)\b\w+\s*={2,3}\s*['"]loading['"].{0,500}\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidAudioSafeRx = regexp.MustCompile(`(?i)typeof\s+\w+\s*={2,3}\s*['"]number['"]|!=\s*null|!==\s*null`)
|
||||
mraidTwoPartExpandRx = regexp.MustCompile(`(?i)mraid\s*\.\s*expand\s*\(\s*[^)\s]`)
|
||||
mraidVideoOpenRx = regexp.MustCompile(`(?i)mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(['"` + "`" + `?#)]|$)`)
|
||||
mraidRejectedScriptAttrsRx = regexp.MustCompile(`(?i)<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)`)
|
||||
mraidConsoleErrorRx = regexp.MustCompile(`(?i)\bconsole\s*\.\s*error\s*\(`)
|
||||
)
|
||||
|
||||
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||
const mraidReference = "GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide."
|
||||
|
||||
func isInSupportedMraidFolderPath(filePath string) bool {
|
||||
supported := map[string]bool{
|
||||
@@ -324,8 +341,13 @@ func isInSupportedMraidFolderPath(filePath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMraid(htmlSrc string) []mraidIssue {
|
||||
func checkMraid(htmlSrc string, filePath ...string) []mraidIssue {
|
||||
sourcePath := ""
|
||||
if len(filePath) > 0 {
|
||||
sourcePath = filePath[0]
|
||||
}
|
||||
ctx := mraidContext{
|
||||
FilePath: sourcePath,
|
||||
HTML: htmlSrc,
|
||||
ScriptText: extractMraidScriptText(htmlSrc),
|
||||
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||
@@ -363,12 +385,31 @@ func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||
}
|
||||
|
||||
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||
func mraidCountJSReferences(ctx mraidContext) int {
|
||||
return len(mraidJsRx.FindAllStringIndex(ctx.HTML, -1))
|
||||
}
|
||||
func mraidHasViewport(ctx mraidContext) bool { return mraidViewportRx.MatchString(ctx.HTML) }
|
||||
func mraidIsNetworkTarget(ctx mraidContext) bool {
|
||||
if ctx.FilePath == "" {
|
||||
return true
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(ctx.FilePath))
|
||||
return isInSupportedMraidFolderPath(ctx.FilePath) ||
|
||||
strings.HasSuffix(name, "_al.html") || strings.HasSuffix(name, "_is.html") || strings.HasSuffix(name, "_un.html") ||
|
||||
strings.HasSuffix(name, "_al.htm") || strings.HasSuffix(name, "_is.htm") || strings.HasSuffix(name, "_un.htm")
|
||||
}
|
||||
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||
_, ok := ctx.MraidCallLines[method]
|
||||
return ok
|
||||
}
|
||||
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||
|
||||
func mraidHasFunctionGuard(ctx mraidContext, method string) bool {
|
||||
escaped := regexp.QuoteMeta(method)
|
||||
rx := regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*!={1,2}\s*['"]undefined['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
@@ -383,6 +424,38 @@ func mraidHasReadyGate(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasDomReadyGate(ctx mraidContext) bool { return mraidDomReadyRx.MatchString(ctx.ScriptText) }
|
||||
func mraidHasReadyStateFallback(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") && mraidHasCall(ctx, "getState")
|
||||
}
|
||||
func mraidHasTypeofGuard(ctx mraidContext) bool {
|
||||
return mraidTypeofGuardRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasReadyTimeout(ctx mraidContext) bool {
|
||||
return mraidReadyTimeoutRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasLateDetection(ctx mraidContext) bool {
|
||||
return mraidLateDetectionRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasOpenGuard(ctx mraidContext) bool { return mraidOpenGuardRx.MatchString(ctx.ScriptText) }
|
||||
func mraidHasOpenLoadingGuard(ctx mraidContext) bool {
|
||||
return mraidOpenLoadingGuardRx.MatchString(ctx.ScriptText) ||
|
||||
(mraidHasCall(ctx, "getState") && mraidVariableLoadingGuardRx.MatchString(ctx.ScriptText)) ||
|
||||
(mraidHasCall(ctx, "getState") && mraidLoadingFallbackRx.MatchString(ctx.ScriptText))
|
||||
}
|
||||
func mraidUsesBrowserNavigation(ctx mraidContext) bool {
|
||||
return mraidWindowOpenRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidUsesLocationNavigation(ctx mraidContext) bool {
|
||||
return mraidLocationNavRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasAudioVolumeSafeHandling(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "audioVolumeChange") && mraidAudioSafeRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasLifecycleSignal(ctx mraidContext, name string) bool {
|
||||
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(ctx.HTML)
|
||||
}
|
||||
|
||||
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||
if line > 0 {
|
||||
@@ -392,56 +465,171 @@ func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
}
|
||||
|
||||
var mraidRules = []mraidRule{
|
||||
{ID: "mraid-js-missing", Severity: "requirement", Title: "mraid.js reference not found", Detail: "MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidIsNetworkTarget(ctx) && mraidCountJSReferences(ctx) == 0 {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "mraid-js-duplicate", Severity: "requirement", Title: "Multiple mraid.js references found", Detail: "Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCountJSReferences(ctx) > 1 {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "viewport-meta", Severity: "best-practice", Title: "Viewport meta tag not found", Detail: "MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasViewport(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "mraid-typeof-guard", Severity: "requirement", Title: "MRAID object is not guarded", Detail: "Guard MRAID access with typeof mraid !== \"undefined\" or an equivalent window.mraid guard before making MRAID calls.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) && !mraidHasTypeofGuard(ctx) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "dom-ready-gate", Severity: "requirement", Title: "DOM readiness is not guarded", Detail: "MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasDomReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-state-fallback", Severity: "requirement", Title: "MRAID ready listener/state fallback pair not found", Detail: "Use both mraid.addEventListener(\"ready\", ...) and mraid.getState() so startup works when ready fires before the listener attaches.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyStateFallback(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-timeout-fallback", Severity: "best-practice", Title: "MRAID ready timeout fallback not found", Detail: "A timeout fallback prevents startup hangs when a container never fires ready.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyTimeout(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "late-mraid-detection", Severity: "best-practice", Title: "Late MRAID injection detection not found", Detail: "Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasLateDetection(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
|
||||
{ID: "viewability", Severity: "best-practice", Title: "MRAID viewability fallback chain not found", Detail: "Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
hasVisibility := mraidHasEventListener(ctx, "exposureChange") && (mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange"))
|
||||
if !mraidHasReference(ctx) || hasVisibility {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
|
||||
{ID: "audio-volume-change", Severity: "best-practice", Title: "audioVolumeChange handling not found", Detail: "Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasAudioVolumeSafeHandling(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{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; avoid hyperlinks, location redirects, and unguarded browser navigation.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHyperlinkRx.MatchString(ctx.HTML) || mraidUsesLocationNavigation(ctx) || (mraidUsesBrowserNavigation(ctx) && !mraidHasCall(ctx, "open")) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "open-guarded-fallback", Severity: "requirement", Title: "mraid.open guarded fallback not found", Detail: "Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "open") && (!mraidHasOpenGuard(ctx) || !mraidHasOpenLoadingGuard(ctx) || !mraidUsesBrowserNavigation(ctx)) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "open-video-url", Severity: "best-practice", Title: "mraid.open appears to target video media", Detail: "Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidVideoOpenRx.MatchString(ctx.ScriptText) && !mraidHasCall(ctx, "playVideo") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{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: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
{ID: "unload-fallback", Severity: "best-practice", Title: "No mraid.unload graceful failure path found", Detail: "Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) && !mraidHasCall(ctx, "unload") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
|
||||
{ID: "resize-properties", Severity: "requirement", Title: "mraid.resize used without setResizeProperties", Detail: "Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasCall(ctx, "setResizeProperties") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
|
||||
{ID: "use-custom-close", Severity: "best-practice", Title: "Deprecated useCustomClose found", Detail: "MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "useCustomClose") || regexp.MustCompile(`(?i)useCustomClose`).MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "useCustomClose"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "two-part-expand", Severity: "best-practice", Title: "Two-part expand appears to be used", Detail: "Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidTwoPartExpandRx.MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without function guard", Detail: "Guard mraid.expand with a function check before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasFunctionGuard(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without function guard", Detail: "Guard mraid.resize with a function check before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasFunctionGuard(ctx, "resize") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "store-picture-support", Severity: "requirement", Title: "storePicture used without supports check", Detail: "Call mraid.supports(\"storePicture\") before mraid.storePicture(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "storePicture") && !mraidHasSupportCheck(ctx, "storePicture") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "storePicture"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "calendar-support", Severity: "requirement", Title: "createCalendarEvent used without supports check", Detail: "Call mraid.supports(\"calendar\") before mraid.createCalendarEvent(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "createCalendarEvent") && !mraidHasSupportCheck(ctx, "calendar") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "createCalendarEvent"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "location-support", Severity: "requirement", Title: "getLocation used without supports check", Detail: "Call mraid.supports(\"location\") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "getLocation") && !mraidHasSupportCheck(ctx, "location") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "getLocation"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "vpaid-support", Severity: "requirement", Title: "VPAID integration used without supports check", Detail: "Call mraid.supports(\"vpaid\") before mraid.initVpaid(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "initVpaid") && !mraidHasSupportCheck(ctx, "vpaid") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "initVpaid"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without function guard", Detail: "Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasFunctionGuard(ctx, "setOrientationProperties") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||
}
|
||||
return nil
|
||||
@@ -460,4 +648,30 @@ var mraidRules = []mraidRule{
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "output-script-hygiene", Severity: "requirement", Title: "Rejected script attributes found", Detail: "Playable output should not contain type=\"module\" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidRejectedScriptAttrsRx.MatchString(ctx.HTML) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "console-error", Severity: "best-practice", Title: "console.error found", Detail: "The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidConsoleErrorRx.MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "lifecycle-stubs", Severity: "requirement", Title: "Playable lifecycle signals not found", Detail: "Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
missing := []string{}
|
||||
for _, name := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
|
||||
if !mraidHasLifecycleSignal(ctx, name) {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
found := mraidIssueFrom(rule, 0)
|
||||
found.Detail = found.Detail + " Missing: " + strings.Join(missing, ", ") + "."
|
||||
return found
|
||||
}},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user