@playcraft/build 0.0.44 → 0.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-builder.d.ts +2 -0
- package/dist/base-builder.js +44 -0
- package/dist/engines/playable-scripts-adapter.d.ts +6 -1
- package/dist/engines/playable-scripts-adapter.js +92 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bigo.js +3 -5
- package/dist/platforms/inmobi.js +3 -5
- package/dist/platforms/liftoff.js +3 -5
- package/dist/platforms/mintegral.js +4 -6
- package/dist/platforms/snapchat.js +1 -1
- package/dist/platforms/tiktok.js +3 -5
- package/dist/types.d.ts +13 -0
- package/dist/validation/html-validator.d.ts +2 -0
- package/dist/validation/html-validator.js +140 -0
- package/dist/validation/index.d.ts +8 -0
- package/dist/validation/index.js +8 -0
- package/dist/validation/package-validator.d.ts +2 -0
- package/dist/validation/package-validator.js +198 -0
- package/dist/validation/profiles.d.ts +5 -0
- package/dist/validation/profiles.js +222 -0
- package/dist/validation/reporter.d.ts +4 -0
- package/dist/validation/reporter.js +30 -0
- package/dist/validation/source-scanner.d.ts +4 -0
- package/dist/validation/source-scanner.js +271 -0
- package/dist/validation/types.d.ts +112 -0
- package/dist/validation/validation-runner.d.ts +5 -0
- package/dist/validation/validation-runner.js +96 -0
- package/dist/validation/zip-reader.d.ts +17 -0
- package/dist/validation/zip-reader.js +84 -0
- package/dist/vite/platform-configs.js +7 -7
- package/dist/vite-builder.d.ts +1 -0
- package/dist/vite-builder.js +43 -0
- package/package.json +2 -2
- package/dist/analyzers/__tests__/optimization-analyzer.test.js +0 -169
- package/dist/templates/__loading__.js +0 -100
- package/dist/templates/__modules__.js +0 -47
- package/dist/templates/__settings__.template.js +0 -20
- package/dist/templates/__start__.js +0 -332
- package/dist/templates/index.html +0 -18
- package/dist/templates/logo.png +0 -0
- package/dist/templates/manifest.json +0 -1
- package/dist/templates/patches/cannon.min.js +0 -28
- package/dist/templates/patches/lz4.js +0 -10
- package/dist/templates/patches/one-page-http-get.js +0 -20
- package/dist/templates/patches/one-page-inline-game-scripts.js +0 -52
- package/dist/templates/patches/one-page-mraid-resize-canvas.js +0 -46
- package/dist/templates/patches/p2.min.js +0 -27
- package/dist/templates/patches/playcraft-no-xhr.js +0 -76
- package/dist/templates/playcanvas-stable.min.js +0 -16363
- package/dist/templates/styles.css +0 -43
- /package/dist/{analyzers/__tests__/optimization-analyzer.test.d.ts → validation/types.js} +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getChannelRuleProfile } from './profiles.js';
|
|
5
|
+
import { SimpleZipReader } from './zip-reader.js';
|
|
6
|
+
const FIVE_MB = 5 * 1024 * 1024;
|
|
7
|
+
export async function validateDeliveryPackage(context, profile = getChannelRuleProfile(context.channel)) {
|
|
8
|
+
if (!context.packagePath) {
|
|
9
|
+
return [{
|
|
10
|
+
ruleId: `${context.channel}.package.path.required`,
|
|
11
|
+
channel: context.channel,
|
|
12
|
+
stage: 'delivery-package',
|
|
13
|
+
severity: 'error',
|
|
14
|
+
status: 'fail',
|
|
15
|
+
message: 'Delivery package path is missing.',
|
|
16
|
+
recommendation: 'Pass the ZIP file or delivery directory path to the validation context.',
|
|
17
|
+
owner: 'playcraft-delivery',
|
|
18
|
+
confidence: 'high',
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
let inventory;
|
|
22
|
+
try {
|
|
23
|
+
inventory = await readPackageInventory(context.packagePath);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return [{
|
|
27
|
+
ruleId: `${context.channel}.package.readable.required`,
|
|
28
|
+
channel: context.channel,
|
|
29
|
+
stage: 'delivery-package',
|
|
30
|
+
severity: 'error',
|
|
31
|
+
status: 'fail',
|
|
32
|
+
message: `Delivery package is not readable: ${context.packagePath}`,
|
|
33
|
+
recommendation: 'Verify the generated ZIP or delivery directory exists and is readable.',
|
|
34
|
+
file: context.packagePath,
|
|
35
|
+
evidence: error instanceof Error ? error.message : String(error),
|
|
36
|
+
owner: 'playcraft-delivery',
|
|
37
|
+
confidence: 'high',
|
|
38
|
+
}];
|
|
39
|
+
}
|
|
40
|
+
const results = [
|
|
41
|
+
boolResult(context.channel, 'package.size.max-5mb', inventory.size <= FIVE_MB, 'Delivery package should be smaller than 5 MB.', 'Reduce artifact size or compression output.', inventory.packagePath, `actual=${inventory.size}, limit=${FIVE_MB}`),
|
|
42
|
+
];
|
|
43
|
+
if (profile.deliveryPackageFormat === 'zip' || profile.deliveryMode === 'zip' || profile.deliveryMode === 'html-backed-zip') {
|
|
44
|
+
results.push(...validateZipLikePackage(context.channel, inventory));
|
|
45
|
+
}
|
|
46
|
+
switch (context.channel) {
|
|
47
|
+
case 'google':
|
|
48
|
+
results.push(boolResult('google', 'package.file-count.max-512', fileEntries(inventory).length <= 512, 'Google ZIP should contain no more than 512 files.', 'Reduce generated files or inline assets.', inventory.packagePath, `files=${fileEntries(inventory).length}`));
|
|
49
|
+
break;
|
|
50
|
+
case 'tiktok':
|
|
51
|
+
results.push(...validateJsonField(context.channel, inventory, 'config.json', 'playable_orientation', [0, 1, 2]));
|
|
52
|
+
break;
|
|
53
|
+
case 'bigo':
|
|
54
|
+
results.push(...validateJsonField(context.channel, inventory, 'config.json', 'orientation', [0, 1, 2]));
|
|
55
|
+
break;
|
|
56
|
+
case 'snapchat':
|
|
57
|
+
results.push(...validateJsonField(context.channel, inventory, 'config.json', 'orientation', [1]));
|
|
58
|
+
break;
|
|
59
|
+
case 'adikteev':
|
|
60
|
+
results.push(...validateAdikteevPackage(inventory));
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
async function readPackageInventory(packagePath) {
|
|
66
|
+
const stat = await fs.stat(packagePath);
|
|
67
|
+
if (stat.isDirectory()) {
|
|
68
|
+
const entries = await readDirectoryEntries(packagePath);
|
|
69
|
+
return {
|
|
70
|
+
packagePath,
|
|
71
|
+
entries,
|
|
72
|
+
readText: (entryPath) => readTextFromDirectorySyncUnsupported(packagePath, entryPath),
|
|
73
|
+
size: await calculateDirectorySize(packagePath),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const buffer = await fs.readFile(packagePath);
|
|
77
|
+
const zip = new SimpleZipReader(buffer);
|
|
78
|
+
return {
|
|
79
|
+
packagePath,
|
|
80
|
+
entries: zip.listEntries(),
|
|
81
|
+
readText: (entryPath) => zip.readEntryText(entryPath),
|
|
82
|
+
size: buffer.length,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function readDirectoryEntries(dir) {
|
|
86
|
+
const entries = [];
|
|
87
|
+
const walk = async (current) => {
|
|
88
|
+
const dirEntries = await fs.readdir(current, { withFileTypes: true });
|
|
89
|
+
for (const entry of dirEntries) {
|
|
90
|
+
const fullPath = path.join(current, entry.name);
|
|
91
|
+
const relative = path.relative(dir, fullPath).replace(/\\/g, '/');
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
entries.push({
|
|
94
|
+
path: `${relative}/`,
|
|
95
|
+
compressedSize: 0,
|
|
96
|
+
uncompressedSize: 0,
|
|
97
|
+
compressionMethod: 0,
|
|
98
|
+
isDirectory: true,
|
|
99
|
+
});
|
|
100
|
+
await walk(fullPath);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const stat = await fs.stat(fullPath);
|
|
104
|
+
entries.push({
|
|
105
|
+
path: relative,
|
|
106
|
+
compressedSize: stat.size,
|
|
107
|
+
uncompressedSize: stat.size,
|
|
108
|
+
compressionMethod: 0,
|
|
109
|
+
isDirectory: false,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await walk(dir);
|
|
115
|
+
return entries;
|
|
116
|
+
}
|
|
117
|
+
function readTextFromDirectorySyncUnsupported(_packagePath, _entryPath) {
|
|
118
|
+
const filePath = path.join(_packagePath, _entryPath);
|
|
119
|
+
try {
|
|
120
|
+
return fsSync.readFileSync(filePath, 'utf-8');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function calculateDirectorySize(dir) {
|
|
127
|
+
let total = 0;
|
|
128
|
+
const walk = async (current) => {
|
|
129
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const fullPath = path.join(current, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
await walk(fullPath);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
total += (await fs.stat(fullPath)).size;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await walk(dir);
|
|
141
|
+
return total;
|
|
142
|
+
}
|
|
143
|
+
function validateZipLikePackage(channel, inventory) {
|
|
144
|
+
const paths = new Set(inventory.entries.map((entry) => entry.path));
|
|
145
|
+
return [
|
|
146
|
+
boolResult(channel, 'package.index-html.root-required', paths.has('index.html'), 'Delivery package should contain root index.html.', 'Place the playable entry file at the root of the delivery package.', inventory.packagePath),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
function validateJsonField(channel, inventory, entryPath, fieldName, allowedValues) {
|
|
150
|
+
const paths = new Set(inventory.entries.map((entry) => entry.path));
|
|
151
|
+
const results = [
|
|
152
|
+
boolResult(channel, `package.${entryPath}.root-required`, paths.has(entryPath), `Delivery package should contain root ${entryPath}.`, `Generate ${entryPath} at the package root.`, inventory.packagePath),
|
|
153
|
+
];
|
|
154
|
+
if (!paths.has(entryPath)) {
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const raw = inventory.readText(entryPath);
|
|
159
|
+
if (!raw) {
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
const json = JSON.parse(raw);
|
|
163
|
+
results.push(boolResult(channel, `package.${fieldName}.valid`, allowedValues.includes(json[fieldName]), `${entryPath} should contain valid ${fieldName}.`, `Set ${fieldName} to one of: ${allowedValues.join(', ')}.`, inventory.packagePath, `${fieldName}=${JSON.stringify(json[fieldName])}`));
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
results.push(boolResult(channel, `package.${entryPath}.json-valid`, false, `${entryPath} should be valid JSON.`, `Fix ${entryPath} so it can be parsed as JSON.`, inventory.packagePath, error instanceof Error ? error.message : String(error)));
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
function validateAdikteevPackage(inventory) {
|
|
171
|
+
const files = fileEntries(inventory);
|
|
172
|
+
const hasHtml = files.some((entry) => entry.path.endsWith('.html') || entry.path.endsWith('.htm'));
|
|
173
|
+
const hasJs = files.some((entry) => entry.path.endsWith('.js'));
|
|
174
|
+
const hasCss = files.some((entry) => entry.path.endsWith('.css'));
|
|
175
|
+
return [
|
|
176
|
+
boolResult('adikteev', 'package.fragment.html.required', hasHtml, 'Adikteev package should contain an HTML fragment/file.', 'Include the HTML fragment required by Adikteev.', inventory.packagePath),
|
|
177
|
+
boolResult('adikteev', 'package.fragment-js.required', hasJs, 'Adikteev package should contain separated JavaScript.', 'Separate JavaScript for CDN upload.', inventory.packagePath),
|
|
178
|
+
boolResult('adikteev', 'package.fragment-css.required', hasCss, 'Adikteev package should contain separated CSS.', 'Separate CSS for CDN upload.', inventory.packagePath),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
function fileEntries(inventory) {
|
|
182
|
+
return inventory.entries.filter((entry) => !entry.isDirectory);
|
|
183
|
+
}
|
|
184
|
+
function boolResult(channel, idSuffix, passed, message, recommendation, file, evidence) {
|
|
185
|
+
return {
|
|
186
|
+
ruleId: `${channel}.${idSuffix}`,
|
|
187
|
+
channel,
|
|
188
|
+
stage: 'delivery-package',
|
|
189
|
+
severity: 'error',
|
|
190
|
+
status: passed ? 'pass' : 'fail',
|
|
191
|
+
message: passed ? `${message}: passed` : message,
|
|
192
|
+
recommendation,
|
|
193
|
+
file,
|
|
194
|
+
evidence,
|
|
195
|
+
owner: 'playcraft-delivery',
|
|
196
|
+
confidence: 'high',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Platform } from '../types.js';
|
|
2
|
+
import type { ChannelRuleProfile, ValidationRule, ValidationStage } from './types.js';
|
|
3
|
+
export declare const CHANNEL_RULE_PROFILES: Record<Platform, ChannelRuleProfile>;
|
|
4
|
+
export declare function getChannelRuleProfile(channel: Platform): ChannelRuleProfile;
|
|
5
|
+
export declare function getRulesForStage(channel: Platform, stage: ValidationStage): ValidationRule[];
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const FIVE_MB = 5 * 1024 * 1024;
|
|
2
|
+
const SOURCE_SPEC = 'temp-file/md/各渠道试玩广告规格-gemini.md';
|
|
3
|
+
function sizeRule(channel) {
|
|
4
|
+
return {
|
|
5
|
+
id: `${channel}.artifact.size.max-5mb`,
|
|
6
|
+
title: 'Playable artifact must be smaller than 5 MB',
|
|
7
|
+
channel,
|
|
8
|
+
stage: 'html-artifact',
|
|
9
|
+
severity: 'error',
|
|
10
|
+
owner: 'playcraft-build',
|
|
11
|
+
detectable: true,
|
|
12
|
+
confidence: 'high',
|
|
13
|
+
description: 'Most playable ad channels cap the final creative artifact at 5 MB.',
|
|
14
|
+
recommendation: 'Compress or inline assets more aggressively, or remove unused media.',
|
|
15
|
+
evidenceType: 'file',
|
|
16
|
+
source: SOURCE_SPEC,
|
|
17
|
+
check: { kind: 'max-size', maxBytes: FIVE_MB },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function externalResourceRule(channel, allowedPatterns) {
|
|
21
|
+
return {
|
|
22
|
+
id: `${channel}.html.external-resource.forbidden`,
|
|
23
|
+
title: 'External runtime resources should not be loaded',
|
|
24
|
+
channel,
|
|
25
|
+
stage: 'html-artifact',
|
|
26
|
+
severity: 'error',
|
|
27
|
+
owner: 'playcraft-build',
|
|
28
|
+
detectable: true,
|
|
29
|
+
confidence: 'high',
|
|
30
|
+
description: 'Playable ads should not depend on external scripts, styles, images, media, or frames unless the channel explicitly provides that SDK.',
|
|
31
|
+
recommendation: 'Inline resources into the HTML artifact, or add a channel-specific allowlist when the SDK is officially required.',
|
|
32
|
+
evidenceType: 'pattern',
|
|
33
|
+
source: SOURCE_SPEC,
|
|
34
|
+
check: { kind: 'external-resources', allowedPatterns },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function forbiddenPatternRule(channel, idSuffix, title, pattern, recommendation, severity = 'error') {
|
|
38
|
+
return {
|
|
39
|
+
id: `${channel}.${idSuffix}`,
|
|
40
|
+
title,
|
|
41
|
+
channel,
|
|
42
|
+
stage: 'html-artifact',
|
|
43
|
+
severity,
|
|
44
|
+
owner: 'playcraft-build',
|
|
45
|
+
detectable: true,
|
|
46
|
+
confidence: 'high',
|
|
47
|
+
description: title,
|
|
48
|
+
recommendation,
|
|
49
|
+
evidenceType: 'pattern',
|
|
50
|
+
source: SOURCE_SPEC,
|
|
51
|
+
check: { kind: 'forbidden-pattern', pattern, flags: 'i' },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function requiredPatternRule(channel, idSuffix, title, pattern, recommendation, severity = 'error') {
|
|
55
|
+
return {
|
|
56
|
+
id: `${channel}.${idSuffix}`,
|
|
57
|
+
title,
|
|
58
|
+
channel,
|
|
59
|
+
stage: 'html-artifact',
|
|
60
|
+
severity,
|
|
61
|
+
owner: 'playcraft-build',
|
|
62
|
+
detectable: true,
|
|
63
|
+
confidence: 'high',
|
|
64
|
+
description: title,
|
|
65
|
+
recommendation,
|
|
66
|
+
evidenceType: 'pattern',
|
|
67
|
+
source: SOURCE_SPEC,
|
|
68
|
+
check: { kind: 'required-pattern', pattern, flags: 'i' },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function sourcePresenceRule(channel, idSuffix, title, recommendation) {
|
|
72
|
+
return {
|
|
73
|
+
id: `${channel}.${idSuffix}`,
|
|
74
|
+
title,
|
|
75
|
+
channel,
|
|
76
|
+
stage: 'prebuild-source',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
owner: 'playcraft-build',
|
|
79
|
+
detectable: true,
|
|
80
|
+
confidence: 'high',
|
|
81
|
+
description: title,
|
|
82
|
+
recommendation,
|
|
83
|
+
evidenceType: 'pattern',
|
|
84
|
+
source: SOURCE_SPEC,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function manualRule(channel, idSuffix, title, recommendation) {
|
|
88
|
+
return {
|
|
89
|
+
id: `${channel}.${idSuffix}`,
|
|
90
|
+
title,
|
|
91
|
+
channel,
|
|
92
|
+
stage: 'runtime-manual',
|
|
93
|
+
severity: 'info',
|
|
94
|
+
owner: 'manual',
|
|
95
|
+
detectable: false,
|
|
96
|
+
confidence: 'medium',
|
|
97
|
+
description: title,
|
|
98
|
+
recommendation,
|
|
99
|
+
evidenceType: 'runtime',
|
|
100
|
+
source: SOURCE_SPEC,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function externalValidatorRule(channel, title, recommendation) {
|
|
104
|
+
return {
|
|
105
|
+
id: `${channel}.external-validator.review.required`,
|
|
106
|
+
title,
|
|
107
|
+
channel,
|
|
108
|
+
stage: 'external-validator',
|
|
109
|
+
severity: 'info',
|
|
110
|
+
owner: 'external',
|
|
111
|
+
detectable: false,
|
|
112
|
+
confidence: 'medium',
|
|
113
|
+
description: title,
|
|
114
|
+
recommendation,
|
|
115
|
+
evidenceType: 'external',
|
|
116
|
+
source: SOURCE_SPEC,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function profile(channel, displayName, deliveryMode, rules, options) {
|
|
120
|
+
return {
|
|
121
|
+
channel,
|
|
122
|
+
displayName,
|
|
123
|
+
defaultStages: ['prebuild-source', 'html-artifact'],
|
|
124
|
+
optionalStages: options?.optionalStages ?? [],
|
|
125
|
+
deliveryMode,
|
|
126
|
+
internalBuildFormat: options?.internalBuildFormat ?? 'html',
|
|
127
|
+
deliveryPackageFormat: options?.deliveryPackageFormat,
|
|
128
|
+
devkitOwned: options?.devkitOwned,
|
|
129
|
+
rules: [sizeRule(channel), ...rules],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export const CHANNEL_RULE_PROFILES = {
|
|
133
|
+
playcraft: {
|
|
134
|
+
channel: 'playcraft',
|
|
135
|
+
displayName: 'PlayCraft',
|
|
136
|
+
defaultStages: ['prebuild-source', 'html-artifact'],
|
|
137
|
+
optionalStages: [],
|
|
138
|
+
deliveryMode: 'mixed',
|
|
139
|
+
internalBuildFormat: 'html',
|
|
140
|
+
deliveryPackageFormat: 'mixed',
|
|
141
|
+
rules: [],
|
|
142
|
+
},
|
|
143
|
+
applovin: profile('applovin', 'AppLovin', 'single-html', [
|
|
144
|
+
externalResourceRule('applovin'),
|
|
145
|
+
requiredPatternRule('applovin', 'cta.mraid-open.required', 'CTA should use mraid.open()', '\\bmraid\\.open\\s*\\(', 'Route store jumps through mraid.open().'),
|
|
146
|
+
manualRule('applovin', 'runtime.orientation-and-cta.manual', 'Landscape, portrait, and CTA visibility need runtime review.', 'Verify both orientations and that CTA stays visible throughout the playable.'),
|
|
147
|
+
externalValidatorRule('applovin', 'AppLovin preview should be used before delivery.', 'Upload the final creative to AppLovin Preview or Preview Tool.'),
|
|
148
|
+
], { optionalStages: ['runtime-manual', 'external-validator'] }),
|
|
149
|
+
google: profile('google', 'Google', 'html-backed-zip', [
|
|
150
|
+
externalResourceRule('google'),
|
|
151
|
+
forbiddenPatternRule('google', 'network.fetch.forbidden', 'Google creatives should not make runtime network requests.', '\\b(fetch|XMLHttpRequest|WebSocket|EventSource)\\b', 'Remove runtime network calls or mark approved analytics separately.'),
|
|
152
|
+
manualRule('google', 'runtime.audio-and-gesture.manual', 'Audio before interaction and download button animation need runtime review.', 'Confirm the creative is silent before interaction and CTA gestures do not scale improperly.'),
|
|
153
|
+
externalValidatorRule('google', 'Google H5 Validator should be used before delivery.', 'Validate the delivery package with Google H5 Validator.'),
|
|
154
|
+
], { optionalStages: ['delivery-package', 'runtime-manual', 'external-validator'], deliveryPackageFormat: 'zip' }),
|
|
155
|
+
facebook: profile('facebook', 'Facebook / Meta', 'mixed', [
|
|
156
|
+
externalResourceRule('facebook'),
|
|
157
|
+
forbiddenPatternRule('facebook', 'sdk.mraid-js.forbidden', 'Facebook creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Facebook playable APIs.'),
|
|
158
|
+
requiredPatternRule('facebook', 'cta.fb-on-click.recommended', 'CTA should call FbPlayableAd.onCTAClick().', 'FbPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through FbPlayableAd.onCTAClick().', 'warning'),
|
|
159
|
+
manualRule('facebook', 'runtime.portrait-and-cta.manual', 'Portrait display and CTA clickability need runtime review.', 'Verify portrait layout and CTA behavior in Meta preview.'),
|
|
160
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
161
|
+
tiktok: profile('tiktok', 'TikTok / Pangle', 'zip', [
|
|
162
|
+
forbiddenPatternRule('tiktok', 'sdk.mraid-js.forbidden', 'TikTok/Pangle creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js; Pangle integration belongs to the channel SDK.'),
|
|
163
|
+
forbiddenPatternRule('tiktok', 'redirect.javascript.forbidden', 'TikTok/Pangle creatives should not contain JavaScript redirects.', '\\blocation\\.(href|assign|replace)\\b|window\\.open\\s*\\(', 'Remove automatic redirects and delegate store jumps to Pangle.'),
|
|
164
|
+
], { optionalStages: ['delivery-package'], deliveryPackageFormat: 'zip', devkitOwned: true, internalBuildFormat: 'delegated' }),
|
|
165
|
+
unity: profile('unity', 'Unity Ads', 'single-html', [
|
|
166
|
+
sourcePresenceRule('unity', 'mraid.viewable-change.required', 'Unity requires MRAID viewableChange before gameplay starts.', 'Add a viewableChange listener before starting playable content.'),
|
|
167
|
+
externalResourceRule('unity'),
|
|
168
|
+
requiredPatternRule('unity', 'cta.mraid-open.required', 'Unity CTA should use mraid.open().', '\\bmraid\\.open\\s*\\(', 'Route the CTA directly to the store through mraid.open().'),
|
|
169
|
+
requiredPatternRule('unity', 'mraid.viewable-change.required', 'Unity playable should wait for MRAID viewableChange.', 'viewableChange', 'Wait for the MRAID viewableChange event before starting gameplay.'),
|
|
170
|
+
manualRule('unity', 'runtime.orientation-and-start.manual', 'Orientation support and gameplay start timing need runtime review.', 'Verify portrait/landscape support and that gameplay waits for viewability.'),
|
|
171
|
+
], { optionalStages: ['runtime-manual'] }),
|
|
172
|
+
ironsource: profile('ironsource', 'ironSource', 'mixed', [
|
|
173
|
+
forbiddenPatternRule('ironsource', 'redirect.auto.forbidden', 'ironSource creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
174
|
+
manualRule('ironsource', 'runtime.loading-audio-controls.manual', 'Loading time, mute, close, and info buttons need runtime review.', 'Verify load time, audio behavior, mute button, close button, and info button.'),
|
|
175
|
+
], { optionalStages: ['runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
176
|
+
liftoff: profile('liftoff', 'Liftoff', 'zip', [
|
|
177
|
+
manualRule('liftoff', 'package.entry.manual', 'ZIP entry and folder structure need delivery review.', 'Confirm the ZIP has a single entry folder and a reachable root entry file.'),
|
|
178
|
+
externalValidatorRule('liftoff', 'Liftoff validator should be used before delivery.', 'Validate with Liftoff Interactive Ad Validator or Creative Verifier.'),
|
|
179
|
+
], { optionalStages: ['delivery-package', 'external-validator'], deliveryPackageFormat: 'zip' }),
|
|
180
|
+
moloco: profile('moloco', 'Moloco', 'single-html', [
|
|
181
|
+
externalResourceRule('moloco'),
|
|
182
|
+
forbiddenPatternRule('moloco', 'sdk.mraid-js.forbidden', 'Moloco creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Facebook-compatible CTA handling.'),
|
|
183
|
+
requiredPatternRule('moloco', 'cta.fb-on-click.required', 'Moloco CTA should use FbPlayableAd.onCTAClick().', 'FbPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through FbPlayableAd.onCTAClick().'),
|
|
184
|
+
manualRule('moloco', 'runtime.portrait-responsive.manual', 'Portrait responsive behavior needs runtime review.', 'Verify portrait layout and CTA visibility.'),
|
|
185
|
+
], { optionalStages: ['runtime-manual'] }),
|
|
186
|
+
bigo: profile('bigo', 'BIGO', 'zip', [
|
|
187
|
+
sourcePresenceRule('bigo', 'lifecycle.game-end.required', 'BIGO requires window.BGY_MRAID.gameEnd() when gameplay ends.', 'Call window.BGY_MRAID.gameEnd() when gameplay ends.'),
|
|
188
|
+
externalResourceRule('bigo', ['static-web.likeevideo.com']),
|
|
189
|
+
requiredPatternRule('bigo', 'cta.bgy-open.required', 'BIGO CTA should use window.BGY_MRAID.open().', 'window\\.BGY_MRAID\\.open\\s*\\(', 'Route CTA clicks through window.BGY_MRAID.open().'),
|
|
190
|
+
requiredPatternRule('bigo', 'lifecycle.game-end.required', 'BIGO creatives should call window.BGY_MRAID.gameEnd().', 'window\\.BGY_MRAID\\.gameEnd\\s*\\(', 'Call gameEnd() when gameplay ends.'),
|
|
191
|
+
], { optionalStages: ['delivery-package'], deliveryPackageFormat: 'zip', devkitOwned: true, internalBuildFormat: 'delegated' }),
|
|
192
|
+
inmobi: profile('inmobi', 'InMobi', 'mixed', [
|
|
193
|
+
requiredPatternRule('inmobi', 'sdk.mraid-js.required', 'InMobi creatives should include mraid.js or MRAID support.', 'mraid', 'Include the channel-required MRAID integration.'),
|
|
194
|
+
requiredPatternRule('inmobi', 'cta.mraid-open.required', 'InMobi redirects should use mraid.open(url).', '\\bmraid\\.open\\s*\\(', 'Route store redirects through mraid.open(url).'),
|
|
195
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
196
|
+
adikteev: profile('adikteev', 'Adikteev', 'fragment-assets', [
|
|
197
|
+
requiredPatternRule('adikteev', 'cta.mraid-open.required', 'Adikteev CTA should use mraid.open().', '\\bmraid\\.open\\s*\\(', 'Route CTA clicks through mraid.open().'),
|
|
198
|
+
requiredPatternRule('adikteev', 'click-pixel-url.required', 'Adikteev requires AK_CLICK_PIXEL_URL exposure.', 'AK_CLICK_PIXEL_URL|AK CLICK PIXEL URL', 'Expose the Adikteev click pixel URL variable.'),
|
|
199
|
+
manualRule('adikteev', 'runtime.cdn-responsive.manual', 'CDN resource loading and responsive layout need runtime review.', 'Verify separated assets load from Adikteev CDN and remain responsive.'),
|
|
200
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'fragment-assets' }),
|
|
201
|
+
remerge: profile('remerge', 'Remerge', 'single-html', [
|
|
202
|
+
externalResourceRule('remerge'),
|
|
203
|
+
forbiddenPatternRule('remerge', 'redirect.auto.forbidden', 'Remerge creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
204
|
+
]),
|
|
205
|
+
mintegral: profile('mintegral', 'Mintegral', 'mixed', [
|
|
206
|
+
sourcePresenceRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral requires sdk.finish() after gameplay ends.', 'Call sdk.finish() when gameplay reaches win or end state.'),
|
|
207
|
+
requiredPatternRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral creatives should call sdk.finish() after win or end.', '\\bsdk\\.finish\\s*\\(', 'Call sdk.finish() at the end of gameplay.'),
|
|
208
|
+
forbiddenPatternRule('mintegral', 'redirect.auto.forbidden', 'Mintegral creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
209
|
+
externalValidatorRule('mintegral', 'Mindworks testing should be used before delivery.', 'Validate through the Mintegral/Mindworks testing entry.'),
|
|
210
|
+
], { optionalStages: ['external-validator'], deliveryPackageFormat: 'mixed' }),
|
|
211
|
+
snapchat: profile('snapchat', 'Snapchat', 'zip', [
|
|
212
|
+
forbiddenPatternRule('snapchat', 'sdk.mraid-js.forbidden', 'Snapchat creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Snapchat playable APIs.'),
|
|
213
|
+
requiredPatternRule('snapchat', 'cta.sc-on-click.required', 'Snapchat CTA should call ScPlayableAd.onCTAClick().', 'ScPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through ScPlayableAd.onCTAClick().'),
|
|
214
|
+
manualRule('snapchat', 'runtime.portrait-and-cta.manual', 'Portrait display and CTA availability need runtime review.', 'Verify portrait layout and CTA behavior.'),
|
|
215
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'zip' }),
|
|
216
|
+
};
|
|
217
|
+
export function getChannelRuleProfile(channel) {
|
|
218
|
+
return CHANNEL_RULE_PROFILES[channel];
|
|
219
|
+
}
|
|
220
|
+
export function getRulesForStage(channel, stage) {
|
|
221
|
+
return getChannelRuleProfile(channel).rules.filter((rule) => rule.stage === stage);
|
|
222
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ValidationReport } from './types.js';
|
|
2
|
+
export declare function saveValidationReport(report: ValidationReport, reportPath: string): Promise<void>;
|
|
3
|
+
export declare function formatValidationSummary(report: ValidationReport): string;
|
|
4
|
+
export declare function hasBlockingValidationErrors(report: ValidationReport): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function saveValidationReport(report, reportPath) {
|
|
4
|
+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
5
|
+
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
6
|
+
}
|
|
7
|
+
export function formatValidationSummary(report) {
|
|
8
|
+
const lines = [
|
|
9
|
+
`[Validation] channel=${report.channel}, chain=${report.buildChain}, status=${report.summary.status}`,
|
|
10
|
+
`[Validation] errors=${report.summary.errorCount}, warnings=${report.summary.warningCount}, manual=${report.summary.manualCount}, external=${report.summary.externalCount}`,
|
|
11
|
+
];
|
|
12
|
+
const failed = report.results.filter((result) => result.status === 'fail');
|
|
13
|
+
const warnings = report.results.filter((result) => result.status === 'warning');
|
|
14
|
+
for (const result of [...failed, ...warnings].slice(0, 10)) {
|
|
15
|
+
lines.push(` - ${result.status.toUpperCase()} ${result.ruleId}: ${result.message}`);
|
|
16
|
+
if (result.file) {
|
|
17
|
+
lines.push(` file=${result.file}${result.line ? `:${result.line}` : ''}`);
|
|
18
|
+
}
|
|
19
|
+
if (result.evidence) {
|
|
20
|
+
lines.push(` evidence=${result.evidence}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (failed.length + warnings.length > 10) {
|
|
24
|
+
lines.push(` ... ${failed.length + warnings.length - 10} more validation results`);
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
export function hasBlockingValidationErrors(report) {
|
|
29
|
+
return report.results.some((result) => result.status === 'fail' && result.severity === 'error');
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Platform } from '../types.js';
|
|
2
|
+
import type { ValidationContext, ValidationResult } from './types.js';
|
|
3
|
+
export declare function scanSourceForCompliance(context: ValidationContext): Promise<ValidationResult[]>;
|
|
4
|
+
export declare function scanSourceForChannels(context: Omit<ValidationContext, 'channel'>, channels: Platform[]): Promise<ValidationResult[]>;
|