├── VERSION ├── .gitignore ├── phpunit.ci.xml ├── phpunit.dist.xml ├── src ├── ChangeFreq.php ├── SitemapIndex.php ├── Sitemap.php ├── NewsSitemap.php └── AbstractSitemap.php ├── composer.json ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── test ├── SitemapIndexTest.php └── SitemapTest.php ├── bin ├── release.php └── Console.php └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 2.2.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # System 2 | .idea 3 | !.gitignore 4 | 5 | # Composer 6 | /vendor/* 7 | composer.lock 8 | 9 | # Test 10 | phpunit.xml 11 | .phpunit.result.cache 12 | -------------------------------------------------------------------------------- /phpunit.ci.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | test 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /phpunit.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | test 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/ChangeFreq.php: -------------------------------------------------------------------------------- 1 | =8.1", 13 | "ext-simplexml": "*" 14 | }, 15 | "require-dev": { 16 | "windwalker/test": "^4.0", 17 | "phpunit/phpunit": "^10.3||^11.0", 18 | "windwalker/dom": "^4.0", 19 | "psr/http-message": "^2.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Asika\\Sitemap\\": "src/" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Simon Asika 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: UnitTest 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | php-versions: [ '8.1', '8.2', '8.3', '8.4' ] 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | # PHP 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php-versions }} 18 | # extensions: mbstring 19 | - name: Get composer cache directory 20 | id: composercache 21 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 22 | - name: Cache composer dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: ${{ steps.composercache.outputs.dir }} 26 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 27 | restore-keys: ${{ runner.os }}-composer- 28 | - name: Install dependencies 29 | run: composer update --prefer-dist --prefer-stable --no-progress --no-suggest 30 | 31 | - name: Run test suite 32 | run: php vendor/bin/phpunit --configuration phpunit.ci.xml 33 | -------------------------------------------------------------------------------- /src/SitemapIndex.php: -------------------------------------------------------------------------------- 1 | autoEscape) { 36 | $loc = htmlspecialchars($loc); 37 | } 38 | 39 | $sitemap = $this->xml->addChild('sitemap'); 40 | 41 | if ($sitemap === null) { 42 | throw new UnexpectedValueException('Add Sitemap to XML failed.'); 43 | } 44 | 45 | $sitemap->addChild('loc', $loc); 46 | 47 | if ($lastmod) { 48 | if (!($lastmod instanceof DateTimeInterface)) { 49 | $lastmod = new DateTimeImmutable($lastmod); 50 | } 51 | 52 | $sitemap->addChild('lastmod', $lastmod->format($this->dateFormat)); 53 | } 54 | 55 | return $this; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/SitemapIndexTest.php: -------------------------------------------------------------------------------- 1 | instance = new SitemapIndex(); 40 | } 41 | 42 | /** 43 | * testAddItem 44 | * 45 | * @return void 46 | */ 47 | public function testAddItem(): void 48 | { 49 | $this->instance->addItem('http://windwalker.io/sitemap.xml'); 50 | 51 | $xml = << 53 | 54 | 55 | http://windwalker.io/sitemap.xml 56 | 57 | 58 | XML; 59 | 60 | self::assertDomStringEqualsDomString($xml, $this->instance->render()); 61 | 62 | $this->instance->addItem('http://windwalker.io/sitemap2.xml', '2015-06-07 10:51:20'); 63 | 64 | $xml = << 66 | 67 | 68 | http://windwalker.io/sitemap.xml 69 | 70 | 71 | http://windwalker.io/sitemap2.xml 72 | 2015-06-07T10:51:20+00:00 73 | 74 | 75 | XML; 76 | 77 | self::assertDomStringEqualsDomString($xml, $this->instance->render()); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Sitemap.php: -------------------------------------------------------------------------------- 1 | autoEscape) { 42 | $loc = htmlspecialchars($loc); 43 | } 44 | 45 | $url = $this->xml->addChild('url'); 46 | 47 | if ($url === null) { 48 | throw new UnexpectedValueException('Add URL to XML failed.'); 49 | } 50 | 51 | $url->addChild('loc', $loc); 52 | 53 | if ($changeFreq) { 54 | if ($changeFreq instanceof ChangeFreq) { 55 | $changeFreq = $changeFreq->value; 56 | } 57 | 58 | $url->addChild('changefreq', (string) $changeFreq); 59 | } 60 | 61 | if ($priority) { 62 | $url->addChild('priority', (string) $priority); 63 | } 64 | 65 | if ($lastmod) { 66 | if (!($lastmod instanceof DateTimeInterface)) { 67 | $lastmod = new DateTimeImmutable($lastmod); 68 | } 69 | 70 | $url->addChild('lastmod', $lastmod->format($this->dateFormat)); 71 | } 72 | 73 | return $this; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/SitemapTest.php: -------------------------------------------------------------------------------- 1 | instance = new Sitemap(); 41 | } 42 | 43 | /** 44 | * testAddItem 45 | * 46 | * @return void 47 | */ 48 | public function testAddItem(): void 49 | { 50 | $this->instance->addItem('http://windwalker.io'); 51 | 52 | $xml = << 54 | 55 | 56 | http://windwalker.io 57 | 58 | 59 | XML; 60 | 61 | self::assertDomStringEqualsDomString($xml, $this->instance->render()); 62 | 63 | $this->instance->addItem('http://windwalker.io/foo/bar/?flower=sakura&fly=bird', '1.0', ChangeFreq::DAILY, '2015-06-07 10:51:20'); 64 | 65 | $xml = << 67 | 68 | 69 | http://windwalker.io 70 | 71 | 72 | http://windwalker.io/foo/bar/?flower=sakura&fly=bird 73 | daily 74 | 1.0 75 | 2015-06-07T10:51:20+00:00 76 | 77 | 78 | XML; 79 | 80 | self::assertDomStringEqualsDomString($xml, $this->instance->render()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/NewsSitemap.php: -------------------------------------------------------------------------------- 1 | xml['xmlns:news'] = $this->newsXmlns; 31 | $this->xml->registerXPathNamespace('news', $this->newsXmlns); 32 | } 33 | 34 | /** 35 | * @param string|\Stringable $loc 36 | * @param string $title 37 | * @param string $publicationName 38 | * @param string $language 39 | * @param DateTimeInterface|string $publicationDate 40 | * 41 | * @return static 42 | * @throws Exception 43 | */ 44 | public function addItem( 45 | string|\Stringable $loc, 46 | string $title, 47 | string $publicationName, 48 | string $language, 49 | DateTimeInterface|string $publicationDate, 50 | ): static { 51 | $loc = (string) $loc; 52 | 53 | if ($this->autoEscape) { 54 | $loc = htmlspecialchars($loc); 55 | } 56 | 57 | $url = $this->xml->addChild('url'); 58 | 59 | if ($url === null) { 60 | throw new UnexpectedValueException('Add URL to XML failed.'); 61 | } 62 | 63 | $url->addChild('loc', $loc); 64 | $news = $url->addChild('xmlns:news:news'); 65 | 66 | if ($news === null) { 67 | throw new UnexpectedValueException('Add URL to XML failed.'); 68 | } 69 | 70 | $publication = $news->addChild('xmlns:news:publication'); 71 | 72 | if ($publication === null) { 73 | throw new UnexpectedValueException('Add URL to XML failed.'); 74 | } 75 | 76 | $publication->addChild('xmlns:news:name', $publicationName); 77 | $publication->addChild('xmlns:news:language', $language); 78 | 79 | if (!($publicationDate instanceof DateTimeInterface)) { 80 | $publicationDate = new DateTimeImmutable($publicationDate); 81 | } 82 | 83 | $news->addChild('xmlns:news:publication_date', $publicationDate->format($this->dateFormat)); 84 | $news->addChild('xmlns:news:title', $title); 85 | 86 | return $this; 87 | } 88 | 89 | public function getNewsXmlns(): string 90 | { 91 | return $this->newsXmlns; 92 | } 93 | 94 | public function setNewsXmlns(string $newsXmlns): static 95 | { 96 | $this->newsXmlns = $newsXmlns; 97 | 98 | return $this; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/AbstractSitemap.php: -------------------------------------------------------------------------------- 1 | xmlns = $xmlns ?: $this->xmlns; 86 | $this->encoding = $encoding; 87 | $this->xmlVersion = $xmlVersion; 88 | 89 | $this->xml = $this->getSimpleXmlElement(); 90 | } 91 | 92 | /** 93 | * @return SimpleXMLElement 94 | */ 95 | public function getSimpleXmlElement(): SimpleXMLElement 96 | { 97 | return $this->xml ??= simplexml_load_string( 98 | sprintf( 99 | '<%s xmlns="%s" />', 100 | $this->xmlVersion, 101 | $this->encoding, 102 | $this->root, 103 | $this->xmlns 104 | ) 105 | ); 106 | } 107 | 108 | /** 109 | * toString 110 | * 111 | * @return string 112 | */ 113 | public function render(): string 114 | { 115 | return $this->xml->asXML(); 116 | } 117 | 118 | public function save(string|\SplFileInfo $file): false|int 119 | { 120 | if (is_string($file)) { 121 | $file = new \SplFileInfo($file); 122 | } 123 | 124 | return file_put_contents($file->getPathname(), $this->render()); 125 | } 126 | 127 | /** 128 | * @return string 129 | */ 130 | public function __toString() 131 | { 132 | return $this->render(); 133 | } 134 | 135 | public function handleResponse( 136 | ResponseInterface $response, 137 | ?StreamInterface $body = null 138 | ): ResponseInterface { 139 | $body ??= $response->getBody(); 140 | $body->rewind(); 141 | $body->write($this->render()); 142 | 143 | return $response->withHeader('content-type', $this->contentType) 144 | ->withBody($body); 145 | } 146 | 147 | public function output(): void 148 | { 149 | header('Content-Type: ' . $this->contentType); 150 | 151 | echo $this->render(); 152 | } 153 | 154 | /** 155 | * Method to get property AutoEscape 156 | * 157 | * @return bool 158 | */ 159 | public function getAutoEscape(): bool 160 | { 161 | return $this->autoEscape; 162 | } 163 | 164 | /** 165 | * Method to set property autoEscape 166 | * 167 | * @param bool $autoEscape 168 | * 169 | * @return static Return self to support chaining. 170 | */ 171 | public function setAutoEscape(bool $autoEscape): static 172 | { 173 | $this->autoEscape = $autoEscape; 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * Method to get property DateFormat 180 | * 181 | * @return string 182 | */ 183 | public function getDateFormat(): string 184 | { 185 | return $this->dateFormat; 186 | } 187 | 188 | /** 189 | * Method to set property dateFormat 190 | * 191 | * @param string $dateFormat 192 | * 193 | * @return static Return self to support chaining. 194 | */ 195 | public function setDateFormat(string $dateFormat): static 196 | { 197 | $this->dateFormat = $dateFormat; 198 | 199 | return $this; 200 | } 201 | 202 | public function getContentType(): string 203 | { 204 | return $this->contentType; 205 | } 206 | 207 | /** 208 | * @param string $contentType 209 | * 210 | * @return static Return self to support chaining. 211 | */ 212 | public function setContentType(string $contentType): static 213 | { 214 | $this->contentType = $contentType; 215 | 216 | return $this; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /bin/release.php: -------------------------------------------------------------------------------- 1 | get('dry-run'); 19 | } 20 | } 21 | 22 | protected function configure(): void 23 | { 24 | $this->addParameter('version', type: static::STRING) 25 | ->description('Can be name or major|minor|patch|alpha|beta|rc') 26 | ->default('patch'); 27 | 28 | $this->addParameter('suffix', type: static::STRING) 29 | ->description('The suffix type. Can be alpha|beta|rc'); 30 | 31 | $this->addParameter('--dry-run|-d', type: static::BOOLEAN) 32 | ->description('Run process but do not execute any commands.'); 33 | 34 | $this->addParameter('--from', type: static::STRING) 35 | ->description('The version to release from. Default is the current version.') 36 | ->required(true); 37 | } 38 | 39 | protected function doExecute(): int 40 | { 41 | foreach ($this->scripts as $script) { 42 | $this->exec($script); 43 | } 44 | 45 | $currentVersion = $this->get('from') ?: trim(file_get_contents(__DIR__ . '/../VERSION')); 46 | $targetVersion = (string) $this->get('version'); 47 | $targetSuffix = (string) $this->get('suffix'); 48 | 49 | if (in_array($targetVersion, ['alpha', 'beta', 'rc'])) { 50 | $targetSuffix = $targetVersion; 51 | $targetVersion = 'patch'; 52 | } 53 | 54 | $targetVersion = static::versionPush($currentVersion, $targetVersion, $targetSuffix); 55 | 56 | $this->writeln('Release version: ' . $targetVersion); 57 | 58 | if (!$this->isDryRun) { 59 | static::writeVersion($targetVersion); 60 | } 61 | 62 | $this->exec(sprintf('git commit -am "Release version: %s"', $targetVersion)); 63 | $this->exec(sprintf('git tag %s', $targetVersion)); 64 | 65 | $this->exec('git push'); 66 | $this->exec('git push --tags'); 67 | 68 | return static::SUCCESS; 69 | } 70 | 71 | protected static function writeVersion(string $version): false|int 72 | { 73 | return file_put_contents(static::versionFile(), $version . "\n"); 74 | } 75 | 76 | protected static function versionFile(): string 77 | { 78 | return __DIR__ . '/../VERSION'; 79 | } 80 | 81 | protected static function versionPush( 82 | string $currentVersion, 83 | string $targetVersion, 84 | string $targetSuffix, 85 | ): string { 86 | [$major, $minor, $patch, $suffixType, $suffixVersion] = static::parseVersion($currentVersion); 87 | 88 | switch ($targetVersion) { 89 | case 'major': 90 | $major++; 91 | $minor = $patch = 0; 92 | if ($targetSuffix) { 93 | $suffixType = $targetSuffix; 94 | $suffixVersion = 1; 95 | } else { 96 | $suffixType = ''; 97 | $suffixVersion = 0; 98 | } 99 | break; 100 | 101 | case 'minor': 102 | $minor++; 103 | $patch = 0; 104 | if ($targetSuffix) { 105 | $suffixType = $targetSuffix; 106 | $suffixVersion = 1; 107 | } else { 108 | $suffixType = ''; 109 | $suffixVersion = 0; 110 | } 111 | break; 112 | 113 | case 'patch': 114 | if (!$suffixType) { 115 | $patch++; 116 | } 117 | if ($targetSuffix) { 118 | if ($suffixType === $targetSuffix) { 119 | $suffixVersion++; 120 | } else { 121 | $suffixType = $targetSuffix; 122 | $suffixVersion = 1; 123 | } 124 | } else { 125 | $suffixType = ''; 126 | $suffixVersion = 0; 127 | } 128 | break; 129 | 130 | default: 131 | return $targetVersion; 132 | } 133 | 134 | $currentVersion = $major . '.' . $minor . '.' . $patch; 135 | 136 | if ($suffixType) { 137 | $currentVersion .= '-' . $suffixType . '.' . $suffixVersion; 138 | } 139 | 140 | return $currentVersion; 141 | } 142 | 143 | public static function parseVersion(string $currentVersion): array 144 | { 145 | [$currentVersion, $prerelease] = explode('-', $currentVersion, 2) + ['', '']; 146 | 147 | [$major, $minor, $patch] = explode('.', $currentVersion, 3) + ['', '0', '0']; 148 | $major = (int) $major; 149 | $minor = (int) $minor; 150 | $patch = (int) $patch; 151 | $prereleaseType = ''; 152 | $prereleaseVersion = 0; 153 | 154 | if ($prerelease) { 155 | $matched = preg_match('/(rc|beta|alpha)[.-]?(\d+)/i', $prerelease, $matches); 156 | 157 | if ($matched) { 158 | $prereleaseType = strtolower($matches[1]); 159 | $prereleaseVersion = (int) $matches[2]; 160 | } 161 | } 162 | 163 | return [$major, $minor, $patch, $prereleaseType, $prereleaseVersion]; 164 | } 165 | 166 | public function exec(string $cmd, \Closure|null|false $output = null, bool $showCmd = true): ExecResult 167 | { 168 | $this->writeln('>> ' . ($this->isDryRun ? '(Dry Run) ' : '') . $cmd); 169 | 170 | if (!$this->isDryRun) { 171 | return parent::exec($cmd, $output, false); 172 | } 173 | 174 | return new ExecResult(); 175 | } 176 | 177 | public function addScript(string $script): static 178 | { 179 | $this->scripts[] = $script; 180 | 181 | return $this; 182 | } 183 | }; 184 | 185 | $app->execute(); 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Sitemap 2 | 3 | 4 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/asika32764/php-sitemap/ci.yml?style=for-the-badge) 5 | [![Packagist Version](https://img.shields.io/packagist/v/asika/sitemap?style=for-the-badge) 6 | ](https://packagist.org/packages/asika/sitemap) 7 | [![Packagist Downloads](https://img.shields.io/packagist/dt/asika/sitemap?style=for-the-badge)](https://packagist.org/packages/asika/sitemap) 8 | 9 | PHP Simple Sitemap Generator. Follows the [W3C Sitemap Protocol](http://www.sitemaps.org/protocol.html) 10 | 11 | 12 | * [PHP Sitemap](#php-sitemap) 13 | * [Installation via Composer](#installation-via-composer) 14 | * [Getting Started](#getting-started) 15 | * [Render it to XML:](#render-it-to-xml) 16 | * [Arguments](#arguments) 17 | * [loc](#loc) 18 | * [changefreq](#changefreq) 19 | * [priority](#priority) 20 | * [lastmod](#lastmod) 21 | * [Google News Sitemap](#google-news-sitemap) 22 | * [Using Sitemap index files (to group multiple sitemap files)](#using-sitemap-index-files-to-group-multiple-sitemap-files) 23 | * [More](#more) 24 | 25 | 26 | ## Installation via Composer 27 | 28 | ```shell 29 | composer require asika/sitemap 30 | ``` 31 | 32 | ## Getting Started 33 | 34 | Create a sitemap object: 35 | 36 | ```php 37 | use Asika\Sitemap\Sitemap; 38 | 39 | $sitemap = new Sitemap(); 40 | ``` 41 | 42 | Add items to sitemap: 43 | 44 | ```php 45 | $sitemap->addItem($url); 46 | $sitemap->addItem($url); 47 | $sitemap->addItem($url); 48 | ``` 49 | 50 | You can add some optional params. 51 | 52 | ```php 53 | use Asika\Sitemap\ChangeFreq; 54 | 55 | $sitemap->addItem($url, '1.0', ChangeFreq::DAILY, '2015-06-07 10:51:20'); 56 | $sitemap->addItem($url, '0.7', ChangeFreq::WEEKLY, new \DateTime('2015-06-03 11:24:20')); 57 | ``` 58 | 59 | The arguments are `loc`, `priority`, `changefreq` and `lastmod`. See this table: 60 | 61 | | Params | Required | Description | 62 | |--------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 63 | | `loc` | required | URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters. | 64 | | `priority` | optional | The priority of this URL relative to other URLs on your site. Valid values range from 0.0 to 1.0. This value does not affect how your pages are compared to pages on other sites—it only lets the search engines know which pages you deem most important for the crawlers. | 65 | | `changefreq` | optional | How frequently the page is likely to change. This value provides general information to search engines and may not correlate exactly to how often they crawl the page. | 66 | | `lastmod` | optional | The date of last modification of the file. This date should be in [W3C Datetime format](http://www.w3.org/TR/NOTE-datetime). This format allows you to omit the time portion, if desired, and use YYYY-MM-DD. | 67 | 68 | See: http://www.sitemaps.org/protocol.html#xmlTagDefinitions 69 | 70 | ### Render it to XML: 71 | 72 | ```php 73 | echo $sitemap->render(); 74 | 75 | // OR 76 | 77 | (string) $sitemap; 78 | ``` 79 | 80 | Save to file: 81 | 82 | ```php 83 | $sitemap->save('/path/to/sitemap.xml'); 84 | 85 | // OR 86 | 87 | $file = new SplFileInfo('/path/to/sitemap.xml'); 88 | 89 | $sitemap->save($file); 90 | ``` 91 | 92 | This is an example to send it as real sitemap for Google or other search engine: 93 | 94 | ```php 95 | header('Content-Type: application/xml'); 96 | 97 | echo $sitemap; 98 | 99 | exit(); 100 | ``` 101 | 102 | Use `output()` to instantly print header and XML body: 103 | 104 | ```php 105 | $sitemap->output(); 106 | 107 | exit(); 108 | ``` 109 | 110 | Handle Psr7 Response 111 | 112 | ```php 113 | $response = new Response(); 114 | 115 | $response = $sitemap->handleResponse($response); 116 | 117 | return $response; 118 | ``` 119 | 120 | The XML output in browser: 121 | 122 | ```xml 123 | 124 | 125 | 126 | http://sitemap.io 127 | 128 | 129 | http://sitemap.io/foo/bar/?flower=sakura&fly=bird 130 | daily 131 | 1.0 132 | 2015-06-07T10:51:20+02:00 133 | 134 | 135 | ``` 136 | 137 | ## Arguments 138 | 139 | ### loc 140 | 141 | The URL will be auto escaped. For example, the `&`, `>` will convert to `&`, `>`. 142 | 143 | If you want to escape it yourself, set auto escape off: 144 | 145 | ```php 146 | $sitemap->setAutoEscape(false); 147 | ``` 148 | 149 | See: http://www.sitemaps.org/protocol.html#escaping 150 | 151 | ### changefreq 152 | 153 | Valid values are: 154 | 155 | ```php 156 | ChangeFreq::ALWAYS; 157 | ChangeFreq::HOURLY; 158 | ChangeFreq::DAILY; 159 | ChangeFreq::WEEKLY; 160 | ChangeFreq::MONTHLY; 161 | ChangeFreq::YEARLY; 162 | ChangeFreq::NEVER; 163 | ``` 164 | 165 | The value `always` should be used to describe documents that change each time they are accessed. 166 | 167 | The value `never` should be used to describe archived URLs. 168 | 169 | Please note that the value of this tag is considered a hint and not a command. Even though search engine crawlers may consider this information when making decisions, 170 | they may crawl pages marked `hourly` less frequently than that, and they may crawl pages marked `yearly` more frequently than that. 171 | 172 | Crawlers may periodically crawl pages marked `never` so that they can handle unexpected changes to those pages. 173 | 174 | ### priority 175 | 176 | The default priority of a page is `0.5`. 177 | Please note that the priority you assign to a page is not likely to influence the position of your URLs in a search engine's result pages. Search engines may use this information when selecting between URLs on the same site, so you can use this tag to increase the likelihood that your most important pages are present in a search index. 178 | Also, please note that assigning a high priority to all of the URLs on your site is not likely to help you. Since the priority is relative, it is only used to select between URLs on your site. 179 | 180 | ### lastmod 181 | 182 | Your date format will auto convert to [W3c Datetime format](http://www.w3.org/TR/NOTE-datetime). for example, if you send 183 | a string look like: `2015-06-07 10:51:20`, Sitemap object will auto convert it to `2015-06-07T10:51:20+02:00`. 184 | 185 | You can set the format you want: 186 | 187 | ```php 188 | $sitemap->setDateFormat(\DateTimeInterface::ISO8601); 189 | 190 | // OR 191 | 192 | $sitemap->setDateFormat('Y-m-d'); 193 | ``` 194 | 195 | ## Google News Sitemap 196 | 197 | Please see [Google News Sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap?visit_id=637247859078479568-4208069007&rd=3) document. 198 | 199 | ```php 200 | $sitemap = new \Asika\Sitemap\NewsSitemap(); 201 | 202 | $sitemap->addItem( 203 | $url, 204 | $newsTitle, 205 | 'Publication Name', 206 | 'en-us', 207 | $publishedDate 208 | ); 209 | ``` 210 | 211 | The format: 212 | 213 | ```xml 214 | 215 | 217 | 218 | http://www.example.org/business/article55.html 219 | 220 | 221 | The Example Times 222 | en 223 | 224 | 2008-12-23 225 | Companies A, B in Merger Talks 226 | 227 | 228 | 229 | ``` 230 | 231 | ## Using Sitemap index files (to group multiple sitemap files) 232 | 233 | ```php 234 | use Asika\Sitemap\SitemapIndex; 235 | 236 | $index = new SitemapIndex(); 237 | 238 | $index->addItem('http://domain.com/sitemap1.xml', $lastmod1); 239 | $index->addItem('http://domain.com/sitemap2.xml', $lastmod2); 240 | 241 | echo $index->render(); 242 | ``` 243 | 244 | Output: 245 | 246 | ```xml 247 | 248 | 249 | 250 | http://domain.com/sitemap1.xml 251 | 2015-06-07T10:51:20+02:00 252 | 253 | 254 | http://domain.com/sitemap2.xml 255 | 2015-06-07T10:51:20+02:00 256 | 257 | 258 | ``` 259 | 260 | See: http://www.sitemaps.org/protocol.html#index 261 | 262 | ## More 263 | 264 | - [Extending the Sitemaps protocol](http://www.sitemaps.org/protocol.html#extending) 265 | -------------------------------------------------------------------------------- /bin/Console.php: -------------------------------------------------------------------------------- 1 | parse($argv ?? $_SERVER['argv'], $validate); 51 | } 52 | 53 | public function __construct( 54 | public $stdout = STDOUT, 55 | public $stderr = STDERR, 56 | public $stdin = STDIN, 57 | public string $heading = '', 58 | public string $epilog = '', 59 | public ?string $commandName = null, 60 | public ArgvParser $parser = new ArgvParser(), 61 | ) { 62 | } 63 | 64 | public function addParameter( 65 | string|array $name, 66 | ParameterType $type, 67 | string $description = '', 68 | bool $required = false, 69 | mixed $default = null, 70 | bool $negatable = false, 71 | ): Parameter { 72 | return $this->parser->addParameter($name, $type, $description, $required, $default, $negatable); 73 | } 74 | 75 | public function addHelpParameter(): Parameter 76 | { 77 | return $this->addParameter('--help|-h', static::BOOLEAN, 'Show description of all parameters', false); 78 | } 79 | 80 | public function addVerbosityParameter(): Parameter 81 | { 82 | return $this->addParameter('--verbosity|-v', static::LEVEL, 'The verbosity level of the output'); 83 | } 84 | 85 | public function get(string $name, mixed $default = null): mixed 86 | { 87 | return $this->params[$name] ?? $default; 88 | } 89 | 90 | protected function configure(): void 91 | { 92 | } 93 | 94 | protected function preprocess(): void 95 | { 96 | } 97 | 98 | protected function doExecute(): int|bool 99 | { 100 | return 0; 101 | } 102 | 103 | public function execute(?array $argv = null, ?\Closure $main = null): int 104 | { 105 | $argv = $argv ?? $_SERVER['argv']; 106 | $this->commandName ??= basename($argv[0]); 107 | try { 108 | $this->disableDefaultParameters || ($this->addHelpParameter() && $this->addVerbosityParameter()); 109 | $this->configure(); 110 | $this->params = $this->parser->parse($argv, false); 111 | if (!$this->disableDefaultParameters) { 112 | $this->verbosity = (int) $this->get('verbosity'); 113 | if ($this->get('help')) { 114 | $this->showHelp(); 115 | 116 | return static::SUCCESS; 117 | } 118 | } 119 | $this->params = $this->parser->validateAndCastParams($this->params); 120 | $this->preprocess(); 121 | $exitCode = $main ? $main->call($this, $this) : $this->doExecute(); 122 | if ($exitCode === true || $exitCode === null) { 123 | $exitCode = 0; 124 | } elseif ($exitCode === false) { 125 | $exitCode = 255; 126 | } 127 | 128 | return (int) $exitCode; 129 | } catch (\Throwable $e) { 130 | return $this->handleException($e); 131 | } 132 | } 133 | 134 | public function showHelp(): void 135 | { 136 | $help = ParameterDescriptor::describe($this->parser, $this->commandName, $this->epilog); 137 | $this->writeln(ltrim($this->heading . "\n\n" . $help))->newLine(); 138 | } 139 | 140 | public function write(string $message, bool $err = false): static 141 | { 142 | fwrite($err ? $this->stderr : $this->stdout, $message); 143 | 144 | return $this; 145 | } 146 | 147 | public function writeln(string $message = '', bool $err = false): static 148 | { 149 | return $this->write($message . "\n", $err); 150 | } 151 | 152 | public function newLine(int $lines = 1, bool $err = false): static 153 | { 154 | return $this->write(str_repeat("\n", $lines), $err); 155 | } 156 | 157 | public function in(): string 158 | { 159 | return rtrim(fread(STDIN, 8192), "\n\r"); 160 | } 161 | 162 | public function ask(string $question = '', string $default = ''): string 163 | { 164 | $this->write($question); 165 | $in = rtrim(fread(STDIN, 8192), "\n\r"); 166 | 167 | return $in === '' ? $default : $in; 168 | } 169 | 170 | public function askConfirm(string $question = '', string $default = ''): bool 171 | { 172 | return (bool) $this->mapBoolean($this->ask($question, $default)); 173 | } 174 | 175 | public function mapBoolean($in): bool|null 176 | { 177 | $in = strtolower((string) $in); 178 | if (in_array($in, $this->boolMapping[0], true)) { 179 | return false; 180 | } 181 | if (in_array($in, $this->boolMapping[1], true)) { 182 | return true; 183 | } 184 | 185 | return null; 186 | } 187 | 188 | public function exec(string $cmd, \Closure|null|false $output = null, bool $showCmd = true): ExecResult 189 | { 190 | !$showCmd || $this->writeln('>> ' . $cmd); 191 | [$outFull, $errFull, $code] = ['', '', 255]; 192 | if ($process = proc_open($cmd, [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]], $pipes)) { 193 | $callback = $output ?: fn($data, $err) => ($output === false) || $this->write($data, $err); 194 | while (($out = fgets($pipes[1])) || $err = fgets($pipes[2])) { 195 | if (isset($out[0])) { 196 | $callback($out, false); 197 | $outFull .= $output === false ? $out : ''; 198 | } 199 | if (isset($err[0])) { 200 | $callback($err, false); 201 | $errFull .= $output === false ? $err : ''; 202 | } 203 | } 204 | 205 | $code = proc_close($process); 206 | } 207 | 208 | return new ExecResult($code, $outFull, $errFull); 209 | } 210 | 211 | public function mustExec(string $cmd, ?\Closure $output = null): ExecResult 212 | { 213 | $result = $this->exec($cmd, $output); 214 | $result->success || throw new \RuntimeException('Command "' . $cmd . '" failed with code ' . $result->code); 215 | 216 | return $result; 217 | } 218 | 219 | protected function handleException(\Throwable $e): int 220 | { 221 | if ($e instanceof InvalidParameterException) { 222 | $this->writeln('[Warning] ' . $e->getMessage(), true)->newLine(err: true) 223 | ->writeln( 224 | $this->commandName . ' ' . ParameterDescriptor::synopsis($this->parser, false), 225 | true 226 | ); 227 | } else { 228 | $this->writeln('[Error] ' . $e->getMessage(), true); 229 | } 230 | if ($this->verbosity > 0) { 231 | $this->writeln('[Backtrace]:', true) 232 | ->writeln($e->getTraceAsString(), true); 233 | } 234 | 235 | return $e->getCode() === 0 ? 255 : $e->getCode(); 236 | } 237 | 238 | public function offsetExists(mixed $offset): bool 239 | { 240 | return array_key_exists($offset, $this->params); 241 | } 242 | 243 | public function offsetGet(mixed $offset): mixed 244 | { 245 | return $this->params[$offset] ?? null; 246 | } 247 | 248 | public function offsetSet(mixed $offset, mixed $value): void 249 | { 250 | throw new \BadMethodCallException('Cannot set params.'); 251 | } 252 | 253 | public function offsetUnset(mixed $offset): void 254 | { 255 | throw new \BadMethodCallException('Cannot unset params.'); 256 | } 257 | } 258 | 259 | class ExecResult 260 | { 261 | public bool $success { 262 | get => $this->code === 0; 263 | } 264 | 265 | public function __construct(public int $code = 0, public string $output = '', public string $errOutput = '') 266 | { 267 | } 268 | } 269 | 270 | class ArgvParser 271 | { 272 | private array $params = []; 273 | 274 | private array $tokens = []; 275 | 276 | private array $existsNames = []; 277 | 278 | private bool $parseOptions = false; 279 | 280 | public private(set) int $currentArgument = 0; 281 | 282 | /** @var array */ 283 | public private(set) array $parameters = []; 284 | 285 | /** @var array */ 286 | public array $arguments { 287 | get => array_filter($this->parameters, static fn($parameter) => $parameter->isArg); 288 | } 289 | 290 | /** @var array */ 291 | public array $options { 292 | get => array_filter($this->parameters, static fn($parameter) => !$parameter->isArg); 293 | } 294 | 295 | public function addParameter( 296 | string|array $name, 297 | ParameterType $type, 298 | string $description = '', 299 | bool $required = false, 300 | mixed $default = null, 301 | bool $negatable = false, 302 | ): Parameter { 303 | if (is_string($name) && str_contains($name, '|')) { 304 | $name = explode('|', $name); 305 | foreach ($name as $n) { 306 | if (!str_starts_with($n, '-')) { 307 | throw new \InvalidArgumentException('Argument name cannot contains "|" sign.'); 308 | } 309 | } 310 | } 311 | $parameter = new Parameter($name, $type, $description, $required, $default, $negatable); 312 | foreach ((array) $parameter->name as $n) { 313 | if (in_array($n, $this->existsNames, true)) { 314 | throw new \InvalidArgumentException('Duplicate parameter name "' . $n . '"'); 315 | } 316 | } 317 | array_push($this->existsNames, ...((array) $parameter->name)); 318 | ($this->parameters[$parameter->primaryName] = $parameter) && $parameter->selfValidate(); 319 | 320 | return $parameter; 321 | } 322 | 323 | public function removeParameter(string $name): void 324 | { 325 | unset($this->parameters[$name]); 326 | } 327 | 328 | public function getArgument(string $name): ?Parameter 329 | { 330 | return array_find($this->arguments, static fn($n) => $n === $name); 331 | } 332 | 333 | public function getArgumentByIndex(int $index): ?Parameter 334 | { 335 | return array_values($this->arguments)[$index] ?? null; 336 | } 337 | 338 | public function getLastArgument(): ?Parameter 339 | { 340 | $args = $this->arguments; 341 | 342 | return $args[array_key_last($args)] ?? null; 343 | } 344 | 345 | public function getOption(string $name): ?Parameter 346 | { 347 | return array_find($this->options, static fn(Parameter $option) => $option->hasName($name)); 348 | } 349 | 350 | public function mustGetOption(string $name): Parameter 351 | { 352 | if (!$option = $this->getOption($name)) { 353 | throw new InvalidParameterException(\sprintf('The "-%s" option does not exist.', $name)); 354 | } 355 | 356 | return $option; 357 | } 358 | 359 | public function parse(array $argv, bool $validate = true): array 360 | { 361 | foreach ($this->parameters as $parameter) { 362 | $parameter->selfValidate(); 363 | } 364 | array_shift($argv); 365 | $this->currentArgument = 0; 366 | $this->parseOptions = true; 367 | $this->params = []; 368 | $this->tokens = $argv; 369 | while (null !== $token = array_shift($this->tokens)) { 370 | $this->parseToken((string) $token); 371 | } 372 | 373 | if ($validate) { 374 | return $this->validateAndCastParams($this->params); 375 | } 376 | 377 | return $this->params; 378 | } 379 | 380 | public function validateAndCastParams(array $params): array 381 | { 382 | foreach ($this->parameters as $parameter) { 383 | if (!array_key_exists($parameter->primaryName, $params)) { 384 | $parameter->assertInput( 385 | !$parameter->isArg || !$parameter->required, 386 | "Required argument \"{$parameter->primaryName}\" is missing." 387 | ); 388 | $params[$parameter->primaryName] = $parameter->defaultValue ?? false; 389 | } else { 390 | $parameter->validate($this->params[$parameter->primaryName]); 391 | $params[$parameter->primaryName] = $parameter->castValue($params[$parameter->primaryName]); 392 | } 393 | } 394 | 395 | return $params; 396 | } 397 | 398 | protected function parseToken(string $token): void 399 | { 400 | if ($this->parseOptions && '' === $token) { 401 | $this->parseArgument($token); 402 | } elseif ($this->parseOptions && '--' === $token) { 403 | $this->parseOptions = false; 404 | } elseif ($this->parseOptions && str_starts_with($token, '--')) { 405 | $this->parseLongOption($token); 406 | } elseif ($this->parseOptions && '-' === $token[0] && '-' !== $token) { 407 | $this->parseShortOption($token); 408 | } else { 409 | $this->parseArgument($token); 410 | } 411 | } 412 | 413 | private function parseShortOption(string $token): void 414 | { 415 | $name = substr($token, 1); 416 | if (\strlen($name) > 1) { 417 | $option = $this->getOption($token); 418 | if ($option && $option->acceptValue) { 419 | $this->setOptionValue($name[0], substr($name, 1)); // -n[value] 420 | } else { 421 | $this->parseShortOptionSet($name); 422 | } 423 | } else { 424 | $this->setOptionValue($name, null); 425 | } 426 | } 427 | 428 | private function parseShortOptionSet(string $name): void 429 | { 430 | $len = \strlen($name); 431 | for ($i = 0; $i < $len; ++$i) { 432 | $option = $this->mustGetOption($name[$i]); 433 | if ($option->acceptValue) { 434 | $this->setOptionValue($option->primaryName, $i === $len - 1 ? null : substr($name, $i + 1)); 435 | break; 436 | } 437 | $this->setOptionValue($option->primaryName, null); 438 | } 439 | } 440 | 441 | private function parseLongOption(string $token): void 442 | { 443 | $name = substr($token, 2); 444 | $pos = strpos($name, '='); 445 | if ($pos !== false) { 446 | $value = substr($name, $pos + 1); 447 | $value !== '' || array_unshift($this->params, $value); 448 | $this->setOptionValue(substr($name, 0, $pos), $value); 449 | } else { 450 | $this->setOptionValue($name, null); 451 | } 452 | } 453 | 454 | private function parseArgument(string $token): void 455 | { 456 | if ($arg = $this->getArgumentByIndex($this->currentArgument)) { 457 | $this->params[$arg->primaryName] = $arg->type === ParameterType::ARRAY ? [$token] : $token; 458 | } elseif (($last = $this->getLastArgument()) && $last->type === ParameterType::ARRAY) { 459 | $this->params[$last->primaryName][] = $token; 460 | } else { 461 | throw new InvalidParameterException("Unknown argument \"$token\"."); 462 | } 463 | $this->currentArgument++; 464 | } 465 | 466 | public function setOptionValue(string $name, mixed $value = null): void 467 | { 468 | $option = $this->getOption($name); 469 | // If option not exists, make sure it is negatable 470 | if (!$option) { 471 | if (str_starts_with($name, 'no-')) { 472 | $option = $this->getOption(substr($name, 3)); 473 | if ($option->type === ParameterType::BOOLEAN && $option->negatable) { 474 | $this->params[$option->primaryName] = false; 475 | } 476 | 477 | return; 478 | } 479 | throw new InvalidParameterException(\sprintf('The "-%s" option does not exist.', $name)); 480 | } 481 | $option->assertInput($value === null || $option->acceptValue, 'Option "%s" does not accept value.'); 482 | // Try get option value from next token 483 | if (\in_array($value, ['', null], true) && $option->acceptValue && \count($this->tokens)) { 484 | $next = array_shift($this->tokens); 485 | if ((isset($next[0]) && '-' !== $next[0]) || \in_array($next, ['', null], true)) { 486 | $value = $next; 487 | } else { 488 | array_unshift($this->tokens, $next); 489 | } 490 | } 491 | if ($option->type === ParameterType::BOOLEAN) { 492 | $value = $value === null || $value; 493 | } 494 | if ($option->type === ParameterType::ARRAY) { 495 | $this->params[$option->primaryName][] = $value; 496 | } elseif ($option->type === ParameterType::LEVEL) { 497 | $this->params[$option->primaryName] ??= 0; 498 | $this->params[$option->primaryName]++; 499 | } else { 500 | $this->params[$option->primaryName] = $value; 501 | } 502 | } 503 | } 504 | 505 | /** 506 | * @method self description(string $value) 507 | * @method self required(bool $value) 508 | * @method self negatable(bool $value) 509 | * @method self default(mixed $value) 510 | */ 511 | class Parameter 512 | { 513 | public bool $isArg { 514 | get => is_string($this->name); 515 | } 516 | 517 | public string $primaryName { 518 | get => is_string($this->name) ? $this->name : $this->name[0]; 519 | } 520 | 521 | public string $synopsis { 522 | get { 523 | if (is_string($this->name)) { 524 | return $this->name; 525 | } 526 | $shorts = []; 527 | $fulls = []; 528 | foreach ($this->name as $n) { 529 | if (strlen($n) === 1) { 530 | $shorts[] = '-' . $n; 531 | } else { 532 | $fulls[] = '--' . $n; 533 | } 534 | } 535 | if ($this->negatable) { 536 | $fulls[] = '--no-' . $this->primaryName; 537 | } 538 | 539 | return implode(', ', array_filter([implode('|', $shorts), implode('|', $fulls)])); 540 | } 541 | } 542 | 543 | public bool $acceptValue { 544 | get => $this->type !== ParameterType::BOOLEAN && $this->type !== ParameterType::LEVEL && !$this->negatable; 545 | } 546 | 547 | public mixed $defaultValue { 548 | get => match ($this->type) { 549 | ParameterType::ARRAY => $this->default ?? [], 550 | ParameterType::LEVEL => $this->default ?? 0, 551 | default => $this->default, 552 | }; 553 | } 554 | 555 | public function __construct( 556 | public string|array $name, 557 | public ParameterType $type, 558 | public string $description = '', 559 | public bool $required = false, 560 | public mixed $default = null, 561 | public bool $negatable = false, 562 | ) { 563 | $this->name = is_string($this->name) && str_starts_with($this->name, '-') ? [$this->name] : $this->name; 564 | if (is_array($this->name)) { 565 | foreach ($this->name as $i => $n) { 566 | $this->assertArg(str_starts_with($n, '--') || strlen($n) <= 2); 567 | $this->name[$i] = ltrim($n, '-'); 568 | } 569 | } 570 | } 571 | 572 | public function selfValidate(): void 573 | { 574 | $this->assertArg( 575 | $this->type !== ParameterType::ARRAY || is_array($this->defaultValue), 576 | "Default value of \"%s\" must be an array." 577 | ); 578 | if ($this->isArg) { 579 | $this->assertArg(!$this->negatable, "Argument \"%s\" cannot be negatable."); 580 | $this->assertArg( 581 | $this->type !== ParameterType::BOOLEAN && $this->type !== ParameterType::LEVEL, 582 | "Argument \"%s\" cannot be type: {$this->type->name}." 583 | ); 584 | } else { 585 | $this->assertArg(!$this->negatable || !$this->required, "Negatable option \"%s\" cannot be required."); 586 | } 587 | $this->assertArg( 588 | !$this->required || $this->default === null, 589 | "Default value of \"%s\" cannot be set when required is true." 590 | ); 591 | } 592 | 593 | public function hasName(string $name): bool 594 | { 595 | $name = ltrim($name, '-'); 596 | 597 | return is_string($this->name) ? $this->name === $name : array_any($this->name, fn($n) => $n === $name); 598 | } 599 | 600 | public function castValue(mixed $value): mixed 601 | { 602 | return match ($this->type) { 603 | ParameterType::INT, ParameterType::LEVEL => (int) $value, 604 | ParameterType::NUMERIC, ParameterType::FLOAT => (float) $value, 605 | ParameterType::BOOLEAN => (bool) $value, 606 | ParameterType::ARRAY => (array) $value, 607 | default => $value, 608 | }; 609 | } 610 | 611 | public function validate(mixed $value): void 612 | { 613 | if ($value === null) { 614 | $this->assertInput(!$this->required, "Required value for \"%s\" is missing."); 615 | 616 | return; 617 | } 618 | $passed = match ($this->type) { 619 | ParameterType::INT => is_numeric($value) && ((string) (int) $value) === $value, 620 | ParameterType::FLOAT => is_numeric($value) && ((string) (float) $value) === $value, 621 | ParameterType::NUMERIC => is_numeric($value), 622 | ParameterType::BOOLEAN => is_bool($value) || $value === '1' || $value === '0', 623 | ParameterType::ARRAY => is_array($value), 624 | default => true, 625 | }; 626 | $this->assertInput($passed, "Invalid value type for \"%s\". Expected %s."); 627 | } 628 | 629 | public function assertArg(mixed $value, ?string $message = ''): void 630 | { 631 | $value || throw new \InvalidArgumentException(sprintf($message, $this->primaryName, $this->type->name)); 632 | } 633 | 634 | public function assertInput(mixed $value, ?string $message = ''): void 635 | { 636 | $value || throw new InvalidParameterException(sprintf($message, $this->primaryName, $this->type->name)); 637 | } 638 | 639 | public function __call(string $name, array $args) 640 | { 641 | if (property_exists($this, $name)) { 642 | $this->{$name} = $args[0]; 643 | $this->selfValidate(); 644 | 645 | return $this; 646 | } 647 | throw new \BadMethodCallException("Method $name() does not exist."); 648 | } 649 | } 650 | 651 | class ParameterDescriptor 652 | { 653 | public static function describe(ArgvParser $parser, string $commandName, string $epilog = ''): string 654 | { 655 | $lines[] = sprintf("Usage:\n %s %s", $commandName, static::synopsis($parser, true)); 656 | if (count($parser->arguments)) { 657 | $lines[] = "\nArguments:"; 658 | $maxColWidth = 0; 659 | foreach ($parser->arguments as $argument) { 660 | $argumentLines[] = static::describeArgument($argument, $maxColWidth); 661 | } 662 | foreach ($argumentLines ?? [] as [$start, $end]) { 663 | $lines[] = ' ' . $start . str_repeat(' ', $maxColWidth - strlen($start) + 4) . $end; 664 | } 665 | } 666 | if (count($parser->options)) { 667 | $lines[] = "\nOptions:"; 668 | $maxColWidth = 0; 669 | foreach ($parser->options as $option) { 670 | $optionLines[] = static::describeOption($option, $maxColWidth); 671 | } 672 | foreach ($optionLines ?? [] as [$start, $end]) { 673 | $lines[] = ' ' . $start . str_repeat(' ', $maxColWidth - strlen($start) + 4) . $end; 674 | } 675 | } 676 | $epilog && ($lines[] = "\nHelp:\n$epilog"); 677 | 678 | return implode("\n", $lines); 679 | } 680 | 681 | public static function describeArgument(Parameter $parameter, int &$maxWidth = 0): array 682 | { 683 | $default = !static::noDefault($parameter) ? ' [default: ' . static::format($parameter->default) . ']' : ''; 684 | $maxWidth = max($maxWidth, strlen($parameter->synopsis)); 685 | 686 | return [$parameter->synopsis, $parameter->description . $default]; 687 | } 688 | 689 | public static function describeOption(Parameter $parameter, int &$maxWidth = 0): array 690 | { 691 | $default = ($parameter->acceptValue || $parameter->negatable) && !static::noDefault($parameter) 692 | ? ' [default: ' . static::format($parameter->default) . ']' 693 | : ''; 694 | $value = '=' . strtoupper($parameter->primaryName); 695 | $value = $parameter->required ? $value : '[' . $value . ']'; 696 | $synopsis = $parameter->synopsis . ($parameter->acceptValue ? $value : ''); 697 | $maxWidth = max($maxWidth, strlen($synopsis)); 698 | 699 | return [ 700 | $synopsis, 701 | $parameter->description . $default . ($parameter->type === ParameterType::ARRAY ? ' (multiple values allowed)' : ''), 702 | ]; 703 | } 704 | 705 | public static function noDefault(Parameter $parameter): bool 706 | { 707 | return $parameter->default === null || (is_array($parameter->default) && count($parameter->default) === 0); 708 | } 709 | 710 | public static function format(mixed $value): string 711 | { 712 | return str_replace('\\\\', '\\', json_encode($value, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); 713 | } 714 | 715 | public static function synopsis(ArgvParser $parser, bool $simple = false): string 716 | { 717 | $elements = []; 718 | if ($simple) { 719 | $elements[] = '[options]'; 720 | } else { 721 | foreach ($parser->options as $option) { 722 | $value = strtoupper($option->primaryName); 723 | $value = !$option->required ? '[' . $value . ']' : $value; 724 | $element = str_replace(', ', '|', $option->synopsis) . ($option->acceptValue ? ' ' . $value : ''); 725 | $elements[] = '[' . $element . ']'; 726 | } 727 | } 728 | if ($elements !== [] && $parser->arguments !== []) { 729 | $elements[] = '[--]'; 730 | } 731 | $tail = ''; 732 | foreach ($parser->arguments as $argument) { 733 | $element = ($argument->type === ParameterType::ARRAY ? '...' : '') . '<' . $argument->primaryName . '>'; 734 | if (!$argument->required) { 735 | $element = '[' . $element; 736 | $tail .= ']'; 737 | } 738 | $elements[] = $element; 739 | } 740 | 741 | return implode(' ', $elements) . $tail; 742 | } 743 | } 744 | 745 | enum ParameterType 746 | { 747 | case STRING; 748 | case INT; 749 | case NUMERIC; 750 | case FLOAT; 751 | case BOOLEAN; 752 | case LEVEL; 753 | case ARRAY; 754 | } 755 | 756 | class InvalidParameterException extends \RuntimeException 757 | { 758 | } 759 | } 760 | --------------------------------------------------------------------------------