├── 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 | 
5 | [
6 | ](https://packagist.org/packages/asika/sitemap)
7 | [](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 |
--------------------------------------------------------------------------------