This commit is contained in:
2025-05-12 14:25:25 +02:00
parent ab2db755ef
commit 9e378ca2b7
2719 changed files with 46505 additions and 60181 deletions

View File

@@ -31,6 +31,7 @@ namespace PHPSTORM_META
'html_input',
'allow_unsafe_links',
'max_nesting_level',
'max_delimiters_per_line',
'renderer',
'renderer/block_separator',
'renderer/inner_separator',
@@ -89,6 +90,7 @@ namespace PHPSTORM_META
'table/alignment_attributes/left',
'table/alignment_attributes/center',
'table/alignment_attributes/right',
'table/max_autocompleted_cells',
'table_of_contents',
'table_of_contents/html_class',
'table_of_contents/max_heading_level',

View File

@@ -6,6 +6,146 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
## [Unreleased][unreleased]
## [2.7.0]
This is a **security release** to address a potential cross-site scripting (XSS) vulnerability when using the `AttributesExtension` with untrusted user input.
### Added
- Added `attributes/allow` config option to specify which attributes users are allowed to set on elements (default allows virtually all attributes)
### Changed
- The `AttributesExtension` blocks all attributes starting with `on` unless explicitly allowed via the `attributes/allow` config option
- The `allow_unsafe_links` option is now respected by the `AttributesExtension` when users specify `href` and `src` attributes
## [2.6.2] - 2025-04-18
### Fixed
- Fixed Attributes extension parsing regression (#1071)
## [2.6.1] - 2024-12-29
### Fixed
- Rendered list items should only add newlines around block-level children (#1059, #1061)
## [2.6.0] - 2024-12-07
This is a **security release** to address potential denial of service attacks when parsing specially crafted,
malicious input from untrusted sources (like user input).
### Added
- Added `max_delimiters_per_line` config option to prevent denial of service attacks when parsing malicious input
- Added `table/max_autocompleted_cells` config option to prevent denial of service attacks when parsing large tables
- The `AttributesExtension` now supports attributes without values (#985, #986)
- The `AutolinkExtension` exposes two new configuration options to override the default behavior (#969, #987):
- `autolink/allowed_protocols` - an array of protocols to allow autolinking for
- `autolink/default_protocol` - the default protocol to use when none is specified
- Added `RegexHelper::isWhitespace()` method to check if a given character is an ASCII whitespace character
- Added `CacheableDelimiterProcessorInterface` to ensure linear complexity for dynamic delimiter processing
- Added `Bracket` delimiter type to optimize bracket parsing
### Changed
- `[` and `]` are no longer added as `Delimiter` objects on the stack; a new `Bracket` type with its own stack is used instead
- `UrlAutolinkParser` no longer parses URLs with more than 127 subdomains
- Expanded reference links can no longer exceed 100kb, or the size of the input document (whichever is greater)
- Delimiters should always provide a non-null value via `DelimiterInterface::getIndex()`
- We'll attempt to infer the index based on surrounding delimiters where possible
- The `DelimiterStack` now accepts integer positions for any `$stackBottom` argument
- Several small performance optimizations
## [2.5.3] - 2024-08-16
### Changed
- Made compatible with CommonMark spec 0.31.1, including:
- Remove `source`, add `search` to list of recognized block tags
## [2.5.2] - 2024-08-14
### Changed
- Boolean attributes now require an explicit `true` value (#1040)
### Fixed
- Fixed regression where text could be misinterpreted as an attribute (#1040)
## [2.5.1] - 2024-07-24
### Fixed
- Fixed attribute parsing incorrectly parsing mustache-like syntax (#1035)
- Fixed incorrect `Table` start line numbers (#1037)
## [2.5.0] - 2024-07-22
### Added
- The `AttributesExtension` now supports attributes without values (#985, #986)
- The `AutolinkExtension` exposes two new configuration options to override the default behavior (#969, #987):
- `autolink/allowed_protocols` - an array of protocols to allow autolinking for
- `autolink/default_protocol` - the default protocol to use when none is specified
### Changed
- Made compatible with CommonMark spec 0.31.0, including:
- Allow closing fence to be followed by tabs
- Remove restrictive limitation on inline comments
- Unicode symbols now treated like punctuation (for purposes of flankingness)
- Trailing tabs on the last line of indented code blocks will be excluded
- Improved HTML comment matching
- `Paragraph`s only containing link reference definitions will be kept in the AST until the `Document` is finalized
- (These were previously removed immediately after parsing the `Paragraph`)
### Fixed
- Fixed list tightness not being determined properly in some edge cases
- Fixed incorrect ending line numbers for several block types in various scenarios
- Fixed lowercase inline HTML declarations not being accepted
## [2.4.4] - 2024-07-22
### Fixed
- Fixed SmartPunct extension changing already-formatted quotation marks (#1030)
## [2.4.3] - 2024-07-22
### Fixed
- Fixed the Attributes extension not supporting CSS level 3 selectors (#1013)
- Fixed `UrlAutolinkParser` incorrectly parsing text containing `www` anywhere before an autolink (#1025)
## [2.4.2] - 2024-02-02
### Fixed
- Fixed declaration parser being too strict
- `FencedCodeRenderer`: don't add `language-` to class if already prefixed
### Deprecated
- Returning dynamic values from `DelimiterProcessorInterface::getDelimiterUse()` is deprecated
- You should instead implement `CacheableDelimiterProcessorInterface` to help the engine perform caching to avoid performance issues.
- Failing to set a delimiter's index (or returning `null` from `DelimiterInterface::getIndex()`) is deprecated and will not be supported in 3.0
- Deprecated `DelimiterInterface::isActive()` and `DelimiterInterface::setActive()`, as these are no longer used by the engine
- Deprecated `DelimiterStack::removeEarlierMatches()` and `DelimiterStack::searchByCharacter()`, as these are no longer used by the engine
- Passing a `DelimiterInterface` as the `$stackBottom` argument to `DelimiterStack::processDelimiters()` or `::removeAll()` is deprecated and will not be supported in 3.0; pass the integer position instead.
### Fixed
- Fixed NUL characters not being replaced in the input
- Fixed quadratic complexity parsing unclosed inline links
- Fixed quadratic complexity parsing emphasis and strikethrough delimiters
- Fixed issue where having 500,000+ delimiters could trigger a [known segmentation fault issue in PHP's garbage collection](https://bugs.php.net/bug.php?id=68606)
- Fixed quadratic complexity deactivating link openers
- Fixed quadratic complexity parsing long backtick code spans with no matching closers
- Fixed catastrophic backtracking when parsing link labels/titles
## [2.4.1] - 2023-08-30
### Fixed
@@ -560,7 +700,18 @@ No changes were introduced since the previous release.
- Alternative 1: Use `CommonMarkConverter` or `GithubFlavoredMarkdownConverter` if you don't need to customize the environment
- Alternative 2: Instantiate a new `Environment` and add the necessary extensions yourself
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.4.1...main
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.7.0...HEAD
[2.7.0]: https://github.com/thephpleague/commonmark/compare/2.6.2...2.7.0
[2.6.2]: https://github.com/thephpleague/commonmark/compare/2.6.1...2.6.2
[2.6.1]: https://github.com/thephpleague/commonmark/compare/2.6.0...2.6.1
[2.6.0]: https://github.com/thephpleague/commonmark/compare/2.5.3...2.6.0
[2.5.3]: https://github.com/thephpleague/commonmark/compare/2.5.2...2.5.3
[2.5.2]: https://github.com/thephpleague/commonmark/compare/2.5.1...2.5.2
[2.5.1]: https://github.com/thephpleague/commonmark/compare/2.5.0...2.5.1
[2.5.0]: https://github.com/thephpleague/commonmark/compare/2.4.4...2.5.0
[2.4.4]: https://github.com/thephpleague/commonmark/compare/2.4.3...2.4.4
[2.4.3]: https://github.com/thephpleague/commonmark/compare/2.4.2...2.4.3
[2.4.2]: https://github.com/thephpleague/commonmark/compare/2.4.1...2.4.2
[2.4.1]: https://github.com/thephpleague/commonmark/compare/2.4.0...2.4.1
[2.4.0]: https://github.com/thephpleague/commonmark/compare/2.3.9...2.4.0
[2.3.9]: https://github.com/thephpleague/commonmark/compare/2.3.8...2.3.9

View File

@@ -54,7 +54,8 @@ echo $converter->convert('# Hello World!');
Please note that only UTF-8 and ASCII encodings are supported. If your Markdown uses a different encoding please convert it to UTF-8 before running it through this library.
🔒 If you will be parsing untrusted input from users, please consider setting the `html_input` and `allow_unsafe_links` options per the example above. See <https://commonmark.thephpleague.com/security/> for more details. If you also do choose to allow raw HTML input from untrusted users, consider using a library (like [HTML Purifier](https://github.com/ezyang/htmlpurifier)) to provide additional HTML filtering.
> [!CAUTION]
> If you will be parsing untrusted input from users, please consider setting the `html_input` and `allow_unsafe_links` options per the example above. See <https://commonmark.thephpleague.com/security/> for more details. If you also do choose to allow raw HTML input from untrusted users, consider using a library (like [HTML Purifier](https://github.com/ezyang/htmlpurifier)) to provide additional HTML filtering.
## 📓 Documentation
@@ -98,7 +99,7 @@ See [our extension documentation](https://commonmark.thephpleague.com/extensions
Custom parsers/renderers can be bundled into extensions which extend CommonMark. Here are some that you may find interesting:
- [Alt Three Emoji](https://github.com/AltThree/Emoji) An emoji parser for CommonMark.
- [Emoji extension](https://github.com/ElGigi/CommonMarkEmoji) - UTF-8 emoji extension with Github tag.
- [Sup Sub extensions](https://github.com/OWS/commonmark-sup-sub-extensions) - Adds support of superscript and subscript (`<sup>` and `<sub>` HTML tags)
- [YouTube iframe extension](https://github.com/zoonru/commonmark-ext-youtube-iframe) - Replaces youtube link with iframe.
- [Lazy Image extension](https://github.com/simonvomeyser/commonmark-ext-lazy-image) - Adds various options for lazy loading of images.
@@ -163,11 +164,13 @@ $ ./tests/benchmark/benchmark.php
## 👥 Credits & Acknowledgements
- [Colin O'Dell][@colinodell]
- [John MacFarlane][@jgm]
- [All Contributors]
This code was originally based on the [CommonMark JS reference implementation][commonmark.js] which is written, maintained, and copyrighted by [John MacFarlane]. This project simply wouldn't exist without his work.
This code is partially based on the [CommonMark JS reference implementation][commonmark.js] which is written, maintained and copyrighted by [John MacFarlane]. This project simply wouldn't exist without his work.
And a huge thanks to all of our amazing contributors:
<a href="https://github.com/thephpleague/commonmark/graphs/contributors">
<img src="https://contrib.rocks/image?repo=thephpleague/commonmark" />
</a>
### Sponsors
@@ -176,7 +179,6 @@ We'd also like to extend our sincere thanks the following sponsors who support o
- [Tidelift](https://tidelift.com/subscription/pkg/packagist-league-commonmark?utm_source=packagist-league-commonmark&utm_medium=referral&utm_campaign=readme) for offering support to both the maintainers and end-users through their [professional support](https://tidelift.com/subscription/pkg/packagist-league-commonmark?utm_source=packagist-league-commonmark&utm_medium=referral&utm_campaign=readme) program
- [Blackfire](https://www.blackfire.io/) for providing an Open-Source Profiler subscription
- [JetBrains](https://www.jetbrains.com/) for supporting this project with complimentary [PhpStorm](https://www.jetbrains.com/phpstorm/) licenses
- [Taylor Otwell](https://twitter.com/taylorotwell) for sponsoring this project through GitHub sponsors
Are you interested in sponsoring development of this project? See <https://www.colinodell.com/sponsor> for a list of ways to contribute.

View File

@@ -31,8 +31,8 @@
"require-dev": {
"ext-json": "*",
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.30.0",
"commonmark/commonmark.js": "0.30.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
@@ -40,10 +40,11 @@
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0",
"symfony/finder": "^5.3 | ^6.0 | ^7.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0"
},
@@ -56,9 +57,9 @@
"type": "package",
"package": {
"name": "commonmark/commonmark.js",
"version": "0.30.0",
"version": "0.31.1",
"dist": {
"url": "https://github.com/commonmark/commonmark.js/archive/0.30.0.zip",
"url": "https://github.com/commonmark/commonmark.js/archive/0.31.1.zip",
"type": "zip"
}
}
@@ -67,9 +68,9 @@
"type": "package",
"package": {
"name": "commonmark/cmark",
"version": "0.30.0",
"version": "0.31.1",
"dist": {
"url": "https://github.com/commonmark/cmark/archive/0.30.0.zip",
"url": "https://github.com/commonmark/cmark/archive/0.31.1.zip",
"type": "zip"
}
}
@@ -80,7 +81,7 @@
"name": "github/gfm",
"version": "0.29.0",
"dist": {
"url": "https://github.com/github/cmark-gfm/archive/0.29.0.gfm.9.zip",
"url": "https://github.com/github/cmark-gfm/archive/0.29.0.gfm.13.zip",
"type": "zip"
}
}
@@ -103,16 +104,18 @@
"phpstan": "phpstan analyse",
"phpunit": "phpunit --no-coverage",
"psalm": "psalm --stats",
"pathological": "tests/pathological/test.php",
"test": [
"@phpcs",
"@phpstan",
"@psalm",
"@phpunit"
"@phpunit",
"@pathological"
]
},
"extra": {
"branch-alias": {
"dev-main": "2.5-dev"
"dev-main": "2.8-dev"
}
},
"config": {

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Delimiter;
use League\CommonMark\Node\Node;
final class Bracket
{
private Node $node;
private ?Bracket $previous;
private bool $hasNext = false;
private int $position;
private bool $image;
private bool $active = true;
public function __construct(Node $node, ?Bracket $previous, int $position, bool $image)
{
$this->node = $node;
$this->previous = $previous;
$this->position = $position;
$this->image = $image;
}
public function getNode(): Node
{
return $this->node;
}
public function getPrevious(): ?Bracket
{
return $this->previous;
}
public function hasNext(): bool
{
return $this->hasNext;
}
public function getPosition(): int
{
return $this->position;
}
public function isImage(): bool
{
return $this->image;
}
/**
* Only valid in the context of non-images (links)
*/
public function isActive(): bool
{
return $this->active;
}
/**
* @internal
*/
public function setHasNext(bool $hasNext): void
{
$this->hasNext = $hasNext;
}
/**
* @internal
*/
public function setActive(bool $active): void
{
$this->active = $active;
}
}

View File

@@ -24,8 +24,14 @@ interface DelimiterInterface
public function canOpen(): bool;
/**
* @deprecated This method is no longer used internally and will be removed in 3.0
*/
public function isActive(): bool;
/**
* @deprecated This method is no longer used internally and will be removed in 3.0
*/
public function setActive(bool $active): void;
public function getChar(): string;

View File

@@ -62,16 +62,20 @@ final class DelimiterParser implements InlineParserInterface
[$canOpen, $canClose] = self::determineCanOpenOrClose($charBefore, $charAfter, $character, $processor);
if (! ($canOpen || $canClose)) {
$inlineContext->getContainer()->appendChild(new Text(\str_repeat($character, $numDelims)));
return true;
}
$node = new Text(\str_repeat($character, $numDelims), [
'delim' => true,
]);
$inlineContext->getContainer()->appendChild($node);
// Add entry to stack to this opener
if ($canOpen || $canClose) {
$delimiter = new Delimiter($character, $numDelims, $node, $canOpen, $canClose);
$inlineContext->getDelimiterStack()->push($delimiter);
}
$delimiter = new Delimiter($character, $numDelims, $node, $canOpen, $canClose, $inlineContext->getCursor()->getPosition());
$inlineContext->getDelimiterStack()->push($delimiter);
return true;
}

View File

@@ -19,16 +19,47 @@ declare(strict_types=1);
namespace League\CommonMark\Delimiter;
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
use League\CommonMark\Node\Inline\AdjacentTextMerger;
use League\CommonMark\Node\Node;
final class DelimiterStack
{
/** @psalm-readonly-allow-private-mutation */
private ?DelimiterInterface $top = null;
/** @psalm-readonly-allow-private-mutation */
private ?Bracket $brackets = null;
/**
* @deprecated This property will be removed in 3.0 once all delimiters MUST have an index/position
*
* @var \SplObjectStorage<DelimiterInterface, int>|\WeakMap<DelimiterInterface, int>
*/
private $missingIndexCache;
private int $remainingDelimiters = 0;
public function __construct(int $maximumStackSize = PHP_INT_MAX)
{
$this->remainingDelimiters = $maximumStackSize;
if (\PHP_VERSION_ID >= 80000) {
/** @psalm-suppress PropertyTypeCoercion */
$this->missingIndexCache = new \WeakMap(); // @phpstan-ignore-line
} else {
$this->missingIndexCache = new \SplObjectStorage(); // @phpstan-ignore-line
}
}
public function push(DelimiterInterface $newDelimiter): void
{
if ($this->remainingDelimiters-- <= 0) {
return;
}
$newDelimiter->setPrevious($this->top);
if ($this->top !== null) {
@@ -38,14 +69,54 @@ final class DelimiterStack
$this->top = $newDelimiter;
}
private function findEarliest(?DelimiterInterface $stackBottom = null): ?DelimiterInterface
/**
* @internal
*/
public function addBracket(Node $node, int $index, bool $image): void
{
$delimiter = $this->top;
while ($delimiter !== null && $delimiter->getPrevious() !== $stackBottom) {
$delimiter = $delimiter->getPrevious();
if ($this->brackets !== null) {
$this->brackets->setHasNext(true);
}
return $delimiter;
$this->brackets = new Bracket($node, $this->brackets, $index, $image);
}
/**
* @psalm-immutable
*/
public function getLastBracket(): ?Bracket
{
return $this->brackets;
}
private function findEarliest(int $stackBottom): ?DelimiterInterface
{
// Move back to first relevant delim.
$delimiter = $this->top;
$lastChecked = null;
while ($delimiter !== null && self::getIndex($delimiter) > $stackBottom) {
$lastChecked = $delimiter;
$delimiter = $delimiter->getPrevious();
}
return $lastChecked;
}
/**
* @internal
*/
public function removeBracket(): void
{
if ($this->brackets === null) {
return;
}
$this->brackets = $this->brackets->getPrevious();
if ($this->brackets !== null) {
$this->brackets->setHasNext(false);
}
}
public function removeDelimiter(DelimiterInterface $delimiter): void
@@ -62,6 +133,19 @@ final class DelimiterStack
/** @psalm-suppress PossiblyNullReference */
$delimiter->getNext()->setPrevious($delimiter->getPrevious());
}
// Nullify all references from the removed delimiter to other delimiters.
// All references to this particular delimiter in the linked list should be gone,
// but it's possible we're still hanging on to other references to things that
// have been (or soon will be) removed, which may interfere with efficient
// garbage collection by the PHP runtime.
// Explicitly releasing these references should help to avoid possible
// segfaults like in https://bugs.php.net/bug.php?id=68606.
$delimiter->setPrevious(null);
$delimiter->setNext(null);
// TODO: Remove the line below once PHP 7.4 support is dropped, as WeakMap won't hold onto the reference, making this unnecessary
unset($this->missingIndexCache[$delimiter]);
}
private function removeDelimiterAndNode(DelimiterInterface $delimiter): void
@@ -72,21 +156,30 @@ final class DelimiterStack
private function removeDelimitersBetween(DelimiterInterface $opener, DelimiterInterface $closer): void
{
$delimiter = $closer->getPrevious();
while ($delimiter !== null && $delimiter !== $opener) {
$delimiter = $closer->getPrevious();
$openerPosition = self::getIndex($opener);
while ($delimiter !== null && self::getIndex($delimiter) > $openerPosition) {
$previous = $delimiter->getPrevious();
$this->removeDelimiter($delimiter);
$delimiter = $previous;
}
}
public function removeAll(?DelimiterInterface $stackBottom = null): void
/**
* @param DelimiterInterface|int|null $stackBottom
*/
public function removeAll($stackBottom = null): void
{
while ($this->top && $this->top !== $stackBottom) {
$stackBottomPosition = \is_int($stackBottom) ? $stackBottom : self::getIndex($stackBottom);
while ($this->top && $this->getIndex($this->top) > $stackBottomPosition) {
$this->removeDelimiter($this->top);
}
}
/**
* @deprecated This method is no longer used internally and will be removed in 3.0
*/
public function removeEarlierMatches(string $character): void
{
$opener = $this->top;
@@ -100,6 +193,20 @@ final class DelimiterStack
}
/**
* @internal
*/
public function deactivateLinkOpeners(): void
{
$opener = $this->brackets;
while ($opener !== null && $opener->isActive()) {
$opener->setActive(false);
$opener = $opener->getPrevious();
}
}
/**
* @deprecated This method is no longer used internally and will be removed in 3.0
*
* @param string|string[] $characters
*/
public function searchByCharacter($characters): ?DelimiterInterface
@@ -120,30 +227,44 @@ final class DelimiterStack
return $opener;
}
public function processDelimiters(?DelimiterInterface $stackBottom, DelimiterProcessorCollection $processors): void
/**
* @param DelimiterInterface|int|null $stackBottom
*
* @todo change $stackBottom to an int in 3.0
*/
public function processDelimiters($stackBottom, DelimiterProcessorCollection $processors): void
{
/** @var array<string, int> $openersBottom */
$openersBottom = [];
$stackBottomPosition = \is_int($stackBottom) ? $stackBottom : self::getIndex($stackBottom);
// Find first closer above stackBottom
$closer = $this->findEarliest($stackBottom);
$closer = $this->findEarliest($stackBottomPosition);
// Move forward, looking for closers, and handling each
while ($closer !== null) {
$delimiterChar = $closer->getChar();
$closingDelimiterChar = $closer->getChar();
$delimiterProcessor = $processors->getDelimiterProcessor($delimiterChar);
$delimiterProcessor = $processors->getDelimiterProcessor($closingDelimiterChar);
if (! $closer->canClose() || $delimiterProcessor === null) {
$closer = $closer->getNext();
continue;
}
if ($delimiterProcessor instanceof CacheableDelimiterProcessorInterface) {
$openersBottomCacheKey = $delimiterProcessor->getCacheKey($closer);
} else {
$openersBottomCacheKey = $closingDelimiterChar;
}
$openingDelimiterChar = $delimiterProcessor->getOpeningCharacter();
$useDelims = 0;
$openerFound = false;
$potentialOpenerFound = false;
$opener = $closer->getPrevious();
while ($opener !== null && $opener !== $stackBottom && $opener !== ($openersBottom[$delimiterChar] ?? null)) {
while ($opener !== null && ($openerPosition = self::getIndex($opener)) > $stackBottomPosition && $openerPosition >= ($openersBottom[$openersBottomCacheKey] ?? 0)) {
if ($opener->canOpen() && $opener->getChar() === $openingDelimiterChar) {
$potentialOpenerFound = true;
$useDelims = $delimiterProcessor->getDelimiterUse($opener, $closer);
@@ -157,23 +278,22 @@ final class DelimiterStack
}
if (! $openerFound) {
if (! $potentialOpenerFound) {
// Only do this when we didn't even have a potential
// opener (one that matches the character and can open).
// If an opener was rejected because of the number of
// delimiters (e.g. because of the "multiple of 3"
// Set lower bound for future searches for openersrule),
// we want to consider it next time because the number
// of delimiters can change as we continue processing.
$openersBottom[$delimiterChar] = $closer->getPrevious();
if (! $closer->canOpen()) {
// We can remove a closer that can't be an opener,
// once we've seen there's no matching opener.
$this->removeDelimiter($closer);
}
// Set lower bound for future searches
// TODO: Remove this conditional check in 3.0. It only exists to prevent behavioral BC breaks in 2.x.
if ($potentialOpenerFound === false || $delimiterProcessor instanceof CacheableDelimiterProcessorInterface) {
$openersBottom[$openersBottomCacheKey] = self::getIndex($closer);
}
if (! $potentialOpenerFound && ! $closer->canOpen()) {
// We can remove a closer that can't be an opener,
// once we've seen there's no matching opener.
$next = $closer->getNext();
$this->removeDelimiter($closer);
$closer = $next;
} else {
$closer = $closer->getNext();
}
$closer = $closer->getNext();
continue;
}
@@ -209,6 +329,68 @@ final class DelimiterStack
}
// Remove all delimiters
$this->removeAll($stackBottom);
$this->removeAll($stackBottomPosition);
}
/**
* @internal
*/
public function __destruct()
{
while ($this->top) {
$this->removeDelimiter($this->top);
}
while ($this->brackets) {
$this->removeBracket();
}
}
/**
* @deprecated This method will be dropped in 3.0 once all delimiters MUST have an index/position
*/
private function getIndex(?DelimiterInterface $delimiter): int
{
if ($delimiter === null) {
return -1;
}
if (($index = $delimiter->getIndex()) !== null) {
return $index;
}
if (isset($this->missingIndexCache[$delimiter])) {
return $this->missingIndexCache[$delimiter];
}
$prev = $delimiter->getPrevious();
$next = $delimiter->getNext();
$i = 0;
do {
$i++;
if ($prev === null) {
break;
}
if ($prev->getIndex() !== null) {
return $this->missingIndexCache[$delimiter] = $prev->getIndex() + $i;
}
} while ($prev = $prev->getPrevious());
$j = 0;
do {
$j++;
if ($next === null) {
break;
}
if ($next->getIndex() !== null) {
return $this->missingIndexCache[$delimiter] = $next->getIndex() - $j;
}
} while ($next = $next->getNext());
// No index was defined on this delimiter, and none could be guesstimated based on the stack.
return $this->missingIndexCache[$delimiter] = $this->getIndex($delimiter->getPrevious()) + 1;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Delimiter\Processor;
use League\CommonMark\Delimiter\DelimiterInterface;
/**
* Special marker interface for delimiter processors that return dynamic values from getDelimiterUse()
*
* In order to guarantee linear performance of delimiter processing, the delimiter stack must be able to
* cache the lower bound when searching for a matching opener. This gets complicated for delimiter processors
* that use a dynamic number of characters (like with emphasis and its "multiple of 3" rule).
*/
interface CacheableDelimiterProcessorInterface extends DelimiterProcessorInterface
{
/**
* Returns a cache key of the factors that determine the number of characters to use.
*
* In order to guarantee linear performance of delimiter processing, the delimiter stack must be able to
* cache the lower bound when searching for a matching opener. This lower bound is usually quite simple;
* for example, with quotes, it's just the last opener with that characted. However, this gets complicated
* for delimiter processors that use a dynamic number of characters (like with emphasis and its "multiple
* of 3" rule), because the delimiter length being considered may change during processing because of that
* dynamic logic in getDelimiterUse(). Therefore, we cannot safely cache the lower bound for these dynamic
* processors without knowing the factors that determine the number of characters to use.
*
* At a minimum, this should include the delimiter character, plus any other factors used to determine
* the result of getDelimiterUse(). The format of the string is not important so long as it is unique
* (compared to other processors) and consistent for a given set of factors.
*
* If getDelimiterUse() always returns the same hard-coded value, this method should return just
* the delimiter character.
*/
public function getCacheKey(DelimiterInterface $closer): string;
}

View File

@@ -58,6 +58,9 @@ interface DelimiterProcessorInterface
* return 0 when it doesn't want to allow this particular combination of
* delimiter runs.
*
* IMPORTANT: Unless this method returns the same hard-coded value in all cases,
* you MUST implement the CacheableDelimiterProcessorInterface interface instead.
*
* @param DelimiterInterface $opener The opening delimiter run
* @param DelimiterInterface $closer The closing delimiter run
*/

View File

@@ -432,6 +432,7 @@ final class Environment implements EnvironmentInterface, EnvironmentBuilderInter
'html_input' => Expect::anyOf(HtmlFilter::STRIP, HtmlFilter::ALLOW, HtmlFilter::ESCAPE)->default(HtmlFilter::ALLOW),
'allow_unsafe_links' => Expect::bool(true),
'max_nesting_level' => Expect::type('int')->default(PHP_INT_MAX),
'max_delimiters_per_line' => Expect::type('int')->default(PHP_INT_MAX),
'renderer' => Expect::structure([
'block_separator' => Expect::string("\n"),
'inner_separator' => Expect::string("\n"),

View File

@@ -19,14 +19,26 @@ use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Attributes\Event\AttributesListener;
use League\CommonMark\Extension\Attributes\Parser\AttributesBlockStartParser;
use League\CommonMark\Extension\Attributes\Parser\AttributesInlineParser;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class AttributesExtension implements ExtensionInterface
final class AttributesExtension implements ConfigurableExtensionInterface
{
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('attributes', Expect::structure([
'allow' => Expect::arrayOf('string')->default([]),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$allowList = $environment->getConfiguration()->get('attributes.allow');
$allowUnsafeLinks = $environment->getConfiguration()->get('allow_unsafe_links');
$environment->addBlockStartParser(new AttributesBlockStartParser());
$environment->addInlineParser(new AttributesInlineParser());
$environment->addEventListener(DocumentParsedEvent::class, [new AttributesListener(), 'processDocument']);
$environment->addEventListener(DocumentParsedEvent::class, [new AttributesListener($allowList, $allowUnsafeLinks), 'processDocument']);
}
}

View File

@@ -29,6 +29,19 @@ final class AttributesListener
private const DIRECTION_PREFIX = 'prefix';
private const DIRECTION_SUFFIX = 'suffix';
/** @var list<string> */
private array $allowList;
private bool $allowUnsafeLinks;
/**
* @param list<string> $allowList
*/
public function __construct(array $allowList = [], bool $allowUnsafeLinks = true)
{
$this->allowList = $allowList;
$this->allowUnsafeLinks = $allowUnsafeLinks;
}
public function processDocument(DocumentParsedEvent $event): void
{
foreach ($event->getDocument()->iterator() as $node) {
@@ -50,7 +63,7 @@ final class AttributesListener
$attributes = AttributesHelper::mergeAttributes($node->getAttributes(), $target);
}
$target->data->set('attributes', $attributes);
$target->data->set('attributes', AttributesHelper::filterAttributes($attributes, $this->allowList, $this->allowUnsafeLinks));
}
$node->detach();

View File

@@ -23,7 +23,7 @@ use League\CommonMark\Util\RegexHelper;
*/
final class AttributesHelper
{
private const SINGLE_ATTRIBUTE = '\s*([.#][_a-z0-9-]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
private const SINGLE_ATTRIBUTE = '\s*([.]-?[_a-z][^\s.}]*|[#][^\s}]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
private const ATTRIBUTE_LIST = '/^{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
/**
@@ -75,6 +75,11 @@ final class AttributesHelper
/** @psalm-suppress PossiblyUndefinedArrayOffset */
[$name, $value] = \explode('=', $attribute, 2);
if ($value === 'true') {
$attributes[$name] = true;
continue;
}
$first = $value[0];
$last = \substr($value, -1);
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'") && \strlen($value) > 1) {
@@ -134,4 +139,42 @@ final class AttributesHelper
return $attributes;
}
/**
* @param array<string, mixed> $attributes
* @param list<string> $allowList
*
* @return array<string, mixed>
*/
public static function filterAttributes(array $attributes, array $allowList, bool $allowUnsafeLinks): array
{
$allowList = \array_fill_keys($allowList, true);
foreach ($attributes as $name => $value) {
$attrNameLower = \strtolower($name);
// Remove any unsafe links
if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) {
unset($attributes[$name]);
continue;
}
// No allowlist?
if ($allowList === []) {
// Just remove JS event handlers
if (\str_starts_with($attrNameLower, 'on')) {
unset($attributes[$name]);
}
continue;
}
// Remove any attributes not in that allowlist (case-sensitive)
if (! isset($allowList[$name])) {
unset($attributes[$name]);
}
}
return $attributes;
}
}

View File

@@ -14,13 +14,26 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Autolink;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class AutolinkExtension implements ExtensionInterface
final class AutolinkExtension implements ConfigurableExtensionInterface
{
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('autolink', Expect::structure([
'allowed_protocols' => Expect::listOf('string')->default(['http', 'https', 'ftp'])->mergeDefaults(false),
'default_protocol' => Expect::string()->default('http'),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addInlineParser(new EmailAutolinkParser());
$environment->addInlineParser(new UrlAutolinkParser());
$environment->addInlineParser(new UrlAutolinkParser(
$environment->getConfiguration()->get('autolink.allowed_protocols'),
$environment->getConfiguration()->get('autolink.default_protocol'),
));
}
}

View File

@@ -34,7 +34,7 @@ final class UrlAutolinkParser implements InlineParserInterface
(?:
(?:xn--[a-z0-9-]++\.)*+xn--[a-z0-9-]++ # a domain name using punycode
|
(?:[\pL\pN\pS\pM\-\_]++\.)+[\pL\pN\pM]++ # a multi-level domain name
(?:[\pL\pN\pS\pM\-\_]++\.){1,127}[\pL\pN\pM]++ # a multi-level domain name; total length must be 253 bytes or less
|
[a-z0-9\-\_]++ # a single-level domain name
)\.?
@@ -56,7 +56,7 @@ final class UrlAutolinkParser implements InlineParserInterface
*
* @psalm-readonly
*/
private array $prefixes = ['www'];
private array $prefixes = ['www.'];
/**
* @psalm-var non-empty-string
@@ -65,10 +65,12 @@ final class UrlAutolinkParser implements InlineParserInterface
*/
private string $finalRegex;
private string $defaultProtocol;
/**
* @param array<int, string> $allowedProtocols
*/
public function __construct(array $allowedProtocols = ['http', 'https', 'ftp'])
public function __construct(array $allowedProtocols = ['http', 'https', 'ftp'], string $defaultProtocol = 'http')
{
/**
* @psalm-suppress PropertyTypeCoercion
@@ -78,6 +80,8 @@ final class UrlAutolinkParser implements InlineParserInterface
foreach ($allowedProtocols as $protocol) {
$this->prefixes[] = $protocol . '://';
}
$this->defaultProtocol = $defaultProtocol;
}
public function getMatchDefinition(): InlineParserMatch
@@ -120,9 +124,9 @@ final class UrlAutolinkParser implements InlineParserInterface
$cursor->advanceBy(\mb_strlen($url, 'UTF-8'));
// Auto-prefix 'http://' onto 'www' URLs
// Auto-prefix 'http(s)://' onto 'www' URLs
if (\substr($url, 0, 4) === 'www.') {
$inlineContext->getContainer()->appendChild(new Link('http://' . $url, $url));
$inlineContext->getContainer()->appendChild(new Link($this->defaultProtocol . '://' . $url, $url));
return true;
}

View File

@@ -20,14 +20,14 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Delimiter\Processor;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis;
use League\CommonMark\Extension\CommonMark\Node\Inline\Strong;
use League\CommonMark\Node\Inline\AbstractStringContainer;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class EmphasisDelimiterProcessor implements DelimiterProcessorInterface, ConfigurationAwareInterface
final class EmphasisDelimiterProcessor implements CacheableDelimiterProcessorInterface, ConfigurationAwareInterface
{
/** @psalm-readonly */
private string $char;
@@ -105,4 +105,15 @@ final class EmphasisDelimiterProcessor implements DelimiterProcessorInterface, C
{
$this->config = $configuration;
}
public function getCacheKey(DelimiterInterface $closer): string
{
return \sprintf(
'%s-%s-%d-%d',
$this->char,
$closer->canOpen() ? 'canOpen' : 'cannotOpen',
$closer->getOriginalLength() % 3,
$closer->getLength(),
);
}
}

View File

@@ -27,7 +27,7 @@ class ListBlock extends AbstractBlock implements TightBlockInterface
public const DELIM_PERIOD = 'period';
public const DELIM_PAREN = 'paren';
protected bool $tight = false;
protected bool $tight = false; // TODO Make lists tight by default in v3
/** @psalm-readonly */
protected ListData $listData;

View File

@@ -44,7 +44,7 @@ final class FencedCodeParser extends AbstractBlockContinueParser
{
// Check for closing code fence
if (! $cursor->isIndented() && $cursor->getNextNonSpaceCharacter() === $this->block->getChar()) {
$match = RegexHelper::matchFirst('/^(?:`{3,}|~{3,})(?= *$)/', $cursor->getLine(), $cursor->getNextNonSpacePosition());
$match = RegexHelper::matchFirst('/^(?:`{3,}|~{3,})(?=[ \t]*$)/', $cursor->getLine(), $cursor->getNextNonSpacePosition());
if ($match !== null && \strlen($match[0]) >= $this->block->getLength()) {
// closing fence - we're at end of line, so we can finalize now
return BlockContinue::finished();

View File

@@ -63,21 +63,14 @@ final class IndentedCodeParser extends AbstractBlockContinueParser
public function closeBlock(): void
{
$reversed = \array_reverse($this->strings->toArray(), true);
foreach ($reversed as $index => $line) {
if ($line !== '' && $line !== "\n" && ! \preg_match('/^(\n *)$/', $line)) {
break;
}
$lines = $this->strings->toArray();
unset($reversed[$index]);
// Note that indented code block cannot be empty, so $lines will always have at least one non-empty element
while (\preg_match('/^[ \t]*$/', \end($lines))) { // @phpstan-ignore-line
\array_pop($lines);
}
$fixed = \array_reverse($reversed);
$tmp = \implode("\n", $fixed);
if (\substr($tmp, -1) !== "\n") {
$tmp .= "\n";
}
$this->block->setLiteral($tmp);
$this->block->setLiteral(\implode("\n", $lines) . "\n");
$this->block->setEndLine($this->block->getStartLine() + \count($lines) - 1);
}
}

View File

@@ -27,10 +27,6 @@ final class ListBlockParser extends AbstractBlockContinueParser
/** @psalm-readonly */
private ListBlock $block;
private bool $hadBlankLine = false;
private int $linesAfterBlank = 0;
public function __construct(ListData $listData)
{
$this->block = new ListBlock($listData);
@@ -48,32 +44,50 @@ final class ListBlockParser extends AbstractBlockContinueParser
public function canContain(AbstractBlock $childBlock): bool
{
if (! $childBlock instanceof ListItem) {
return false;
}
// Another list item is being added to this list block.
// If the previous line was blank, that means this list
// block is "loose" (not tight).
if ($this->hadBlankLine && $this->linesAfterBlank === 1) {
$this->block->setTight(false);
$this->hadBlankLine = false;
}
return true;
return $childBlock instanceof ListItem;
}
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
{
if ($cursor->isBlank()) {
$this->hadBlankLine = true;
$this->linesAfterBlank = 0;
} elseif ($this->hadBlankLine) {
$this->linesAfterBlank++;
}
// List blocks themselves don't have any markers, only list items. So try to stay in the list.
// If there is a block start other than list item, canContain makes sure that this list is closed.
return BlockContinue::at($cursor);
}
public function closeBlock(): void
{
$item = $this->block->firstChild();
while ($item instanceof AbstractBlock) {
// check for non-final list item ending with blank line:
if ($item->next() !== null && self::endsWithBlankLine($item)) {
$this->block->setTight(false);
break;
}
// recurse into children of list item, to see if there are spaces between any of them
$subitem = $item->firstChild();
while ($subitem instanceof AbstractBlock) {
if ($subitem->next() && self::endsWithBlankLine($subitem)) {
$this->block->setTight(false);
break 2;
}
$subitem = $subitem->next();
}
$item = $item->next();
}
$lastChild = $this->block->lastChild();
if ($lastChild instanceof AbstractBlock) {
$this->block->setEndLine($lastChild->getEndLine());
}
}
private static function endsWithBlankLine(AbstractBlock $block): bool
{
$next = $block->next();
return $next instanceof AbstractBlock && $block->getEndLine() !== $next->getStartLine() - 1;
}
}

View File

@@ -58,6 +58,7 @@ final class ListBlockStartParser implements BlockStartParserInterface, Configura
if (! ($matched instanceof ListBlockParser) || ! $listData->equals($matched->getBlock()->getListData())) {
$listBlockParser = new ListBlockParser($listData);
// We start out with assuming a list is tight. If we find a blank line, we set it to loose later.
// TODO for 3.0: Just make them tight by default in the block so we can remove this call
$listBlockParser->getBlock()->setTight(true);
return BlockStart::of($listBlockParser, $listItemParser)->at($cursor);

View File

@@ -13,11 +13,9 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Node\Block\ListData;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
use League\CommonMark\Parser\Block\BlockContinue;
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
@@ -28,8 +26,6 @@ final class ListItemParser extends AbstractBlockContinueParser
/** @psalm-readonly */
private ListItem $block;
private bool $hadBlankLine = false;
public function __construct(ListData $listData)
{
$this->block = new ListItem($listData);
@@ -47,18 +43,7 @@ final class ListItemParser extends AbstractBlockContinueParser
public function canContain(AbstractBlock $childBlock): bool
{
if ($this->hadBlankLine) {
// We saw a blank line in this list item, that means the list block is loose.
//
// spec: if any of its constituent list items directly contain two block-level elements with a blank line
// between them
$parent = $this->block->parent();
if ($parent instanceof ListBlock) {
$parent->setTight(false);
}
}
return true;
return ! $childBlock instanceof ListItem;
}
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
@@ -69,9 +54,6 @@ final class ListItemParser extends AbstractBlockContinueParser
return BlockContinue::none();
}
$activeBlock = $activeBlockParser->getBlock();
// If the active block is a code block, blank lines in it should not affect if the list is tight.
$this->hadBlankLine = $activeBlock instanceof Paragraph || $activeBlock instanceof ListItem;
$cursor->advanceToNextNonSpaceOrTab();
return BlockContinue::at($cursor);
@@ -87,4 +69,14 @@ final class ListItemParser extends AbstractBlockContinueParser
// Note: We'll hit this case for lazy continuation lines, they will get added later.
return BlockContinue::none();
}
public function closeBlock(): void
{
if (($lastChild = $this->block->lastChild()) instanceof AbstractBlock) {
$this->block->setEndLine($lastChild->getEndLine());
} else {
// Empty list item
$this->block->setEndLine($this->block->getStartLine());
}
}
}

View File

@@ -18,12 +18,27 @@ namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
final class BacktickParser implements InlineParserInterface
{
/**
* Max bound for backtick code span delimiters.
*
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
*/
private const MAX_BACKTICKS = 1000;
/** @var \WeakReference<Cursor>|null */
private ?\WeakReference $lastCursor = null;
private bool $lastCursorScanned = false;
/** @var array<int, int> backtick count => position of known ender */
private array $seenBackticks = [];
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('`+');
@@ -38,11 +53,7 @@ final class BacktickParser implements InlineParserInterface
$currentPosition = $cursor->getPosition();
$previousState = $cursor->saveState();
while ($matchingTicks = $cursor->match('/`+/m')) {
if ($matchingTicks !== $ticks) {
continue;
}
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
$c = \preg_replace('/\n/m', ' ', $code) ?? '';
@@ -67,4 +78,55 @@ final class BacktickParser implements InlineParserInterface
return true;
}
/**
* Locates the matching closer for a backtick code span.
*
* Leverages some caching to avoid traversing the same cursor multiple times when
* we've already seen all the potential backtick closers.
*
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
*
* @param int $openTickLength Number of backticks in the opening sequence
* @param Cursor $cursor Cursor to scan
*
* @return bool True if a matching closer was found, false otherwise
*/
private function findMatchingTicks(int $openTickLength, Cursor $cursor): bool
{
// Reset the seenBackticks cache if this is a new cursor
if ($this->lastCursor === null || $this->lastCursor->get() !== $cursor) {
$this->seenBackticks = [];
$this->lastCursor = \WeakReference::create($cursor);
$this->lastCursorScanned = false;
}
if ($openTickLength > self::MAX_BACKTICKS) {
return false;
}
// Return if we already know there's no closer
if ($this->lastCursorScanned && isset($this->seenBackticks[$openTickLength]) && $this->seenBackticks[$openTickLength] <= $cursor->getPosition()) {
return false;
}
while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) {
$numTicks = \strlen($ticks);
// Did we find the closer?
if ($numTicks === $openTickLength) {
return true;
}
// Store position of closer
if ($numTicks <= self::MAX_BACKTICKS) {
$this->seenBackticks[$numTicks] = $cursor->getPosition() - $numTicks;
}
}
// Got through whole input without finding closer
$this->lastCursorScanned = true;
return false;
}
}

View File

@@ -16,7 +16,6 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
use League\CommonMark\Delimiter\Delimiter;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
@@ -38,8 +37,7 @@ final class BangParser implements InlineParserInterface
$inlineContext->getContainer()->appendChild($node);
// Add entry to stack for this opener
$delimiter = new Delimiter('!', 1, $node, true, false, $cursor->getPosition());
$inlineContext->getDelimiterStack()->push($delimiter);
$inlineContext->getDelimiterStack()->addBracket($node, $cursor->getPosition(), true);
return true;
}

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
use League\CommonMark\Delimiter\Bracket;
use League\CommonMark\Environment\EnvironmentAwareInterface;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource;
@@ -46,14 +47,14 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
public function parse(InlineParserContext $inlineContext): bool
{
// Look through stack of delimiters for a [ or !
$opener = $inlineContext->getDelimiterStack()->searchByCharacter(['[', '!']);
$opener = $inlineContext->getDelimiterStack()->getLastBracket();
if ($opener === null) {
return false;
}
if (! $opener->isActive()) {
// no matched opener; remove from emphasis stack
$inlineContext->getDelimiterStack()->removeDelimiter($opener);
if (! $opener->isImage() && ! $opener->isActive()) {
// no matched opener; remove from stack
$inlineContext->getDelimiterStack()->removeBracket();
return false;
}
@@ -70,21 +71,19 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
// Inline link?
if ($result = $this->tryParseInlineLinkAndTitle($cursor)) {
$link = $result;
} elseif ($link = $this->tryParseReference($cursor, $inlineContext->getReferenceMap(), $opener->getIndex(), $startPos)) {
} elseif ($link = $this->tryParseReference($cursor, $inlineContext->getReferenceMap(), $opener, $startPos)) {
$reference = $link;
$link = ['url' => $link->getDestination(), 'title' => $link->getTitle()];
} else {
// No match
$inlineContext->getDelimiterStack()->removeDelimiter($opener); // Remove this opener from stack
// No match; remove this opener from stack
$inlineContext->getDelimiterStack()->removeBracket();
$cursor->restoreState($previousState);
return false;
}
$isImage = $opener->getChar() === '!';
$inline = $this->createInline($link['url'], $link['title'], $isImage, $reference ?? null);
$opener->getInlineNode()->replaceWith($inline);
$inline = $this->createInline($link['url'], $link['title'], $opener->isImage(), $reference ?? null);
$opener->getNode()->replaceWith($inline);
while (($label = $inline->next()) !== null) {
// Is there a Mention or Link contained within this link?
// CommonMark does not allow nested links, so we'll restore the original text.
@@ -104,8 +103,9 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
// Process delimiters such as emphasis inside link/image
$delimiterStack = $inlineContext->getDelimiterStack();
$stackBottom = $opener->getPrevious();
$stackBottom = $opener->getPosition();
$delimiterStack->processDelimiters($stackBottom, $this->environment->getDelimiterProcessors());
$delimiterStack->removeBracket();
$delimiterStack->removeAll($stackBottom);
// Merge any adjacent Text nodes together
@@ -113,8 +113,8 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
// processEmphasis will remove this and later delimiters.
// Now, for a link, we also remove earlier link openers (no links in links)
if (! $isImage) {
$inlineContext->getDelimiterStack()->removeEarlierMatches('[');
if (! $opener->isImage()) {
$inlineContext->getDelimiterStack()->deactivateLinkOpeners();
}
return true;
@@ -168,21 +168,23 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
return ['url' => $dest, 'title' => $title];
}
private function tryParseReference(Cursor $cursor, ReferenceMapInterface $referenceMap, ?int $openerIndex, int $startPos): ?ReferenceInterface
private function tryParseReference(Cursor $cursor, ReferenceMapInterface $referenceMap, Bracket $opener, int $startPos): ?ReferenceInterface
{
if ($openerIndex === null) {
return null;
}
$savePos = $cursor->saveState();
$beforeLabel = $cursor->getPosition();
$n = LinkParserHelper::parseLinkLabel($cursor);
if ($n === 0 || $n === 2) {
$start = $openerIndex;
$length = $startPos - $openerIndex;
} else {
if ($n > 2) {
$start = $beforeLabel + 1;
$length = $n - 2;
} elseif (! $opener->hasNext()) {
// Empty or missing second label means to use the first label as the reference.
// The reference must not contain a bracket. If we know there's a bracket, we don't even bother checking it.
$start = $opener->getPosition();
$length = $startPos - $start;
} else {
$cursor->restoreState($savePos);
return null;
}
$referenceLabel = $cursor->getSubstring($start, $length);

View File

@@ -16,7 +16,6 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
use League\CommonMark\Delimiter\Delimiter;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
@@ -36,8 +35,7 @@ final class OpenBracketParser implements InlineParserInterface
$inlineContext->getContainer()->appendChild($node);
// Add entry to stack for this opener
$delimiter = new Delimiter('[', 1, $node, true, false, $inlineContext->getCursor()->getPosition());
$inlineContext->getDelimiterStack()->push($delimiter);
$inlineContext->getDelimiterStack()->addBracket($node, $inlineContext->getCursor()->getPosition(), false);
return true;
}

View File

@@ -41,7 +41,12 @@ final class FencedCodeRenderer implements NodeRendererInterface, XmlNodeRenderer
$infoWords = $node->getInfoWords();
if (\count($infoWords) !== 0 && $infoWords[0] !== '') {
$attrs->append('class', 'language-' . $infoWords[0]);
$class = $infoWords[0];
if (! \str_starts_with($class, 'language-')) {
$class = 'language-' . $class;
}
$attrs->append('class', $class);
}
return new HtmlElement(

View File

@@ -17,8 +17,9 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\CommonMark\Renderer\Block;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Block\TightBlockInterface;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
@@ -39,11 +40,14 @@ final class ListItemRenderer implements NodeRendererInterface, XmlNodeRendererIn
ListItem::assertInstanceOf($node);
$contents = $childRenderer->renderNodes($node->children());
if (\substr($contents, 0, 1) === '<' && ! $this->startsTaskListItem($node)) {
$inTightList = ($parent = $node->parent()) && $parent instanceof TightBlockInterface && $parent->isTight();
if ($this->needsBlockSeparator($node->firstChild(), $inTightList)) {
$contents = "\n" . $contents;
}
if (\substr($contents, -1, 1) === '>') {
if ($this->needsBlockSeparator($node->lastChild(), $inTightList)) {
$contents .= "\n";
}
@@ -65,10 +69,12 @@ final class ListItemRenderer implements NodeRendererInterface, XmlNodeRendererIn
return [];
}
private function startsTaskListItem(ListItem $block): bool
private function needsBlockSeparator(?Node $child, bool $inTightList): bool
{
$firstChild = $block->firstChild();
if ($child instanceof Paragraph && $inTightList) {
return false;
}
return $firstChild instanceof Paragraph && $firstChild->firstChild() instanceof TaskListItemMarker;
return $child instanceof AbstractBlock;
}
}

View File

@@ -24,12 +24,19 @@ use League\CommonMark\Util\RegexHelper;
final class QuoteParser implements InlineParserInterface
{
/**
* @deprecated This constant is no longer used and will be removed in a future major release
*/
public const DOUBLE_QUOTES = [Quote::DOUBLE_QUOTE, Quote::DOUBLE_QUOTE_OPENER, Quote::DOUBLE_QUOTE_CLOSER];
/**
* @deprecated This constant is no longer used and will be removed in a future major release
*/
public const SINGLE_QUOTES = [Quote::SINGLE_QUOTE, Quote::SINGLE_QUOTE_OPENER, Quote::SINGLE_QUOTE_CLOSER];
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::oneOf(...\array_merge(self::DOUBLE_QUOTES, self::SINGLE_QUOTES));
return InlineParserMatch::oneOf(Quote::SINGLE_QUOTE, Quote::DOUBLE_QUOTE);
}
/**
@@ -39,8 +46,7 @@ final class QuoteParser implements InlineParserInterface
{
$char = $inlineContext->getFullMatch();
$cursor = $inlineContext->getCursor();
$normalizedCharacter = $this->getNormalizedQuoteCharacter($char);
$index = $cursor->getPosition();
$charBefore = $cursor->peek(-1);
if ($charBefore === null) {
@@ -58,28 +64,15 @@ final class QuoteParser implements InlineParserInterface
$canOpen = $leftFlanking && ! $rightFlanking;
$canClose = $rightFlanking;
$node = new Quote($normalizedCharacter, ['delim' => true]);
$node = new Quote($char, ['delim' => true]);
$inlineContext->getContainer()->appendChild($node);
// Add entry to stack to this opener
$inlineContext->getDelimiterStack()->push(new Delimiter($normalizedCharacter, 1, $node, $canOpen, $canClose));
$inlineContext->getDelimiterStack()->push(new Delimiter($char, 1, $node, $canOpen, $canClose, $index));
return true;
}
private function getNormalizedQuoteCharacter(string $character): string
{
if (\in_array($character, self::DOUBLE_QUOTES, true)) {
return Quote::DOUBLE_QUOTE;
}
if (\in_array($character, self::SINGLE_QUOTES, true)) {
return Quote::SINGLE_QUOTE;
}
return $character;
}
/**
* @return bool[]
*/

View File

@@ -14,10 +14,10 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Strikethrough;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
use League\CommonMark\Node\Inline\AbstractStringContainer;
final class StrikethroughDelimiterProcessor implements DelimiterProcessorInterface
final class StrikethroughDelimiterProcessor implements CacheableDelimiterProcessorInterface
{
public function getOpeningCharacter(): string
{
@@ -44,7 +44,8 @@ final class StrikethroughDelimiterProcessor implements DelimiterProcessorInterfa
return 0;
}
return \min($opener->getLength(), $closer->getLength());
// $opener and $closer are the same length so we just return one of them
return $opener->getLength();
}
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
@@ -60,4 +61,9 @@ final class StrikethroughDelimiterProcessor implements DelimiterProcessorInterfa
$opener->insertAfter($strikethrough);
}
public function getCacheKey(DelimiterInterface $closer): string
{
return '~' . $closer->getLength();
}
}

View File

@@ -41,6 +41,7 @@ final class TableExtension implements ConfigurableExtensionInterface
'center' => (clone $attributeArraySchema)->default(['align' => 'center']),
'right' => (clone $attributeArraySchema)->default(['align' => 'right']),
]),
'max_autocompleted_cells' => Expect::int()->min(0)->default(TableParser::DEFAULT_MAX_AUTOCOMPLETED_CELLS),
]));
}
@@ -52,7 +53,7 @@ final class TableExtension implements ConfigurableExtensionInterface
}
$environment
->addBlockStartParser(new TableStartParser())
->addBlockStartParser(new TableStartParser($environment->getConfiguration()->get('table/max_autocompleted_cells')))
->addRenderer(Table::class, $tableRenderer)
->addRenderer(TableSection::class, new TableSectionRenderer())

View File

@@ -25,6 +25,11 @@ use League\CommonMark\Util\ArrayCollection;
final class TableParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface
{
/**
* @internal
*/
public const DEFAULT_MAX_AUTOCOMPLETED_CELLS = 10_000;
/** @psalm-readonly */
private Table $block;
@@ -54,6 +59,8 @@ final class TableParser extends AbstractBlockContinueParser implements BlockCont
/** @psalm-readonly-allow-private-mutation */
private bool $nextIsSeparatorLine = true;
private int $remainingAutocompletedCells;
/**
* @param array<int, string|null> $columns
* @param array<int, string> $headerCells
@@ -62,12 +69,13 @@ final class TableParser extends AbstractBlockContinueParser implements BlockCont
*
* @phpstan-param array<int, TableCell::ALIGN_*|null> $columns
*/
public function __construct(array $columns, array $headerCells)
public function __construct(array $columns, array $headerCells, int $remainingAutocompletedCells = self::DEFAULT_MAX_AUTOCOMPLETED_CELLS)
{
$this->block = new Table();
$this->bodyLines = new ArrayCollection();
$this->columns = $columns;
$this->headerCells = $headerCells;
$this->block = new Table();
$this->bodyLines = new ArrayCollection();
$this->columns = $columns;
$this->headerCells = $headerCells;
$this->remainingAutocompletedCells = $remainingAutocompletedCells;
}
public function canHaveLazyContinuationLines(): bool
@@ -121,6 +129,12 @@ final class TableParser extends AbstractBlockContinueParser implements BlockCont
// Body can not have more columns than head
for ($i = 0; $i < $headerColumns; $i++) {
// It can have less columns though, in which case we'll autocomplete the empty ones (up to some limit)
if (! isset($cells[$i]) && $this->remainingAutocompletedCells-- <= 0) {
// Too many cells were auto-completed, so we'll just stop here
return;
}
$cell = $cells[$i] ?? '';
$tableCell = $this->parseCell($cell, $i, $inlineParser);
$row->appendChild($tableCell);
@@ -138,14 +152,12 @@ final class TableParser extends AbstractBlockContinueParser implements BlockCont
private function parseCell(string $cell, int $column, InlineParserEngineInterface $inlineParser): TableCell
{
$tableCell = new TableCell();
$tableCell = new TableCell(TableCell::TYPE_DATA, $this->columns[$column] ?? null);
if ($column < \count($this->columns)) {
$tableCell->setAlign($this->columns[$column]);
if ($cell !== '') {
$inlineParser->parse(\trim($cell), $tableCell);
}
$inlineParser->parse(\trim($cell), $tableCell);
return $tableCell;
}

View File

@@ -23,6 +23,13 @@ use League\CommonMark\Parser\MarkdownParserStateInterface;
final class TableStartParser implements BlockStartParserInterface
{
private int $maxAutocompletedCells;
public function __construct(int $maxAutocompletedCells = TableParser::DEFAULT_MAX_AUTOCOMPLETED_CELLS)
{
$this->maxAutocompletedCells = $maxAutocompletedCells;
}
public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart
{
$paragraph = $parserState->getParagraphContent();
@@ -35,8 +42,8 @@ final class TableStartParser implements BlockStartParserInterface
return BlockStart::none();
}
$lines = \explode("\n", $paragraph);
$lastLine = \array_pop($lines);
$lastLineBreak = \strrpos($paragraph, "\n");
$lastLine = $lastLineBreak === false ? $paragraph : \substr($paragraph, $lastLineBreak + 1);
$headerCells = TableParser::split($lastLine);
if (\count($headerCells) > \count($columns)) {
@@ -47,13 +54,13 @@ final class TableStartParser implements BlockStartParserInterface
$parsers = [];
if (\count($lines) > 0) {
if ($lastLineBreak !== false) {
$p = new ParagraphParser();
$p->addLine(\implode("\n", $lines));
$p->addLine(\substr($paragraph, 0, $lastLineBreak));
$parsers[] = $p;
}
$parsers[] = new TableParser($columns, $headerCells);
$parsers[] = new TableParser($columns, $headerCells, $this->maxAutocompletedCells);
return BlockStart::of(...$parsers)
->at($cursor)

View File

@@ -18,4 +18,6 @@ namespace League\CommonMark\Node\Block;
class Paragraph extends AbstractBlock
{
/** @internal */
public bool $onlyContainsLinkReferenceDefinitions = false;
}

View File

@@ -51,6 +51,7 @@ final class SlugNormalizer implements TextNormalizerInterface, ConfigurationAwar
$slug = \mb_substr($slug, 0, $length, 'UTF-8');
}
// @phpstan-ignore-next-line Because it thinks mb_substr() returns false on PHP 7.4
return $slug;
}
}

View File

@@ -34,6 +34,11 @@ final class TextNormalizer implements TextNormalizerInterface
$text = \preg_replace('/[ \t\r\n]+/', ' ', \trim($text));
\assert(\is_string($text));
// Is it strictly ASCII? If so, we can use strtolower() instead (faster)
if (\mb_check_encoding($text, 'ASCII')) {
return \strtolower($text);
}
return \mb_convert_case($text, \MB_CASE_FOLD, 'UTF-8');
}
}

View File

@@ -15,6 +15,7 @@ namespace League\CommonMark\Parser\Block;
use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Reference\ReferenceMapInterface;
@@ -50,4 +51,30 @@ final class DocumentBlockParser extends AbstractBlockContinueParser
{
return BlockContinue::at($cursor);
}
public function closeBlock(): void
{
$this->removeLinkReferenceDefinitions();
}
private function removeLinkReferenceDefinitions(): void
{
$emptyNodes = [];
$walker = $this->document->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
// TODO for v3: It would be great if we could find an alternate way to identify such paragraphs.
// Unfortunately, we can't simply check for empty paragraphs here because inlines haven't been processed yet,
// meaning all paragraphs will appear blank here, and we don't have a way to check the status of the reference parser
// which is attached to the (already-closed) paragraph parser.
if ($event->isEntering() && $node instanceof Paragraph && $node->onlyContainsLinkReferenceDefinitions) {
$emptyNodes[] = $node;
}
}
foreach ($emptyNodes as $node) {
$node->detach();
}
}
}

View File

@@ -59,9 +59,7 @@ final class ParagraphParser extends AbstractBlockContinueParser implements Block
public function closeBlock(): void
{
if ($this->referenceParser->hasReferences() && $this->referenceParser->getParagraphContent() === '') {
$this->block->detach();
}
$this->block->onlyContainsLinkReferenceDefinitions = $this->referenceParser->hasReferences() && $this->referenceParser->getParagraphContent() === '';
}
public function parseInlines(InlineParserEngineInterface $inlineParser): void

View File

@@ -322,20 +322,21 @@ class Cursor
*/
public function advanceToNextNonSpaceOrNewline(): int
{
$remainder = $this->getRemainder();
$currentCharacter = $this->getCurrentCharacter();
// Optimization: Avoid the regex if we know there are no spaces or newlines
if ($remainder === '' || ($remainder[0] !== ' ' && $remainder[0] !== "\n")) {
if ($currentCharacter !== ' ' && $currentCharacter !== "\n") {
$this->previousPosition = $this->currentPosition;
return 0;
}
$matches = [];
\preg_match('/^ *(?:\n *)?/', $remainder, $matches, \PREG_OFFSET_CAPTURE);
\preg_match('/^ *(?:\n *)?/', $this->getRemainder(), $matches, \PREG_OFFSET_CAPTURE);
// [0][0] contains the matched text
// [0][1] contains the index of that match
\assert(isset($matches[0]));
$increment = $matches[0][1] + \strlen($matches[0][0]);
$this->advanceBy($increment);

View File

@@ -42,12 +42,12 @@ final class InlineParserContext
*/
private array $matches;
public function __construct(Cursor $contents, AbstractBlock $container, ReferenceMapInterface $referenceMap)
public function __construct(Cursor $contents, AbstractBlock $container, ReferenceMapInterface $referenceMap, int $maxDelimitersPerLine = PHP_INT_MAX)
{
$this->referenceMap = $referenceMap;
$this->container = $container;
$this->cursor = $contents;
$this->delimiterStack = new DelimiterStack();
$this->delimiterStack = new DelimiterStack($maxDelimitersPerLine);
}
public function getContainer(): AbstractBlock

View File

@@ -59,7 +59,7 @@ final class InlineParserEngine implements InlineParserEngineInterface
$contents = \trim($contents);
$cursor = new Cursor($contents);
$inlineParserContext = new InlineParserContext($cursor, $block, $this->referenceMap);
$inlineParserContext = new InlineParserContext($cursor, $block, $this->referenceMap, $this->environment->getConfiguration()->get('max_delimiters_per_line'));
// Have all parsers look at the line to determine what they might want to parse and what positions they exist at
foreach ($this->matchParsers($contents) as $matchPosition => $parsers) {

View File

@@ -32,6 +32,7 @@ use League\CommonMark\Parser\Block\BlockStart;
use League\CommonMark\Parser\Block\BlockStartParserInterface;
use League\CommonMark\Parser\Block\DocumentBlockParser;
use League\CommonMark\Parser\Block\ParagraphParser;
use League\CommonMark\Reference\MemoryLimitedReferenceMap;
use League\CommonMark\Reference\ReferenceInterface;
use League\CommonMark\Reference\ReferenceMap;
@@ -102,7 +103,7 @@ final class MarkdownParser implements MarkdownParserInterface
// finalizeAndProcess
$this->closeBlockParsers(\count($this->activeBlockParsers), $this->lineNumber);
$this->processInlines();
$this->processInlines(\strlen($input));
$this->environment->dispatch(new DocumentParsedEvent($documentParser->getBlock()));
@@ -115,6 +116,9 @@ final class MarkdownParser implements MarkdownParserInterface
*/
private function parseLine(string $line): void
{
// replace NUL characters for security
$line = \str_replace("\0", "\u{FFFD}", $line);
$this->cursor = new Cursor($line);
$matches = $this->parseBlockContinuation();
@@ -158,12 +162,13 @@ final class MarkdownParser implements MarkdownParserInterface
$unmatchedBlocks = 0;
}
$oldBlockLineStart = null;
if ($blockStart->isReplaceActiveBlockParser()) {
$this->prepareActiveBlockParserForReplacement();
$oldBlockLineStart = $this->prepareActiveBlockParserForReplacement();
}
foreach ($blockStart->getBlockParsers() as $newBlockParser) {
$blockParser = $this->addChild($newBlockParser);
$blockParser = $this->addChild($newBlockParser, $oldBlockLineStart);
$tryBlockStarts = $newBlockParser->isContainer();
}
}
@@ -176,7 +181,7 @@ final class MarkdownParser implements MarkdownParserInterface
} else {
// finalize any blocks not matched
if ($unmatchedBlocks > 0) {
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber);
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber - 1);
}
if (! $blockParser->isContainer()) {
@@ -262,9 +267,9 @@ final class MarkdownParser implements MarkdownParserInterface
/**
* Walk through a block & children recursively, parsing string content into inline content where appropriate.
*/
private function processInlines(): void
private function processInlines(int $inputSize): void
{
$p = new InlineParserEngine($this->environment, $this->referenceMap);
$p = new InlineParserEngine($this->environment, new MemoryLimitedReferenceMap($this->referenceMap, $inputSize));
foreach ($this->closedBlockParsers as $blockParser) {
$blockParser->parseInlines($p);
@@ -275,12 +280,12 @@ final class MarkdownParser implements MarkdownParserInterface
* Add block of type tag as a child of the tip. If the tip can't accept children, close and finalize it and try
* its parent, and so on til we find a block that can accept children.
*/
private function addChild(BlockContinueParserInterface $blockParser): BlockContinueParserInterface
private function addChild(BlockContinueParserInterface $blockParser, ?int $startLineNumber = null): BlockContinueParserInterface
{
$blockParser->getBlock()->setStartLine($this->lineNumber);
$blockParser->getBlock()->setStartLine($startLineNumber ?? $this->lineNumber);
while (! $this->getActiveBlockParser()->canContain($blockParser->getBlock())) {
$this->closeBlockParsers(1, $this->lineNumber - 1);
$this->closeBlockParsers(1, ($startLineNumber ?? $this->lineNumber) - 1);
}
$this->getActiveBlockParser()->getBlock()->appendChild($blockParser->getBlock());
@@ -307,7 +312,10 @@ final class MarkdownParser implements MarkdownParserInterface
return $popped;
}
private function prepareActiveBlockParserForReplacement(): void
/**
* @return int|null The line number where the old block started
*/
private function prepareActiveBlockParserForReplacement(): ?int
{
// Note that we don't want to parse inlines or finalize this block, as it's getting replaced.
$old = $this->deactivateBlockParser();
@@ -317,6 +325,8 @@ final class MarkdownParser implements MarkdownParserInterface
}
$old->getBlock()->detach();
return $old->getBlock()->getStartLine();
}
/**

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Reference;
final class MemoryLimitedReferenceMap implements ReferenceMapInterface
{
private ReferenceMapInterface $decorated;
private const MINIMUM_SIZE = 100_000;
private int $remaining;
public function __construct(ReferenceMapInterface $decorated, int $maxSize)
{
$this->decorated = $decorated;
$this->remaining = \max(self::MINIMUM_SIZE, $maxSize);
}
public function add(ReferenceInterface $reference): void
{
$this->decorated->add($reference);
}
public function contains(string $label): bool
{
return $this->decorated->contains($label);
}
public function get(string $label): ?ReferenceInterface
{
$reference = $this->decorated->get($label);
if ($reference === null) {
return null;
}
// Check for expansion limit
$this->remaining -= \strlen($reference->getDestination()) + \strlen($reference->getTitle());
if ($this->remaining < 0) {
return null;
}
return $reference;
}
/**
* @return \Traversable<string, ReferenceInterface>
*/
public function getIterator(): \Traversable
{
return $this->decorated->getIterator();
}
public function count(): int
{
return $this->decorated->count();
}
}

View File

@@ -48,6 +48,10 @@ final class ReferenceMap implements ReferenceMapInterface
public function contains(string $label): bool
{
if ($this->references === []) {
return false;
}
$label = $this->normalizer->normalize($label);
return isset($this->references[$label]);
@@ -55,6 +59,10 @@ final class ReferenceMap implements ReferenceMapInterface
public function get(string $label): ?ReferenceInterface
{
if ($this->references === []) {
return null;
}
$label = $this->normalizer->normalize($label);
return $this->references[$label] ?? null;

View File

@@ -30,15 +30,8 @@ final class LinkParserHelper
*/
public static function parseLinkDestination(Cursor $cursor): ?string
{
if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
// Chop off surrounding <..>:
return UrlEncoder::unescapeAndEncode(
RegexHelper::unescape(\substr($res, 1, -1))
);
}
if ($cursor->getCurrentCharacter() === '<') {
return null;
return self::parseDestinationBraces($cursor);
}
$destination = self::manuallyParseLinkDestination($cursor);
@@ -69,7 +62,7 @@ final class LinkParserHelper
public static function parsePartialLinkLabel(Cursor $cursor): ?string
{
return $cursor->match('/^(?:[^\\\\\[\]]+|\\\\.?)*/');
return $cursor->match('/^(?:[^\\\\\[\]]++|\\\\.?)*+/');
}
/**
@@ -100,27 +93,27 @@ final class LinkParserHelper
private static function manuallyParseLinkDestination(Cursor $cursor): ?string
{
$oldPosition = $cursor->getPosition();
$oldState = $cursor->saveState();
$remainder = $cursor->getRemainder();
$openParens = 0;
while (($c = $cursor->getCurrentCharacter()) !== null) {
if ($c === '\\' && ($peek = $cursor->peek()) !== null && RegexHelper::isEscapable($peek)) {
$cursor->advanceBy(2);
$len = \strlen($remainder);
for ($i = 0; $i < $len; $i++) {
$c = $remainder[$i];
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) {
$i++;
} elseif ($c === '(') {
$cursor->advanceBy(1);
$openParens++;
// Limit to 32 nested parens for pathological cases
if ($openParens > 32) {
return null;
}
} elseif ($c === ')') {
if ($openParens < 1) {
break;
}
$cursor->advanceBy(1);
$openParens--;
} elseif (\preg_match(RegexHelper::REGEX_WHITESPACE_CHAR, $c)) {
} elseif (\ord($c) <= 32 && RegexHelper::isWhitespace($c)) {
break;
} else {
$cursor->advanceBy(1);
}
}
@@ -128,15 +121,45 @@ final class LinkParserHelper
return null;
}
if ($cursor->getPosition() === $oldPosition && (! isset($c) || $c !== ')')) {
if ($i === 0 && (! isset($c) || $c !== ')')) {
return null;
}
$newPos = $cursor->getPosition();
$cursor->restoreState($oldState);
$destination = \substr($remainder, 0, $i);
$cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
$cursor->advanceBy($newPos - $cursor->getPosition());
return $destination;
}
return $cursor->getPreviousText();
/** @var \WeakReference<Cursor>|null */
private static ?\WeakReference $lastCursor = null;
private static bool $lastCursorLacksClosingBrace = false;
private static function parseDestinationBraces(Cursor $cursor): ?string
{
// Optimization: If we've previously parsed this cursor and returned `null`, we know
// that no closing brace exists, so we can skip the regex entirely. This helps avoid
// certain pathological cases where the regex engine can take a very long time to
// determine that no match exists.
if (self::$lastCursor !== null && self::$lastCursor->get() === $cursor) {
if (self::$lastCursorLacksClosingBrace) {
return null;
}
} else {
self::$lastCursor = \WeakReference::create($cursor);
}
if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
self::$lastCursorLacksClosingBrace = false;
// Chop off surrounding <..>:
return UrlEncoder::unescapeAndEncode(
RegexHelper::unescape(\substr($res, 1, -1))
);
}
self::$lastCursorLacksClosingBrace = true;
return null;
}
}

View File

@@ -41,7 +41,7 @@ final class RegexHelper
public const PARTIAL_REG_CHAR = '[^\\\\()\x00-\x20]';
public const PARTIAL_IN_PARENS_NOSP = '\((' . self::PARTIAL_REG_CHAR . '|' . self::PARTIAL_ESCAPED_CHAR . '|\\\\)*\)';
public const PARTIAL_TAGNAME = '[a-z][a-z0-9-]*';
public const PARTIAL_BLOCKTAGNAME = '(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)';
public const PARTIAL_BLOCKTAGNAME = '(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)';
public const PARTIAL_ATTRIBUTENAME = '[a-z_:][a-z0-9:._-]*';
public const PARTIAL_UNQUOTEDVALUE = '[^"\'=<>`\x00-\x20]+';
public const PARTIAL_SINGLEQUOTEDVALUE = '\'[^\']*\'';
@@ -53,19 +53,19 @@ final class RegexHelper
public const PARTIAL_CLOSETAG = '<\/' . self::PARTIAL_TAGNAME . '\s*[>]';
public const PARTIAL_OPENBLOCKTAG = '<' . self::PARTIAL_BLOCKTAGNAME . self::PARTIAL_ATTRIBUTE . '*' . '\s*\/?>';
public const PARTIAL_CLOSEBLOCKTAG = '<\/' . self::PARTIAL_BLOCKTAGNAME . '\s*[>]';
public const PARTIAL_HTMLCOMMENT = '<!---->|<!--(?:-?[^>-])(?:-?[^-])*-->';
public const PARTIAL_HTMLCOMMENT = '<!-->|<!--->|<!--[\s\S]*?-->';
public const PARTIAL_PROCESSINGINSTRUCTION = '[<][?][\s\S]*?[?][>]';
public const PARTIAL_DECLARATION = '<![A-Z]+' . '\s+[^>]*>';
public const PARTIAL_DECLARATION = '<![A-Za-z]+' . '[^>]*>';
public const PARTIAL_CDATA = '<!\[CDATA\[[\s\S]*?]\]>';
public const PARTIAL_HTMLTAG = '(?:' . self::PARTIAL_OPENTAG . '|' . self::PARTIAL_CLOSETAG . '|' . self::PARTIAL_HTMLCOMMENT . '|' .
self::PARTIAL_PROCESSINGINSTRUCTION . '|' . self::PARTIAL_DECLARATION . '|' . self::PARTIAL_CDATA . ')';
public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s\/>]|$)' . '|' .
'\/' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s>]|$)' . '|' . '[?!])';
public const PARTIAL_LINK_TITLE = '^(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*"' .
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*\'' .
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*\))';
public const PARTIAL_LINK_TITLE = '^(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*+"' .
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*+\'' .
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
public const REGEX_PUNCTUATION = '/^[\x{2000}-\x{206F}\x{2E00}-\x{2E7F}\p{Pc}\p{Pd}\p{Pe}\p{Pf}\p{Pi}\p{Po}\p{Ps}\\\\\'!"#\$%&\(\)\*\+,\-\.\\/:;<=>\?@\[\]\^_`\{\|\}~]/u';
public const REGEX_PUNCTUATION = '/^[!"#$%&\'()*+,\-.\\/:;<=>?@\\[\\]\\\\^_`{|}~\p{P}\p{S}]/u';
public const REGEX_UNSAFE_PROTOCOL = '/^javascript:|vbscript:|file:|data:/i';
public const REGEX_SAFE_DATA_PROTOCOL = '/^data:image\/(?:png|gif|jpeg|webp)/i';
public const REGEX_NON_SPACE = '/[^ \t\f\v\r\n]/';
@@ -83,6 +83,12 @@ final class RegexHelper
return \preg_match('/' . self::PARTIAL_ESCAPABLE . '/', $character) === 1;
}
public static function isWhitespace(string $character): bool
{
/** @psalm-suppress InvalidLiteralArgument */
return $character !== '' && \strpos(" \t\n\x0b\x0c\x0d", $character) !== false;
}
/**
* @psalm-pure
*/

View File

@@ -40,6 +40,7 @@ final class SpecReader
$exampleNumber = 0;
foreach ($matches as $match) {
\assert(isset($match[1], $match[2], $match[3]));
if (isset($match[4])) {
$currentSection = $match[4];
continue;

View File

@@ -1,4 +1,4 @@
Copyright (c) 2013-2023 Frank de Jonge
Copyright (c) 2013-2024 Frank de Jonge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -71,10 +71,10 @@ class LocalFilesystemAdapter implements FilesystemAdapter, ChecksumProvider
public function __construct(
string $location,
VisibilityConverter $visibility = null,
?VisibilityConverter $visibility = null,
private int $writeFlags = LOCK_EX,
private int $linkHandling = self::DISALLOW_LINKS,
MimeTypeDetector $mimeTypeDetector = null,
?MimeTypeDetector $mimeTypeDetector = null,
bool $lazyRootCreation = false,
bool $useInconclusiveMimeTypeFallback = false,
) {
@@ -271,7 +271,7 @@ class LocalFilesystemAdapter implements FilesystemAdapter, ChecksumProvider
$this->resolveDirectoryVisibility($config->get(Config::OPTION_DIRECTORY_VISIBILITY))
);
if ( ! @copy($sourcePath, $destinationPath)) {
if ($sourcePath !== $destinationPath && ! @copy($sourcePath, $destinationPath)) {
throw UnableToCopyFile::because(error_get_last()['message'] ?? 'unknown', $source, $destination);
}

View File

@@ -1,4 +1,4 @@
Copyright (c) 2013-2023 Frank de Jonge
Copyright (c) 2013-2024 Frank de Jonge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -25,17 +25,20 @@
"ext-zip": "*",
"ext-fileinfo": "*",
"ext-ftp": "*",
"ext-mongodb": "^1.3",
"microsoft/azure-storage-blob": "^1.1",
"phpunit/phpunit": "^9.5.11|^10.0",
"phpstan/phpstan": "^1.10",
"phpseclib/phpseclib": "^3.0.34",
"aws/aws-sdk-php": "^3.220.0",
"phpseclib/phpseclib": "^3.0.36",
"aws/aws-sdk-php": "^3.295.10",
"composer/semver": "^3.0",
"friendsofphp/php-cs-fixer": "^3.5",
"google/cloud-storage": "^1.23",
"async-aws/s3": "^1.5 || ^2.0",
"async-aws/simple-s3": "^1.1 || ^2.0",
"sabre/dav": "^4.3.1"
"mongodb/mongodb": "^1.2",
"sabre/dav": "^4.6.0",
"guzzlehttp/psr7": "^2.6"
},
"conflict": {
"async-aws/core": "<1.19.0",

View File

@@ -32,6 +32,7 @@ for which ever storage is right for you.
* **[AsyncAws S3](https://flysystem.thephpleague.com/docs/adapter/async-aws-s3/)**
* **[Google Cloud Storage](https://flysystem.thephpleague.com/docs/adapter/google-cloud-storage/)**
* **[Azure Blob Storage](https://flysystem.thephpleague.com/docs/adapter/azure-blob-storage/)**
* **[MongoDB GridFS](https://flysystem.thephpleague.com/docs/adapter/gridfs/)**
* **[WebDAV](https://flysystem.thephpleague.com/docs/adapter/webdav/)**
* **[ZipArchive](https://flysystem.thephpleague.com/docs/adapter/zip-archive/)**
@@ -45,6 +46,7 @@ for which ever storage is right for you.
* **[Dropbox](https://github.com/spatie/flysystem-dropbox)**
* **[ReplicateAdapter](https://github.com/ajgarlag/flysystem-replicate)**
* **[Uploadcare](https://github.com/vormkracht10/flysystem-uploadcare)**
* **[Useful adapters (FallbackAdapter, LogAdapter, ReadWriteAdapter, RetryAdapter)](https://github.com/ElGigi/FlysystemUsefulAdapters)**
You can always [create an adapter](https://flysystem.thephpleague.com/docs/advanced/creating-an-adapter/) yourself.

View File

@@ -25,7 +25,7 @@ class Filesystem implements FilesystemOperator
public function __construct(
private FilesystemAdapter $adapter,
array $config = [],
PathNormalizer $pathNormalizer = null,
?PathNormalizer $pathNormalizer = null,
private ?PublicUrlGenerator $publicUrlGenerator = null,
private ?TemporaryUrlGenerator $temporaryUrlGenerator = null,
) {
@@ -187,7 +187,10 @@ class Filesystem implements FilesystemOperator
?? throw UnableToGeneratePublicUrl::noGeneratorConfigured($path);
$config = $this->config->extend($config);
return $this->publicUrlGenerator->publicUrl($this->pathNormalizer->normalizePath($path), $config);
return $this->publicUrlGenerator->publicUrl(
$this->pathNormalizer->normalizePath($path),
$config,
);
}
public function temporaryUrl(string $path, DateTimeInterface $expiresAt, array $config = []): string
@@ -214,9 +217,15 @@ class Filesystem implements FilesystemOperator
}
try {
return $this->adapter->checksum($path, $config);
return $this->adapter->checksum(
$this->pathNormalizer->normalizePath($path),
$config,
);
} catch (ChecksumAlgoIsNotSupported) {
return $this->calculateChecksumFromStream($path, $config);
return $this->calculateChecksumFromStream(
$this->pathNormalizer->normalizePath($path),
$config,
);
}
}

View File

@@ -7,6 +7,7 @@ namespace League\Flysystem;
use DateTimeInterface;
use Throwable;
use function compact;
use function method_exists;
use function sprintf;
@@ -33,6 +34,15 @@ class MountManager implements FilesystemOperator
$this->config = new Config($config);
}
/**
* It is not recommended to mount filesystems after creation because interacting
* with the Mount Manager becomes unpredictable. Use this as an escape hatch.
*/
public function dangerouslyMountFilesystems(string $key, FilesystemOperator $filesystem): void
{
$this->mountFilesystem($key, $filesystem);
}
/**
* @param array<string,FilesystemOperator> $filesystems
*/
@@ -156,15 +166,15 @@ class MountManager implements FilesystemOperator
}
}
public function visibility(string $location): string
public function visibility(string $path): string
{
/** @var FilesystemOperator $filesystem */
[$filesystem, $path] = $this->determineFilesystemAndPath($location);
[$filesystem, $location] = $this->determineFilesystemAndPath($path);
try {
return $filesystem->visibility($path);
return $filesystem->visibility($location);
} catch (UnableToRetrieveMetadata $exception) {
throw UnableToRetrieveMetadata::visibility($location, $exception->reason(), $exception);
throw UnableToRetrieveMetadata::visibility($path, $exception->reason(), $exception);
}
}
@@ -318,11 +328,7 @@ class MountManager implements FilesystemOperator
}
}
/**
* @param mixed $key
* @param mixed $filesystem
*/
private function guardAgainstInvalidMount($key, $filesystem): void
private function guardAgainstInvalidMount(mixed $key, mixed $filesystem): void
{
if ( ! is_string($key)) {
throw UnableToMountFilesystem::becauseTheKeyIsNotValid($key);
@@ -391,10 +397,11 @@ class MountManager implements FilesystemOperator
try {
if ($visibility == null && $retainVisibility) {
$visibility = $sourceFilesystem->visibility($sourcePath);
$config = $config->extend(compact('visibility'));
}
$stream = $sourceFilesystem->readStream($sourcePath);
$destinationFilesystem->writeStream($destinationPath, $stream, $visibility ? compact(Config::OPTION_VISIBILITY) : []);
$destinationFilesystem->writeStream($destinationPath, $stream, $config->toArray());
} catch (UnableToRetrieveMetadata | UnableToReadFile | UnableToWriteFile $exception) {
throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
}

View File

@@ -14,7 +14,7 @@ class UnableToCheckExistence extends RuntimeException implements FilesystemOpera
parent::__construct($message, $code, $previous);
}
public static function forLocation(string $path, Throwable $exception = null): static
public static function forLocation(string $path, ?Throwable $exception = null): static
{
return new static("Unable to check existence for: {$path}", 0, $exception);
}

View File

@@ -32,7 +32,7 @@ final class UnableToCopyFile extends RuntimeException implements FilesystemOpera
public static function fromLocationTo(
string $sourcePath,
string $destinationPath,
Throwable $previous = null
?Throwable $previous = null
): UnableToCopyFile {
$e = new static("Unable to copy file from $sourcePath to $destinationPath", 0 , $previous);
$e->source = $sourcePath;

View File

@@ -22,7 +22,7 @@ final class UnableToDeleteDirectory extends RuntimeException implements Filesyst
public static function atLocation(
string $location,
string $reason = '',
Throwable $previous = null
?Throwable $previous = null
): UnableToDeleteDirectory {
$e = new static(rtrim("Unable to delete directory located at: {$location}. {$reason}"), 0, $previous);
$e->location = $location;

View File

@@ -19,7 +19,7 @@ final class UnableToDeleteFile extends RuntimeException implements FilesystemOpe
*/
private $reason;
public static function atLocation(string $location, string $reason = '', Throwable $previous = null): UnableToDeleteFile
public static function atLocation(string $location, string $reason = '', ?Throwable $previous = null): UnableToDeleteFile
{
$e = new static(rtrim("Unable to delete file located at: {$location}. {$reason}"), 0, $previous);
$e->location = $location;

View File

@@ -37,7 +37,7 @@ final class UnableToMoveFile extends RuntimeException implements FilesystemOpera
public static function fromLocationTo(
string $sourcePath,
string $destinationPath,
Throwable $previous = null
?Throwable $previous = null
): UnableToMoveFile {
$message = $previous?->getMessage() ?? "Unable to move file from $sourcePath to $destinationPath";
$e = new static($message, 0, $previous);

View File

@@ -19,7 +19,7 @@ final class UnableToReadFile extends RuntimeException implements FilesystemOpera
*/
private $reason = '';
public static function fromLocation(string $location, string $reason = '', Throwable $previous = null): UnableToReadFile
public static function fromLocation(string $location, string $reason = '', ?Throwable $previous = null): UnableToReadFile
{
$e = new static(rtrim("Unable to read file from location: {$location}. {$reason}"), 0, $previous);
$e->location = $location;

View File

@@ -24,27 +24,27 @@ final class UnableToRetrieveMetadata extends RuntimeException implements Filesys
*/
private $reason;
public static function lastModified(string $location, string $reason = '', Throwable $previous = null): self
public static function lastModified(string $location, string $reason = '', ?Throwable $previous = null): self
{
return static::create($location, FileAttributes::ATTRIBUTE_LAST_MODIFIED, $reason, $previous);
}
public static function visibility(string $location, string $reason = '', Throwable $previous = null): self
public static function visibility(string $location, string $reason = '', ?Throwable $previous = null): self
{
return static::create($location, FileAttributes::ATTRIBUTE_VISIBILITY, $reason, $previous);
}
public static function fileSize(string $location, string $reason = '', Throwable $previous = null): self
public static function fileSize(string $location, string $reason = '', ?Throwable $previous = null): self
{
return static::create($location, FileAttributes::ATTRIBUTE_FILE_SIZE, $reason, $previous);
}
public static function mimeType(string $location, string $reason = '', Throwable $previous = null): self
public static function mimeType(string $location, string $reason = '', ?Throwable $previous = null): self
{
return static::create($location, FileAttributes::ATTRIBUTE_MIME_TYPE, $reason, $previous);
}
public static function create(string $location, string $type, string $reason = '', Throwable $previous = null): self
public static function create(string $location, string $type, string $reason = '', ?Throwable $previous = null): self
{
$e = new static("Unable to retrieve the $type for file at location: $location. {$reason}", 0, $previous);
$e->reason = $reason;

View File

@@ -27,7 +27,7 @@ final class UnableToSetVisibility extends RuntimeException implements Filesystem
return $this->reason;
}
public static function atLocation(string $filename, string $extraMessage = '', Throwable $previous = null): self
public static function atLocation(string $filename, string $extraMessage = '', ?Throwable $previous = null): self
{
$message = "Unable to set visibility for file {$filename}. $extraMessage";
$e = new static(rtrim($message), 0, $previous);

View File

@@ -19,7 +19,7 @@ final class UnableToWriteFile extends RuntimeException implements FilesystemOper
*/
private $reason;
public static function atLocation(string $location, string $reason = '', Throwable $previous = null): UnableToWriteFile
public static function atLocation(string $location, string $reason = '', ?Throwable $previous = null): UnableToWriteFile
{
$e = new static(rtrim("Unable to write file at location: {$location}. {$reason}"), 0, $previous);
$e->location = $location;

View File

@@ -1,5 +1,14 @@
# Changelog
## 1.16.0 - 2025-09-21
- Updated lookup
- Prepped for 8.4 implicit nullable deprecation
## 1.15.0 - 2024-01-28
- Updated lookup
## 1.14.0 - 2022-10-17
### Updated

View File

@@ -13,7 +13,7 @@ class ExtensionMimeTypeDetector implements MimeTypeDetector, ExtensionLookup
*/
private $extensions;
public function __construct(ExtensionToMimeTypeMap $extensions = null)
public function __construct(?ExtensionToMimeTypeMap $extensions = null)
{
$this->extensions = $extensions ?: new GeneratedExtensionToMimeTypeMap();
}

View File

@@ -41,7 +41,7 @@ class FinfoMimeTypeDetector implements MimeTypeDetector, ExtensionLookup
public function __construct(
string $magicFile = '',
ExtensionToMimeTypeMap $extensionMap = null,
?ExtensionToMimeTypeMap $extensionMap = null,
?int $bufferSampleSize = null,
array $inconclusiveMimetypes = self::INCONCLUSIVE_MIME_TYPES
) {

View File

@@ -82,10 +82,12 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'azv' => 'image/vnd.airzip.accelerator.azv',
'azw' => 'application/vnd.amazon.ebook',
'b16' => 'image/vnd.pco.b16',
'bary' => 'model/vnd.bary',
'bat' => 'application/x-msdownload',
'bcpio' => 'application/x-bcpio',
'bdf' => 'application/x-font-bdf',
'bdm' => 'application/vnd.syncml.dm+wbxml',
'bdo' => 'application/vnd.nato.bindingdataobject+xml',
'bdoc' => 'application/x-bdoc',
'bed' => 'application/vnd.realvnc.bed',
'bh2' => 'application/vnd.fujitsu.oasysprs',
@@ -100,6 +102,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'boz' => 'application/x-bzip2',
'bpk' => 'application/octet-stream',
'bpmn' => 'application/octet-stream',
'brf' => 'application/braille',
'bsp' => 'model/vnd.valve.source.compiled-map',
'btf' => 'image/prs.btif',
'btif' => 'image/prs.btif',
@@ -341,6 +344,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'geojson' => 'application/geo+json',
'gex' => 'application/vnd.geometry-explorer',
'ggb' => 'application/vnd.geogebra.file',
'ggs' => 'application/vnd.geogebra.slides',
'ggt' => 'application/vnd.geogebra.tool',
'ghf' => 'application/vnd.groove-help',
'gif' => 'image/gif',
@@ -465,6 +469,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'jsonml' => 'application/jsonml+json',
'jsx' => 'text/jsx',
'jt' => 'model/jt',
'jxl' => 'image/jxl',
'jxr' => 'image/jxr',
'jxra' => 'image/jxra',
'jxrs' => 'image/jxrs',
@@ -520,6 +525,8 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'lzh' => 'application/octet-stream',
'm1v' => 'video/mpeg',
'm2a' => 'audio/mpeg',
'm2t' => 'video/mp2t',
'm2ts' => 'video/mp2t',
'm2v' => 'video/mpeg',
'm3a' => 'audio/mpeg',
'm3u' => 'text/plain',
@@ -626,7 +633,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'msp' => 'application/octet-stream',
'msty' => 'application/vnd.muvee.style',
'mtl' => 'model/mtl',
'mts' => 'model/vnd.mts',
'mts' => 'video/mp2t',
'mus' => 'application/vnd.musician',
'musd' => 'application/mmt-usd+xml',
'musicxml' => 'application/vnd.recordare.musicxml+xml',
@@ -1171,6 +1178,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'xbm' => 'image/x-xbitmap',
'xca' => 'application/xcap-caps+xml',
'xcs' => 'application/calendar+xml',
'xdcf' => 'application/vnd.gov.sk.xmldatacontainer+xml',
'xdf' => 'application/xcap-diff+xml',
'xdm' => 'application/vnd.syncml.dm+xml',
'xdp' => 'application/vnd.adobe.xdp+xml',
@@ -1522,6 +1530,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'application/vnd.fuzzysheet' => ['fzs'],
'application/vnd.genomatix.tuxedo' => ['txd'],
'application/vnd.geogebra.file' => ['ggb'],
'application/vnd.geogebra.slides' => ['ggs'],
'application/vnd.geogebra.tool' => ['ggt'],
'application/vnd.geometry-explorer' => ['gex', 'gre'],
'application/vnd.geonext' => ['gxt'],
@@ -1533,6 +1542,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'application/vnd.google-apps.spreadsheet' => ['gsheet'],
'application/vnd.google-earth.kml+xml' => ['kml'],
'application/vnd.google-earth.kmz' => ['kmz'],
'application/vnd.gov.sk.xmldatacontainer+xml' => ['xdcf'],
'application/vnd.grafeq' => ['gqf', 'gqs'],
'application/vnd.groove-account' => ['gac'],
'application/vnd.groove-help' => ['ghf'],
@@ -1648,6 +1658,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'application/vnd.musician' => ['mus'],
'application/vnd.muvee.style' => ['msty'],
'application/vnd.mynfc' => ['taglet'],
'application/vnd.nato.bindingdataobject+xml' => ['bdo'],
'application/vnd.neurolanguage.nlu' => ['nlu'],
'application/vnd.nitf' => ['ntf', 'nitf'],
'application/vnd.noblenet-directory' => ['nnd'],
@@ -2028,6 +2039,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'image/jphc' => ['jhc'],
'image/jpm' => ['jpm', 'jpgm'],
'image/jpx' => ['jpx', 'jpf'],
'image/jxl' => ['jxl'],
'image/jxr' => ['jxr'],
'image/jxra' => ['jxra'],
'image/jxrs' => ['jxrs'],
@@ -2110,6 +2122,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'model/step-xml+zip' => ['stpxz'],
'model/stl' => ['stl'],
'model/u3d' => ['u3d'],
'model/vnd.bary' => ['bary'],
'model/vnd.cld' => ['cld'],
'model/vnd.collada+xml' => ['dae'],
'model/vnd.dwf' => ['dwf'],
@@ -2207,7 +2220,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'video/jpeg' => ['jpgv'],
'video/jpm' => ['jpm', 'jpgm'],
'video/mj2' => ['mj2', 'mjp2'],
'video/mp2t' => ['ts'],
'video/mp2t' => ['ts', 'm2t', 'm2ts', 'mts'],
'video/mp4' => ['mp4', 'mp4v', 'mpg4', 'f4v'],
'video/mpeg' => ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'],
'video/ogg' => ['ogv'],
@@ -2274,6 +2287,7 @@ class GeneratedExtensionToMimeTypeMap implements ExtensionToMimeTypeMap, Extensi
'application/cdr' => ['cdr'],
'application/STEP' => ['step', 'stp'],
'application/x-ndjson' => ['ndjson'],
'application/braille' => ['brf'],
];
public function lookupMimeType(string $extension): ?string