From 14cccc2ecf00b5cc051035b7e32ba7b856faa999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Emarianfoo=E2=80=9C?= <13335743+marianfoo@users.noreply.github.com> Date: Mon, 14 Apr 2025 22:16:37 +0200 Subject: [PATCH 1/2] feat: Add hmtl linter output --- README.md | 3 +- src/cli/base.ts | 7 +- src/formatter/html.ts | 282 +++++++++++++++++++++++++++++++ test/lib/formatter/html.ts | 328 +++++++++++++++++++++++++++++++++++++ 4 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 src/formatter/html.ts create mode 100644 test/lib/formatter/html.ts diff --git a/README.md b/README.md index b42d74ab1..904339046 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - [`--details`](#--details) - [`--format`](#--format) - [`--fix`](#--fix) + - [Dry Run Mode](#dry-run-mode) - [`--ignore-pattern`](#--ignore-pattern) - [`--config`](#--config) - [`--ui5-config`](#--ui5-config) @@ -150,7 +151,7 @@ ui5lint --details #### `--format` -Choose the output format. Currently, `stylish` (default), `json` and `markdown` are supported. +Choose the output format. Currently, `stylish` (default), `json`, `markdown` and `html` are supported. **Example:** ```sh diff --git a/src/cli/base.ts b/src/cli/base.ts index d19e39ac4..b826f8462 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -2,6 +2,7 @@ import {Argv, ArgumentsCamelCase, CommandModule, MiddlewareFunction} from "yargs import {Text} from "../formatter/text.js"; import {Json} from "../formatter/json.js"; import {Markdown} from "../formatter/markdown.js"; +import {Html} from "../formatter/html.js"; import {Coverage} from "../formatter/coverage.js"; import {writeFile} from "node:fs/promises"; import baseMiddleware from "./middlewares/base.js"; @@ -104,7 +105,7 @@ const lintCommand: FixedCommandModule = { describe: "Set the output format for the linter result", default: "stylish", type: "string", - choices: ["stylish", "json", "markdown"], + choices: ["stylish", "json", "markdown", "html"], }) .option("ui5-config", { describe: "Set a custom path for the UI5 Config (default: './ui5.yaml' if that file exists)", @@ -183,6 +184,10 @@ async function handleLint(argv: ArgumentsCamelCase) { const markdownFormatter = new Markdown(); process.stdout.write(markdownFormatter.format(res, details, getVersion(), fix)); process.stdout.write("\n"); + } else if (format === "html") { + const htmlFormatter = new Html(); + process.stdout.write(htmlFormatter.format(res, details, getVersion(), fix)); + process.stdout.write("\n"); } else if (format === "" || format === "stylish") { const textFormatter = new Text(rootDir); process.stderr.write(textFormatter.format(res, details, fix)); diff --git a/src/formatter/html.ts b/src/formatter/html.ts new file mode 100644 index 000000000..25a7602f3 --- /dev/null +++ b/src/formatter/html.ts @@ -0,0 +1,282 @@ +import {LintResult, LintMessage} from "../linter/LinterContext.js"; +import {LintMessageSeverity} from "../linter/messages.js"; + +export class Html { + format(lintResults: LintResult[], showDetails: boolean, version: string, autofix: boolean): string { + let totalErrorCount = 0; + let totalWarningCount = 0; + let totalFatalErrorCount = 0; + + // Build the HTML content + let resultsHtml = ""; + lintResults.forEach(({filePath, messages, errorCount, warningCount, fatalErrorCount}) => { + if (!errorCount && !warningCount) { + // Skip files without errors or warnings + return; + } + // Accumulate totals + totalErrorCount += errorCount; + totalWarningCount += warningCount; + totalFatalErrorCount += fatalErrorCount; + + // Add the file path as a section header + resultsHtml += `
+

${filePath}

+ + + + + + + + ${showDetails ? "" : ""} + + + `; + + // Sort messages by severity (fatal errors first, then errors, then warnings) + messages.sort((a, b) => { + // Handle fatal errors first to push them to the bottom + if (a.fatal !== b.fatal) { + return a.fatal ? -1 : 1; // Fatal errors go to the top + } + // Then, compare by severity + if (a.severity !== b.severity) { + return b.severity - a.severity; + } + // If severity is the same, compare by line number + if ((a.line ?? 0) !== (b.line ?? 0)) { + return (a.line ?? 0) - (b.line ?? 0); + } + // If both severity and line number are the same, compare by column number + return (a.column ?? 0) - (b.column ?? 0); + }); + + // Format each message + messages.forEach((msg) => { + const severityClass = this.getSeverityClass(msg.severity, msg.fatal); + const severityText = this.formatSeverity(msg.severity, msg.fatal); + const location = this.formatLocation(msg.line, msg.column); + const rule = this.formatRuleId(msg.ruleId, version); + + resultsHtml += ``; + resultsHtml += ``; + resultsHtml += ``; + resultsHtml += ``; + resultsHtml += ``; + if (showDetails && msg.messageDetails) { + resultsHtml += ``; + } else if (showDetails) { + resultsHtml += ``; + } + resultsHtml += ``; + }); + + resultsHtml += `
SeverityRuleLocationMessageDetails
${severityText}${rule}${location}${msg.message}${this.formatMessageDetails(msg)}
`; + }); + + // Build summary + const summary = `
+

Summary

+

+ ${totalErrorCount + totalWarningCount} problems + (${totalErrorCount} errors, ${totalWarningCount} warnings) +

+ ${totalFatalErrorCount ? `

${totalFatalErrorCount} fatal errors

` : ""} + ${!autofix && (totalErrorCount + totalWarningCount > 0) ? + "

Run ui5lint --fix to resolve all auto-fixable problems

" : + ""} +
`; + + // Full HTML document with some basic styling + const html = ` + + + + + UI5 Linter Report + + + +

UI5 Linter Report

+

Generated on ${new Date().toLocaleString()} with UI5 Linter v${version}

+ + ${summary} + + ${resultsHtml ? `

Findings

${resultsHtml}` : "

No issues found. Your code looks great!

"} + + ${!showDetails && (totalErrorCount + totalWarningCount) > 0 ? + "
Note: Use ui5lint --details " + + "to show more information about the findings.
" : + ""} + +`; + + return html; + } + + // Formats the severity of the lint message + private formatSeverity(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string { + if (fatal === true) { + return "Fatal Error"; + } else if (severity === LintMessageSeverity.Warning) { + return "Warning"; + } else if (severity === LintMessageSeverity.Error) { + return "Error"; + } else { + throw new Error(`Unknown severity: ${LintMessageSeverity[severity]}`); + } + } + + // Returns CSS class name based on severity + private getSeverityClass(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string { + if (fatal === true) { + return "fatal-error"; + } else if (severity === LintMessageSeverity.Warning) { + return "warning"; + } else if (severity === LintMessageSeverity.Error) { + return "error"; + } else { + return ""; + } + } + + // Formats the location of the lint message (line and column numbers) + private formatLocation(line?: number, column?: number): string { + // Default to 0 if line or column are not provided + return `${line ?? 0}:${column ?? 0}`; + } + + // Formats additional message details if available + private formatMessageDetails(msg: LintMessage): string { + if (!msg.messageDetails) { + return ""; + } + // Replace multiple spaces, tabs, or newlines with a single space for clean output + // This more comprehensive regex handles all whitespace characters + const cleanedDetails = msg.messageDetails.replace(/[\s\t\r\n]+/g, " "); + + // Convert URLs to hyperlinks + // This regex matches http/https URLs and also patterns like ui5.sap.com/... with or without protocol + return cleanedDetails.replace( + /(https?:\/\/[^\s)]+)|(\([^(]*?)(https?:\/\/[^\s)]+)([^)]*?\))|(\b(?:www\.|ui5\.sap\.com)[^\s)]+)/g, + (match, directUrl, beforeParen, urlInParen, afterParen, domainUrl) => { + if (directUrl) { + // Direct URL without parentheses + return `${directUrl}`; + } else if (urlInParen) { + // URL inside parentheses - keep the parentheses as text but make the URL a link + return `${beforeParen}${urlInParen}${afterParen}`; + } else if (domainUrl) { + // Domain starting with www. or ui5.sap.com without http(s):// + const fullUrl = typeof domainUrl === "string" && domainUrl.startsWith("www.") ? + `http://${domainUrl}` : + `https://${domainUrl}`; + return `${domainUrl}`; + } + return match; + } + ); + } + + // Formats the rule of the lint message (ruleId and link to rules.md) + private formatRuleId(ruleId: string, version: string): string { + return `${ruleId}`; + } +} diff --git a/test/lib/formatter/html.ts b/test/lib/formatter/html.ts new file mode 100644 index 000000000..ceee67beb --- /dev/null +++ b/test/lib/formatter/html.ts @@ -0,0 +1,328 @@ +import anyTest, {TestFn} from "ava"; +import {Html} from "../../../src/formatter/html.js"; +import {LintResult} from "../../../src/linter/LinterContext.js"; +import {LintMessageSeverity} from "../../../src/linter/messages.js"; + +const test = anyTest as TestFn<{ + lintResults: LintResult[]; +}>; + +test.beforeEach((t) => { + t.context.lintResults = [ + { + filePath: "webapp/Component.js", + messages: [ + { + ruleId: "rule1", + severity: LintMessageSeverity.Error, + line: 1, + column: 1, + message: "Error message", + messageDetails: "Message details", + }, + { + ruleId: "rule2", + severity: LintMessageSeverity.Warning, + line: 2, + column: 2, + message: "Warning message", + messageDetails: "Message details", + }, + ], + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + warningCount: 1, + }, + { + filePath: "webapp/Main.controller.js", + messages: [ + { + ruleId: "rule3", + severity: LintMessageSeverity.Error, + line: 11, + column: 3, + message: "Another error message", + messageDetails: "Message details", + }, + { + ruleId: "rule3", + severity: LintMessageSeverity.Error, + line: 12, + column: 3, + message: "Another error message", + messageDetails: "Message details", + fatal: true, + }, + { + ruleId: "rule3", + severity: LintMessageSeverity.Error, + line: 3, + column: 6, + message: "Another error message", + messageDetails: "Message details", + fatal: true, + }, + { + ruleId: "rule3", + severity: LintMessageSeverity.Warning, + line: 12, + column: 3, + message: "Another error message", + messageDetails: "Message details", + }, + { + ruleId: "rule3", + severity: LintMessageSeverity.Error, + line: 11, + column: 2, + message: "Another error message", + messageDetails: "Message details", + }, + ], + coverageInfo: [], + errorCount: 4, + fatalErrorCount: 2, + warningCount: 1, + }, + // Add a file with empty messages to cover the skip condition + { + filePath: "webapp/Empty.js", + messages: [], + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + warningCount: 0, + }, + ]; +}); + +test("Default", (t) => { + const {lintResults} = t.context; + + const htmlFormatter = new Html(); + const htmlResult = htmlFormatter.format(lintResults, false, "1.2.3", false); + + t.snapshot(htmlResult); +}); + +test("Details", (t) => { + const {lintResults} = t.context; + + const htmlFormatter = new Html(); + const htmlResult = htmlFormatter.format(lintResults, true, "1.2.3", false); + + t.snapshot(htmlResult); +}); + +test("No findings", (t) => { + const htmlFormatter = new Html(); + const htmlResult = htmlFormatter.format([], true, "1.2.3", false); + + t.snapshot(htmlResult); +}); + +// Test for message with no messageDetails +test("Message with no details", (t) => { + const lintResults: LintResult[] = [ + { + filePath: "webapp/NoDetails.js", + messages: [ + { + ruleId: "rule1", + severity: LintMessageSeverity.Error, + line: 1, + column: 1, + message: "Error with no details", + // No messageDetails provided + }, + ], + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + warningCount: 0, + }, + ]; + + const htmlFormatter = new Html(); + const htmlResult = htmlFormatter.format(lintResults, true, "1.2.3", false); + + t.snapshot(htmlResult); + t.true(htmlResult.includes("Error with no details")); +}); + +// Test formatSeverity and getSeverityClass directly to cover all branches +test("Formatter helpers - all branches", (t) => { + const htmlFormatter = new Html(); + + // Test normal cases for formatSeverity + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatSeverity(LintMessageSeverity.Error, false), "Error"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatSeverity(LintMessageSeverity.Warning, false), "Warning"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatSeverity(LintMessageSeverity.Error, true), "Fatal Error"); + + // Test for error case in formatSeverity (invalid severity) + const error = t.throws(() => { + // @ts-expect-error Testing invalid severity and accessing private method + htmlFormatter.formatSeverity(999, false); + }, {instanceOf: Error}); + t.true(error.message.includes("Unknown severity")); + + // Test normal cases for getSeverityClass + // @ts-expect-error Accessing private method + t.is(htmlFormatter.getSeverityClass(LintMessageSeverity.Error, false), "error"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.getSeverityClass(LintMessageSeverity.Warning, false), "warning"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.getSeverityClass(LintMessageSeverity.Error, true), "fatal-error"); + + // Test default case for getSeverityClass (invalid severity) + // @ts-expect-error Testing invalid severity and accessing private method + t.is(htmlFormatter.getSeverityClass(999, false), ""); + + // Test formatLocation with undefined values + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatLocation(undefined, undefined), "0:0"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatLocation(10, undefined), "10:0"); + // @ts-expect-error Accessing private method + t.is(htmlFormatter.formatLocation(undefined, 20), "0:20"); +}); + +// Test formatMessageDetails directly to cover regex replacement +test("Formatter messageDetails with whitespace", (t) => { + const htmlFormatter = new Html(); + + // Create a message with extra whitespace and newlines in messageDetails + const lintResults: LintResult[] = [ + { + filePath: "webapp/WhitespaceDetails.js", + messages: [ + { + ruleId: "rule1", + severity: LintMessageSeverity.Error, + line: 1, + column: 1, + message: "Error with whitespace in details", + messageDetails: "This has multiple spaces\nand newlines\r\nand\ttabs", + }, + ], + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + warningCount: 0, + }, + ]; + + const htmlResult = htmlFormatter.format(lintResults, true, "1.2.3", false); + + // Check the HTML content directly instead of the normalized text + // @ts-expect-error Accessing private method + const formattedDetails = htmlFormatter.formatMessageDetails(lintResults[0].messages[0]); + // Ensure we're testing exactly the expected output after whitespace normalization + t.is(formattedDetails, "This has multiple spaces and newlines and tabs"); + t.true(htmlResult.includes(formattedDetails)); + + // Also directly test the private method for messageDetails + // @ts-expect-error Accessing private method + const testDetails = htmlFormatter.formatMessageDetails({ + messageDetails: "Multiple spaces\nand\nnewlines", + ruleId: "test-rule", + severity: LintMessageSeverity.Error, + message: "Test message", + line: 1, + column: 1, + fatal: false, + }); + + t.is(testDetails, "Multiple spaces and newlines"); +}); + +// Test URL detection in messageDetails +test("URL detection in message details", (t) => { + const htmlFormatter = new Html(); + + // Test various URL formats + const testCases = [ + { + input: "Check https://example.com/api for details", + expected: "Check https://example.com/api for details", + }, + { + input: "Documentation at (https://ui5.sap.com/api/)", + expected: "Documentation at (https://ui5.sap.com/api/)", + }, + { + input: "See www.example.org for more information", + expected: "See www.example.org for more information", + }, + { + input: "UI5 docs ui5.sap.com/topic/documentation", + expected: "UI5 docs ui5.sap.com/topic/documentation", + }, + { + input: "No URLs in this text", + expected: "No URLs in this text", + }, + ]; + + // Test each case with the formatter + testCases.forEach(({input, expected}) => { + // @ts-expect-error Accessing private method + const result = htmlFormatter.formatMessageDetails({ + messageDetails: input, + ruleId: "test-rule", + severity: LintMessageSeverity.Error, + message: "Test message", + line: 1, + column: 1, + }); + + t.is(result, expected, `Failed to format URL in: ${input}`); + }); + + // Also verify the URLs work in the complete HTML output + const lintResults: LintResult[] = [ + { + filePath: "webapp/UrlsInDetails.js", + messages: [ + { + ruleId: "rule1", + severity: LintMessageSeverity.Error, + line: 1, + column: 1, + message: "Error with URL in details", + messageDetails: "See https://ui5.sap.com and www.example.com", + }, + ], + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + warningCount: 0, + }, + ]; + + const htmlResult = htmlFormatter.format(lintResults, true, "1.2.3", false); + + // Make sure both URLs were converted to links in the HTML + t.true(htmlResult.includes("https://ui5.sap.com")); + t.true(htmlResult.includes("www.example.com")); +}); + +// Test with undefined messageDetails +test("Formatter messageDetails with undefined", (t) => { + const htmlFormatter = new Html(); + + // @ts-expect-error Accessing private method + const result = htmlFormatter.formatMessageDetails({ + ruleId: "test-rule", + severity: LintMessageSeverity.Error, + message: "Test message", + line: 1, + column: 1, + }); + + t.is(result, "", "Should return empty string for undefined messageDetails"); +}); From 9c00d8d224830b3125c0a2a465c2da3022fa1ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Emarianfoo=E2=80=9C?= <13335743+marianfoo@users.noreply.github.com> Date: Mon, 14 Apr 2025 22:21:42 +0200 Subject: [PATCH 2/2] test: Add snapshots --- test/lib/formatter/snapshots/html.ts.md | 577 ++++++++++++++++++++++ test/lib/formatter/snapshots/html.ts.snap | Bin 0 -> 1775 bytes 2 files changed, 577 insertions(+) create mode 100644 test/lib/formatter/snapshots/html.ts.md create mode 100644 test/lib/formatter/snapshots/html.ts.snap diff --git a/test/lib/formatter/snapshots/html.ts.md b/test/lib/formatter/snapshots/html.ts.md new file mode 100644 index 000000000..f3598bfa5 --- /dev/null +++ b/test/lib/formatter/snapshots/html.ts.md @@ -0,0 +1,577 @@ +# Snapshot report for `test/lib/formatter/html.ts` + +The actual snapshot is saved in `html.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## Default + +> Snapshot 1 + + `␊ + ␊ + ␊ + ␊ + ␊ + UI5 Linter Report␊ + ␊ + ␊ + ␊ +

UI5 Linter Report

␊ +

Generated on 4/14/2025, 10:20:15 PM with UI5 Linter v1.2.3

␊ + ␊ +
␊ +

Summary

␊ +

␊ + 7 problems ␊ + (5 errors, 2 warnings)␊ +

␊ +

2 fatal errors

␊ +

Run ui5lint --fix to resolve all auto-fixable problems

␊ +
␊ + ␊ +

Findings

␊ +

webapp/Component.js

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
SeverityRuleLocationMessage
Errorrule11:1Error message
Warningrule22:2Warning message
␊ +

webapp/Main.controller.js

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
SeverityRuleLocationMessage
Fatal Errorrule33:6Another error message
Fatal Errorrule312:3Another error message
Errorrule311:2Another error message
Errorrule311:3Another error message
Warningrule312:3Another error message
␊ + ␊ +
Note: Use ui5lint --details to show more information about the findings.
␊ + ␊ + ` + +## Details + +> Snapshot 1 + + `␊ + ␊ + ␊ + ␊ + ␊ + UI5 Linter Report␊ + ␊ + ␊ + ␊ +

UI5 Linter Report

␊ +

Generated on 4/14/2025, 10:20:15 PM with UI5 Linter v1.2.3

␊ + ␊ +
␊ +

Summary

␊ +

␊ + 7 problems ␊ + (5 errors, 2 warnings)␊ +

␊ +

2 fatal errors

␊ +

Run ui5lint --fix to resolve all auto-fixable problems

␊ +
␊ + ␊ +

Findings

␊ +

webapp/Component.js

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
SeverityRuleLocationMessageDetails
Errorrule11:1Error messageMessage details
Warningrule22:2Warning messageMessage details
␊ +

webapp/Main.controller.js

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
SeverityRuleLocationMessageDetails
Fatal Errorrule33:6Another error messageMessage details
Fatal Errorrule312:3Another error messageMessage details
Errorrule311:2Another error messageMessage details
Errorrule311:3Another error messageMessage details
Warningrule312:3Another error messageMessage details
␊ + ␊ + ␊ + ␊ + ` + +## No findings + +> Snapshot 1 + + `␊ + ␊ + ␊ + ␊ + ␊ + UI5 Linter Report␊ + ␊ + ␊ + ␊ +

UI5 Linter Report

␊ +

Generated on 4/14/2025, 10:20:15 PM with UI5 Linter v1.2.3

␊ + ␊ +
␊ +

Summary

␊ +

␊ + 0 problems ␊ + (0 errors, 0 warnings)␊ +

␊ + ␊ + ␊ +
␊ + ␊ +

No issues found. Your code looks great!

␊ + ␊ + ␊ + ␊ + ` + +## Message with no details + +> Snapshot 1 + + `␊ + ␊ + ␊ + ␊ + ␊ + UI5 Linter Report␊ + ␊ + ␊ + ␊ +

UI5 Linter Report

␊ +

Generated on 4/14/2025, 10:20:15 PM with UI5 Linter v1.2.3

␊ + ␊ +
␊ +

Summary

␊ +

␊ + 1 problems ␊ + (1 errors, 0 warnings)␊ +

␊ + ␊ +

Run ui5lint --fix to resolve all auto-fixable problems

␊ +
␊ + ␊ +

Findings

␊ +

webapp/NoDetails.js

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
SeverityRuleLocationMessageDetails
Errorrule11:1Error with no details
␊ + ␊ + ␊ + ␊ + ` diff --git a/test/lib/formatter/snapshots/html.ts.snap b/test/lib/formatter/snapshots/html.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..7546cab2385059cca1c151751af124a375052db3 GIT binary patch literal 1775 zcmV*Cq zLgA}omi)P#XgEX640l&8d{Lk;lDtR^`qZBD*rzD^wHyAO&*6|ym@%^!V*lXK4$?&K*r8vul z)IEp<*U0rzZ$@LC9>qAN5!&XZ50q0)3A0s17#?{8*Q{eKA6`PDn}0Igsa^??EV?q6g76%UkO4dy4u|FY+a1eFy*{LVbpT@_W0dw-Xf3h;ynGHyFdD-XBp6|`xN1Af z9SkvBTHW9CzxJ`+U|22QTH~#mY~IEsx|~Q+@Yrq^N)D34ggjd?O^e)Sm}py)m=-F4 zy?U*Mepw#Z4FhfhwSt@r-rBFpm|+E>Id#|l{o<@hFh+8P0W?nw@Y*$7C)~=C74wZD zX&-c4S)y^SZNlh;2T-2JvcC`WIe7l^$!v9MwR{}}Tm4{iWL8Jyf$O%jHS!CsxQE5SCbLuRQEo*$nGF6=X$6pY9| zJQrLEMpPemU(zv3qN(6ej@j+Q334X-kO?kSPNFu62CB$1BI`oCk{LtyI>-kJZL)3K zC@%E!C@u1Z6kD$~x2i6yHN%{PqJP5xyoE$0%Z_q~2M6)Nvojd^}ZSYhYVv%;LC+_|McK6pMTZV>gL+ofcCA~;<|8IF;zJuR{@y2-HfqH zV-hB2_B^=0Gu~FahfaMzhE8>5hvk0dy_tHOuGQ2l`|{DBke3mGF$fNOj<@IduD{<0 z&kcMx@b=;L3CyTYVPTD_H}D6;(8)(T){Yg%bP5q8N*(p;peb*3%L-F}bY3-tPU=@} zx%sf3LN3LaVWz4J_{)7T38wnsL!IyHD>K8%o0*}~Qt-*h2a}TZj1swNUKn{+a0ts} z8x?e)QLe$Z6FLta(*|0AL?zf1fiPyyvg|g;-`G{J7CJGVRyb3CbWC~aIA!oG0xy%G zLT!cVa5Td)$#dtq$a2Av>%kA@lwm!<__=zvLR~Yg`b{>EWzXmwrzoku3LTv;b(|Fp z*BW1n$i(Ja#|bJ$Cd(6=9i4W|visVWP@4b>wQMk&KRjsy$=ePoiNi0Go3Kg2{WBA(+M-3Fw+S$|3AV^+5Vzopx5mWTd2`-a~9CD z?}sGdY{mCrg`Hm>%J)_hxtq{)%kaLpXz%#l{FZR##C-ZbNPbc$qGEXP-bTZEelX=`#na6Eq9(S|M zQ~xi>F8SzG09C3$1xXnw19&3}2`0&y5SI!j5{dqzvGfMcG;8RW!O2BK!`AuJXTMwG zPu6E0f9m*C$Dca>)bXc|KXv@6<4+%qKY6SC$-51Ix<5aFor+qQe~`Z#d>lW3t