@govtechsg/oobee 0.10.85 → 0.10.87

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.
Files changed (62) hide show
  1. package/.github/workflows/publish.yml +10 -0
  2. package/DETAILS.md +29 -0
  3. package/dist/cli.js +18 -5
  4. package/dist/combine.js +3 -1
  5. package/dist/constants/cliFunctions.js +2 -2
  6. package/dist/constants/common.js +70 -17
  7. package/dist/constants/constants.js +604 -1
  8. package/dist/crawlers/commonCrawlerFunc.js +3 -2
  9. package/dist/crawlers/crawlDomain.js +38 -13
  10. package/dist/crawlers/crawlIntelligentSitemap.js +62 -30
  11. package/dist/crawlers/crawlSitemap.js +141 -84
  12. package/dist/crawlers/custom/utils.js +218 -71
  13. package/dist/crawlers/guards/urlGuard.js +8 -15
  14. package/dist/crawlers/runCustom.js +18 -11
  15. package/dist/generateHtmlReport.js +18 -11
  16. package/dist/generateOobeeClientScanner.js +570 -0
  17. package/dist/mergeAxeResults/itemReferences.js +60 -25
  18. package/dist/mergeAxeResults/sentryTelemetry.js +4 -1
  19. package/dist/mergeAxeResults.js +23 -13
  20. package/dist/npmIndex.js +10 -2
  21. package/dist/proxyService.js +18 -3
  22. package/dist/services/s3Uploader.js +21 -10
  23. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  24. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  25. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  26. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
  27. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
  28. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  29. package/dist/static/ejs/summary.ejs +19 -8
  30. package/dist/utils.js +4 -3
  31. package/fix-summary-html-oom-pr.md +62 -0
  32. package/oobee-client-scanner.js +34992 -0
  33. package/package.json +5 -5
  34. package/src/cli.ts +19 -5
  35. package/src/combine.ts +5 -1
  36. package/src/constants/cliFunctions.ts +2 -2
  37. package/src/constants/common.ts +87 -22
  38. package/src/constants/constants.ts +602 -1
  39. package/src/crawlers/commonCrawlerFunc.ts +4 -3
  40. package/src/crawlers/crawlDomain.ts +39 -13
  41. package/src/crawlers/crawlIntelligentSitemap.ts +63 -30
  42. package/src/crawlers/crawlSitemap.ts +165 -100
  43. package/src/crawlers/custom/utils.ts +241 -80
  44. package/src/crawlers/guards/urlGuard.ts +24 -31
  45. package/src/crawlers/runCustom.ts +29 -11
  46. package/src/generateHtmlReport.ts +21 -11
  47. package/src/generateOobeeClientScanner.ts +591 -0
  48. package/src/mergeAxeResults/itemReferences.ts +70 -26
  49. package/src/mergeAxeResults/sentryTelemetry.ts +4 -1
  50. package/src/mergeAxeResults.ts +26 -14
  51. package/src/npmIndex.ts +12 -2
  52. package/src/proxyService.ts +25 -4
  53. package/src/services/s3Uploader.ts +23 -11
  54. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  55. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  56. package/src/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  57. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
  58. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
  59. package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  60. package/src/static/ejs/summary.ejs +19 -8
  61. package/src/utils.ts +4 -3
  62. package/testStaticJSScanner.html +534 -0
@@ -5,7 +5,7 @@ import printMessage from 'print-message';
5
5
  import path from 'path';
6
6
  import ejs from 'ejs';
7
7
  import { fileURLToPath } from 'url';
8
- import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
8
+ import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, } from './constants/constants.js';
9
9
  import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
10
10
  import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage, register, } from './utils.js';
11
11
  import { consoleLogger } from './logs.js';
@@ -119,10 +119,10 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
119
119
  const { topFilePath, bottomFilePath } = await splitHtmlAndCreateFiles(htmlFilePath, storagePath);
120
120
  const prefixData = fs.readFileSync(path.join(storagePath, 'report-partial-top.htm.txt'), 'utf-8');
121
121
  const suffixData = fs.readFileSync(path.join(storagePath, 'report-partial-bottom.htm.txt'), 'utf-8');
122
- // Create lighter version with item references for embedding in HTML
123
- const scanItemsWithHtmlGroupRefs = convertItemsToReferences(allIssues);
122
+ // Create the lighter scanItems payload for embedding in the HTML report.
123
+ const lightScanItemsPayload = convertItemsToReferences(allIssues);
124
124
  // Write the lighter items to a file and get the base64 path
125
- const { jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath, base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath, } = await writeJsonFileAndCompressedJsonFile(scanItemsWithHtmlGroupRefs.items, storagePath, 'scanItems-light');
125
+ const { jsonFilePath: lightScanItemsPayloadJsonFilePath, base64FilePath: lightScanItemsPayloadBase64FilePath, } = await writeJsonFileAndCompressedJsonFile(lightScanItemsPayload, storagePath, 'scanItems-light');
126
126
  return new Promise((resolve, reject) => {
127
127
  const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
128
128
  encoding: 'utf8',
@@ -135,8 +135,8 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
135
135
  await Promise.all([
136
136
  fs.promises.unlink(topFilePath),
137
137
  fs.promises.unlink(bottomFilePath),
138
- fs.promises.unlink(scanItemsWithHtmlGroupRefsBase64FilePath),
139
- fs.promises.unlink(scanItemsWithHtmlGroupRefsJsonFilePath),
138
+ fs.promises.unlink(lightScanItemsPayloadBase64FilePath),
139
+ fs.promises.unlink(lightScanItemsPayloadJsonFilePath),
140
140
  ]);
141
141
  }
142
142
  catch (err) {
@@ -172,22 +172,28 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
172
172
  } else {
173
173
  console.warn('Skipping fetch GenAI feature as it is local report');
174
174
  }
175
+
176
+ var scanData = null;
177
+ var scanItems = null;
175
178
  \n`);
176
179
  outputStream.write('</script>\n<script type="text/plain" id="scanDataRaw">');
177
180
  scanDetailsReadStream.pipe(outputStream, { end: false });
178
181
  scanDetailsReadStream.on('end', async () => {
179
182
  outputStream.write('</script>\n<script>\n');
180
- outputStream.write("var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n");
183
+ outputStream.write("var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); console.log('[report] scanData loaded'); })();\n");
181
184
  outputStream.write('</script>\n');
182
185
  // Write scanItems in 2MB chunks using a stream to avoid loading entire file into memory
183
186
  try {
184
187
  let chunkIndex = 1;
185
- const scanItemsStream = fs.createReadStream(scanItemsWithHtmlGroupRefsBase64FilePath, {
188
+ const scanItemsStream = fs.createReadStream(lightScanItemsPayloadBase64FilePath, {
186
189
  encoding: 'utf8',
187
190
  highWaterMark: CHUNK_SIZE,
188
191
  });
189
192
  for await (const chunk of scanItemsStream) {
190
- outputStream.write(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
193
+ const ok = outputStream.write(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
194
+ if (!ok) {
195
+ await new Promise(resolve => outputStream.once('drain', resolve));
196
+ }
191
197
  chunkIndex++;
192
198
  }
193
199
  outputStream.write('<script>\n');
@@ -203,6 +209,7 @@ var scanItemsPromise = (async () => {
203
209
  i++;
204
210
  }
205
211
  scanItems = await decodeUnzipParse(chunks);
212
+ console.log('[report] scanItems loaded');
206
213
  })();\n`);
207
214
  outputStream.write(suffixData);
208
215
  outputStream.end();
@@ -302,9 +309,9 @@ const wcagOccurrencesMap = new Map();
302
309
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
303
310
  const { url, pageTitle, filePath } = pageResults;
304
311
  const totalIssuesInPage = new Set();
305
- Object.keys(pageResults.mustFix.rules).forEach(k => totalIssuesInPage.add(k));
306
- Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
307
- Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
312
+ Object.keys(pageResults.mustFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
313
+ Object.keys(pageResults.goodToFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
314
+ Object.keys(pageResults.needsReview?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
308
315
  allIssues.topFiveMostIssues.push({
309
316
  url,
310
317
  pageTitle,
@@ -592,6 +599,7 @@ generateJsonFiles = false) => {
592
599
  a11yRuleShortDescriptionMap,
593
600
  disabilityBadgesMap,
594
601
  a11yRuleLongDescriptionMap,
602
+ a11yRuleStepByStepGuide,
595
603
  wcagCriteriaLabels: constants.wcagCriteriaLabels,
596
604
  scanPagesDetail: {
597
605
  pagesAffected: [],
@@ -721,11 +729,13 @@ generateJsonFiles = false) => {
721
729
  const browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
722
730
  // Should consider refactor constants.userDataDirectory to be a parameter in future
723
731
  await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length, 'summary', browserChannel, constants.userDataDirectory), 1);
732
+ // Brief delay to allow lingering async crawlee storage operations to flush
733
+ await new Promise(resolve => setTimeout(resolve, 3000));
724
734
  try {
725
735
  await fs.promises.rm(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
726
736
  }
727
737
  catch (error) {
728
- consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
738
+ // Silently ignore folder may already be gone or still locked
729
739
  }
730
740
  try {
731
741
  await fs.promises.rm(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
package/dist/npmIndex.js CHANGED
@@ -4,7 +4,7 @@ import axe from 'axe-core';
4
4
  import { JSDOM } from 'jsdom';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { EnqueueStrategy } from 'crawlee';
7
- import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
7
+ import constants, { BrowserTypes, RuleFlags, ScannerTypes, a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide } from './constants/constants.js';
8
8
  import { deleteClonedProfiles, getBrowserToRun, getPlaywrightLaunchOptions, submitForm, } from './constants/common.js';
9
9
  import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
10
10
  import { createAndUpdateResultsFolders, getVersion } from './utils.js';
@@ -489,6 +489,10 @@ const processAndSubmitResults = async (scanData, name, email, metadata) => {
489
489
  if (constants.a11yRuleShortDescriptionMap[ruleId]) {
490
490
  mergedResults[category].rules[ruleId].description = constants.a11yRuleShortDescriptionMap[ruleId];
491
491
  }
492
+ // Add short description, long description and step-by-step guide
493
+ mergedResults[category].rules[ruleId].shortDescription = a11yRuleShortDescriptionMap[ruleId];
494
+ mergedResults[category].rules[ruleId].longDescription = a11yRuleLongDescriptionMap[ruleId];
495
+ mergedResults[category].rules[ruleId].stepByStepGuide = a11yRuleStepByStepGuide[ruleId];
492
496
  // Add url to items
493
497
  mergedResults[category].rules[ruleId].items.forEach((item) => {
494
498
  item.url = result.url;
@@ -554,6 +558,10 @@ const processAndSubmitResults = async (scanData, name, email, metadata) => {
554
558
  if (constants.a11yRuleShortDescriptionMap[rule.rule]) {
555
559
  rule.description = constants.a11yRuleShortDescriptionMap[rule.rule];
556
560
  }
561
+ // Add short description, long description and step-by-step guide
562
+ rule.shortDescription = a11yRuleShortDescriptionMap[rule.rule];
563
+ rule.longDescription = a11yRuleLongDescriptionMap[rule.rule];
564
+ rule.stepByStepGuide = a11yRuleStepByStepGuide[rule.rule];
557
565
  if (rule.items) {
558
566
  rule.items.forEach((item) => {
559
567
  // Ensure item URL matches the result URL
@@ -637,4 +645,4 @@ export const scanPage = async (pages, config) => {
637
645
  }
638
646
  return processAndSubmitResults(scanData, name, email, metadata);
639
647
  };
640
- export { RuleFlags };
648
+ export { RuleFlags, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, getOobeeFunctionsScript };
@@ -57,7 +57,7 @@ function parseEnvProxyCommon() {
57
57
  if (https)
58
58
  info.https = stripScheme(https);
59
59
  if (socks)
60
- info.socks = stripScheme(socks);
60
+ info.socks = socks; // keep original scheme so proxyInfoToResolution can use the right protocol
61
61
  if (noProxy)
62
62
  info.bypassList = semiJoin(noProxy.split(/[,;]/));
63
63
  const { username, password } = readCredsFromEnv();
@@ -384,6 +384,14 @@ function buildIncludeOnlyPac(proxyServer, includeList) {
384
384
  ].join('\n');
385
385
  return pac;
386
386
  }
387
+ /**
388
+ * Convert an info.socks value to a full proxy server URL.
389
+ * When the value already carries a scheme (e.g. ALL_PROXY=http://..., socks4://...),
390
+ * it is used as-is. Bare host:port values (from scutil) default to socks5://.
391
+ */
392
+ function toSocksServer(socks) {
393
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(socks) ? socks : `socks5://${socks}`;
394
+ }
387
395
  export function proxyInfoToResolution(info) {
388
396
  if (!info)
389
397
  return { kind: 'none' };
@@ -396,7 +404,7 @@ export function proxyInfoToResolution(info) {
396
404
  else if (info.https)
397
405
  proxyServer = `http://${info.https}`;
398
406
  else if (info.socks)
399
- proxyServer = `socks5://${info.socks}`;
407
+ proxyServer = toSocksServer(info.socks);
400
408
  if (proxyServer) {
401
409
  // If credentials exist, embed them for the manual proxy auth
402
410
  // PAC scripts themselves don't carry auth, but Playwright's proxy option can
@@ -408,6 +416,13 @@ export function proxyInfoToResolution(info) {
408
416
  const pacDataUrl = `data:application/x-ns-proxy-autoconfig;base64,${Buffer.from(pac).toString('base64')}`;
409
417
  return { kind: 'pac', pacUrl: pacDataUrl, bypass: info.bypassList };
410
418
  }
419
+ // No direct proxy server was found — the configured proxy is PAC-based or auto-detect only.
420
+ // INCLUDE_PROXY needs a concrete server address to build a routing PAC script, so it cannot
421
+ // be applied here. Warn and fall through to use the existing PAC/autodetect as-is.
422
+ console.warn('INCLUDE_PROXY is set but no direct proxy server address was found. ' +
423
+ 'INCLUDE_PROXY requires HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY to be set with a direct ' +
424
+ 'server address; it cannot be applied to a PAC URL or auto-detect proxy. ' +
425
+ 'INCLUDE_PROXY will be ignored.');
411
426
  }
412
427
  // Prefer manual proxies first (these work with Playwright's proxy option)
413
428
  if (info.http) {
@@ -428,7 +443,7 @@ export function proxyInfoToResolution(info) {
428
443
  }
429
444
  if (info.socks) {
430
445
  return { kind: 'manual', settings: {
431
- server: `socks5://${info.socks}`,
446
+ server: toSocksServer(info.socks),
432
447
  username: info.username,
433
448
  password: info.password,
434
449
  bypass: info.bypassList,
@@ -5,6 +5,17 @@ import mime from 'mime-types';
5
5
  import { consoleLogger } from '../logs.js';
6
6
  const REGION = process.env.AWS_REGION || 'ap-southeast-1';
7
7
  const s3Client = new S3Client({ region: REGION });
8
+ // S3 user metadata is sent over REST as x-amz-meta-* HTTP headers.
9
+ // To avoid request-header validation failures in the Node/AWS SDK path,
10
+ // normalize to printable ASCII before attaching metadata values.
11
+ const sanitizeS3MetadataValue = (value) => {
12
+ return value
13
+ .normalize('NFKD') // e.g. "é" -> "e" + combining accent, "A" -> "A"
14
+ .replace(/[\u0300-\u036f]/g, '') // e.g. remove the combining accent from the decomposed "é"
15
+ .replace(/[^\x20-\x7E]+/g, ' ') // e.g. "公益金" or emoji -> " "
16
+ .replace(/\s+/g, ' ') // e.g. "Community Chest \n" -> "Community Chest "
17
+ .trim(); // e.g. " Homepage | Community Chest " -> "Homepage | Community Chest"
18
+ };
8
19
  export const uploadFileToS3 = async (localFilePath, s3Key, metadata) => {
9
20
  const fileStream = fs.readFileSync(localFilePath);
10
21
  const contentType = mime.lookup(localFilePath) || 'application/octet-stream';
@@ -38,31 +49,31 @@ export const uploadFolderToS3 = async (localFolderPath, s3Prefix, scanMetadata)
38
49
  const files = getAllFiles(localFolderPath, localFolderPath);
39
50
  const allowedFileExtRegex = /\.(html|csv|pdf|zip)$/;
40
51
  const metadata = {
41
- scanid: scanMetadata.scanId,
42
- userid: scanMetadata.userId,
43
- useremail: scanMetadata.email,
52
+ scanid: sanitizeS3MetadataValue(scanMetadata.scanId),
53
+ userid: sanitizeS3MetadataValue(scanMetadata.userId),
54
+ useremail: sanitizeS3MetadataValue(scanMetadata.email),
44
55
  };
45
56
  // Add optional metadata fields if present
46
57
  if (scanMetadata.messageId) {
47
- metadata.messageid = scanMetadata.messageId;
58
+ metadata.messageid = sanitizeS3MetadataValue(scanMetadata.messageId);
48
59
  }
49
60
  if (scanMetadata.amplitudeUserId) {
50
- metadata.amplitudeuserid = scanMetadata.amplitudeUserId;
61
+ metadata.amplitudeuserid = sanitizeS3MetadataValue(scanMetadata.amplitudeUserId);
51
62
  }
52
63
  if (scanMetadata.deviceId) {
53
- metadata.deviceid = scanMetadata.deviceId;
64
+ metadata.deviceid = sanitizeS3MetadataValue(scanMetadata.deviceId);
54
65
  }
55
66
  if (scanMetadata.orgId) {
56
- metadata.orgid = scanMetadata.orgId;
67
+ metadata.orgid = sanitizeS3MetadataValue(scanMetadata.orgId);
57
68
  }
58
69
  if (scanMetadata.userRole) {
59
- metadata.userrole = scanMetadata.userRole;
70
+ metadata.userrole = sanitizeS3MetadataValue(scanMetadata.userRole);
60
71
  }
61
72
  if (scanMetadata.siteName) {
62
- metadata.sitename = scanMetadata.siteName;
73
+ metadata.sitename = sanitizeS3MetadataValue(scanMetadata.siteName);
63
74
  }
64
75
  if (scanMetadata.durationExceeded !== undefined) {
65
- metadata.durationexceeded = scanMetadata.durationExceeded;
76
+ metadata.durationexceeded = sanitizeS3MetadataValue(scanMetadata.durationExceeded);
66
77
  }
67
78
  consoleLogger.info(`Uploading ${files.length} files to S3...`);
68
79
  const uploadPromises = files.map(async (relativePath) => {
@@ -28,8 +28,11 @@ async function decodeUnzipParse(input) {
28
28
  offset += arr.length;
29
29
  }
30
30
 
31
- // Step 2: Decompress with pako (GZIP)
32
- const decompressed = pako.ungzip(merged, { to: 'string' });
31
+ // Step 2: Decompress with pako (GZIP) to bytes first to avoid large-string
32
+ // construction inside pako for very large payloads.
33
+ const decompressedBytes = pako.ungzip(merged);
34
+
35
+ const decompressed = new TextDecoder().decode(decompressedBytes);
33
36
 
34
37
  // Step 3: Parse JSON
35
38
  return JSON.parse(decompressed);
@@ -37,4 +40,4 @@ async function decodeUnzipParse(input) {
37
40
  throw new Error(`Failed to decode/unzip/parse: ${err.message}`);
38
41
  }
39
42
  }
40
- </script>
43
+ </script>
@@ -21,7 +21,7 @@
21
21
  >
22
22
  </div>
23
23
  <div class="display-url-container">
24
- <a href="${page.url}" target="_blank">${page.pageTitle.length > 0 ? page.pageTitle : page.url}</a>
24
+ <a href="${page.url}" target="_blank">${page.pageTitle?.length > 0 ? page.pageTitle : page.url}</a>
25
25
  <p>${page.url}</p>
26
26
  </div>
27
27
  </div>
@@ -29,7 +29,7 @@
29
29
  } else {
30
30
  listItem.innerHTML = `
31
31
  <a href="${page.url}" target="_blank">
32
- ${page.pageTitle.length > 0 ? page.pageTitle : page.url}
32
+ ${page.pageTitle?.length > 0 ? page.pageTitle : page.url}
33
33
  <svg class="link-external-icon" width="16" height="12" viewBox="0 0 8 8" aria-hidden="true" focusable="false">
34
34
  <path d="M7.11111 7.11111H0.888889V0.888889H4V0H0.888889C0.395556 0 0 0.4 0 0.888889V7.11111C0 7.6 0.395556 8 0.888889 8H7.11111C7.6 8 8 7.6 8 7.11111V4H7.11111V7.11111ZM4.88889 0V0.888889H6.48444L2.11556 5.25778L2.74222 5.88444L7.11111 1.51556V3.11111H8V0H4.88889Z" fill="#5735DF"/>
35
35
  </svg>