diff --git a/.stylelintrc.js b/.stylelintrc.js
index 03e6d25f..ef7353b7 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -1 +1 @@
-module.exports = require('@silverstripe/eslint-config/.stylelintrc');
\ No newline at end of file
+module.exports = require('@silverstripe/eslint-config/.stylelintrc');
diff --git a/README.md b/README.md
index bfae3ef3..8979980b 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Silverstripe backend.
There are also a few CMS reports that comes out of the box:
- A "Users, Groups and Permissions" report allowing administrators to get a quick overview of who has access to the CMS.
- A "Site-wide content report" report allowing CMS users to get a quick overview of content across the site.
+- An "External broken links report" allowing users with permissions to track broken external links.
## Troubleshooting
@@ -35,7 +36,9 @@ SilverStripe\Reports\Report:
```
Note that some reports may have overridden the `getCount` method, and for those reports this may not apply.
-## Customising the "Site-wide content report"
+## Site-wide content report
+
+### Customising the report
In order to customise the columns included in a report you can build a custom extension and apply it to the
SitewideContentReport as necessary.
@@ -78,9 +81,9 @@ the following key => value pairs:
* `casting`: Specify a field type (e.g. `Text` or `Int`) in order to assist with field casting. This is not
necessary if `formatting` is used.
-## Performance considerations
+### Performance considerations
-### Large data sets
+#### Large data sets
If your project has a large number of pages or files, you may experience server timeouts while trying to export
this report to CSV. To avoid this issue, you can either increase your server timeout limit, or you can install
@@ -114,6 +117,102 @@ SilverStripe\Reports\SitewideContentReport\SitewideContentReport:
- SitewideContentReportQueuedExportExtension
```
+## External broken links report
+
+### Features
+
+* Add external links to broken links reports
+* Add a task to track external broken links
+
+### Report
+
+A new report is added called 'External Broken links report'. When viewing this report, a user may press
+the "Create new report" button which will trigger an ajax request to initiate a report run.
+
+In this initial ajax request this module will do one of two things, depending on which modules are included:
+
+* If the queuedjobs module is installed, a new queued job will be initiated. The queuedjobs module will then
+ manage the progress of the task.
+* If the queuedjobs module is absent, then the controller will fallback to running a buildtask in the background.
+ This is less robust, as a failure or error during this process will abort the run.
+
+In either case, the background task will loop over every page in the system, inspecting all external urls and
+checking the status code returned by requesting each one. If a URL returns a response code that is considered
+"broken" (defined as < 200 or > 302) then the `ss-broken` css class will be assigned to that url, and
+a line item will be added to the report. If a previously broken link has been corrected or fixed, then
+this class is removed.
+
+In the actual report generated the user can click on any broken link item to either view the link in their browser,
+or edit the containing page in the CMS.
+
+While a report is running the current status of this report will be displayed on the report details page, along
+with the status. The user may leave this page and return to it later to view the ongoing status of this report.
+
+Any subsequent report may not be generated until a prior report has completed.
+
+### Dev task
+
+Run `sake tasks:CheckExternalLinksTask` to check your site for external
+broken links.
+
+### Queued job
+
+If you have the queuedjobs module installed you can set the task to be run every so often.
+
+### Whitelisting codes
+
+If you want to ignore or whitelist certain HTTP codes this can be setup via `ignore_codes` in the config.yml
+file in `mysite/_config`:
+
+```yml
+SilverStripe\Reports\ExternalLinks\Tasks\CheckExternalLinksTask:
+ ignore_codes:
+ - 401
+ - 403
+ - 501
+```
+
+### Follow 301 redirects
+
+You may want to follow a redirected URL a example of this would be redirecting from http to https
+can give you a false poitive as the http code of 301 will be returned which will be classed
+as a working link.
+
+To allow redirects to be followed setup the following config in your config.yml
+
+```yaml
+# Follow 301 redirects
+SilverStripe\Reports\ExternalLinks\Tasks\CurlLinkChecker:
+ follow_location: 1
+```
+
+### Bypass cache
+
+By default the task will attempt to cache any results the cache can be bypassed with the
+following config in config.yml.
+
+```yaml
+# Bypass SS_Cache
+SilverStripe\Reports\ExternalLinks\Tasks\CurlLinkChecker::
+ bypass_cache: 1
+```
+
+### Headers
+
+You may want to set headers to be sent with the CURL request (eg: user-agent) to avoid website rejecting the request thinking it is a bot.
+You can set them with the following config in config.yml.
+
+```yaml
+# Headers
+SilverStripe\Reports\ExternalLinks\Tasks\CurlLinkChecker:
+ headers:
+ - 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0'
+ - 'accept-encoding: gzip, deflate, br'
+ - 'referer: https://www.domain.com/'
+ - 'sec-fetch-mode: navigate'
+ ...
+```
+
## Links ##
* [License](./LICENSE)
diff --git a/_config/config.yml b/_config/config.yml
deleted file mode 100644
index e69de29b..00000000
diff --git a/_config/externallinks.yml b/_config/externallinks.yml
new file mode 100644
index 00000000..05f13efe
--- /dev/null
+++ b/_config/externallinks.yml
@@ -0,0 +1,9 @@
+---
+Name: reports-externallinksdependencies
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\Reports\ExternalLinks\Tasks\LinkChecker: SilverStripe\Reports\ExternalLinks\Tasks\CurlLinkChecker
+ Psr\SimpleCache\CacheInterface.CurlLinkChecker:
+ factory: SilverStripe\Core\Cache\CacheFactory
+ constructor:
+ namespace: 'curllinkchecker'
diff --git a/client/dist/js/BrokenExternalLinksReport.js b/client/dist/js/BrokenExternalLinksReport.js
new file mode 100644
index 00000000..8fc25d5c
--- /dev/null
+++ b/client/dist/js/BrokenExternalLinksReport.js
@@ -0,0 +1 @@
+!function(){"use strict";var t={669:function(t){t.exports=jQuery}},e={};var n=function n(o){var r=e[o];if(void 0!==r)return r.exports;var s=e[o]={exports:{}};return t[o](s,s.exports,n),s.exports}(669);n.entwine("ss",(t=>{t(".external-links-report__create-report").entwine({PollTimeout:null,ButtonIsLoading:!1,ReloadContent:!1,onclick(t){t.preventDefault(),this.buttonLoading(),this.start()},onmatch(){this.poll()},start(){const e=this;t(".external-links-report__report-progress").empty().text("Running report 0%"),t.ajax({url:"admin/externallinks/start",async:!0,timeout:3e3,success(){e.setReloadContent(!0),e.poll()},error(){e.buttonReset()}})},getButton(){return t(".external-links-report__create-report")},buttonLoading(){if(this.getButtonIsLoading())return;this.setButtonIsLoading(!0);const e=this.getButton();e.addClass("btn--loading loading"),e.attr("disabled",!0),e.is("button")&&(e.append(t('
')),e.css(`${e.outerWidth()}px`))},buttonReset(){this.setButtonIsLoading(!1);const t=this.getButton();t.removeClass("btn--loading loading"),t.attr("disabled",!1),t.find(".btn__loading-icon").remove(),t.css("width","auto")},poll(){const e=this;this.buttonLoading(),t.ajax({url:"admin/externallinks/getJobStatus",async:!0,success(n){if(!n||"object"==typeof n&&n.length<1)return void e.buttonReset();const o=n.Completed?n.Completed:0,r=n.Total?n.Total:0;if("Completed"===n.Status)return e.getReloadContent()&&(t(".cms-container").loadPanel(document.location.href,null,{},!0,!1),e.setReloadContent(!1)),t(".external-links-report__report-progress").text(`Report finished ${o}/${r}`),void e.buttonReset();if(o{t(".external-links-report__create-report").poll()}),1e3))},error(){e.buttonReset()}})}})}))}();
\ No newline at end of file
diff --git a/client/dist/styles/BrokenExternalLinksReport.css b/client/dist/styles/BrokenExternalLinksReport.css
new file mode 100644
index 00000000..fb190497
--- /dev/null
+++ b/client/dist/styles/BrokenExternalLinksReport.css
@@ -0,0 +1 @@
+.external-links-report__create-report,.external-links-report__report-progress{margin-top:20px}
diff --git a/client/src/js/BrokenExternalLinksReport.js b/client/src/js/BrokenExternalLinksReport.js
new file mode 100644
index 00000000..0f25afc7
--- /dev/null
+++ b/client/src/js/BrokenExternalLinksReport.js
@@ -0,0 +1,148 @@
+/* global jQuery */
+(function ($) {
+ // eslint-disable-next-line no-shadow
+ $.entwine('ss', ($) => {
+ $('.external-links-report__create-report').entwine({
+ PollTimeout: null,
+ ButtonIsLoading: false,
+ ReloadContent: false,
+
+ onclick(e) {
+ e.preventDefault();
+
+ this.buttonLoading();
+ this.start();
+ },
+
+ onmatch() {
+ // poll the current job and update the front end status
+ this.poll();
+ },
+
+ start() {
+ const self = this;
+ // initiate a new job
+ $('.external-links-report__report-progress')
+ .empty()
+ .text('Running report 0%');
+
+ $.ajax({
+ url: 'admin/externallinks/start',
+ async: true,
+ timeout: 3000,
+ success() {
+ self.setReloadContent(true);
+ self.poll();
+ },
+ error() {
+ self.buttonReset();
+ }
+ });
+ },
+
+ /**
+ * Get the "create report" button selector
+ *
+ * @return {Object}
+ */
+ getButton() {
+ return $('.external-links-report__create-report');
+ },
+
+ /**
+ * Sets the button into a loading state. See LeftAndMain.js.
+ */
+ buttonLoading() {
+ if (this.getButtonIsLoading()) {
+ return;
+ }
+ this.setButtonIsLoading(true);
+
+ const $button = this.getButton();
+
+ // set button to "submitting" state
+ $button.addClass('btn--loading loading');
+ $button.attr('disabled', true);
+
+ if ($button.is('button')) {
+ $button.append($(
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'));
+
+ $button.css(`${$button.outerWidth()}px`);
+ }
+ },
+
+ /**
+ * Reset the button back to its original state after loading. See LeftAndMain.js.
+ */
+ buttonReset() {
+ this.setButtonIsLoading(false);
+
+ const $button = this.getButton();
+
+ $button.removeClass('btn--loading loading');
+ $button.attr('disabled', false);
+ $button.find('.btn__loading-icon').remove();
+ $button.css('width', 'auto');
+ },
+
+ poll() {
+ const self = this;
+ this.buttonLoading();
+
+ $.ajax({
+ url: 'admin/externallinks/getJobStatus',
+ async: true,
+ success(data) {
+ // No report, so let user create one
+ if (!data || (typeof data === 'object' && data.length < 1)) {
+ self.buttonReset();
+ return;
+ }
+
+ // Parse data
+ const completed = data.Completed ? data.Completed : 0;
+ const total = data.Total ? data.Total : 0;
+
+ // If complete status
+ if (data.Status === 'Completed') {
+ if (self.getReloadContent()) {
+ $('.cms-container').loadPanel(document.location.href, null, {}, true, false);
+ self.setReloadContent(false);
+ }
+ $('.external-links-report__report-progress')
+ .text(`Report finished ${completed}/${total}`);
+
+ self.buttonReset();
+ return;
+ }
+
+ // If incomplete update status
+ if (completed < total) {
+ const percent = (completed / total) * 100;
+ $('.external-links-report__report-progress')
+ .text(`Running report ${completed}/${total} (${percent.toFixed(2)}%)`);
+ }
+
+ // Ensure the regular poll method is run
+ // kill any existing timeout
+ if (self.getPollTimeout() !== null) {
+ clearTimeout(self.getPollTimeout());
+ }
+
+ self.setPollTimeout(setTimeout(() => {
+ $('.external-links-report__create-report').poll();
+ }, 1000));
+ },
+ error() {
+ self.buttonReset();
+ }
+ });
+ }
+ });
+ });
+}(jQuery));
diff --git a/client/src/styles/BrokenExternalLinksReport.scss b/client/src/styles/BrokenExternalLinksReport.scss
new file mode 100644
index 00000000..166abe11
--- /dev/null
+++ b/client/src/styles/BrokenExternalLinksReport.scss
@@ -0,0 +1,4 @@
+.external-links-report__create-report,
+.external-links-report__report-progress {
+ margin-top: 20px;
+}
diff --git a/code/ExternalLinks/Controllers/CMSExternalLinksController.php b/code/ExternalLinks/Controllers/CMSExternalLinksController.php
new file mode 100644
index 00000000..b043879b
--- /dev/null
+++ b/code/ExternalLinks/Controllers/CMSExternalLinksController.php
@@ -0,0 +1,67 @@
+getResponse()->addHeader('X-Content-Type-Options', 'nosniff');
+ $track = BrokenExternalPageTrackStatus::get_latest();
+ if ($track) {
+ return $this->jsonSuccess(200, [
+ 'TrackID' => $track->ID,
+ 'Status' => $track->Status,
+ 'Completed' => $track->getCompletedPages(),
+ 'Total' => $track->getTotalPages()
+ ]);
+ }
+ return $this->jsonSuccess(200, []);
+ }
+
+ /**
+ * Starts a broken external link check
+ */
+ public function start(): HTTPResponse
+ {
+ // return if the a job is already running
+ $status = BrokenExternalPageTrackStatus::get_latest();
+ if ($status && $status->Status == 'Running') {
+ return $this->jsonSuccess(200, []);
+ }
+
+ // Create a new job
+ if (class_exists(QueuedJobService::class)) {
+ // Force the creation of a new run
+ BrokenExternalPageTrackStatus::create_status();
+ $checkLinks = new CheckExternalLinksJob();
+ singleton(QueuedJobService::class)->queueJob($checkLinks);
+ } else {
+ $task = CheckExternalLinksTask::create();
+ $task->runLinksCheck(PolyOutput::create(PolyOutput::FORMAT_HTML));
+ }
+ return $this->jsonSuccess(200, []);
+ }
+}
diff --git a/code/ExternalLinks/Jobs/CheckExternalLinksJob.php b/code/ExternalLinks/Jobs/CheckExternalLinksJob.php
new file mode 100644
index 00000000..fc47ad68
--- /dev/null
+++ b/code/ExternalLinks/Jobs/CheckExternalLinksJob.php
@@ -0,0 +1,46 @@
+runLinksCheck(PolyOutput::create(PolyOutput::FORMAT_ANSI), 1);
+ $this->currentStep = $track->CompletedPages;
+ $this->totalSteps = $track->TotalPages;
+ $this->isComplete = $track->Status === 'Completed';
+ }
+}
diff --git a/code/ExternalLinks/Model/BrokenExternalLink.php b/code/ExternalLinks/Model/BrokenExternalLink.php
new file mode 100644
index 00000000..fa4ef8c7
--- /dev/null
+++ b/code/ExternalLinks/Model/BrokenExternalLink.php
@@ -0,0 +1,86 @@
+ 'Varchar(2083)', // 2083 is the maximum length of a URL in Internet Explorer.
+ 'HTTPCode' => 'Int'
+ );
+
+ private static $has_one = array(
+ 'Track' => BrokenExternalPageTrack::class,
+ 'Status' => BrokenExternalPageTrackStatus::class
+ );
+
+ private static $summary_fields = array(
+ 'Created' => 'Checked',
+ 'Link' => 'External Link',
+ 'HTTPCodeDescription' => 'HTTP Error Code',
+ 'Page.Title' => 'Page link is on'
+ );
+
+ private static $searchable_fields = array(
+ 'HTTPCode' => array('title' => 'HTTP Code')
+ );
+
+ /**
+ * @return SiteTree
+ */
+ public function Page()
+ {
+ return $this->Track()->Page();
+ }
+
+ public function canEdit($member = false)
+ {
+ return false;
+ }
+
+ public function canView($member = false)
+ {
+ $member = $member ? $member : Security::getCurrentUser();
+ $codes = ['CMS_ACCESS_CMSMain'];
+ return Permission::checkMember($member, $codes);
+ }
+
+ /**
+ * Retrieve a human readable description of a response code
+ *
+ * @return string
+ */
+ public function getHTTPCodeDescription()
+ {
+ $code = $this->HTTPCode;
+
+ try {
+ $response = HTTPResponse::create('', $code);
+ // Assume that $code = 0 means there was no response
+ $description = $code ?
+ $response->getStatusDescription() :
+ _t(__CLASS__ . '.NOTAVAILABLE', 'Server Not Available');
+ } catch (InvalidArgumentException $e) {
+ $description = _t(__CLASS__ . '.UNKNOWNRESPONSE', 'Unknown Response Code');
+ }
+
+ return sprintf("%d (%s)", $code, $description);
+ }
+}
diff --git a/code/ExternalLinks/Model/BrokenExternalPageTrack.php b/code/ExternalLinks/Model/BrokenExternalPageTrack.php
new file mode 100644
index 00000000..215667ff
--- /dev/null
+++ b/code/ExternalLinks/Model/BrokenExternalPageTrack.php
@@ -0,0 +1,42 @@
+ BrokenLinks()
+ * @method BrokenExternalPageTrackStatus Status()
+ */
+class BrokenExternalPageTrack extends DataObject
+{
+ private static $table_name = 'BrokenExternalPageTrack';
+
+ private static $db = array(
+ 'Processed' => 'Boolean'
+ );
+
+ private static $has_one = array(
+ 'Page' => SiteTree::class,
+ 'Status' => BrokenExternalPageTrackStatus::class
+ );
+
+ private static $has_many = array(
+ 'BrokenLinks' => BrokenExternalLink::class
+ );
+
+ /**
+ * @return SiteTree
+ */
+ public function Page()
+ {
+ return Versioned::get_by_stage(SiteTree::class, 'Stage')
+ ->byID($this->PageID);
+ }
+}
diff --git a/code/ExternalLinks/Model/BrokenExternalPageTrackStatus.php b/code/ExternalLinks/Model/BrokenExternalPageTrackStatus.php
new file mode 100644
index 00000000..39d4d1b8
--- /dev/null
+++ b/code/ExternalLinks/Model/BrokenExternalPageTrackStatus.php
@@ -0,0 +1,170 @@
+ BrokenLinks()
+ * @method HasManyList TrackedPages()
+ */
+class BrokenExternalPageTrackStatus extends DataObject implements i18nEntityProvider
+{
+ private static $table_name = 'BrokenExternalPageTrackStatus';
+
+ private static $db = array(
+ 'Status' => 'Enum("Completed, Running", "Running")',
+ 'JobInfo' => 'Varchar(255)'
+ );
+
+ private static $has_many = array(
+ 'TrackedPages' => BrokenExternalPageTrack::class,
+ 'BrokenLinks' => BrokenExternalLink::class
+ );
+
+ /**
+ * Get the latest track status
+ *
+ * @return BrokenExternalPageTrackStatus
+ */
+ public static function get_latest()
+ {
+ return BrokenExternalPageTrackStatus::get()
+ ->sort('ID', 'DESC')
+ ->first();
+ }
+
+ /**
+ * Returns the list of provided translations for this object
+ *
+ * @return array
+ */
+ public function provideI18nEntities()
+ {
+ return [
+ __CLASS__ . '.SINGULARNAME' => 'Broken External Page Track Status',
+ __CLASS__ . '.PLURALNAME' => 'Broken External Page Track Statuses',
+ __CLASS__ . '.PLURALS' => [
+ 'one' => 'A Broken External Page Track Status',
+ 'other' => '{count} Broken External Page Track Statuses',
+ ],
+ ];
+ }
+
+ /**
+ * Gets the list of Pages yet to be checked
+ *
+ * @return DataList|void
+ */
+ public function getIncompletePageList()
+ {
+ $pageIDs = $this
+ ->getIncompleteTracks()
+ ->column('PageID');
+ if ($pageIDs) {
+ return Versioned::get_by_stage(SiteTree::class, 'Stage')
+ ->byIDs($pageIDs);
+ }
+ }
+
+ /**
+ * Get the list of incomplete BrokenExternalPageTrack
+ *
+ * @return DataList
+ */
+ public function getIncompleteTracks()
+ {
+ return $this
+ ->TrackedPages()
+ ->filter('Processed', 0);
+ }
+
+ /**
+ * Get total pages count
+ *
+ * @return int
+ */
+ public function getTotalPages()
+ {
+ return $this->TrackedPages()->count();
+ }
+
+ /**
+ * Get completed pages count
+ *
+ * @return int
+ */
+ public function getCompletedPages()
+ {
+ return $this
+ ->TrackedPages()
+ ->filter('Processed', 1)
+ ->count();
+ }
+
+ /**
+ * Returns the latest run, or otherwise creates a new one
+ *
+ * @return BrokenExternalPageTrackStatus
+ */
+ public static function get_or_create()
+ {
+ // Check the current status
+ $status = BrokenExternalPageTrackStatus::get_latest();
+ if ($status && $status->Status == 'Running') {
+ $status->updateStatus();
+ return $status;
+ }
+
+ return BrokenExternalPageTrackStatus::create_status();
+ }
+
+ /**
+ * Create and prepare a new status
+ *
+ * @return BrokenExternalPageTrackStatus
+ */
+ public static function create_status()
+ {
+ // If the script is to be started create a new status
+ $status = BrokenExternalPageTrackStatus::create();
+ $status->updateJobInfo('Creating new tracking object');
+
+ // Setup all pages to test
+ $pageIDs = Versioned::get_by_stage(SiteTree::class, 'Stage')
+ ->column('ID');
+ foreach ($pageIDs as $pageID) {
+ $trackPage = BrokenExternalPageTrack::create();
+ $trackPage->PageID = $pageID;
+ $trackPage->StatusID = $status->ID;
+ $trackPage->write();
+ }
+
+ return $status;
+ }
+
+ public function updateJobInfo($message)
+ {
+ $this->JobInfo = $message;
+ $this->write();
+ }
+
+ /**
+ * Self check status
+ */
+ public function updateStatus()
+ {
+ if ($this->CompletedPages == $this->TotalPages) {
+ $this->Status = 'Completed';
+ $this->updateJobInfo('Setting to completed');
+ }
+ }
+}
diff --git a/code/ExternalLinks/Reports/BrokenExternalLinksReport.php b/code/ExternalLinks/Reports/BrokenExternalLinksReport.php
new file mode 100644
index 00000000..0275ff69
--- /dev/null
+++ b/code/ExternalLinks/Reports/BrokenExternalLinksReport.php
@@ -0,0 +1,97 @@
+ "Checked",
+ 'Link' => array(
+ 'title' => 'External Link',
+ 'formatting' => function ($value, $item) {
+ return sprintf(
+ '%s',
+ Convert::raw2att($item->Link),
+ Convert::raw2xml($item->Link)
+ );
+ }
+ ),
+ 'HTTPCodeDescription' => 'HTTP Error Code',
+ "Title" => array(
+ "title" => 'Page link is on',
+ 'formatting' => function ($value, $item) {
+ $page = $item->Page();
+ return sprintf(
+ '%s',
+ Convert::raw2att($page->CMSEditLink()),
+ Convert::raw2xml($page->Title)
+ );
+ }
+ )
+ );
+ }
+
+ /**
+ * Alias of columns(), to support the export to csv action
+ * in {@link GridFieldExportButton} generateExportFileData method.
+ * @return array
+ */
+ public function getColumns()
+ {
+ return $this->columns();
+ }
+
+ public function sourceRecords()
+ {
+ $track = BrokenExternalPageTrackStatus::get_latest();
+ if ($track) {
+ // Filter items that are attached to archived Pages
+ return $track->BrokenLinks()->exclude('Track.Page.ID', null);
+ }
+ return ArrayList::create();
+ }
+
+ public function getCMSFields()
+ {
+ Requirements::css('silverstripe/reports: client/dist/styles/BrokenExternalLinksReport.css');
+ Requirements::javascript('silverstripe/reports: client/dist/js/BrokenExternalLinksReport.js');
+
+ $fields = parent::getCMSFields();
+
+ $runReportButton = FormAction::create('createReport', _t(__CLASS__ . '.RUNREPORT', 'Create new report'))
+ ->addExtraClass('btn-primary external-links-report__create-report')
+ ->setUseButtonTag(true);
+ $fields->push($runReportButton);
+
+ $reportResultSpan = '';
+ $reportResult = LiteralField::create('ResultTitle', $reportResultSpan);
+ $fields->push($reportResult);
+
+ return $fields;
+ }
+}
diff --git a/code/ExternalLinks/Tasks/CheckExternalLinksTask.php b/code/ExternalLinks/Tasks/CheckExternalLinksTask.php
new file mode 100644
index 00000000..f93027c1
--- /dev/null
+++ b/code/ExternalLinks/Tasks/CheckExternalLinksTask.php
@@ -0,0 +1,222 @@
+ '%$' . LinkChecker::class
+ ];
+
+ protected static string $commandName = 'CheckExternalLinksTask';
+
+ /**
+ * Define a list of HTTP response codes that should not be treated as "broken", where they usually
+ * might be.
+ *
+ * @config
+ * @var array
+ */
+ private static $ignore_codes = [];
+
+ /**
+ * @var LinkChecker
+ */
+ protected $linkChecker;
+
+ protected string $title = 'Checking broken External links in the SiteTree';
+
+ protected static string $description = 'A task that records external broken links in the SiteTree';
+
+ protected function execute(InputInterface $input, PolyOutput $output): int
+ {
+ $this->runLinksCheck($output);
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @param LinkChecker $linkChecker
+ */
+ public function setLinkChecker(LinkChecker $linkChecker)
+ {
+ $this->linkChecker = $linkChecker;
+ }
+
+ /**
+ * @return LinkChecker
+ */
+ public function getLinkChecker()
+ {
+ return $this->linkChecker;
+ }
+
+ /**
+ * Check the status of a single link on a page
+ *
+ * @param BrokenExternalPageTrack $pageTrack
+ * @param DOMNode $link
+ */
+ protected function checkPageLink(BrokenExternalPageTrack $pageTrack, DOMNode $link)
+ {
+ $class = $link->getAttribute('class');
+ $href = $link->getAttribute('href');
+ $markedBroken = preg_match('/\b(ss-broken)\b/', $class ?? '');
+
+ // Check link
+ $httpCode = $this->linkChecker->checkLink($href);
+ if ($httpCode === null) {
+ return; // Null link means uncheckable, such as an internal link
+ }
+
+ // If this code is broken then mark as such
+ if ($foundBroken = $this->isCodeBroken($httpCode)) {
+ // Create broken record
+ $brokenLink = new BrokenExternalLink();
+ $brokenLink->Link = $href;
+ $brokenLink->HTTPCode = $httpCode;
+ $brokenLink->TrackID = $pageTrack->ID;
+ $brokenLink->StatusID = $pageTrack->StatusID; // Slight denormalisation here for performance reasons
+ $brokenLink->write();
+ }
+
+ // Check if we need to update CSS class, otherwise return
+ if ($markedBroken == $foundBroken) {
+ return;
+ }
+ if ($foundBroken) {
+ $class .= ' ss-broken';
+ } else {
+ $class = preg_replace('/\s*\b(ss-broken)\b\s*/', ' ', $class ?? '');
+ }
+ $link->setAttribute('class', trim($class ?? ''));
+ }
+
+ /**
+ * Determine if the given HTTP code is "broken"
+ *
+ * @param int $httpCode
+ * @return bool True if this is a broken code
+ */
+ protected function isCodeBroken($httpCode)
+ {
+ // Null represents no request attempted
+ if ($httpCode === null) {
+ return false;
+ }
+
+ // do we have any whitelisted codes
+ $ignoreCodes = $this->config()->get('ignore_codes');
+ if (is_array($ignoreCodes) && in_array($httpCode, $ignoreCodes ?? [])) {
+ return false;
+ }
+
+ // Check if code is outside valid range
+ return $httpCode < 200 || $httpCode > 302;
+ }
+
+ /**
+ * Runs the links checker and returns the track used
+ *
+ * @param int $limit Limit to number of pages to run, or null to run all
+ * @return BrokenExternalPageTrackStatus
+ */
+ public function runLinksCheck(PolyOutput $output, $limit = null)
+ {
+ // Check the current status
+ $status = BrokenExternalPageTrackStatus::get_or_create();
+
+ // Calculate pages to run
+ $pageTracks = $status->getIncompleteTracks();
+ if ($limit) {
+ $pageTracks = $pageTracks->limit($limit);
+ }
+
+ // Check each page
+ foreach ($pageTracks as $pageTrack) {
+ // Flag as complete
+ $pageTrack->Processed = 1;
+ $pageTrack->write();
+
+ // Check value of html area
+ $page = $pageTrack->Page();
+ $output->writeln("Checking {$page->Title}");
+ $htmlValue = Injector::inst()->create(HTMLValue::class, $page->Content);
+ if (!$htmlValue->isValid()) {
+ continue;
+ }
+
+ // Check each link
+ $links = $htmlValue->getElementsByTagName('a');
+ foreach ($links as $link) {
+ $this->checkPageLink($pageTrack, $link);
+ }
+
+ // Update content of page based on link fixes / breakages
+ $htmlValue->saveHTML();
+ $page->Content = $htmlValue->getContent();
+ try {
+ $page->write();
+ } catch (ValidationException $ex) {
+ $output->writeln("Exception caught for {$page->Title}, skipping. Message: " . $ex->getMessage());
+ continue;
+ }
+
+ // Once all links have been created for this page update HasBrokenLinks
+ $count = $pageTrack->BrokenLinks()->count();
+ $output->writeln("Found {$count} broken links");
+ if ($count) {
+ $siteTreeTable = DataObject::getSchema()->tableName(SiteTree::class);
+ // Bypass the ORM as syncLinkTracking does not allow you to update HasBrokenLink to true
+ DB::query(sprintf(
+ 'UPDATE "%s" SET "HasBrokenLink" = 1 WHERE "ID" = \'%d\'',
+ $siteTreeTable,
+ intval($pageTrack->ID)
+ ));
+ }
+ }
+
+ $status->updateJobInfo('Updating completed pages');
+ $status->updateStatus();
+ return $status;
+ }
+
+ private function updateCompletedPages($trackID = 0)
+ {
+ $noPages = BrokenExternalPageTrack::get()
+ ->filter(array(
+ 'TrackID' => $trackID,
+ 'Processed' => 1
+ ))
+ ->count();
+ $track = BrokenExternalPageTrackStatus::get_latest();
+ $track->CompletedPages = $noPages;
+ $track->write();
+ return $noPages;
+ }
+
+ private function updateJobInfo($message)
+ {
+ $track = BrokenExternalPageTrackStatus::get_latest();
+ if ($track) {
+ $track->JobInfo = $message;
+ $track->write();
+ }
+ }
+}
diff --git a/code/ExternalLinks/Tasks/CurlLinkChecker.php b/code/ExternalLinks/Tasks/CurlLinkChecker.php
new file mode 100644
index 00000000..49178149
--- /dev/null
+++ b/code/ExternalLinks/Tasks/CurlLinkChecker.php
@@ -0,0 +1,101 @@
+get(CacheInterface::class . '.CurlLinkChecker');
+ }
+
+ /**
+ * Determine the http status code for a given link
+ *
+ * @param string $href URL to check
+ * @return int HTTP status code, or null if not checkable (not a link)
+ */
+ public function checkLink($href)
+ {
+ // Skip non-external links
+ if (!preg_match('/^https?[^:]*:\/\//', $href ?? '')) {
+ return null;
+ }
+
+ $cacheKey = md5($href ?? '');
+ if (!$this->config()->get('bypass_cache')) {
+ // Check if we have a cached result
+ $result = $this->getCache()->get($cacheKey, false);
+ if ($result !== false) {
+ return $result;
+ }
+ }
+
+ // No cached result so just request
+ $handle = curl_init($href);
+ curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 5);
+ curl_setopt($handle, CURLOPT_TIMEOUT, 10);
+ if ($this->config()->get('follow_location')) {
+ curl_setopt($handle, CURLOPT_FOLLOWLOCATION, true);
+ }
+
+ // Add headers
+ $headers = (array) $this->config()->get('headers');
+ if (!empty($headers)) {
+ curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);
+ }
+
+ // Retrieve http code
+ curl_exec($handle);
+ $httpCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
+ curl_close($handle);
+
+ if (!$this->config()->get('bypass_cache')) {
+ // Cache result
+ $this->getCache()->set($cacheKey, $httpCode);
+ }
+
+ return $httpCode;
+ }
+}
diff --git a/code/ExternalLinks/Tasks/LinkChecker.php b/code/ExternalLinks/Tasks/LinkChecker.php
new file mode 100644
index 00000000..029fb5f1
--- /dev/null
+++ b/code/ExternalLinks/Tasks/LinkChecker.php
@@ -0,0 +1,17 @@
+
+
diff --git a/tests/behat/features/external-links.feature b/tests/behat/features/external-links.feature
new file mode 100644
index 00000000..104039dc
--- /dev/null
+++ b/tests/behat/features/external-links.feature
@@ -0,0 +1,34 @@
+Feature: External links report
+ As a website user
+ I want to use the external links report
+
+ Background:
+ Given the "group" "EDITOR group" has permissions "CMS_ACCESS_LeftAndMain"
+ # Need to use single quotes rather than escaped double quotes when defining the fixture otherwise
+ # it'll end up saved as " and the hyperlink will be wrong
+ # When the page is published it should be converted by tinymce to double quotes
+ Given a "page" "My page" has the "Content" "My link content
"
+
+ Scenario: Operate the external links report
+ Given I am logged in with "ADMIN" permissions
+
+ # Publish page
+ When I go to "/admin/pages"
+ And I follow "My page"
+ And I press the "Publish" button
+
+ # Run report
+ When I go to "/admin/reports"
+ And I follow "External broken links"
+ And I press the "Create new report" button
+
+ # Run queuedjob, new job will be the first row
+ When I go to "/admin/queuedjobs"
+ When I click on the ".gridfield-button-jobexecute" element
+ And I wait for 15 seconds
+
+ # Assert report
+ When I go to "/admin/reports"
+ And I follow "External broken links"
+ Then I should see "http://fsdjoifidsohfiohfsoifhiodshfhdosi.com"
+ And I should see "My page"
diff --git a/tests/php/ExternalLinks/ExternalLinksTest.php b/tests/php/ExternalLinks/ExternalLinksTest.php
new file mode 100644
index 00000000..d9058884
--- /dev/null
+++ b/tests/php/ExternalLinks/ExternalLinksTest.php
@@ -0,0 +1,150 @@
+registerService($checker, LinkChecker::class);
+ }
+
+ public function testLinks()
+ {
+ // Run link checker
+ $task = CheckExternalLinksTask::create();
+ $task->runLinksCheck(PolyOutput::create(PolyOutput::FORMAT_ANSI, PolyOutput::VERBOSITY_QUIET));
+
+ // Get all links checked
+ $status = BrokenExternalPageTrackStatus::get_latest();
+ $this->assertEquals('Completed', $status->Status);
+ $this->assertEquals(5, $status->TotalPages);
+ $this->assertEquals(5, $status->CompletedPages);
+
+ // Check all pages have had the correct HTML adjusted
+ for ($i = 1; $i <= 5; $i++) {
+ $page = $this->objFromFixture(ExternalLinksTestPage::class, 'page' . $i);
+ $this->assertNotEmpty($page->Content);
+ $this->assertEquals(
+ $page->ExpectedContent,
+ $page->Content,
+ "Assert that the content of page{$i} has been updated"
+ );
+ }
+
+ // Check that the correct report of broken links is generated
+ $links = $status
+ ->BrokenLinks()
+ ->sort('Link');
+
+ $this->assertEquals(4, $links->count());
+ $this->assertEquals(
+ array(
+ 'http://www.broken.com',
+ 'http://www.broken.com/url/thing',
+ 'http://www.broken.com/url/thing',
+ 'http://www.nodomain.com'
+ ),
+ array_values($links->map('ID', 'Link')->toArray() ?? [])
+ );
+
+ // Check response codes are correct
+ $expected = array(
+ 'http://www.broken.com' => 403,
+ 'http://www.broken.com/url/thing' => 404,
+ 'http://www.nodomain.com' => 0
+ );
+ $actual = $links->map('Link', 'HTTPCode')->toArray();
+ $this->assertEquals($expected, $actual);
+
+ // Check response descriptions are correct
+ i18n::set_locale('en_NZ');
+ $expected = array(
+ 'http://www.broken.com' => '403 (Forbidden)',
+ 'http://www.broken.com/url/thing' => '404 (Not Found)',
+ 'http://www.nodomain.com' => '0 (Server Not Available)'
+ );
+ $actual = $links->map('Link', 'HTTPCodeDescription')->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ /**
+ * Test that broken links appears in the reports list
+ */
+ public function testReportExists()
+ {
+ $reports = Report::get_reports();
+ $reportNames = array();
+ foreach ($reports as $report) {
+ $reportNames[] = get_class($report);
+ }
+ $this->assertContains(
+ BrokenExternalLinksReport::class,
+ $reportNames,
+ 'BrokenExternalLinksReport is in reports list'
+ );
+ }
+
+ public function testArchivedPagesAreHiddenFromReport()
+ {
+ // Run link checker
+ $task = CheckExternalLinksTask::create();
+ $task->runLinksCheck(PolyOutput::create(PolyOutput::FORMAT_ANSI, PolyOutput::VERBOSITY_QUIET));
+
+ // Ensure report lists all broken links
+ $this->assertEquals(4, BrokenExternalLinksReport::create()->sourceRecords()->count());
+
+ // Archive a page
+ $page = $this->objFromFixture(ExternalLinksTestPage::class, 'page1');
+ $page->doArchive();
+
+ // Ensure report does not list the link associated with an archived page
+ $this->assertEquals(3, BrokenExternalLinksReport::create()->sourceRecords()->count());
+ }
+
+ public static function provideGetJobStatus(): array
+ {
+ return [
+ 'ADMIN - valid permission' => ['ADMIN', 200],
+ 'CMS_ACCESS_CMSMain - valid permission' => ['CMS_ACCESS_CMSMain', 200],
+ 'VIEW_SITE - not enough permission' => ['VIEW_SITE', 403],
+ ];
+ }
+
+ #[DataProvider('provideGetJobStatus')]
+ public function testGetJobStatus(
+ string $permission,
+ int $expectedResponseCode
+ ): void {
+ $this->logInWithPermission($permission);
+
+ $response = $this->get('admin/externallinks/start', null, ['Accept' => 'application/json']);
+ $this->assertEquals($expectedResponseCode, $response->getStatusCode());
+
+ $response = $this->get('admin/externallinks/getJobStatus', null, ['Accept' => 'application/json']);
+ $this->assertEquals($expectedResponseCode, $response->getStatusCode());
+ }
+}
diff --git a/tests/php/ExternalLinks/ExternalLinksTest.yml b/tests/php/ExternalLinks/ExternalLinksTest.yml
new file mode 100644
index 00000000..e8fd979f
--- /dev/null
+++ b/tests/php/ExternalLinks/ExternalLinksTest.yml
@@ -0,0 +1,56 @@
+SilverStripe\Reports\Tests\ExternalLinks\Stubs\ExternalLinksTestPage:
+ # Tests mix of broken and working external links
+ page1:
+ Title: 'Page 1'
+ Content: >
+ Links
+ This is a working site
+ Other Links
+ but this isn't
+ ExpectedContent: >
+ Links
+ This is a working site
+ Other Links
+ but this isn't
+ # Tests broken external link staying broken
+ page2:
+ Title: 'Page 2'
+ Content: >
+ Still Broken
+ ExpectedContent: >
+ Still Broken
+ # Tests internal broken links not marking a page as broken
+ page3:
+ Title: 'Page 3'
+ Content: >
+ Links
+ Home page
+ Broken internal page
+ This is a working site
+ ExpectedContent: >
+ Links
+ Home page
+ Broken internal page
+ This is a working site
+ # Tests httpcode = 0
+ page4:
+ Title: 'Page 4'
+ Content: >
+ This shouldn't even have a HTTP response
+ Another Link
+ Copied from another page
+ ExpectedContent: >
+ This shouldn't even have a HTTP response
+ Another Link
+ Copied from another page
+ # Test page with no broken links
+ page5:
+ Title: 'Page 5'
+ Content: >
+ Internal Link
+ Another Link
+ This is a working site
+ ExpectedContent: >
+ Internal Link
+ Another Link
+ This is a working site
diff --git a/tests/php/ExternalLinks/Model/BrokenExternalLinkTest.php b/tests/php/ExternalLinks/Model/BrokenExternalLinkTest.php
new file mode 100644
index 00000000..d7957d2e
--- /dev/null
+++ b/tests/php/ExternalLinks/Model/BrokenExternalLinkTest.php
@@ -0,0 +1,53 @@
+HTTPCode = $httpCode;
+ $this->assertSame($expected, $link->getHTTPCodeDescription());
+ }
+
+ public static function httpCodeProvider(): array
+ {
+ return [
+ [200, '200 (OK)'],
+ [302, '302 (Found)'],
+ [404, '404 (Not Found)'],
+ [500, '500 (Internal Server Error)'],
+ [789, '789 (Unknown Response Code)'],
+ ];
+ }
+
+ public static function permissionProvider(): array
+ {
+ return [
+ ['admin', 'ADMIN'],
+ ['content-author', 'CMS_ACCESS_CMSMain'],
+ ['asset-admin', 'CMS_ACCESS_AssetAdmin'],
+ ];
+ }
+
+ #[DataProvider('permissionProvider')]
+ public function testCanViewReport(string $user, string $permission)
+ {
+ $this->logOut();
+ $this->logInWithPermission($permission);
+
+ $link = new BrokenExternalLink();
+
+ if ($user === 'asset-admin') {
+ $this->assertFalse($link->canView());
+ } else {
+ $this->assertTrue($link->canView());
+ }
+ }
+}
diff --git a/tests/php/ExternalLinks/Stubs/ExternalLinksTestPage.php b/tests/php/ExternalLinks/Stubs/ExternalLinksTestPage.php
new file mode 100644
index 00000000..6cc4e9ad
--- /dev/null
+++ b/tests/php/ExternalLinks/Stubs/ExternalLinksTestPage.php
@@ -0,0 +1,15 @@
+ 'HTMLText'
+ );
+}
diff --git a/tests/php/ExternalLinks/Stubs/PretendLinkChecker.php b/tests/php/ExternalLinks/Stubs/PretendLinkChecker.php
new file mode 100644
index 00000000..3df2c6e8
--- /dev/null
+++ b/tests/php/ExternalLinks/Stubs/PretendLinkChecker.php
@@ -0,0 +1,30 @@
+=3.0.0 <4.0.0"
immutable "^4.0.0"
@@ -5291,9 +5302,11 @@ semver@^6.3.0, semver@^6.3.1:
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.3.7, semver@^7.5.4:
- version "7.6.3"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
- integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
+ version "7.6.0"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
+ integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
+ dependencies:
+ lru-cache "^6.0.0"
serialize-javascript@^6.0.1:
version "6.0.2"
@@ -5348,7 +5361,16 @@ shebang-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
-side-channel@^1.0.4, side-channel@^1.0.6:
+side-channel@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+ integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+ dependencies:
+ call-bind "^1.0.0"
+ get-intrinsic "^1.0.2"
+ object-inspect "^1.9.0"
+
+side-channel@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
@@ -5406,9 +5428,9 @@ socks-proxy-agent@^7.0.0:
socks "^2.6.2"
socks@^2.6.2:
- version "2.8.3"
- resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5"
- integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.1.tgz#22c7d9dd7882649043cba0eafb49ae144e3457af"
+ integrity sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==
dependencies:
ip-address "^9.0.5"
smart-buffer "^4.2.0"
@@ -5453,9 +5475,9 @@ spdx-expression-parse@^3.0.0:
spdx-license-ids "^3.0.0"
spdx-license-ids@^3.0.0:
- version "3.0.20"
- resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz#e44ed19ed318dd1e5888f93325cee800f0f51b89"
- integrity sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==
+ version "3.0.17"
+ resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz#887da8aa73218e51a1d917502d79863161a93f9c"
+ integrity sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==
sprintf-js@^1.1.3:
version "1.1.3"
@@ -5469,13 +5491,6 @@ ssri@^9.0.0, ssri@^9.0.1:
dependencies:
minipass "^3.1.1"
-stop-iteration-iterator@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
- integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==
- dependencies:
- internal-slot "^1.0.4"
-
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -5485,15 +5500,7 @@ stop-iteration-iterator@^1.0.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
-string.prototype.includes@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz#8986d57aee66d5460c144620a6d873778ad7289f"
- integrity sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==
- dependencies:
- define-properties "^1.1.3"
- es-abstract "^1.17.5"
-
-string.prototype.matchall@^4.0.11:
+string.prototype.matchall@^4.0.10:
version "4.0.11"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a"
integrity sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==
@@ -5511,14 +5518,6 @@ string.prototype.matchall@^4.0.11:
set-function-name "^2.0.2"
side-channel "^1.0.6"
-string.prototype.repeat@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a"
- integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==
- dependencies:
- define-properties "^1.1.3"
- es-abstract "^1.17.5"
-
string.prototype.trim@^1.2.9:
version "1.2.9"
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4"
@@ -5584,18 +5583,18 @@ strip-json-comments@^3.1.1:
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
stylelint-config-recommended-scss@^14.0.0:
- version "14.1.0"
- resolved "https://registry.yarnpkg.com/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.1.0.tgz#1a5855655cddcb5f77c10f38c76567adf2bb9aa3"
- integrity sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==
+ version "14.0.0"
+ resolved "https://registry.yarnpkg.com/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.0.0.tgz#d3482c9817dada80b5ec01685b38fc8af8f7263f"
+ integrity sha512-HDvpoOAQ1RpF+sPbDOT2Q2/YrBDEJDnUymmVmZ7mMCeNiFSdhRdyGEimBkz06wsN+HaFwUh249gDR+I9JR7Onw==
dependencies:
postcss-scss "^4.0.9"
- stylelint-config-recommended "^14.0.1"
- stylelint-scss "^6.4.0"
+ stylelint-config-recommended "^14.0.0"
+ stylelint-scss "^6.0.0"
-stylelint-config-recommended@^14.0.0, stylelint-config-recommended@^14.0.1:
- version "14.0.1"
- resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz#d25e86409aaf79ee6c6085c2c14b33c7e23c90c6"
- integrity sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==
+stylelint-config-recommended@^14.0.0:
+ version "14.0.0"
+ resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-14.0.0.tgz#b395c7014838d2aaca1755eebd914d0bb5274994"
+ integrity sha512-jSkx290CglS8StmrLp2TxAppIajzIBZKYm3IxT89Kg6fGlxbPiTiyH9PS5YUuVAFwaJLl1ikiXX0QWjI0jmgZQ==
stylelint-config-sass-guidelines@^11.1.0:
version "11.1.0"
@@ -5606,41 +5605,39 @@ stylelint-config-sass-guidelines@^11.1.0:
stylelint-scss "^6.2.1"
stylelint-config-standard@^36.0.0:
- version "36.0.1"
- resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz#727cbb2a1ef3e210f5ce8329cde531129f156609"
- integrity sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==
+ version "36.0.0"
+ resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-36.0.0.tgz#6704c044d611edc12692d4a5e37b039a441604d4"
+ integrity sha512-3Kjyq4d62bYFp/Aq8PMKDwlgUyPU4nacXsjDLWJdNPRUgpuxALu1KnlAHIj36cdtxViVhXexZij65yM0uNIHug==
dependencies:
- stylelint-config-recommended "^14.0.1"
+ stylelint-config-recommended "^14.0.0"
-stylelint-scss@^6.2.1, stylelint-scss@^6.4.0:
- version "6.5.1"
- resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-6.5.1.tgz#bcb6a4ada71a0adbf181e155548e5f25ee4aeece"
- integrity sha512-ZLqdqihm6uDYkrsOeD6YWb+stZI8Wn92kUNDhE4M+g9g1aCnRv0JlOrttFiAJJwaNzpdQgX3YJb5vDQXVuO9Ww==
+stylelint-scss@^6.0.0, stylelint-scss@^6.2.1:
+ version "6.3.1"
+ resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-6.3.1.tgz#eb56f23f4d3e0896647365ab1681653a00bdbc2b"
+ integrity sha512-w/czBoWUZxJNk5fBRPODcXSN4qcPv3WHjTSSpFovVY+TE3MZTMR0yRlbmaDYrm8tTWHvpwQAuEBZ0lk2wwkboQ==
dependencies:
- css-tree "2.3.1"
- is-plain-object "5.0.0"
- known-css-properties "^0.34.0"
+ known-css-properties "^0.31.0"
postcss-media-query-parser "^0.2.3"
- postcss-resolve-nested-selector "^0.1.4"
- postcss-selector-parser "^6.1.1"
+ postcss-resolve-nested-selector "^0.1.1"
+ postcss-selector-parser "^6.1.0"
postcss-value-parser "^4.2.0"
stylelint@^16.3.1:
- version "16.9.0"
- resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.9.0.tgz#81615c0608b9dc645486e08e35c6c9206e1ba132"
- integrity sha512-31Nm3WjxGOBGpQqF43o3wO9L5AC36TPIe6030Lnm13H3vDMTcS21DrLh69bMX+DBilKqMMVLian4iG6ybBoNRQ==
- dependencies:
- "@csstools/css-parser-algorithms" "^3.0.1"
- "@csstools/css-tokenizer" "^3.0.1"
- "@csstools/media-query-list-parser" "^3.0.1"
- "@csstools/selector-specificity" "^4.0.0"
+ version "16.6.1"
+ resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.6.1.tgz#84735aca2bb5cde535572b7a9b878d2ec983a570"
+ integrity sha512-yNgz2PqWLkhH2hw6X9AweV9YvoafbAD5ZsFdKN9BvSDVwGvPh+AUIrn7lYwy1S7IHmtFin75LLfX1m0D2tHu8Q==
+ dependencies:
+ "@csstools/css-parser-algorithms" "^2.6.3"
+ "@csstools/css-tokenizer" "^2.3.1"
+ "@csstools/media-query-list-parser" "^2.1.11"
+ "@csstools/selector-specificity" "^3.1.1"
"@dual-bundle/import-meta-resolve" "^4.1.0"
balanced-match "^2.0.0"
colord "^2.9.3"
cosmiconfig "^9.0.0"
css-functions-list "^3.2.2"
css-tree "^2.3.1"
- debug "^4.3.6"
+ debug "^4.3.4"
fast-glob "^3.3.2"
fastest-levenshtein "^1.0.16"
file-entry-cache "^9.0.0"
@@ -5648,24 +5645,24 @@ stylelint@^16.3.1:
globby "^11.1.0"
globjoin "^0.1.4"
html-tags "^3.3.1"
- ignore "^5.3.2"
+ ignore "^5.3.1"
imurmurhash "^0.1.4"
is-plain-object "^5.0.0"
- known-css-properties "^0.34.0"
+ known-css-properties "^0.31.0"
mathml-tag-names "^2.1.3"
meow "^13.2.0"
- micromatch "^4.0.8"
+ micromatch "^4.0.7"
normalize-path "^3.0.0"
picocolors "^1.0.1"
- postcss "^8.4.41"
- postcss-resolve-nested-selector "^0.1.6"
+ postcss "^8.4.38"
+ postcss-resolve-nested-selector "^0.1.1"
postcss-safe-parser "^7.0.0"
- postcss-selector-parser "^6.1.2"
+ postcss-selector-parser "^6.1.0"
postcss-value-parser "^4.2.0"
resolve-from "^5.0.0"
string-width "^4.2.3"
strip-ansi "^7.1.0"
- supports-hyperlinks "^3.1.0"
+ supports-hyperlinks "^3.0.0"
svg-tags "^1.0.0"
table "^6.8.2"
write-file-atomic "^5.0.1"
@@ -5691,10 +5688,10 @@ supports-color@^8.0.0:
dependencies:
has-flag "^4.0.0"
-supports-hyperlinks@^3.1.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz#b56150ff0173baacc15f21956450b61f2b18d3ac"
- integrity sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==
+supports-hyperlinks@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz#c711352a5c89070779b4dad54c05a2f14b15c94b"
+ integrity sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==
dependencies:
has-flag "^4.0.0"
supports-color "^7.0.0"
@@ -5749,9 +5746,9 @@ terser-webpack-plugin@^5.3.10:
terser "^5.26.0"
terser@^5.26.0:
- version "5.31.6"
- resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.6.tgz#c63858a0f0703988d0266a82fcbf2d7ba76422b1"
- integrity sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==
+ version "5.30.1"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.30.1.tgz#4faaeedf00d322eb953dcc1f4eeaa9711c15f093"
+ integrity sha512-PJhOnRttZqqmIujxOQOMu4QuFGvh43lR7Youln3k6OJvmxwZ5FxK5rbCEh8XABRCpLf7ZnhrZuclCNCASsScnA==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2"
@@ -5871,10 +5868,10 @@ unbox-primitive@^1.0.2:
has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2"
-undici-types@~6.19.2:
- version "6.19.8"
- resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
- integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
+undici-types@~5.26.4:
+ version "5.26.5"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+ integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
@@ -5913,15 +5910,15 @@ unique-slug@^3.0.0:
dependencies:
imurmurhash "^0.1.4"
-update-browserslist-db@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e"
- integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==
+update-browserslist-db@^1.0.13:
+ version "1.0.13"
+ resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
+ integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==
dependencies:
- escalade "^3.1.2"
- picocolors "^1.0.1"
+ escalade "^3.1.1"
+ picocolors "^1.0.0"
-uri-js@^4.2.2:
+uri-js@^4.2.2, uri-js@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
@@ -5954,9 +5951,9 @@ walk-up-path@^1.0.0:
integrity sha512-hwj/qMDUEjCU5h0xr90KGCf0tg0/LgJbmOWgrWKYlcJZM7XvquvUJZ0G/HMGr7F7OQMOUuPHWP9JpriinkAlkg==
watchpack@^2.4.1:
- version "2.4.2"
- resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da"
- integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff"
+ integrity sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
@@ -5969,9 +5966,9 @@ wcwidth@^1.0.0:
defaults "^1.0.3"
webpack-bundle-analyzer@^4.7.0:
- version "4.10.2"
- resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz#633af2862c213730be3dbdf40456db171b60d5bd"
- integrity sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==
+ version "4.10.1"
+ resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz#84b7473b630a7b8c21c741f81d8fe4593208b454"
+ integrity sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==
dependencies:
"@discoveryjs/json-ext" "0.5.7"
acorn "^8.0.4"
@@ -5981,6 +5978,7 @@ webpack-bundle-analyzer@^4.7.0:
escape-string-regexp "^4.0.0"
gzip-size "^6.0.0"
html-escaper "^2.0.2"
+ is-plain-object "^5.0.0"
opener "^1.5.2"
picocolors "^1.0.0"
sirv "^2.0.3"
@@ -6060,12 +6058,12 @@ which-boxed-primitive@^1.0.2:
is-symbol "^1.0.3"
which-builtin-type@^1.1.3:
- version "1.1.4"
- resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.4.tgz#592796260602fc3514a1b5ee7fa29319b72380c3"
- integrity sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b"
+ integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==
dependencies:
- function.prototype.name "^1.1.6"
- has-tostringtag "^1.0.2"
+ function.prototype.name "^1.1.5"
+ has-tostringtag "^1.0.0"
is-async-function "^2.0.0"
is-date-object "^1.0.5"
is-finalizationregistry "^1.0.2"
@@ -6074,10 +6072,10 @@ which-builtin-type@^1.1.3:
is-weakref "^1.0.2"
isarray "^2.0.5"
which-boxed-primitive "^1.0.2"
- which-collection "^1.0.2"
- which-typed-array "^1.1.15"
+ which-collection "^1.0.1"
+ which-typed-array "^1.1.9"
-which-collection@^1.0.1, which-collection@^1.0.2:
+which-collection@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0"
integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==
@@ -6092,7 +6090,7 @@ which-module@^2.0.0:
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
-which-typed-array@^1.1.13, which-typed-array@^1.1.14, which-typed-array@^1.1.15:
+which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9:
version "1.1.15"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d"
integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==
@@ -6129,11 +6127,6 @@ wildcard@^2.0.0:
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67"
integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==
-word-wrap@^1.2.5:
- version "1.2.5"
- resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
- integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -6185,9 +6178,9 @@ yallist@^4.0.0:
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^2.3.4:
- version "2.5.0"
- resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d"
- integrity sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed"
+ integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==
yargs-parser@^18.1.2:
version "18.1.3"
@@ -6220,6 +6213,6 @@ yocto-queue@^0.1.0:
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yocto-queue@^1.0.0:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110"
- integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
+ integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==